From 2f6832f99c5e40ddd561a3a7893aebc226e13b7b Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 29 Aug 2026 23:34:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E7=9F=AD=E5=89=A7=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=20-=20=E5=AE=8C=E5=96=84=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E5=88=86=E9=95=9C=E3=80=81=E8=B5=84=E4=BA=A7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E6=8A=80=E8=83=BD=E8=BF=90=E8=A1=8C=E4=B8=8E?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- assets/user-rou.jpg | Bin 0 -> 72414 bytes backend/cmd/migrate-sqlite-postgres/main.go | 2 + backend/cmd/server/main.go | 3 + backend/internal/database/schema.go | 21 + backend/internal/handler/project.go | 24 + backend/internal/handler/skills.go | 157 +++ backend/internal/model/models_platform.go | 93 +- backend/internal/model/models_project.go | 17 +- .../internal/repository/project_character.go | 12 + backend/internal/repository/repository.go | 160 ++- .../internal/repository/resource_reference.go | 4 + backend/internal/repository/skill_packages.go | 68 ++ backend/internal/repository/skills.go | 18 +- .../repository/task_provider_poll_test.go | 27 + backend/internal/service/project.go | 34 +- backend/internal/service/project_character.go | 92 +- .../service/project_character_voice_test.go | 66 ++ .../internal/service/project_settings_test.go | 131 +++ backend/internal/service/project_shot.go | 66 +- backend/internal/service/project_workflow.go | 125 +++ .../service/project_workflow_v2_test.go | 238 +++- backend/internal/service/prompt_template.go | 11 + .../service/prompt_template_defaults.go | 10 +- backend/internal/service/provider.go | 3 + backend/internal/service/provider_progress.go | 109 ++ .../service/provider_progress_test.go | 42 + backend/internal/service/service.go | 1 + backend/internal/service/skill_packages.go | 1000 +++++++++++++++++ .../internal/service/skill_packages_test.go | 158 +++ backend/internal/service/skills.go | 78 +- backend/internal/service/skills_seed.go | 2 +- backend/internal/service/storyboard.go | 199 +++- .../service/storyboard_prompt_test.go | 56 +- .../internal/service/storyboard_workflow.go | 4 +- backend/internal/service/task_output.go | 62 +- backend/internal/service/task_output_test.go | 28 + backend/internal/service/task_worker.go | 10 + canvas-agent/README.md | 4 +- canvas-agent/src/agents.ts | 55 +- canvas-agent/src/http-server.ts | 6 +- canvas-agent/src/local-runtime-host.ts | 11 +- canvas-agent/src/local-runtime.ts | 6 +- canvas-agent/src/modules/canvas-agent-http.ts | 42 +- canvas-agent/test/skills.test.ts | 41 +- .../content/docs/backend/backend-database.mdx | 13 + docs/content/docs/progress/pending-test.mdx | 73 ++ .../components/canvas/asset-picker-modal.tsx | 2 +- .../canvas/canvas-assistant-panel.tsx | 35 +- .../components/canvas/canvas-context-menu.tsx | 4 +- .../canvas/canvas-local-agent-panel.tsx | 28 +- .../canvas/canvas-node-generation.ts | 21 +- .../canvas/canvas-project-asset-modal.tsx | 2 +- .../canvas-resource-mention-textarea.tsx | 184 ++- web/src/components/layout/app-top-nav.tsx | 53 +- .../layout/workspace-top-bar-extension.tsx | 44 + .../components/layout/workspace-top-bar.tsx | 9 +- .../skills/skill-runtime-picker.tsx | 57 + web/src/components/video-player.css | 101 +- web/src/lib/app-theme.ts | 8 +- web/src/lib/asset-storage-revision.ts | 16 +- web/src/lib/canvas/canvas-project-domain.ts | 4 +- .../lib/canvas/canvas-resource-references.ts | 12 +- web/src/lib/canvas/canvas-skill-mentions.ts | 120 +- web/src/lib/character-voice-formats.ts | 54 + web/src/lib/generation-task-display.ts | 3 + .../canvas-generation-executor-types.ts | 2 + .../canvas-image-generation-executor.ts | 4 + .../canvas-media-generation-executors.ts | 8 +- .../canvas/canvas-text-generation-executor.ts | 5 +- web/src/pages/canvas/project.tsx | 1 + .../canvas/use-canvas-generation-executor.ts | 13 +- .../canvas/use-canvas-generation-retry.ts | 28 +- web/src/pages/canvas/use-canvas-storyboard.ts | 17 +- .../canvas/use-canvas-viewport-controller.ts | 9 +- web/src/pages/create/creation-references.ts | 12 +- web/src/pages/create/index.tsx | 23 +- web/src/pages/projects/detail.tsx | 77 +- web/src/pages/projects/detail/assets.tsx | 133 ++- .../detail/chapter-storyboard-production.ts | 167 +++ web/src/pages/projects/detail/chapters.tsx | 366 ++++-- web/src/pages/projects/detail/overview.tsx | 33 +- .../projects/detail/project-asset-linking.ts | 13 + .../projects/detail/project-chapter-ai.ts | 109 +- web/src/pages/projects/detail/settings.tsx | 88 +- .../detail/workflow-chapter-navigator.tsx | 73 ++ .../detail/workflow-production-workbench.tsx | 180 ++- .../pages/projects/detail/workflow-shared.tsx | 17 +- .../detail/workflow-shot-references.ts | 89 ++ web/src/pages/projects/detail/workflow.css | 172 ++- web/src/pages/projects/index.tsx | 3 +- web/src/pages/skills/index.tsx | 29 +- web/src/pages/skills/skill-detail-drawer.tsx | 295 ++++- web/src/pages/skills/skill-editor-drawer.tsx | 15 +- web/src/pages/skills/skill-install-modal.tsx | 145 +++ web/src/services/api/projects.ts | 25 +- web/src/services/api/skills.ts | 101 +- web/src/services/api/task-center.ts | 8 +- web/src/services/skill-runtime.ts | 442 ++++++++ web/src/stores/use-asset-store.ts | 15 +- web/src/styles/globals.css | 317 +++++- web/src/types/canvas.ts | 4 + web/test/asset-storage-revision.test.ts | 33 + .../canvas-resource-mention-editor.test.ts | 22 + web/test/generation-task-local.test.ts | 13 + web/test/project-asset-linking.test.ts | 32 + .../project-chapter-skill-runtime.test.ts | 38 + web/test/project-chapter-storyboard.test.ts | 93 ++ .../project-chapter-task-recovery.test.ts | 47 + .../project-character-voice-formats.test.ts | 31 + web/test/skill-runtime.test.ts | 141 +++ web/test/workflow-shot-delete.test.ts | 19 + web/test/workflow-shot-references.test.ts | 87 ++ 113 files changed, 7016 insertions(+), 723 deletions(-) create mode 100644 assets/user-rou.jpg create mode 100644 backend/internal/repository/skill_packages.go create mode 100644 backend/internal/service/project_character_voice_test.go create mode 100644 backend/internal/service/project_settings_test.go create mode 100644 backend/internal/service/provider_progress.go create mode 100644 backend/internal/service/provider_progress_test.go create mode 100644 backend/internal/service/skill_packages.go create mode 100644 backend/internal/service/skill_packages_test.go create mode 100644 web/src/components/layout/workspace-top-bar-extension.tsx create mode 100644 web/src/components/skills/skill-runtime-picker.tsx create mode 100644 web/src/lib/character-voice-formats.ts create mode 100644 web/src/pages/projects/detail/chapter-storyboard-production.ts create mode 100644 web/src/pages/projects/detail/project-asset-linking.ts create mode 100644 web/src/pages/projects/detail/workflow-chapter-navigator.tsx create mode 100644 web/src/pages/projects/detail/workflow-shot-references.ts create mode 100644 web/src/pages/skills/skill-install-modal.tsx create mode 100644 web/src/services/skill-runtime.ts create mode 100644 web/test/asset-storage-revision.test.ts create mode 100644 web/test/project-asset-linking.test.ts create mode 100644 web/test/project-chapter-skill-runtime.test.ts create mode 100644 web/test/project-chapter-storyboard.test.ts create mode 100644 web/test/project-chapter-task-recovery.test.ts create mode 100644 web/test/project-character-voice-formats.test.ts create mode 100644 web/test/skill-runtime.test.ts create mode 100644 web/test/workflow-shot-delete.test.ts create mode 100644 web/test/workflow-shot-references.test.ts diff --git a/README.md b/README.md index 7c82ca4fe..4976139a7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ > 项目仍在快速开发,数据结构和外部接口可能直接调整。默认适合个人、本地或可信环境部署;未经安全配置,不要直接作为公网多人服务使用。 -当前版本以仓库根目录 [`VERSION`](VERSION) 为准。 +演示环境:[https://ddcat.pronhubcn.com](https://ddcat.pronhubcn.com) + +- 账号:`test` +- 密码:`test123456` ## 赞助商 @@ -48,6 +51,7 @@ | fish | fish | [cihai.sea@gmail.com](mailto:cihai.sea@gmail.com) | AI 界热于助人的拖油瓶 | | QAyong | QAyong
ID:QAyong
B站:QAyong
| [2110491559@qq.com](mailto:2110491559@qq.com) | AI 短剧合规,资产确权 | | _K37ix. | _K37ix. | [2773843782@qq.com](mailto:2773843782@qq.com) | Making things that think | +| Rou | Rou | [rou325089@163.com](mailto:rou325089@163.com) | 上善若水 | ## 交流与反馈 diff --git a/assets/user-rou.jpg b/assets/user-rou.jpg new file mode 100644 index 0000000000000000000000000000000000000000..86ad26eec27640fc09d9b6c8e7d9ee7a5a48d733 GIT binary patch literal 72414 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 Ah5!Hn literal 0 HcmV?d00001 diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 834cddd7b..3b503cded 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -244,6 +244,8 @@ func migrations() []tableMigration { migrateTable[model.StorageLocation]("storage_locations"), migrateTable[model.UserDailyUploadUsage]("user_daily_upload_usages"), migrateTable[model.Skill]("skills"), + migrateTable[model.SkillVersion]("skill_versions"), + migrateTable[model.SkillFile]("skill_files"), migrateTable[model.UserSkillState]("user_skill_states"), migrateTable[model.Resource]("resources"), migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"), diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 278c96f2d..1388fd7c5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -56,6 +56,9 @@ func main() { if err := svc.EnsureBuiltinSkills(); err != nil { log.Fatal(err) } + if err := svc.EnsureSkillPackages(); err != nil { + log.Fatal(err) + } if summary, err := svc.MigrateLegacyStorage(); err != nil { log.Printf("storage migration skipped after error: %v", err) } else if summary.Backup != "" { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index d69b51e26..8995a222d 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -46,6 +46,8 @@ func Models() []any { &model.StorageLocation{}, &model.UserDailyUploadUsage{}, &model.Skill{}, + &model.SkillVersion{}, + &model.SkillFile{}, &model.UserSkillState{}, &model.Resource{}, &model.ResourceDeletionJob{}, @@ -105,6 +107,9 @@ func MigrateSchema(db *gorm.DB) error { if err := db.AutoMigrate(Models()...); err != nil { return err } + if err := backfillProjectUnitWordCounts(db); err != nil { + return err + } if err := migrateChannelModelPriceTierSelectors(db); err != nil { return err } @@ -140,6 +145,22 @@ 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 } +func backfillProjectUnitWordCounts(db *gorm.DB) error { + var units []model.ProjectUnit + if err := db.Select("id", "source_text").Where("word_count = 0 AND source_text <> ''").Find(&units).Error; err != nil { + return fmt.Errorf("读取待回填章节字数:%w", err) + } + return db.Transaction(func(tx *gorm.DB) error { + for _, unit := range units { + wordCount := model.ProjectUnitWordCount(unit.SourceText) + if err := tx.Model(&model.ProjectUnit{}).Where("id = ?", unit.ID).Update("word_count", wordCount).Error; err != nil { + return fmt.Errorf("回填章节 %s 字数:%w", unit.ID, err) + } + } + return nil + }) +} + // migrateChannelModelPriceTierSelectors upgrades the old video-only unique key to // a canonical SKU selector key. It never changes task, route-attempt, or billing // foreign keys, so completed work remains auditable after a product SKU merge. diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 6d44e90d7..fa5c105c2 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -697,6 +697,18 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"shot": shot, "revision": revision}) }) + r.DELETE("/projects/:id/shots/:shotId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProjectShot(user.ID, c.Param("id"), c.Param("shotId")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"deleted": true}) + }) r.POST("/projects/:id/shots/:shotId/assets", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -716,6 +728,18 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"reference": reference}) }) + r.DELETE("/projects/:id/shots/:shotId/assets/:referenceId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.UnlinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), c.Param("referenceId")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"unlinked": true}) + }) r.POST("/projects/:id/asset-candidates", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/handler/skills.go b/backend/internal/handler/skills.go index ba739fdce..d388dc013 100644 --- a/backend/internal/handler/skills.go +++ b/backend/internal/handler/skills.go @@ -1,7 +1,11 @@ package handler import ( + "mime" + "net/http" + "path/filepath" "strconv" + "strings" "infinite-canvas/backend/internal/service" @@ -9,6 +13,62 @@ import ( ) func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/skills/install", 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, service.SkillPackageUploadMaxBytes) + file, err := c.FormFile("file") + if err != nil { + failService(c, service.BadAuthRequest("请选择 Markdown 或 ZIP 技能文件")) + return + } + isPrivate, err := parseOptionalBool(c.PostForm("is_private")) + if err != nil { + failService(c, service.BadAuthRequest("is_private 必须是布尔值")) + return + } + sourceType := strings.ToLower(strings.TrimSpace(c.PostForm("source_type"))) + if sourceType == "" { + switch strings.ToLower(filepath.Ext(file.Filename)) { + case ".md", ".markdown": + sourceType = "markdown" + case ".zip": + sourceType = "zip" + } + } + skill, err := svc.InstallSkillUpload(user.ID, sourceType, file, service.SkillInstallRequest{ + Name: c.PostForm("name"), Description: c.PostForm("description"), Tag: c.PostForm("tag"), IsPrivate: isPrivate, + }) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"skill": skill}) + }) + + r.POST("/skills/install/github", 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, 32<<10) + var req service.SkillGitHubInstallRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("GitHub 技能数据格式无效")) + return + } + skill, err := svc.InstallGitHubSkill(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"skill": skill}) + }) + r.GET("/skills", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -56,6 +116,95 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"skill": skill}) }) + r.GET("/skills/:id/files", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + files, err := svc.SkillPackageFiles(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"files": files}) + }) + + r.GET("/skills/:id/file", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + file, err := svc.SkillPackageFile(user.ID, c.Param("id"), c.Query("path")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"file": file}) + }) + + r.GET("/skills/:id/file/raw", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + data, mimeType, fileName, err := svc.SkillPackageRawFile(user.ID, c.Param("id"), c.Query("path")) + if err != nil { + failService(c, err) + return + } + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "default-src 'none'; sandbox") + c.Header("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": fileName})) + c.Data(http.StatusOK, mimeType, data) + }) + + r.GET("/skills/:id/bundle", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + bundle, err := svc.SkillPackageBundle(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + c.Header("Cache-Control", "private, no-store") + ok(c, gin.H{"bundle": bundle}) + }) + + r.GET("/skills/:id/search", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + results, err := svc.SearchSkillPackage(user.ID, c.Param("id"), c.Query("q")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"results": results}) + }) + + r.POST("/skills/:id/sync", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + skill, err := svc.SyncGitHubSkill(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"skill": skill}) + }) + r.POST("/skills", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -113,6 +262,14 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { r.DELETE("/skills/:id/like", setSkillLiked(svc, false)) } +func parseOptionalBool(value string) (bool, error) { + value = strings.TrimSpace(value) + if value == "" { + return false, nil + } + return strconv.ParseBool(value) +} + func setSkillAdded(svc *service.Service, added bool) gin.HandlerFunc { return func(c *gin.Context) { user, err := currentUser(c, svc) diff --git a/backend/internal/model/models_platform.go b/backend/internal/model/models_platform.go index 35c8bafb4..dc81297ad 100644 --- a/backend/internal/model/models_platform.go +++ b/backend/internal/model/models_platform.go @@ -76,33 +76,74 @@ type UserDailyUploadUsage struct { } type Skill struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - OwnerID string `json:"ownerId" gorm:"index;size:36"` - AuthorName string `json:"authorName" gorm:"size:120;index"` - AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` - Name string `json:"name" gorm:"size:80;index"` - Description string `json:"description" gorm:"size:500"` - Instruction string `json:"instruction" gorm:"type:text"` - Status int `json:"status" gorm:"index"` - Source int `json:"source" gorm:"index"` - Tag string `json:"tag" gorm:"size:32;index"` - SortWeight int `json:"sortWeight" gorm:"index"` - IsPrivate bool `json:"isPrivate" gorm:"index"` - MarkdownURL string `json:"markdownUrl" gorm:"size:500"` - ShowcaseMediaJSON string `json:"-" gorm:"type:text"` - ExtraInfo string `json:"extraInfo" gorm:"type:text"` - InitialLikeCount int64 `json:"initialLikeCount"` - InitialAddedCount int64 `json:"initialAddedCount"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` + ID string `json:"id" gorm:"primaryKey;size:36"` + OwnerID string `json:"ownerId" gorm:"index;size:36"` + AuthorName string `json:"authorName" gorm:"size:120;index"` + AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` + Name string `json:"name" gorm:"size:80;index"` + Description string `json:"description" gorm:"size:500"` + Instruction string `json:"instruction" gorm:"type:text"` + CurrentVersionID string `json:"currentVersionId" gorm:"size:36;index"` + VersionLabel string `json:"versionLabel" gorm:"size:64"` + ContentHash string `json:"contentHash" gorm:"size:64;index"` + FileCount int `json:"fileCount"` + TotalBytes int64 `json:"totalBytes"` + SourceType string `json:"sourceType" gorm:"size:24;index"` + SourceURL string `json:"sourceUrl" gorm:"size:1000"` + SourceRef string `json:"sourceRef" gorm:"size:255"` + SourceSubdir string `json:"sourceSubdir" gorm:"size:1000"` + SourceCommit string `json:"sourceCommit" gorm:"size:64"` + SyncStatus string `json:"syncStatus" gorm:"size:24;index"` + SyncError string `json:"syncError" gorm:"type:text"` + AutoUpdate bool `json:"autoUpdate" gorm:"index"` + LastCheckedAt *time.Time `json:"lastCheckedAt"` + LastSyncedAt *time.Time `json:"lastSyncedAt"` + Status int `json:"status" gorm:"index"` + Source int `json:"source" gorm:"index"` + Tag string `json:"tag" gorm:"size:32;index"` + SortWeight int `json:"sortWeight" gorm:"index"` + IsPrivate bool `json:"isPrivate" gorm:"index"` + MarkdownURL string `json:"markdownUrl" gorm:"size:500"` + ShowcaseMediaJSON string `json:"-" gorm:"type:text"` + ExtraInfo string `json:"extraInfo" gorm:"type:text"` + InitialLikeCount int64 `json:"initialLikeCount"` + InitialAddedCount int64 `json:"initialAddedCount"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +type SkillVersion struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + SkillID string `json:"skillId" gorm:"size:36;index"` + VersionLabel string `json:"versionLabel" gorm:"size:64"` + ContentHash string `json:"contentHash" gorm:"size:64;index"` + EntryPath string `json:"entryPath" gorm:"size:1000"` + PackageKey string `json:"packageKey" gorm:"size:1000"` + FileCount int `json:"fileCount"` + TotalBytes int64 `json:"totalBytes"` + SourceCommit string `json:"sourceCommit" gorm:"size:64"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +type SkillFile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + SkillVersionID string `json:"skillVersionId" gorm:"size:36;index;uniqueIndex:idx_skill_version_file_path,priority:1"` + Path string `json:"path" gorm:"size:1000;uniqueIndex:idx_skill_version_file_path,priority:2"` + Kind string `json:"kind" gorm:"size:24;index"` + MimeType string `json:"mimeType" gorm:"size:255"` + Size int64 `json:"size"` + SHA256 string `json:"sha256" gorm:"size:64"` + CreatedAt time.Time `json:"createdAt"` } type UserSkillState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` - SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` - Added bool `json:"added" gorm:"index"` - Liked bool `json:"liked" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` + SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` + InstalledVersionID string `json:"installedVersionId" gorm:"size:36;index"` + AutoUpdate bool `json:"autoUpdate"` + Added bool `json:"added" gorm:"index"` + Liked bool `json:"liked" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index eddda7003..54263bb2a 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -1,6 +1,19 @@ package model -import "time" +import ( + "html" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +var projectUnitHTMLTagPattern = regexp.MustCompile(`<[^>]+>`) + +func ProjectUnitWordCount(sourceText string) int { + plainText := projectUnitHTMLTagPattern.ReplaceAllString(sourceText, "") + return utf8.RuneCountInString(strings.TrimSpace(html.UnescapeString(plainText))) +} const AssetIDMaxLength = 80 @@ -164,6 +177,7 @@ type Project struct { AspectRatio string `json:"aspectRatio" gorm:"size:16"` SourceType string `json:"sourceType" gorm:"size:32"` Description string `json:"description" gorm:"type:text"` + CoverResourceID string `json:"coverResourceId,omitempty" gorm:"index;size:36"` StylePresetID string `json:"stylePresetId" gorm:"size:64"` StyleProfileJSON string `json:"styleProfileJson" gorm:"type:text"` Status ProjectStatus `json:"status" gorm:"index;size:24"` @@ -195,6 +209,7 @@ type ProjectUnit struct { Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` Title string `json:"title" gorm:"size:240"` SourceText string `json:"sourceText" gorm:"type:text"` + WordCount int `json:"wordCount"` Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` Position int `json:"position"` CreatedAt time.Time `json:"createdAt"` diff --git a/backend/internal/repository/project_character.go b/backend/internal/repository/project_character.go index 4b9c0f5e9..020e612f1 100644 --- a/backend/internal/repository/project_character.go +++ b/backend/internal/repository/project_character.go @@ -82,6 +82,18 @@ func (r *Repository) EnsureVoiceProfiles(profiles []model.VoiceProfile) error { }).Create(&profiles).Error } +func (r *Repository) CreateVoiceProfile(profile *model.VoiceProfile) error { + return r.db.Create(profile).Error +} + +func (r *Repository) VoiceProfileBySampleResource(userID string, resourceID string) (*model.VoiceProfile, error) { + var profile model.VoiceProfile + if err := r.db.First(&profile, "user_id = ? AND sample_resource_id = ? AND status = ?", userID, resourceID, "active").Error; err != nil { + return nil, err + } + return &profile, nil +} + // CreateProjectCharacter 将角色身份、首版本和项目关联放入同一事务。 func (r *Repository) CreateProjectCharacter(projectID string, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) error { return r.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index bc0f304dc..3fe7c3209 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -3,6 +3,7 @@ package repository import ( "errors" "fmt" + "slices" "sort" "strings" "time" @@ -29,6 +30,8 @@ var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empt var ErrProjectHasActiveTasks = errors.New("project has active tasks") +var ErrProjectUnitShotsChanged = errors.New("project unit shots changed") + type Repository struct { db *gorm.DB } @@ -459,6 +462,17 @@ func (r *Repository) UpdateTaskProgress(id string, stage string, progress int) e }).Error } +// UpdateTaskProviderProgress records upstream-reported progress without allowing +// a delayed or out-of-order poll response to move the public percentage backwards. +func (r *Repository) UpdateTaskProviderProgress(id string, progress int) error { + progress = max(0, min(100, progress)) + return r.db.Model(&model.Task{}).Where("id = ? AND status = ?", id, model.TaskStatusRunning).Updates(map[string]any{ + "stage": "上游生成中", + "progress": gorm.Expr("CASE WHEN progress < ? THEN ? ELSE progress END", progress, progress), + "updated_at": time.Now(), + }).Error +} + func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskStatus, session *model.Session, message *model.Message, results []model.Result) error { return r.db.Transaction(func(tx *gorm.DB) error { updated := tx.Model(&model.Task{}). @@ -606,6 +620,15 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl return tasks, err } +// SuccessfulWorkflowTasksForProject 返回需要补偿工作流产物的成功任务。 +// 不设分页,供项目详情读取时修复浏览器中断造成的历史断点。 +func (r *Repository) SuccessfulWorkflowTasksForProject(userID string, projectID string) ([]model.Task, error) { + var tasks []model.Task + err := r.db.Where("user_id = ? AND project_id = ? AND status = ?", userID, projectID, model.TaskStatusSucceeded). + Order("completed_at asc, created_at asc").Find(&tasks).Error + return tasks, err +} + func (r *Repository) Session(id string) (*model.Session, error) { var session model.Session if err := r.db.First(&session, "id = ?", id).Error; err != nil { @@ -1106,7 +1129,8 @@ func (r *Repository) CreateProject(project *model.Project) error { func (r *Repository) UpdateProject(project *model.Project) error { return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{ "name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType, - "description": project.Description, "style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON, + "description": project.Description, "cover_resource_id": project.CoverResourceID, + "style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON, "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt, }).Error } @@ -1214,7 +1238,7 @@ func (r *Repository) ProjectUnits(projectID string) ([]model.ProjectUnit, error) func (r *Repository) ProjectUnitSummaries(projectID string) ([]model.ProjectUnit, error) { var units []model.ProjectUnit - err := r.db.Select("id", "project_id", "kind", "title", "status", "position", "created_at", "updated_at").Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error + err := r.db.Select("id", "project_id", "kind", "title", "word_count", "status", "position", "created_at", "updated_at").Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error return units, err } @@ -1265,7 +1289,7 @@ func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUni func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit, invalidateWorkflow bool) error { return r.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{ - "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, + "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "word_count": unit.WordCount, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, }) if result.Error != nil { return result.Error @@ -1690,8 +1714,19 @@ func (r *Repository) SaveShotWithRevision(shot *model.Shot, revision *model.Shot }) } -func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision) error { +func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision, references []model.ShotAssetReference, expectedShotIDs []string) error { return r.db.Transaction(func(tx *gorm.DB) error { + if expectedShotIDs != nil { + var currentShotIDs []string + if err := tx.Model(&model.Shot{}).Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("id asc").Pluck("id", ¤tShotIDs).Error; err != nil { + return err + } + expected := append([]string(nil), expectedShotIDs...) + sort.Strings(expected) + if !slices.Equal(currentShotIDs, expected) { + return ErrProjectUnitShotsChanged + } + } shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID) if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.ShotArtifact{}).Error; err != nil { return err @@ -1716,6 +1751,11 @@ func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, sh return err } } + if len(references) > 0 { + if err := tx.Create(&references).Error; err != nil { + return err + } + } if err := invalidateUnitWorkflowTx(tx, projectID, unitID, "storyboard", time.Now()); err != nil { return err } @@ -1731,6 +1771,54 @@ func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Sho return &shot, nil } +// DeleteProjectShot 原子删除单个镜头的领域关联,并重新压紧同章节镜头顺序。 +func (r *Repository) DeleteProjectShot(projectID string, shotID string, updatedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotAssetReference{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + return err + } + result := tx.Where("id = ? AND project_id = ?", shotID, projectID).Delete(&model.Shot{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + var remaining []model.Shot + if err := tx.Select("id", "position").Where("project_id = ? AND unit_id = ?", projectID, shot.UnitID).Order("position asc, created_at asc, id asc").Find(&remaining).Error; err != nil { + return err + } + for position, item := range remaining { + if item.Position == position { + continue + } + if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", item.ID, projectID).Update("position", position).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) +} + func (r *Repository) ProjectShotRevisions(projectID string) ([]model.ShotRevision, error) { var revisions []model.ShotRevision err := r.db.Table("shot_revisions").Select("shot_revisions.*"). @@ -1806,6 +1894,32 @@ func (r *Repository) UpsertShotAssetReferenceAndInvalidate(projectID string, ref }) } +func (r *Repository) DeleteShotAssetReferenceAndInvalidate(projectID string, shotID string, referenceID string, updatedAt time.Time) (bool, error) { + deleted := false + err := r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Where("id = ? AND shot_id = ?", referenceID, shotID).Delete(&model.ShotAssetReference{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + deleted = true + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { + return err + } + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) + return deleted, err +} + func invalidateUnitWorkflowTx(tx *gorm.DB, projectID string, unitID string, fromStepKey string, updatedAt time.Time) error { if strings.TrimSpace(unitID) == "" { return nil @@ -2010,19 +2124,45 @@ func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, ne // RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。 func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation, productionLink *model.ProductionTaskLink, artifact *model.ShotArtifact) error { return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).FirstOrCreate(link).Error; err != nil { + var existingLink model.WorkflowStepTask + if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).First(&existingLink).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(link).Error; err != nil { + return err + } + } else if err != nil { return err } if representation != nil { - if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).FirstOrCreate(representation).Error; err != nil { + var existingRepresentation model.AssetRepresentation + if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).First(&existingRepresentation).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(representation).Error; err != nil { + return err + } + } else if err != nil { return err } } if productionLink != nil { - if err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).Assign(map[string]any{ - "project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, "shot_id": productionLink.ShotID, - "workflow_step_id": productionLink.WorkflowStepID, "artifact_type": productionLink.ArtifactType, "updated_at": productionLink.UpdatedAt, - }).FirstOrCreate(productionLink).Error; err != nil { + var existingProductionLink model.ProductionTaskLink + err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).First(&existingProductionLink).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(productionLink).Error; err != nil { + return err + } + } else if err != nil { + return err + } else if err := tx.Model(&existingProductionLink).Updates(map[string]any{ + "project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, + "workflow_step_id": productionLink.WorkflowStepID, "updated_at": productionLink.UpdatedAt, + }).Error; err != nil { + return err + } + } + if artifact != nil { + var existing model.ShotArtifact + if err := tx.Where("task_id = ? AND shot_id = ? AND type = ?", artifact.TaskID, artifact.ShotID, artifact.Type).First(&existing).Error; err == nil { + artifact = nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } } diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index a6a4b0311..4b2563e85 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -1,6 +1,7 @@ package repository import ( + "slices" "strings" "infinite-canvas/backend/internal/model" @@ -108,6 +109,9 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s } for _, project := range projects { snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "项目", ID: project.ID, Title: project.Name, PrimaryJSON: project.StyleProfileJSON}) + if project.CoverResourceID != "" && slices.Contains(resourceIDs, project.CoverResourceID) { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "项目主图", ID: project.ID, Title: project.Name, ResourceID: project.CoverResourceID}) + } } var styles []model.StyleProfile diff --git a/backend/internal/repository/skill_packages.go b/backend/internal/repository/skill_packages.go new file mode 100644 index 000000000..3726149af --- /dev/null +++ b/backend/internal/repository/skill_packages.go @@ -0,0 +1,68 @@ +package repository + +import ( + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func (r *Repository) CreateSkillWithPackage(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile, ownerState *model.UserSkillState) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(skill).Error; err != nil { + return err + } + if err := tx.Create(version).Error; err != nil { + return err + } + if len(files) > 0 { + if err := tx.Create(&files).Error; err != nil { + return err + } + } + return tx.Create(ownerState).Error + }) +} + +func (r *Repository) AddSkillVersion(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(version).Error; err != nil { + return err + } + if len(files) > 0 { + if err := tx.Create(&files).Error; err != nil { + return err + } + } + return tx.Save(skill).Error + }) +} + +func (r *Repository) SkillVersion(id string) (*model.SkillVersion, error) { + var version model.SkillVersion + if err := r.db.First(&version, "id = ?", id).Error; err != nil { + return nil, err + } + return &version, nil +} + +func (r *Repository) SkillFiles(versionID string) ([]model.SkillFile, error) { + var files []model.SkillFile + err := r.db.Where("skill_version_id = ?", versionID).Order("path asc").Find(&files).Error + return files, err +} + +func (r *Repository) SkillsForPackageEnsure(userSource int) ([]model.Skill, error) { + var skills []model.Skill + err := r.db.Where("status = ? AND (current_version_id = '' OR source <> ?)", 1, userSource).Find(&skills).Error + return skills, err +} + +func (r *Repository) AutoUpdatingGitHubSkills() ([]model.Skill, error) { + var skills []model.Skill + err := r.db.Where("status = ? AND source_type = ? AND auto_update = ?", 1, "github", true).Find(&skills).Error + return skills, err +} + +func (r *Repository) SaveSkill(skill *model.Skill) error { + return r.db.Save(skill).Error +} diff --git a/backend/internal/repository/skills.go b/backend/internal/repository/skills.go index 028c7e392..264d257bd 100644 --- a/backend/internal/repository/skills.go +++ b/backend/internal/repository/skills.go @@ -101,6 +101,22 @@ func (r *Repository) DeleteSkill(id string) error { if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil { return err } + var versions []model.SkillVersion + if err := tx.Where("skill_id = ?", id).Find(&versions).Error; err != nil { + return err + } + versionIDs := make([]string, 0, len(versions)) + for _, version := range versions { + versionIDs = append(versionIDs, version.ID) + } + if len(versionIDs) > 0 { + if err := tx.Delete(&model.SkillFile{}, "skill_version_id IN ?", versionIDs).Error; err != nil { + return err + } + } + if err := tx.Delete(&model.SkillVersion{}, "skill_id = ?", id).Error; err != nil { + return err + } return tx.Delete(&model.Skill{}, "id = ?", id).Error }) } @@ -128,7 +144,7 @@ func (r *Repository) UserSkillStatesBySkillIDs(userID string, skillIDs []string) func (r *Repository) SetUserSkillAdded(state *model.UserSkillState) error { return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}, {Name: "skill_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"added", "updated_at"}), + DoUpdates: clause.AssignmentColumns([]string{"added", "installed_version_id", "auto_update", "updated_at"}), }).Create(state).Error } diff --git a/backend/internal/repository/task_provider_poll_test.go b/backend/internal/repository/task_provider_poll_test.go index 76b0b84e8..8771d906c 100644 --- a/backend/internal/repository/task_provider_poll_test.go +++ b/backend/internal/repository/task_provider_poll_test.go @@ -48,4 +48,31 @@ func TestDeferredProviderPollKeepsOriginalTaskIdentityWithoutImmediateReclaim(t } } +func TestUpdateTaskProviderProgressIsMonotonic(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:provider-progress?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + task := model.Task{ID: "task-progress", UserID: "user-1", Type: "canvas_video", Status: model.TaskStatusRunning, Stage: "正在连接上游"} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + repo := New(db) + for _, progress := range []int{12, 48, 31, 76} { + if err := repo.UpdateTaskProviderProgress(task.ID, progress); err != nil { + t.Fatal(err) + } + } + var stored model.Task + if err := db.First(&stored, "id = ?", task.ID).Error; err != nil { + t.Fatal(err) + } + if stored.Progress != 76 || stored.Stage != "上游生成中" { + t.Fatalf("stored progress = %d, stage = %q", stored.Progress, stored.Stage) + } +} + func ptrTime(value time.Time) *time.Time { return &value } diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index d293630b8..bf5330249 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -29,6 +29,7 @@ type UpdateProjectRequest struct { AspectRatio string `json:"aspectRatio"` SourceType string `json:"sourceType"` Description *string `json:"description"` + CoverResourceID *string `json:"coverResourceId"` StylePresetID *string `json:"stylePresetId"` StyleProfileJSON *string `json:"styleProfileJson"` Status string `json:"status"` @@ -90,6 +91,7 @@ type ProjectDetail struct { ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"` ShotReferences []model.ShotAssetReference `json:"shotReferences"` AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"` + Tasks []TaskSummary `json:"tasks"` } func (s *Service) ListProjects(userID string) ([]ProjectSummary, error) { @@ -150,6 +152,13 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) return ProjectDetail{}, err } } + if tasks, tasksErr := s.repo.SuccessfulWorkflowTasksForProject(userID, project.ID); tasksErr == nil { + for _, task := range tasks { + if err := s.RegisterTaskOutputFromTask(task); err != nil { + _ = s.log(userID, task.ID, "error", "项目读取时工作流产物补偿失败", err.Error()) + } + } + } // 项目工作台只返回章节摘要,长篇小说正文由单章接口按需读取。 units, err := s.repo.ProjectUnitSummaries(project.ID) if err != nil { @@ -195,7 +204,11 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } - return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotRevisions: shotRevisions, ShotArtifacts: shotArtifacts, ShotReferences: shotReferences, AssetCandidates: candidates}, nil + tasks, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: project.ID}) + if err != nil { + return ProjectDetail{}, err + } + return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotRevisions: shotRevisions, ShotArtifacts: shotArtifacts, ShotReferences: shotReferences, AssetCandidates: candidates, Tasks: tasks}, nil } func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) { @@ -262,6 +275,22 @@ func (s *Service) UpdateProject(userID string, id string, req UpdateProjectReque if req.Description != nil { project.Description = strings.TrimSpace(*req.Description) } + if req.CoverResourceID != nil { + coverResourceID := strings.TrimSpace(*req.CoverResourceID) + if coverResourceID != "" { + resource, resourceErr := s.repo.ResourceForUser(userID, coverResourceID) + if resourceErr != nil { + if errors.Is(resourceErr, gorm.ErrRecordNotFound) { + return model.Project{}, BadAuthRequest("项目主图不存在或不属于当前用户") + } + return model.Project{}, resourceErr + } + if resource.Status != model.ResourceStatusReady || resource.Kind != "image" { + return model.Project{}, BadAuthRequest("项目主图必须是已就绪的图片资源") + } + } + project.CoverResourceID = coverResourceID + } if req.StylePresetID != nil { project.StylePresetID = strings.TrimSpace(*req.StylePresetID) } @@ -448,7 +477,7 @@ func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int position = 0 } now := time.Now() - unit := model.ProjectUnit{ID: newID(), ProjectID: projectID, Kind: kind, Title: title, SourceText: req.SourceText, Status: model.ProjectUnitStatusDraft, Position: position, CreatedAt: now, UpdatedAt: now} + unit := model.ProjectUnit{ID: newID(), ProjectID: projectID, Kind: kind, Title: title, SourceText: req.SourceText, WordCount: model.ProjectUnitWordCount(req.SourceText), Status: model.ProjectUnitStatusDraft, Position: position, CreatedAt: now, UpdatedAt: now} return unit, nil } @@ -465,6 +494,7 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri unit.Title = title } unit.SourceText = req.SourceText + unit.WordCount = model.ProjectUnitWordCount(req.SourceText) if status := model.ProjectUnitStatus(strings.TrimSpace(req.Status)); status != "" { if status != model.ProjectUnitStatusDraft && status != model.ProjectUnitStatusReady && status != model.ProjectUnitStatusCompleted { return model.ProjectUnit{}, BadAuthRequest("不支持的章节状态") diff --git a/backend/internal/service/project_character.go b/backend/internal/service/project_character.go index 1d1e2fdf4..1162332c9 100644 --- a/backend/internal/service/project_character.go +++ b/backend/internal/service/project_character.go @@ -34,8 +34,10 @@ type ReplaceCharacterRepresentationsRequest struct { } type BindCharacterVoiceRequest struct { - VoiceProfileID string `json:"voiceProfileId"` - Instructions string `json:"instructions"` + VoiceProfileID string `json:"voiceProfileId"` + SampleResourceID string `json:"sampleResourceId"` + VoiceName string `json:"voiceName"` + Instructions string `json:"instructions"` } type CharacterRepresentationSummary struct { @@ -153,8 +155,11 @@ func (s *Service) CreateProjectCharacter(userID string, projectID string, req Cr func (s *Service) ProjectCharacter(userID string, projectID string, assetID string) (ProjectCharacterDetail, error) { asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID)) - if err != nil { - return ProjectCharacterDetail{}, err + if err != nil || asset == nil { + if err != nil { + return ProjectCharacterDetail{}, err + } + return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用") } return s.projectCharacterDetail(userID, projectID, asset) } @@ -324,12 +329,42 @@ func (s *Service) reconcileCharacterTurnaroundTasks(userID string, projectID str func (s *Service) BindProjectCharacterVoice(userID string, projectID string, assetID string, req BindCharacterVoiceRequest) (ProjectCharacterDetail, error) { asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID)) - if err != nil { - return ProjectCharacterDetail{}, err + if err != nil || asset == nil { + if err != nil { + return ProjectCharacterDetail{}, err + } + return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用") } - profile, err := s.repo.VoiceProfileForUser(userID, strings.TrimSpace(req.VoiceProfileID)) - if err != nil || profile.Status != "active" { - return ProjectCharacterDetail{}, BadAuthRequest("选择的声音素材不可用") + var profile *model.VoiceProfile + sampleResourceID := strings.TrimSpace(req.SampleResourceID) + if sampleResourceID != "" { + resource, resourceErr := s.repo.ResourceForUser(userID, sampleResourceID) + if resourceErr != nil || resource == nil || resource.Kind != "audio" || resource.Status != model.ResourceStatusReady || !isSupportedVoiceSampleMimeType(resource.MimeType) { + return ProjectCharacterDetail{}, BadAuthRequest("请选择已上传完成的支持格式音频:MP3、WAV、M4A/AAC、FLAC、OGG/Opus 或 WebM") + } + var profileErr error + profile, profileErr = s.repo.VoiceProfileBySampleResource(userID, sampleResourceID) + if profileErr != nil { + if !errors.Is(profileErr, gorm.ErrRecordNotFound) { + return ProjectCharacterDetail{}, profileErr + } + voiceName := strings.TrimSpace(req.VoiceName) + if voiceName == "" { + voiceName = "上传声音 · " + sampleResourceID[:min(8, len(sampleResourceID))] + } + profile = &model.VoiceProfile{ID: newID(), UserID: userID, Name: voiceName, Provider: "user_upload", VoiceKey: "sample:" + sampleResourceID, Language: "按样本使用", Timbre: "用户上传样本", SampleResourceID: sampleResourceID, CompatibleModelsJSON: "[]", Status: "active", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := s.repo.CreateVoiceProfile(profile); err != nil { + return ProjectCharacterDetail{}, err + } + } + } else { + profile, err = s.repo.VoiceProfileForUser(userID, strings.TrimSpace(req.VoiceProfileID)) + if err != nil || profile == nil || profile.Status != "active" { + return ProjectCharacterDetail{}, BadAuthRequest("选择的声音素材不可用") + } + } + if profile == nil || strings.TrimSpace(profile.ID) == "" { + return ProjectCharacterDetail{}, BadAuthRequest("声音素材不可用,请重新选择") } binding := &model.CharacterVoiceBinding{ID: newID(), VoiceProfileID: profile.ID, Instructions: strings.TrimSpace(req.Instructions), CreatedAt: time.Now(), UpdatedAt: time.Now()} if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", nil, binding, false); err != nil { @@ -338,10 +373,27 @@ func (s *Service) BindProjectCharacterVoice(userID string, projectID string, ass return s.ProjectCharacter(userID, projectID, asset.ID) } +func isSupportedVoiceSampleMimeType(value string) bool { + mime := strings.ToLower(strings.TrimSpace(strings.SplitN(value, ";", 2)[0])) + switch mime { + case "audio/mpeg", "audio/mp3", "audio/x-mpeg", + "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave", "audio/x-pn-wav", + "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac", "audio/aacp", + "audio/flac", "audio/x-flac", + "audio/ogg", "application/ogg", "audio/opus", "audio/webm": + return true + default: + return false + } +} + func (s *Service) UnbindProjectCharacterVoice(userID string, projectID string, assetID string) (ProjectCharacterDetail, error) { asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID)) - if err != nil { - return ProjectCharacterDetail{}, err + if err != nil || asset == nil { + if err != nil { + return ProjectCharacterDetail{}, err + } + return ProjectCharacterDetail{}, BadAuthRequest("角色资产不可用") } if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", nil, nil, true); err != nil { return ProjectCharacterDetail{}, err @@ -362,9 +414,15 @@ func (s *Service) createNextCharacterVersion(projectID string, asset *model.Asse } func (s *Service) prepareNextCharacterVersion(asset *model.Asset, name string, definitionJSON string, replacementRepresentations []model.AssetRepresentation, replacementVoice *model.CharacterVoiceBinding, dropVoice bool) (model.Asset, model.AssetVersion, []model.AssetRepresentation, *model.CharacterVoiceBinding, error) { + if asset == nil { + return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色资产不可用") + } current, err := s.repo.AssetVersion(asset.PrimaryVersionID) - if err != nil { - return model.Asset{}, model.AssetVersion{}, nil, nil, err + if err != nil || current == nil { + if err != nil { + return model.Asset{}, model.AssetVersion{}, nil, nil, err + } + return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色当前版本不可用") } versions, err := s.repo.AssetVersions(asset.ID) if err != nil { @@ -398,8 +456,10 @@ func (s *Service) prepareNextCharacterVersion(asset *model.Asset, name string, d voice := replacementVoice if voice == nil && !dropVoice { currentVoice, voiceErr := s.repo.CharacterVoiceBinding(current.ID) - if voiceErr == nil { + if voiceErr == nil && currentVoice != nil { voice = &model.CharacterVoiceBinding{ID: newID(), AssetVersionID: next.ID, VoiceProfileID: currentVoice.VoiceProfileID, Instructions: currentVoice.Instructions, CreatedAt: now, UpdatedAt: now} + } else if voiceErr == nil { + return model.Asset{}, model.AssetVersion{}, nil, nil, BadAuthRequest("角色声音绑定数据不完整") } else if !errors.Is(voiceErr, gorm.ErrRecordNotFound) { return model.Asset{}, model.AssetVersion{}, nil, nil, voiceErr } @@ -465,9 +525,9 @@ func (s *Service) characterCard(userID string, asset *model.Asset) (CharacterCar var voice *CharacterVoiceSummary voiceStatus := "missing" binding, bindingErr := s.repo.CharacterVoiceBinding(version.ID) - if bindingErr == nil { + if bindingErr == nil && binding != nil { profile, profileErr := s.repo.VoiceProfileForUser(userID, binding.VoiceProfileID) - if profileErr == nil && profile.Status == "active" { + if profileErr == nil && profile != nil && profile.Status == "active" { voice = &CharacterVoiceSummary{Profile: voiceProfileSummary(*profile), Instructions: binding.Instructions} voiceStatus = "ready" } else { diff --git a/backend/internal/service/project_character_voice_test.go b/backend/internal/service/project_character_voice_test.go new file mode 100644 index 000000000..6a9c13f59 --- /dev/null +++ b/backend/internal/service/project_character_voice_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestIsSupportedVoiceSampleMimeType(t *testing.T) { + for _, mime := range []string{ + "audio/mpeg", "audio/mp3", "audio/x-mpeg", " AUDIO/MPEG ", + "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave", "audio/x-pn-wav", + "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac", "audio/aacp", + "audio/flac", "audio/x-flac", "audio/ogg", "application/ogg", "audio/opus", "audio/webm", + "audio/ogg; codecs=opus", "audio/webm;codecs=opus", + } { + if !isSupportedVoiceSampleMimeType(mime) { + t.Fatalf("isSupportedVoiceSampleMimeType(%q) = false, want true", mime) + } + } + for _, mime := range []string{"audio/aiff", "audio/amr", "audio/x-ms-wma", "video/mp4", "application/octet-stream", ""} { + if isSupportedVoiceSampleMimeType(mime) { + t.Fatalf("isSupportedVoiceSampleMimeType(%q) = true, want false", mime) + } + } +} + +func TestBindProjectCharacterVoiceWithSampleResource(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.Project{}, &model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.ProjectAssetLink{}, + &model.CharacterVoiceBinding{}, &model.VoiceProfile{}, &model.Resource{}, &model.Shot{}, &model.ShotAssetReference{}, + ); err != nil { + t.Fatal(err) + } + + now := time.Now() + project := model.Project{ID: "project-voice", UserID: "user-voice", Name: "声音绑定测试", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + asset := model.Asset{ID: "character-voice", UserID: project.UserID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: "character-voice-v1", Title: "张振天"} + asset.PayloadJSON = `{"id":"character-voice","kind":"entity","category":"character","status":"confirmed","primaryVersionId":"character-voice-v1","title":"张振天","data":{"definition":{}}}` + version := model.AssetVersion{ID: asset.PrimaryVersionID, AssetID: asset.ID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: `{}`, + CreatedAt: now, UpdatedAt: now} + link := model.ProjectAssetLink{ID: "project-character-voice-link", ProjectID: project.ID, AssetID: asset.ID, CreatedAt: now} + resource := model.Resource{ID: "voice-sample", UserID: project.UserID, Kind: "audio", Status: model.ResourceStatusReady, MimeType: "audio/wav", ObjectKey: "voice-sample.wav", CreatedAt: now, UpdatedAt: now} + for _, value := range []any{&project, &asset, &version, &link, &resource} { + if err := db.Create(value).Error; err != nil { + t.Fatal(err) + } + } + + result, err := (&Service{repo: repository.New(db)}).BindProjectCharacterVoice(project.UserID, project.ID, asset.ID, BindCharacterVoiceRequest{SampleResourceID: resource.ID, VoiceName: "张振天原声"}) + if err != nil { + t.Fatal(err) + } + if result.Character.Voice == nil || result.Character.Voice.Profile.SampleResourceID != resource.ID { + t.Fatalf("voice binding = %+v, want sample resource %q", result.Character, resource.ID) + } +} diff --git a/backend/internal/service/project_settings_test.go b/backend/internal/service/project_settings_test.go new file mode 100644 index 000000000..d9ab57d4e --- /dev/null +++ b/backend/internal/service/project_settings_test.go @@ -0,0 +1,131 @@ +package service + +import ( + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.Project{}, &model.ProjectUnit{}, &model.Resource{}, &model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, + &model.CanvasProject{}, &model.StyleProfile{}, &model.ProjectAssetCandidate{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, + &model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{}, + ); err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} + +func TestUpdateProjectCoverRequiresOwnedReadyImage(t *testing.T) { + service, db := newProjectSettingsTestService(t) + now := time.Now() + project := model.Project{ID: "project-cover", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + resources := []model.Resource{ + {ID: "cover-ready", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady}, + {ID: "cover-pending", UserID: "user-1", Kind: "image", Status: model.ResourceStatusPending}, + {ID: "cover-video", UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady}, + {ID: "cover-other-user", UserID: "user-2", Kind: "image", Status: model.ResourceStatusReady}, + } + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&resources).Error; err != nil { + t.Fatal(err) + } + + coverID := "cover-ready" + updated, err := service.UpdateProject("user-1", project.ID, UpdateProjectRequest{CoverResourceID: &coverID}) + if err != nil { + t.Fatal(err) + } + if updated.CoverResourceID != coverID { + t.Fatalf("cover resource id = %q, want %q", updated.CoverResourceID, coverID) + } + var persisted model.Project + if err := db.First(&persisted, "id = ?", project.ID).Error; err != nil { + t.Fatal(err) + } + if persisted.CoverResourceID != coverID { + t.Fatalf("persisted cover resource id = %q, want %q", persisted.CoverResourceID, coverID) + } + + for _, invalidID := range []string{"cover-pending", "cover-video", "cover-other-user", "missing"} { + if _, err := service.UpdateProject("user-1", project.ID, UpdateProjectRequest{CoverResourceID: &invalidID}); err == nil { + t.Fatalf("UpdateProject cover %q succeeded, want validation error", invalidID) + } + } + + empty := "" + cleared, err := service.UpdateProject("user-1", project.ID, UpdateProjectRequest{CoverResourceID: &empty}) + if err != nil { + t.Fatal(err) + } + if cleared.CoverResourceID != "" { + t.Fatalf("cover resource id = %q after clear", cleared.CoverResourceID) + } + if err := db.First(&persisted, "id = ?", project.ID).Error; err != nil { + t.Fatal(err) + } + if persisted.CoverResourceID != "" { + t.Fatalf("persisted cover resource id = %q after clear", persisted.CoverResourceID) + } +} + +func TestProjectUnitSummaryPersistsWordCount(t *testing.T) { + service, db := newProjectSettingsTestService(t) + now := time.Now() + project := model.Project{ID: "project-words", UserID: "user-1", Name: "长篇短剧", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + + unit, err := service.CreateProjectUnit("user-1", project.ID, CreateProjectUnitRequest{Title: "第一章", SourceText: "

张振天 归来

"}) + if err != nil { + t.Fatal(err) + } + if unit.WordCount != 6 { + t.Fatalf("created word count = %d, want 6", unit.WordCount) + } + + unit, err = service.UpdateProjectUnit("user-1", project.ID, unit.ID, UpdateProjectUnitRequest{Title: unit.Title, SourceText: "

新的正文

"}) + if err != nil { + t.Fatal(err) + } + if unit.WordCount != 4 { + t.Fatalf("updated word count = %d, want 4", unit.WordCount) + } + + summaries, err := service.repo.ProjectUnitSummaries(project.ID) + if err != nil { + t.Fatal(err) + } + if len(summaries) != 1 || summaries[0].WordCount != 4 || summaries[0].SourceText != "" { + t.Fatalf("summary = %+v", summaries) + } +} + +func TestProjectCoverAppearsInResourceReferences(t *testing.T) { + service, db := newProjectSettingsTestService(t) + project := model.Project{ID: "project-cover-reference", UserID: "user-1", Name: "封面引用", CoverResourceID: "cover-resource"} + if err := db.Create(&project).Error; err != nil { + t.Fatal(err) + } + snapshot, err := service.repo.ResourceReferenceSnapshot("user-1", "", []string{"cover-resource"}) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Direct) != 1 || snapshot.Direct[0].ResourceID != "cover-resource" || snapshot.Direct[0].Kind != "项目主图" { + t.Fatalf("direct references = %+v", snapshot.Direct) + } +} diff --git a/backend/internal/service/project_shot.go b/backend/internal/service/project_shot.go index 9396d2fcd..b9400bd0b 100644 --- a/backend/internal/service/project_shot.go +++ b/backend/internal/service/project_shot.go @@ -2,10 +2,12 @@ package service import ( "encoding/json" + "errors" "strings" "time" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" ) type CreateProjectShotRequest struct { @@ -35,7 +37,13 @@ type ShotRevisionInput struct { } type ReplaceProjectUnitShotsRequest struct { - Shots []CreateProjectShotRequest `json:"shots"` + Shots []ReplaceProjectUnitShotInput `json:"shots"` + ExpectedShotIDs []string `json:"expectedShotIds"` +} + +type ReplaceProjectUnitShotInput struct { + CreateProjectShotRequest + AssetVersionIDs []string `json:"assetVersionIds"` } type LinkShotAssetRequest struct { @@ -124,6 +132,7 @@ func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitI now := time.Now() shots := make([]model.Shot, 0, len(req.Shots)) revisions := make([]model.ShotRevision, 0, len(req.Shots)) + references := make([]model.ShotAssetReference, 0) for position, input := range req.Shots { title := strings.TrimSpace(input.Title) description := strings.TrimSpace(input.Description) @@ -138,9 +147,27 @@ func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitI revision.Version = 1 shots = append(shots, model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, CurrentRevisionID: revision.ID, Title: title, Description: revision.PlotDescription, Position: position, DurationMs: revision.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now}) revisions = append(revisions, revision) + seenVersions := make(map[string]bool, len(input.AssetVersionIDs)) + for _, rawVersionID := range input.AssetVersionIDs { + versionID := strings.TrimSpace(rawVersionID) + if versionID == "" || seenVersions[versionID] { + continue + } + if len(seenVersions) >= 6 { + return nil, BadAuthRequest("单个分镜最多引用 6 个资产版本") + } + if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil { + return nil, err + } + seenVersions[versionID] = true + references = append(references, model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: "reference", Status: "linked", CreatedAt: now}) + } } // 章节级重生成是一个整体写操作,旧镜头与引用必须和新镜头在同一事务中替换。 - if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots, revisions); err != nil { + if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots, revisions, references, req.ExpectedShotIDs); err != nil { + if errors.Is(err, repository.ErrProjectUnitShotsChanged) { + return nil, BadAuthRequest("本章分镜已发生变化,请刷新后重新确认") + } return nil, err } return shots, nil @@ -169,6 +196,20 @@ func (s *Service) CreateShotRevision(userID string, projectID string, shotID str return *shot, revision, nil } +func (s *Service) DeleteProjectShot(userID string, projectID string, shotID string) error { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { + return err + } + shotID = strings.TrimSpace(shotID) + if shotID == "" { + return BadAuthRequest("镜头不能为空") + } + if _, err := s.repo.ShotForProject(projectID, shotID); err != nil { + return err + } + return s.repo.DeleteProjectShot(projectID, shotID, time.Now()) +} + func newShotRevision(userID string, shotID string, input ShotRevisionInput, fallbackDescription string, fallbackDuration int64, now time.Time) (model.ShotRevision, error) { plotDescription := strings.TrimSpace(input.PlotDescription) if plotDescription == "" { @@ -232,6 +273,27 @@ func (s *Service) LinkShotAsset(userID string, projectID string, shotID string, return reference, nil } +func (s *Service) UnlinkShotAsset(userID string, projectID string, shotID string, referenceID string) error { + if _, err := s.activeProjectForUser(userID, projectID); err != nil { + return err + } + if _, err := s.repo.ShotForProject(projectID, shotID); err != nil { + return err + } + referenceID = strings.TrimSpace(referenceID) + if referenceID == "" { + return BadAuthRequest("镜头资产引用不能为空") + } + deleted, err := s.repo.DeleteShotAssetReferenceAndInvalidate(projectID, shotID, referenceID, time.Now()) + if err != nil { + return err + } + if !deleted { + return NotFound("镜头资产引用不存在") + } + return nil +} + func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, req CreateAssetCandidatesRequest) ([]model.ProjectAssetCandidate, error) { if _, err := s.activeProjectForUser(userID, projectID); err != nil { return nil, err diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go index d0cea6d4d..2b4e95016 100644 --- a/backend/internal/service/project_workflow.go +++ b/backend/internal/service/project_workflow.go @@ -463,6 +463,12 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { input.Role, _ = input.Metadata["role"].(string) } } + if strings.TrimSpace(input.ResourceID) == "" { + input.ResourceID, input.MediaType = taskOutputResource(task.ResultJSON, task.Type) + } + if strings.TrimSpace(input.MediaType) == "" && strings.TrimSpace(input.ResourceID) != "" { + input.MediaType = taskOutputMediaType(task.Type) + } if strings.TrimSpace(input.WorkflowStepID) == "" { return nil } @@ -475,10 +481,129 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error { if projectID == "" { return errors.New("任务未提供短剧项目 ID,无法登记产物") } + if strings.TrimSpace(input.ResourceID) != "" && strings.TrimSpace(input.AssetVersionID) == "" && strings.TrimSpace(input.ShotID) != "" { + assetVersionID, assetErr := s.ensureGeneratedProjectAsset(task, projectID, input.ShotID, input.ResourceID, input.MediaType) + if assetErr != nil { + return assetErr + } + input.AssetVersionID = assetVersionID + } _, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON}) return err } +func taskOutputMediaType(taskType string) string { + if strings.Contains(strings.ToLower(taskType), "video") { + return "video" + } + return "image" +} + +func taskOutputResource(raw string, taskType string) (string, string) { + if strings.TrimSpace(raw) == "" { + return "", "" + } + var value any + if json.Unmarshal([]byte(raw), &value) != nil { + return "", "" + } + return findTaskOutputResource(value, taskOutputMediaType(taskType)) +} + +func findTaskOutputResource(value any, mediaType string) (string, string) { + switch item := value.(type) { + case []any: + for _, child := range item { + if id, kind := findTaskOutputResource(child, mediaType); id != "" { + return id, kind + } + } + case map[string]any: + for _, key := range []string{"resourceId", "storageKey", "url", "dataUrl", "resultUrl", "outputUrl"} { + if raw, ok := item[key].(string); ok { + text := strings.TrimSpace(raw) + if strings.HasPrefix(text, "resource:") { + return strings.TrimPrefix(text, "resource:"), mediaType + } + if strings.HasPrefix(text, "/api/resources/") { + id := strings.TrimPrefix(text, "/api/resources/") + if slash := strings.IndexByte(id, '/'); slash >= 0 { + id = id[:slash] + } + if id != "" { + return id, mediaType + } + } + } + } + for _, child := range item { + if id, kind := findTaskOutputResource(child, mediaType); id != "" { + return id, kind + } + } + } + return "", "" +} + +func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, shotID string, resourceID string, mediaType string) (string, error) { + representations, err := s.repo.AssetRepresentationsForTask(task.ID) + if err != nil { + return "", err + } + for _, representation := range representations { + if representation.Role == "output" && representation.ResourceID == resourceID && representation.AssetVersionID != "" { + return representation.AssetVersionID, nil + } + } + resource, err := s.repo.ResourceForUser(task.UserID, resourceID) + if err != nil { + return "", err + } + if resource.Status != model.ResourceStatusReady { + return "", BadAuthRequest("生成资源尚未就绪,无法登记项目素材") + } + shot, err := s.repo.ShotForProject(projectID, shotID) + if err != nil { + return "", err + } + now := time.Now() + assetID := "workflow-asset-" + task.ID + versionID := "workflow-version-" + task.ID + label := "图片" + if mediaType == "video" { + label = "视频" + } + title := strings.TrimSpace(shot.Title) + if title == "" { + title = "镜头产物" + } + title += " · " + label + payload, _ := json.Marshal(map[string]any{ + "id": assetID, "kind": mediaType, "category": model.AssetCategoryOther, "status": model.AssetVersionStatusConfirmed, + "primaryVersionId": versionID, "title": title, + "data": map[string]any{"storageKey": "resource:" + resourceID, "url": "/api/resources/" + resourceID + "/file", "mimeType": resource.MimeType, "bytes": resource.Size}, + "metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}}, + }) + asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now} + version := &model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: "{}", Prompt: task.Prompt, Note: "工作流生成产物", CreatedAt: now, UpdatedAt: now} + folderID, err := s.resolveProjectAssetFolderID(projectID, nil) + if err != nil { + return "", err + } + position, err := s.repo.NextProjectAssetPosition(projectID, folderID) + if err != nil { + return "", err + } + link := &model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now} + if err := s.repo.UpsertAsset(asset); err != nil { + return "", err + } + if _, err := s.repo.LinkProjectAsset(asset, version, link); err != nil { + return "", err + } + return versionID, nil +} + func validWorkflowStepStatus(status model.WorkflowStepStatus) bool { switch status { case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped: diff --git a/backend/internal/service/project_workflow_v2_test.go b/backend/internal/service/project_workflow_v2_test.go index 0d7707bed..70dc9c10c 100644 --- a/backend/internal/service/project_workflow_v2_test.go +++ b/backend/internal/service/project_workflow_v2_test.go @@ -19,7 +19,8 @@ func newProjectWorkflowV2TestService(t *testing.T) (*Service, *gorm.DB) { t.Fatal(err) } if err := db.AutoMigrate( - &model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{}, + &model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{}, &model.ProjectAssetCandidate{}, + &model.AssetRepresentation{}, &model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{}, &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{}, &model.ProductionTaskLink{}, &model.Task{}, &model.Resource{}, @@ -29,6 +30,56 @@ func newProjectWorkflowV2TestService(t *testing.T) (*Service, *gorm.DB) { return &Service{repo: repository.New(db)}, db } +func TestRegisterTaskOutputFromTaskPersistsMediaAssetAndArtifactIdempotently(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + var videoStep model.WorkflowStepInstance + for _, step := range workflow.Steps { + if step.StepKey == "video" { + videoStep = step + } + } + shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物抬头", DurationMs: 3000}) + if err != nil { + t.Fatal(err) + } + now := time.Now() + resource := model.Resource{ID: "resource-video-1", UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady, MimeType: "video/mp4", Size: 1024, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + task := model.Task{ID: "workflow-video-task-1", UserID: "user-1", ProjectID: project.ID, Type: "canvas_video", Status: model.TaskStatusSucceeded, + InputJSON: `{"metadata":{"workflowStepId":"` + videoStep.ID + `","domainProjectId":"` + project.ID + `","unitId":"` + unit.ID + `","shotId":"` + shot.ID + `","artifactType":"video","role":"output"}}`, + ResultJSON: `{"mode":"video","video":{"resourceId":"resource-video-1","storageKey":"resource:resource-video-1","mimeType":"video/mp4"}}`, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + if err := service.RegisterTaskOutputFromTask(task); err != nil { + t.Fatal(err) + } + if err := service.RegisterTaskOutputFromTask(task); err != nil { + t.Fatal(err) + } + for table, query := range map[string]string{ + "assets": "id = 'workflow-asset-workflow-video-task-1'", + "project_asset_links": "asset_id = 'workflow-asset-workflow-video-task-1'", + "asset_representations": "task_id = 'workflow-video-task-1' AND role = 'output'", + "shot_artifacts": "task_id = 'workflow-video-task-1' AND type = 'video'", + } { + var count int64 + if err := db.Table(table).Where(query).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("%s count = %d, error = %v", table, count, err) + } + } +} + func seedWorkflowProject(t *testing.T, db *gorm.DB) (model.Project, model.ProjectUnit) { t.Helper() now := time.Now() @@ -144,6 +195,191 @@ func TestCreateShotRevisionInvalidatesExistingArtifacts(t *testing.T) { } } +func TestReplaceProjectUnitShotsCreatesGeneratedAssetReferencesAtomically(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + now := time.Now() + asset := model.Asset{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategory("prop"), Status: model.AssetVersionStatus("ready"), PrimaryVersionID: "asset-version-1", Title: "旧信封", CreatedAt: now, UpdatedAt: now} + version := model.AssetVersion{ID: "asset-version-1", AssetID: asset.ID, Version: 1, Status: model.AssetVersionStatus("ready"), CreatedAt: now, UpdatedAt: now} + link := model.ProjectAssetLink{ID: "project-asset-link-1", ProjectID: project.ID, AssetID: asset.ID, CreatedAt: now} + for _, item := range []any{&asset, &version, &link} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + shots, err := service.ReplaceProjectUnitShots("user-1", project.ID, unit.ID, ReplaceProjectUnitShotsRequest{ExpectedShotIDs: []string{}, Shots: []ReplaceProjectUnitShotInput{{ + CreateProjectShotRequest: CreateProjectShotRequest{Title: "SC.01", Description: "人物拾起信封", DurationMs: 3000, Revision: ShotRevisionInput{PlotDescription: "人物拾起信封"}}, + AssetVersionIDs: []string{version.ID, version.ID}, + }}}) + if err != nil { + t.Fatal(err) + } + if len(shots) != 1 { + t.Fatalf("shot count = %d, want 1", len(shots)) + } + var references []model.ShotAssetReference + if err := db.Where("shot_id = ?", shots[0].ID).Find(&references).Error; err != nil { + t.Fatal(err) + } + if len(references) != 1 || references[0].AssetVersionID != version.ID || references[0].Role != "reference" { + t.Fatalf("references = %+v, want one generated reference", references) + } + _, err = service.ReplaceProjectUnitShots("user-1", project.ID, unit.ID, ReplaceProjectUnitShotsRequest{ExpectedShotIDs: []string{"stale-shot-id"}, Shots: []ReplaceProjectUnitShotInput{{ + CreateProjectShotRequest: CreateProjectShotRequest{Title: "SC.02", Description: "并发替换", DurationMs: 3000}, + }}}) + if err == nil || err.Error() != "本章分镜已发生变化,请刷新后重新确认" { + t.Fatalf("concurrent replacement error = %v", err) + } + + _, err = service.ReplaceProjectUnitShots("user-1", project.ID, unit.ID, ReplaceProjectUnitShotsRequest{Shots: []ReplaceProjectUnitShotInput{{ + CreateProjectShotRequest: CreateProjectShotRequest{Title: "SC.02", Description: "无效资产", DurationMs: 3000}, + AssetVersionIDs: []string{"missing-version"}, + }}}) + if err == nil { + t.Fatal("missing project asset version should reject replacement") + } + var storedShots int64 + if err := db.Model(&model.Shot{}).Where("project_id = ? AND unit_id = ?", project.ID, unit.ID).Count(&storedShots).Error; err != nil { + t.Fatal(err) + } + if storedShots != 1 { + t.Fatalf("stored shots = %d, want original replacement preserved", storedShots) + } +} + +func TestUnlinkShotAssetDeletesReferenceAndInvalidatesProduction(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物走入画面", DurationMs: 3000}) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&model.WorkflowStepInstance{}).Where("workflow_instance_id = ? AND step_key IN ?", workflow.Instance.ID, []string{"storyboard", "previz", "video", "delivery"}).Updates(map[string]any{"status": model.WorkflowStepStatusCompleted}).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + reference := model.ShotAssetReference{ID: "reference-1", ShotID: shot.ID, AssetVersionID: "asset-version-1", Role: "reference", Status: "linked", CreatedAt: now} + artifact := model.ShotArtifact{ID: "artifact-unlink-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: shot.ID, Type: "video", Version: 1, Status: "ready", Selected: true, CreatedAt: now, UpdatedAt: now} + for _, item := range []any{&reference, &artifact} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := service.UnlinkShotAsset("user-2", project.ID, shot.ID, reference.ID); err == nil { + t.Fatal("another user should not unlink the shot asset") + } + if err := service.UnlinkShotAsset("user-1", project.ID, shot.ID, reference.ID); err != nil { + t.Fatal(err) + } + var referenceCount int64 + if err := db.Model(&model.ShotAssetReference{}).Where("id = ?", reference.ID).Count(&referenceCount).Error; err != nil { + t.Fatal(err) + } + if referenceCount != 0 { + t.Fatalf("reference count = %d, want 0", referenceCount) + } + var storedArtifact model.ShotArtifact + if err := db.First(&storedArtifact, "id = ?", artifact.ID).Error; err != nil { + t.Fatal(err) + } + if storedArtifact.Status != "stale" || storedArtifact.Selected { + t.Fatalf("artifact status=%s selected=%v, want stale false", storedArtifact.Status, storedArtifact.Selected) + } + var storyboardStep model.WorkflowStepInstance + if err := db.First(&storyboardStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "storyboard").Error; err != nil { + t.Fatal(err) + } + if storyboardStep.Status != model.WorkflowStepStatusRunning { + t.Fatalf("storyboard step status = %s, want running", storyboardStep.Status) + } + if err := service.UnlinkShotAsset("user-1", project.ID, shot.ID, reference.ID); err == nil { + t.Fatal("second unlink should report missing reference") + } +} + +func TestDeleteProjectShotCleansRelationsAndCompactsPositions(t *testing.T) { + service, db := newProjectWorkflowV2TestService(t) + project, unit := seedWorkflowProject(t, db) + if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil { + t.Fatal(err) + } + workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID) + if err != nil { + t.Fatal(err) + } + shots := make([]model.Shot, 0, 3) + for index, title := range []string{"SC.01", "SC.02", "SC.03"} { + shot, createErr := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{ + UnitID: unit.ID, Title: title, Description: "镜头画面", Position: index, DurationMs: 3000, + }) + if createErr != nil { + t.Fatal(createErr) + } + shots = append(shots, shot) + } + if err := db.Model(&model.WorkflowStepInstance{}).Where("workflow_instance_id = ? AND step_key IN ?", workflow.Instance.ID, []string{"storyboard", "previz", "video", "delivery"}).Updates(map[string]any{"status": model.WorkflowStepStatusCompleted}).Error; err != nil { + t.Fatal(err) + } + target := shots[1] + now := time.Now() + reference := model.ShotAssetReference{ID: "delete-reference-1", ShotID: target.ID, AssetVersionID: "asset-version-1", Role: "reference", Status: "linked", CreatedAt: now} + artifact := model.ShotArtifact{ID: "delete-artifact-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: target.ID, RevisionID: target.CurrentRevisionID, Type: "video", Version: 1, Status: "ready", Selected: true, CreatedAt: now, UpdatedAt: now} + candidate := model.ProjectAssetCandidate{ID: "delete-candidate-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: target.ID, Name: "雨伞", Category: model.AssetCategory("prop"), Status: "pending_confirmation", DetailsJSON: "{}", CreatedAt: now, UpdatedAt: now} + productionLink := model.ProductionTaskLink{ID: "delete-production-link-1", TaskID: "delete-task-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: target.ID, ArtifactType: "video", CreatedAt: now, UpdatedAt: now} + for _, item := range []any{&reference, &artifact, &candidate, &productionLink} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := service.DeleteProjectShot("user-2", project.ID, target.ID); err == nil { + t.Fatal("another user should not delete the shot") + } + if err := service.DeleteProjectShot("user-1", project.ID, target.ID); err != nil { + t.Fatal(err) + } + + var remaining []model.Shot + if err := db.Where("project_id = ? AND unit_id = ?", project.ID, unit.ID).Order("position asc").Find(&remaining).Error; err != nil { + t.Fatal(err) + } + if len(remaining) != 2 || remaining[0].ID != shots[0].ID || remaining[0].Position != 0 || remaining[1].ID != shots[2].ID || remaining[1].Position != 1 { + t.Fatalf("remaining shots = %+v, want first and third compacted to positions 0 and 1", remaining) + } + for name, query := range map[string]*gorm.DB{ + "shot": db.Model(&model.Shot{}).Where("id = ?", target.ID), + "revision": db.Model(&model.ShotRevision{}).Where("shot_id = ?", target.ID), + "artifact": db.Model(&model.ShotArtifact{}).Where("shot_id = ?", target.ID), + "reference": db.Model(&model.ShotAssetReference{}).Where("shot_id = ?", target.ID), + "candidate": db.Model(&model.ProjectAssetCandidate{}).Where("shot_id = ?", target.ID), + "production link": db.Model(&model.ProductionTaskLink{}).Where("shot_id = ?", target.ID), + } { + var count int64 + if err := query.Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("%s count = %d, want 0", name, count) + } + } + var storyboardStep model.WorkflowStepInstance + if err := db.First(&storyboardStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "storyboard").Error; err != nil { + t.Fatal(err) + } + if storyboardStep.Status != model.WorkflowStepStatusRunning { + t.Fatalf("storyboard step status = %s, want running", storyboardStep.Status) + } +} + func TestUpdateChapterSourceInvalidatesAllUnitArtifacts(t *testing.T) { service, db := newProjectWorkflowV2TestService(t) project, unit := seedWorkflowProject(t, db) diff --git a/backend/internal/service/prompt_template.go b/backend/internal/service/prompt_template.go index a1b2b4137..9793c9963 100644 --- a/backend/internal/service/prompt_template.go +++ b/backend/internal/service/prompt_template.go @@ -7,6 +7,7 @@ import ( "regexp" "sort" "strings" + "time" "infinite-canvas/backend/internal/model" @@ -89,6 +90,16 @@ func (s *Service) EnsureDefaultPromptTemplates() error { return err } if count > 0 { + if definition.Operation == promptOperationStoryboardVideo { + active, activeErr := s.repo.ActivePromptTemplate(definition.Operation) + if activeErr == nil && active.CreatedBy == "" && strings.HasPrefix(active.Content, legacyStoryboardVideoPromptPreamble) { + active.Content = strings.TrimPrefix(active.Content, legacyStoryboardVideoPromptPreamble) + active.UpdatedAt = time.Now() + if err := s.repo.SavePromptTemplate(active); err != nil { + return err + } + } + } continue } if err := s.repo.SavePromptTemplate(&model.PromptTemplate{ diff --git a/backend/internal/service/prompt_template_defaults.go b/backend/internal/service/prompt_template_defaults.go index 1858f97c7..8684538cc 100644 --- a/backend/internal/service/prompt_template_defaults.go +++ b/backend/internal/service/prompt_template_defaults.go @@ -14,6 +14,8 @@ const ( promptOperationCharacterTurnaround = "character_turnaround" ) +const legacyStoryboardVideoPromptPreamble = "生成单一连续镜头的视频执行提示词。一个镜头只保留一个叙事目标、一个主运镜和一条主要动作链;摄影机运动必须有起点、动机和停止点。优先保证角色身份、表演、关键动作和连续性,次要环境效果可以简化。\n\n" + func defaultPromptDefinitions() []PromptOperationDefinition { return []PromptOperationDefinition{ { @@ -50,9 +52,7 @@ func defaultPromptDefinitions() []PromptOperationDefinition { Operation: promptOperationStoryboardVideo, Label: "分镜视频", Category: "生成", OutputType: "text", Description: "把单镜头结构转换为视频模型使用的紧凑执行提示词。", Variables: []PromptTemplateVariable{{Label: "项目视觉", Placeholder: "{{项目视觉}}"}, {Label: "镜头意图", Placeholder: "{{镜头意图}}"}, {Label: "首帧构图", Placeholder: "{{首帧构图}}"}, {Label: "表演与调度", Placeholder: "{{表演与调度}}"}, {Label: "摄影机", Placeholder: "{{摄影机}}"}, {Label: "时间节拍", Placeholder: "{{时间节拍}}"}, {Label: "运动与结尾", Placeholder: "{{运动与结尾}}"}, {Label: "声音", Placeholder: "{{声音}}"}, {Label: "执行优先级", Placeholder: "{{执行优先级}}"}, {Label: "负面要求", Placeholder: "{{负面要求}}"}}, - DefaultContent: `生成单一连续镜头的视频执行提示词。一个镜头只保留一个叙事目标、一个主运镜和一条主要动作链;摄影机运动必须有起点、动机和停止点。优先保证角色身份、表演、关键动作和连续性,次要环境效果可以简化。 - -【项目视觉】 + DefaultContent: `【项目视觉】 {{项目视觉}} 【镜头意图】 @@ -145,7 +145,7 @@ const storyboardPlanJSONSchema = `{ "description": {"type": "string"}, "durationSeconds": {"type": "integer", "minimum": 1, "maximum": 60}, "dialogue": {"type": "string"}, - "characterIds": {"type": "array", "items": {"type": "string"}}, + "characterIds": {"type": "array", "description": "优先填写当前角色资产 ID;尚未确认资产的角色填写角色名称", "items": {"type": "string"}}, "narrativeIntent": {"type": "string"}, "viewerPOV": {"type": "string"}, "performanceBlocking": {"type": "string"}, @@ -243,7 +243,7 @@ func storyboardExecutionContract(durationRule string, countRule string) string { - ` + countRule + ` - 单镜头最多 2 名主要角色、1 个主运镜、1 条主要动作链、3 个 timeBeats 和 3 个 mustHave;超限必须拆镜或在固定镜头数内重新分配。 - dialogue 只写本镜头实际念出的台词或简短旁白,字数上限按 1 秒最多约 5 个中文字符计算(至少 24 字);超长台词/旁白必须拆镜或精简,不得用 dialogue 承载长段叙述。 -- characterIds 只能引用当前角色版本中的 assetId;没有角色时返回空数组。 +- characterIds 优先填写当前角色版本中的 assetId;角色只有名称、尚未确认资产时填写角色名称,服务端会保留名称引用。不要编造 ID;没有角色时返回空数组。 - assetRefs 只能引用当前画布资产中的 nodeId;不要根据相似名称编造 ID。每镜最多 6 个,priority 越大表示越重要。 - styleGuide 最多 120 个中文字符;visualPrompt 只描述首帧,videoPrompt 只描述运动和结尾状态。 - 画幅比例由视频节点参数控制,提示词不得写入具体比例,也不要讨论画幅配置。 diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index cfdfc51b7..f469f458b 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -4544,6 +4544,9 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in if metadata.RequestKind != "" { requestKind = metadata.RequestKind } + if requestErr == nil && statusCode >= 200 && statusCode < 300 && (requestKind == "create" || requestKind == "poll") && (metadata.Capability == "image" || metadata.Capability == "video") { + metadata.Service.syncProviderTaskProgress(metadata.TaskID, responseBody) + } apiFormat := "openai" if req.Header.Get("x-goog-api-key") != "" { apiFormat = "gemini" diff --git a/backend/internal/service/provider_progress.go b/backend/internal/service/provider_progress.go new file mode 100644 index 000000000..06cf50c21 --- /dev/null +++ b/backend/internal/service/provider_progress.go @@ -0,0 +1,109 @@ +package service + +import ( + "encoding/json" + "log" + "math" + "strconv" + "strings" +) + +var providerProgressKeys = []string{"progress", "progresspercent", "progresspercentage", "percent", "percentage", "completion", "completionpercent"} + +var providerProgressContainers = []string{"data", "result", "task", "output", "operation", "response", "meta", "metadata"} + +// syncProviderTaskProgress is deliberately best-effort: progress telemetry must +// never turn a successful provider response into a failed generation request. +func (s *Service) syncProviderTaskProgress(taskID string, responseBody []byte) { + if s == nil || s.repo == nil || strings.TrimSpace(taskID) == "" { + return + } + progress, ok := providerProgressFromResponse(responseBody) + if !ok { + return + } + if err := s.repo.UpdateTaskProviderProgress(taskID, progress); err != nil { + log.Printf("provider progress sync failed: task_id=%s error=%v", taskID, err) + } +} + +func providerProgressFromResponse(responseBody []byte) (int, bool) { + if len(responseBody) == 0 { + return 0, false + } + var payload any + if err := json.Unmarshal(responseBody, &payload); err != nil { + return 0, false + } + return findProviderProgress(payload, 0) +} + +func findProviderProgress(value any, depth int) (int, bool) { + if depth > 8 { + return 0, false + } + switch current := value.(type) { + case map[string]any: + for _, wantedKey := range providerProgressKeys { + for key, candidate := range current { + if normalizeProviderProgressKey(key) != wantedKey { + continue + } + if progress, ok := normalizeProviderProgressValue(candidate); ok { + return progress, true + } + } + } + for _, wantedContainer := range providerProgressContainers { + for key, nested := range current { + if normalizeProviderProgressKey(key) != wantedContainer { + continue + } + if progress, ok := findProviderProgress(nested, depth+1); ok { + return progress, true + } + } + } + case []any: + for _, nested := range current { + if progress, ok := findProviderProgress(nested, depth+1); ok { + return progress, true + } + } + } + return 0, false +} + +func normalizeProviderProgressValue(value any) (int, bool) { + var number float64 + explicitPercent := false + switch current := value.(type) { + case float64: + number = current + case string: + text := strings.TrimSpace(current) + if text == "" { + return 0, false + } + explicitPercent = strings.HasSuffix(text, "%") + text = strings.TrimSpace(strings.TrimSuffix(text, "%")) + parsed, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0, false + } + number = parsed + default: + return 0, false + } + if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 || number > 100 { + return 0, false + } + if !explicitPercent && number > 0 && number < 1 { + number *= 100 + } + return int(math.Round(number)), true +} + +func normalizeProviderProgressKey(value string) string { + return strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(value))) +} diff --git a/backend/internal/service/provider_progress_test.go b/backend/internal/service/provider_progress_test.go new file mode 100644 index 000000000..ebcc4be91 --- /dev/null +++ b/backend/internal/service/provider_progress_test.go @@ -0,0 +1,42 @@ +package service + +import "testing" + +func TestProviderProgressFromResponse(t *testing.T) { + tests := []struct { + name string + body string + want int + ok bool + }{ + {name: "direct number", body: `{"progress":35}`, want: 35, ok: true}, + {name: "nested percent string", body: `{"data":{"task":{"progress_percent":"68%"}}}`, want: 68, ok: true}, + {name: "fraction", body: `{"result":{"progress":0.42}}`, want: 42, ok: true}, + {name: "fractional explicit percent", body: `{"progress":"0.5%"}`, want: 1, ok: true}, + {name: "camel case", body: `{"operation":{"progressPercentage":87.6}}`, want: 88, ok: true}, + {name: "unrelated completion tokens", body: `{"usage":{"completion_tokens":120}}`, ok: false}, + {name: "invalid range", body: `{"progress":140}`, ok: false}, + {name: "not json", body: `not-json`, ok: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := providerProgressFromResponse([]byte(tt.body)) + if got != tt.want || ok != tt.ok { + t.Fatalf("providerProgressFromResponse() = (%d, %v), want (%d, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestTaskUsesUpstreamReportedProgress(t *testing.T) { + for _, taskType := range []string{"canvas_image", "canvas_video", "video_shot"} { + if !taskUsesUpstreamReportedProgress(taskType) { + t.Fatalf("task type %q should use upstream progress", taskType) + } + } + for _, taskType := range []string{"canvas_text", "canvas_audio", "agent_storyboard"} { + if taskUsesUpstreamReportedProgress(taskType) { + t.Fatalf("task type %q should keep internal progress", taskType) + } + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index eab4457d7..adbeea47b 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -126,6 +126,7 @@ func (s *Service) taskBilling() *taskBillingCoordinator { func (s *Service) StartWorker() { s.taskWorker().start() s.startResourceDeletionWorker() + s.startSkillSyncWorker() } func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) { diff --git a/backend/internal/service/skill_packages.go b/backend/internal/service/skill_packages.go new file mode 100644 index 000000000..5fb59d856 --- /dev/null +++ b/backend/internal/service/skill_packages.go @@ -0,0 +1,1000 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +const ( + maxSkillPackageBytes = 20 << 20 + maxSkillFileBytes = 8 << 20 + maxSkillPackageFiles = 512 + maxSkillPreviewBytes = 512 << 10 + SkillPackageUploadMaxBytes = maxSkillPackageBytes + (1 << 20) +) + +type SkillInstallRequest struct { + Name string + Description string + Tag string + IsPrivate bool +} + +type SkillGitHubInstallRequest struct { + URL string `json:"url"` + Ref string `json:"ref"` + Subdir string `json:"subdir"` + Tag string `json:"tag"` + IsPrivate bool `json:"is_private"` + AutoUpdate bool `json:"auto_update"` +} + +type SkillPackageFileItem struct { + Path string `json:"path"` + Kind string `json:"kind"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type SkillPackageFileContent struct { + File SkillPackageFileItem `json:"file"` + Content string `json:"content"` + Binary bool `json:"binary"` +} + +type SkillPackageBundleFile struct { + Path string `json:"path"` + MimeType string `json:"mime_type"` + ContentBase64 string `json:"content_base64"` +} + +type SkillPackageBundle struct { + SkillID string `json:"skill_id"` + Name string `json:"name"` + Description string `json:"description"` + VersionID string `json:"version_id"` + Version string `json:"version"` + ContentHash string `json:"content_hash"` + Files []SkillPackageBundleFile `json:"files"` +} + +type SkillFileSearchResult struct { + Path string `json:"path"` + Line int `json:"line"` + Snippet string `json:"snippet"` +} + +type skillPackageMetadata struct { + Name string + Description string + Version string +} + +type skillPackageArchive struct { + Files map[string][]byte + Metadata skillPackageMetadata + ContentHash string + TotalBytes int64 +} + +type githubSkillSpec struct { + Owner string + Repo string + Ref string + Subdir string +} + +func (s *Service) EnsureSkillPackages() error { + skills, err := s.repo.SkillsForPackageEnsure(skillSourceUser) + if err != nil { + return err + } + for index := range skills { + skill := &skills[index] + archive, err := archiveFromMarkdown([]byte(skill.Instruction), skill.Name, skill.Description) + if err != nil { + return fmt.Errorf("迁移技能 %s 文件包失败: %w", skill.ID, err) + } + if skill.CurrentVersionID != "" && skill.ContentHash == archive.ContentHash { + continue + } + sourceType := "builtin" + if skill.Source == skillSourceUser { + sourceType = "markdown" + } + if err := s.addSkillArchiveVersion(skill, archive, sourceType, "", "", "", "", false); err != nil { + return fmt.Errorf("保存技能 %s 文件包失败: %w", skill.ID, err) + } + } + return nil +} + +func (s *Service) InstallSkillUpload(userID string, sourceType string, header *multipart.FileHeader, req SkillInstallRequest) (*SkillItem, error) { + sourceType = strings.ToLower(strings.TrimSpace(sourceType)) + if sourceType == "" && header != nil { + switch strings.ToLower(path.Ext(header.Filename)) { + case ".md", ".markdown": + sourceType = "markdown" + case ".zip": + sourceType = "zip" + } + } + if sourceType != "markdown" && sourceType != "zip" { + return nil, BadAuthRequest("技能文件仅支持 Markdown 或 ZIP") + } + if header == nil || header.Size <= 0 || header.Size > maxSkillPackageBytes { + return nil, BadAuthRequest("技能文件大小必须在 1B-20MB 之间") + } + file, err := header.Open() + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxSkillPackageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxSkillPackageBytes { + return nil, BadAuthRequest("技能文件不能超过 20MB") + } + var archive skillPackageArchive + if sourceType == "markdown" { + archive, err = archiveFromMarkdown(data, req.Name, req.Description) + } else { + archive, err = archiveFromZip(data, "") + } + if err != nil { + return nil, err + } + return s.createSkillFromArchive(userID, archive, req, sourceType, "", "", "", "", false) +} + +func (s *Service) InstallGitHubSkill(userID string, req SkillGitHubInstallRequest) (*SkillItem, error) { + spec, err := parseGitHubSkillURL(req.URL, req.Ref, req.Subdir) + if err != nil { + return nil, err + } + archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(context.Background(), spec) + if err != nil { + var appErr *AppError + if errors.As(err, &appErr) { + return nil, err + } + return nil, WrapAppError(http.StatusBadGateway, "GitHub 技能读取失败,请检查仓库地址和网络", err) + } + return s.createSkillFromArchive(userID, archive, SkillInstallRequest{Tag: req.Tag, IsPrivate: req.IsPrivate}, "github", canonicalURL, resolvedRef, spec.Subdir, commit, req.AutoUpdate) +} + +func (s *Service) SyncGitHubSkill(userID string, skillID string) (*SkillItem, error) { + skill, err := s.ownedSkill(userID, skillID) + if err != nil { + return nil, err + } + if skill.SourceType != "github" { + return nil, BadAuthRequest("只有 GitHub 技能可以同步") + } + if err := s.syncGitHubSkill(skill); err != nil { + var appErr *AppError + if errors.As(err, &appErr) { + return nil, err + } + return nil, WrapAppError(http.StatusBadGateway, "GitHub 技能同步失败,请稍后重试", err) + } + return s.SkillDetail(userID, skill.ID) +} + +func (s *Service) startSkillSyncWorker() { + go func() { + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for range ticker.C { + skills, err := s.repo.AutoUpdatingGitHubSkills() + if err != nil { + continue + } + for index := range skills { + _ = s.syncGitHubSkill(&skills[index]) + } + } + }() +} + +func (s *Service) syncGitHubSkill(skill *model.Skill) error { + now := time.Now() + spec, err := parseGitHubSkillURL(skill.SourceURL, skill.SourceRef, skill.SourceSubdir) + if err != nil { + skill.SyncStatus = "failed" + skill.SyncError = err.Error() + skill.LastCheckedAt = &now + _ = s.repo.SaveSkill(skill) + return err + } + archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(context.Background(), spec) + skill.LastCheckedAt = &now + if err != nil { + skill.SyncStatus = "failed" + skill.SyncError = err.Error() + _ = s.repo.SaveSkill(skill) + return err + } + if commit == skill.SourceCommit { + skill.SyncStatus = "synced" + skill.SyncError = "" + return s.repo.SaveSkill(skill) + } + skill.Name = archive.Metadata.Name + skill.Description = archive.Metadata.Description + skill.Instruction = string(archive.Files["SKILL.md"]) + if err := s.addSkillArchiveVersion(skill, archive, "github", canonicalURL, resolvedRef, spec.Subdir, commit, skill.AutoUpdate); err != nil { + skill.SyncStatus = "failed" + skill.SyncError = err.Error() + _ = s.repo.SaveSkill(skill) + return err + } + return nil +} + +func (s *Service) createSingleMarkdownSkill(userID string, req SkillMutationRequest) (*SkillItem, error) { + archive, err := archiveFromMarkdown([]byte(req.Instruction), req.SkillName, req.Description) + if err != nil { + return nil, err + } + return s.createSkillFromArchive(userID, archive, SkillInstallRequest{Name: req.SkillName, Description: req.Description, Tag: req.Tag, IsPrivate: req.IsPrivate}, "markdown", req.MarkdownURL, "", "", "", false) +} + +func (s *Service) updateSingleMarkdownSkill(skill *model.Skill, req SkillMutationRequest) error { + if strings.TrimSpace(req.Instruction) == "" { + return s.repo.SaveSkill(skill) + } + archive, err := archiveFromMarkdown([]byte(req.Instruction), req.SkillName, req.Description) + if err != nil { + return err + } + skill.Instruction = req.Instruction + return s.addSkillArchiveVersion(skill, archive, "markdown", req.MarkdownURL, "", "", "", false) +} + +func (s *Service) createSkillFromArchive(userID string, archive skillPackageArchive, req SkillInstallRequest, sourceType string, sourceURL string, sourceRef string, sourceSubdir string, sourceCommit string, autoUpdate bool) (*SkillItem, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + name = archive.Metadata.Name + } + description := strings.TrimSpace(req.Description) + if description == "" { + description = archive.Metadata.Description + } + if utf8.RuneCountInString(name) == 0 || utf8.RuneCountInString(name) > 80 { + return nil, BadAuthRequest("技能名称必须为 1-80 个字符") + } + if utf8.RuneCountInString(description) == 0 || utf8.RuneCountInString(description) > 500 { + return nil, BadAuthRequest("技能简介必须为 1-500 个字符") + } + tag := strings.TrimSpace(req.Tag) + if _, ok := skillCategoryLabels[tag]; !ok { + tag = "others" + } + skillID := newID() + versionID := newID() + packageKey, version, files, err := s.persistSkillArchive(skillID, versionID, archive, sourceCommit) + if err != nil { + return nil, err + } + now := time.Now() + versionLabel := archive.Metadata.Version + if versionLabel == "" { + versionLabel = "1" + if sourceCommit != "" { + versionLabel = shortCommit(sourceCommit) + } + } + version.VersionLabel = versionLabel + skill := &model.Skill{ + ID: skillID, OwnerID: userID, Name: name, Description: description, Instruction: string(archive.Files["SKILL.md"]), + CurrentVersionID: versionID, VersionLabel: versionLabel, ContentHash: archive.ContentHash, FileCount: len(archive.Files), TotalBytes: archive.TotalBytes, + SourceType: sourceType, SourceURL: sourceURL, SourceRef: sourceRef, SourceSubdir: sourceSubdir, SourceCommit: sourceCommit, + SyncStatus: "synced", AutoUpdate: autoUpdate, LastCheckedAt: &now, LastSyncedAt: &now, + Status: skillStatusEnabled, Source: skillSourceUser, Tag: tag, IsPrivate: req.IsPrivate, MarkdownURL: sourceURL, ShowcaseMediaJSON: "[]", + } + state := &model.UserSkillState{ID: newID(), UserID: userID, SkillID: skillID, Added: true, InstalledVersionID: versionID, AutoUpdate: autoUpdate} + if err := s.repo.CreateSkillWithPackage(skill, version, files, state); err != nil { + _ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey))) + return nil, err + } + return s.SkillDetail(userID, skillID) +} + +func (s *Service) addSkillArchiveVersion(skill *model.Skill, archive skillPackageArchive, sourceType string, sourceURL string, sourceRef string, sourceSubdir string, sourceCommit string, autoUpdate bool) error { + versionID := newID() + packageKey, version, files, err := s.persistSkillArchive(skill.ID, versionID, archive, sourceCommit) + if err != nil { + return err + } + versionLabel := archive.Metadata.Version + if versionLabel == "" { + versionLabel = "1" + if sourceCommit != "" { + versionLabel = shortCommit(sourceCommit) + } else if skill.CurrentVersionID != "" { + versionLabel = time.Now().UTC().Format("20060102-150405") + } + } + version.VersionLabel = versionLabel + now := time.Now() + skill.CurrentVersionID = versionID + skill.VersionLabel = versionLabel + skill.ContentHash = archive.ContentHash + skill.FileCount = len(archive.Files) + skill.TotalBytes = archive.TotalBytes + skill.SourceType = sourceType + skill.SourceURL = sourceURL + skill.SourceRef = sourceRef + skill.SourceSubdir = sourceSubdir + skill.SourceCommit = sourceCommit + skill.SyncStatus = "synced" + skill.SyncError = "" + skill.AutoUpdate = autoUpdate + skill.LastCheckedAt = &now + skill.LastSyncedAt = &now + if err := s.repo.AddSkillVersion(skill, version, files); err != nil { + _ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey))) + return err + } + return nil +} + +func (s *Service) persistSkillArchive(skillID string, versionID string, archive skillPackageArchive, sourceCommit string) (string, *model.SkillVersion, []model.SkillFile, error) { + packageKey := path.Join(skillID, versionID+".zip") + absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o750); err != nil { + return "", nil, nil, err + } + data, err := encodeSkillArchive(archive.Files) + if err != nil { + return "", nil, nil, err + } + temporary, err := os.CreateTemp(filepath.Dir(absolute), ".skill-*.zip") + if err != nil { + return "", nil, nil, err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return "", nil, nil, err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return "", nil, nil, err + } + if err := temporary.Close(); err != nil { + return "", nil, nil, err + } + if err := os.Rename(temporaryPath, absolute); err != nil { + return "", nil, nil, err + } + version := &model.SkillVersion{ID: versionID, SkillID: skillID, ContentHash: archive.ContentHash, EntryPath: "SKILL.md", PackageKey: packageKey, FileCount: len(archive.Files), TotalBytes: archive.TotalBytes, SourceCommit: sourceCommit} + paths := sortedSkillPaths(archive.Files) + files := make([]model.SkillFile, 0, len(paths)) + for _, filePath := range paths { + content := archive.Files[filePath] + digest := sha256.Sum256(content) + files = append(files, model.SkillFile{ID: newID(), SkillVersionID: versionID, Path: filePath, Kind: skillFileKind(filePath), MimeType: skillFileMime(filePath, content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}) + } + return packageKey, version, files, nil +} + +func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, err + } + files, err := s.repo.SkillFiles(skill.CurrentVersionID) + if err != nil { + return nil, err + } + items := make([]SkillPackageFileItem, 0, len(files)) + for _, file := range files { + items = append(items, skillFileItem(file)) + } + return items, nil +} + +func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) { + skill, version, file, content, err := s.readSkillPackageFile(userID, skillID, filePath) + _ = skill + _ = version + if err != nil { + return nil, err + } + binary := !isPreviewText(file.MimeType, file.Path) + if binary { + return &SkillPackageFileContent{File: skillFileItem(*file), Binary: true}, nil + } + if len(content) > maxSkillPreviewBytes { + return nil, BadAuthRequest("文件超过 512KB,请下载后查看") + } + return &SkillPackageFileContent{File: skillFileItem(*file), Content: string(content)}, nil +} + +func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) { + _, _, file, content, err := s.readSkillPackageFile(userID, skillID, filePath) + if err != nil { + return nil, "", "", err + } + return content, file.MimeType, path.Base(file.Path), nil +} + +func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, err + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if err != nil { + return nil, err + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, err + } + bundle := &SkillPackageBundle{SkillID: skill.ID, Name: skill.Name, Description: skill.Description, VersionID: version.ID, Version: version.VersionLabel, ContentHash: version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(files))} + for _, file := range files { + content, err := s.readSkillArchiveEntry(version, file.Path) + if err != nil { + return nil, err + } + bundle.Files = append(bundle.Files, SkillPackageBundleFile{Path: file.Path, MimeType: file.MimeType, ContentBase64: base64.StdEncoding.EncodeToString(content)}) + } + return bundle, nil +} + +func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, err + } + query = strings.TrimSpace(query) + if query == "" || utf8.RuneCountInString(query) > 120 { + return nil, BadAuthRequest("搜索关键词必须为 1-120 个字符") + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if err != nil { + return nil, err + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, err + } + needle := strings.ToLower(query) + results := make([]SkillFileSearchResult, 0, 20) + for _, file := range files { + if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes { + continue + } + content, err := s.readSkillArchiveEntry(version, file.Path) + if err != nil { + return nil, err + } + for lineIndex, line := range strings.Split(string(content), "\n") { + if strings.Contains(strings.ToLower(line), needle) { + results = append(results, SkillFileSearchResult{Path: file.Path, Line: lineIndex + 1, Snippet: truncateRunes(strings.TrimSpace(line), 240)}) + if len(results) >= 50 { + break + } + } + } + } + return results, nil +} + +func (s *Service) readSkillPackageFile(userID string, skillID string, filePath string) (*model.Skill, *model.SkillVersion, *model.SkillFile, []byte, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, nil, nil, nil, err + } + filePath, err = normalizeSkillPath(filePath) + if err != nil { + return nil, nil, nil, nil, err + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if err != nil { + return nil, nil, nil, nil, err + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, nil, nil, nil, err + } + for index := range files { + if files[index].Path != filePath { + continue + } + content, err := s.readSkillArchiveEntry(version, filePath) + return skill, version, &files[index], content, err + } + return nil, nil, nil, nil, BadAuthRequest("技能文件不存在") +} + +func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) { + absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(version.PackageKey)) + reader, err := zip.OpenReader(absolute) + if err != nil { + return nil, err + } + defer reader.Close() + for _, entry := range reader.File { + if entry.Name != filePath { + continue + } + file, err := entry.Open() + if err != nil { + return nil, err + } + defer file.Close() + return io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + } + return nil, BadAuthRequest("技能文件不存在") +} + +func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription string) (skillPackageArchive, error) { + if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) { + return skillPackageArchive{}, BadAuthRequest("Markdown 文件为空、过大或不是 UTF-8") + } + metadata := parseSkillPackageMetadata(data) + if metadata.Name == "" { + metadata.Name = strings.TrimSpace(fallbackName) + } + if metadata.Description == "" { + metadata.Description = strings.TrimSpace(fallbackDescription) + } + files := map[string][]byte{"SKILL.md": data} + return finalizeSkillArchive(files, metadata) +} + +func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return skillPackageArchive{}, BadAuthRequest("ZIP 文件无法解析") + } + if len(reader.File) > maxSkillPackageFiles+64 { + return skillPackageArchive{}, BadAuthRequest("技能包文件数量不能超过 512 个") + } + raw := make(map[string][]byte) + var total int64 + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + if entry.Mode()&os.ModeSymlink != 0 || !entry.Mode().IsRegular() { + return skillPackageArchive{}, BadAuthRequest("技能包不能包含软链接或特殊文件") + } + entryPath, err := normalizeSkillPath(entry.Name) + if err != nil { + return skillPackageArchive{}, err + } + if strings.HasPrefix(entryPath, "__MACOSX/") || path.Base(entryPath) == ".DS_Store" { + continue + } + if _, exists := raw[entryPath]; exists { + return skillPackageArchive{}, BadAuthRequest("技能包包含重复文件路径") + } + if entry.UncompressedSize64 > maxSkillFileBytes { + return skillPackageArchive{}, BadAuthRequest("技能包中单个文件不能超过 8MB") + } + file, err := entry.Open() + if err != nil { + return skillPackageArchive{}, err + } + content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + closeErr := file.Close() + if readErr != nil { + return skillPackageArchive{}, readErr + } + if closeErr != nil { + return skillPackageArchive{}, closeErr + } + if len(content) > maxSkillFileBytes { + return skillPackageArchive{}, BadAuthRequest("技能包中单个文件不能超过 8MB") + } + total += int64(len(content)) + if total > maxSkillPackageBytes { + return skillPackageArchive{}, BadAuthRequest("技能包解压后不能超过 20MB") + } + raw[entryPath] = content + } + files, err := normalizeSkillArchiveRoot(raw, subdir) + if err != nil { + return skillPackageArchive{}, err + } + metadata := parseSkillPackageMetadata(files["SKILL.md"]) + return finalizeSkillArchive(files, metadata) +} + +func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string][]byte, error) { + if len(raw) == 0 { + return nil, BadAuthRequest("技能包为空") + } + paths := sortedSkillPaths(raw) + firstParts := strings.SplitN(paths[0], "/", 2) + if len(firstParts) == 2 { + wrapper := firstParts[0] + "/" + allWrapped := true + for _, filePath := range paths { + if !strings.HasPrefix(filePath, wrapper) { + allWrapped = false + break + } + } + if allWrapped { + next := make(map[string][]byte, len(raw)) + for filePath, content := range raw { + next[strings.TrimPrefix(filePath, wrapper)] = content + } + raw = next + } + } + subdir = strings.Trim(strings.ReplaceAll(strings.TrimSpace(subdir), "\\", "/"), "/") + if subdir != "" { + normalized, err := normalizeSkillPath(subdir) + if err != nil { + return nil, err + } + prefix := normalized + "/" + filtered := make(map[string][]byte) + for filePath, content := range raw { + if strings.HasPrefix(filePath, prefix) { + filtered[strings.TrimPrefix(filePath, prefix)] = content + } + } + if len(filtered) == 0 { + return nil, BadAuthRequest("GitHub 子目录不存在或为空") + } + raw = filtered + } + skillRoots := make([]string, 0, 2) + for filePath := range raw { + if filePath == "SKILL.md" { + skillRoots = []string{""} + break + } + if path.Base(filePath) == "SKILL.md" { + skillRoots = append(skillRoots, path.Dir(filePath)) + } + } + if len(skillRoots) == 0 { + return nil, BadAuthRequest("技能包中缺少 SKILL.md") + } + if len(skillRoots) > 1 { + return nil, BadAuthRequest("技能包包含多个 SKILL.md,请指定单个技能目录") + } + root := skillRoots[0] + files := make(map[string][]byte) + for filePath, content := range raw { + if root != "" { + prefix := root + "/" + if !strings.HasPrefix(filePath, prefix) { + continue + } + filePath = strings.TrimPrefix(filePath, prefix) + } + files[filePath] = content + } + if len(files) > maxSkillPackageFiles { + return nil, BadAuthRequest("技能包文件数量不能超过 512 个") + } + return files, nil +} + +func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) { + if _, ok := files["SKILL.md"]; !ok { + return skillPackageArchive{}, BadAuthRequest("技能包入口必须是 SKILL.md") + } + if metadata.Name == "" { + return skillPackageArchive{}, BadAuthRequest("SKILL.md 缺少 name,且无法从标题推断") + } + if metadata.Description == "" { + return skillPackageArchive{}, BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断") + } + if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 { + return skillPackageArchive{}, BadAuthRequest("技能名称或简介超过长度限制") + } + hash := sha256.New() + var total int64 + for _, filePath := range sortedSkillPaths(files) { + content := files[filePath] + total += int64(len(content)) + hash.Write([]byte(filePath)) + hash.Write([]byte{0}) + hash.Write(content) + hash.Write([]byte{0}) + } + return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hex.EncodeToString(hash.Sum(nil)), TotalBytes: total}, nil +} + +func encodeSkillArchive(files map[string][]byte) ([]byte, error) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for _, filePath := range sortedSkillPaths(files) { + header := &zip.FileHeader{Name: filePath, Method: zip.Deflate} + header.SetMode(0o600) + header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)) + entry, err := writer.CreateHeader(header) + if err != nil { + return nil, err + } + if _, err := entry.Write(files[filePath]); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func parseSkillPackageMetadata(data []byte) skillPackageMetadata { + text := string(data) + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + metadata := skillPackageMetadata{} + bodyStart := 0 + if len(lines) > 2 && strings.TrimSpace(lines[0]) == "---" { + metadataIndent := -1 + for index := 1; index < len(lines); index++ { + line := lines[index] + if strings.TrimSpace(line) == "---" { + bodyStart = index + 1 + break + } + trimmed := strings.TrimSpace(line) + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if indent == 0 { + metadataIndent = -1 + } + key, value, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = yamlScalar(value) + switch { + case indent == 0 && key == "name": + metadata.Name = value + case indent == 0 && key == "description": + metadata.Description = value + case indent == 0 && key == "metadata": + metadataIndent = indent + case metadataIndent >= 0 && indent > metadataIndent && key == "version": + metadata.Version = value + } + } + } + body := lines[bodyStart:] + if metadata.Name == "" { + for _, line := range body { + if strings.HasPrefix(strings.TrimSpace(line), "# ") { + metadata.Name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "# ")) + break + } + } + } + if metadata.Description == "" { + paragraph := make([]string, 0, 4) + for _, line := range body { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + if len(paragraph) > 0 { + break + } + continue + } + if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "```") { + continue + } + paragraph = append(paragraph, trimmed) + } + metadata.Description = truncateRunes(strings.Join(paragraph, " "), 500) + } + metadata.Name = truncateRunes(strings.TrimSpace(metadata.Name), 80) + metadata.Description = truncateRunes(strings.TrimSpace(metadata.Description), 500) + metadata.Version = truncateRunes(strings.TrimSpace(metadata.Version), 64) + return metadata +} + +func yamlScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' { + if decoded, err := strconv.Unquote(value); err == nil { + return strings.TrimSpace(decoded) + } + } + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.TrimSpace(value[1 : len(value)-1]) + } + return value +} + +func parseGitHubSkillURL(rawURL string, requestedRef string, requestedSubdir string) (githubSkillSpec, error) { + target, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || target.Scheme != "https" || !strings.EqualFold(target.Hostname(), "github.com") || target.User != nil { + return githubSkillSpec{}, BadAuthRequest("请输入公开的 https://github.com 仓库地址") + } + parts := strings.Split(strings.Trim(target.Path, "/"), "/") + if len(parts) < 2 { + return githubSkillSpec{}, BadAuthRequest("GitHub 地址必须包含 owner/repository") + } + spec := githubSkillSpec{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), Ref: strings.TrimSpace(requestedRef), Subdir: strings.Trim(strings.ReplaceAll(strings.TrimSpace(requestedSubdir), "\\", "/"), "/")} + if len(parts) >= 4 && parts[2] == "tree" { + if spec.Ref == "" { + spec.Ref = parts[3] + } + if spec.Subdir == "" && len(parts) > 4 { + spec.Subdir = strings.Join(parts[4:], "/") + } + } + if len(parts) > 2 && parts[2] != "tree" { + return githubSkillSpec{}, BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录") + } + if spec.Owner == "" || spec.Repo == "" { + return githubSkillSpec{}, BadAuthRequest("GitHub 仓库地址无效") + } + return spec, nil +} + +func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPackageArchive, string, string, string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resolvedRef := spec.Ref + if resolvedRef == "" { + var repoInfo struct { + DefaultBranch string `json:"default_branch"` + } + if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo)), &repoInfo); err != nil { + return skillPackageArchive{}, "", "", "", err + } + resolvedRef = repoInfo.DefaultBranch + } + var commitInfo struct { + SHA string `json:"sha"` + } + if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(resolvedRef)), &commitInfo); err != nil { + return skillPackageArchive{}, "", "", "", err + } + if commitInfo.SHA == "" { + return skillPackageArchive{}, "", "", "", errors.New("GitHub 未返回提交版本") + } + downloadURL := fmt.Sprintf("https://codeload.github.com/%s/%s/zip/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(commitInfo.SHA)) + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + request.Header.Set("User-Agent", DefaultOutboundUserAgent) + response, err := client.Do(request) + if err != nil { + return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: HTTP %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxSkillPackageBytes+1)) + if err != nil { + return skillPackageArchive{}, "", "", "", err + } + if len(data) > maxSkillPackageBytes { + return skillPackageArchive{}, "", "", "", BadAuthRequest("GitHub 技能包超过 20MB") + } + archive, err := archiveFromZip(data, spec.Subdir) + if err != nil { + return skillPackageArchive{}, "", "", "", err + } + canonicalURL := fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo) + return archive, commitInfo.SHA, canonicalURL, resolvedRef, nil +} + +func githubJSON(ctx context.Context, client *http.Client, target string, output any) error { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", DefaultOutboundUserAgent) + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("读取 GitHub 信息失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("读取 GitHub 信息失败: HTTP %d", response.StatusCode) + } + return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output) +} + +func normalizeSkillPath(value string) (string, error) { + value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if value == "" || strings.ContainsRune(value, 0) || strings.HasPrefix(value, "/") { + return "", BadAuthRequest("技能文件路径无效") + } + clean := path.Clean(value) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.HasPrefix(clean, ".git/") || clean == ".git" { + return "", BadAuthRequest("技能文件路径越界或包含禁止目录") + } + if utf8.RuneCountInString(clean) > 1000 { + return "", BadAuthRequest("技能文件路径过长") + } + return clean, nil +} + +func skillFileMime(filePath string, content []byte) string { + if filePath == "SKILL.md" || strings.HasSuffix(strings.ToLower(filePath), ".md") { + return "text/markdown; charset=utf-8" + } + if detected := mime.TypeByExtension(strings.ToLower(path.Ext(filePath))); detected != "" { + return detected + } + return http.DetectContentType(content) +} + +func skillFileKind(filePath string) string { + extension := strings.ToLower(path.Ext(filePath)) + switch extension { + case ".md", ".mdx": + return "markdown" + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": + return "image" + case ".mp4", ".webm", ".mov": + return "video" + case ".mp3", ".wav", ".m4a", ".ogg": + return "audio" + case ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".sh", ".json", ".yaml", ".yml", ".toml", ".html", ".css": + return "code" + case ".txt", ".csv": + return "text" + default: + return "binary" + } +} + +func isPreviewText(mimeType string, filePath string) bool { + return strings.HasPrefix(mimeType, "text/") || strings.Contains(mimeType, "json") || strings.Contains(mimeType, "yaml") || strings.Contains(mimeType, "toml") || skillFileKind(filePath) == "code" +} + +func skillFileItem(file model.SkillFile) SkillPackageFileItem { + return SkillPackageFileItem{Path: file.Path, Kind: file.Kind, MimeType: file.MimeType, Size: file.Size, SHA256: file.SHA256} +} + +func sortedSkillPaths(files map[string][]byte) []string { + paths := make([]string, 0, len(files)) + for filePath := range files { + paths = append(paths, filePath) + } + sort.Strings(paths) + return paths +} + +func shortCommit(value string) string { + if len(value) <= 12 { + return value + } + return value[:12] +} + +func isSkillPackageNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, os.ErrNotExist) +} diff --git a/backend/internal/service/skill_packages_test.go b/backend/internal/service/skill_packages_test.go new file mode 100644 index 000000000..39657bc2c --- /dev/null +++ b/backend/internal/service/skill_packages_test.go @@ -0,0 +1,158 @@ +package service + +import ( + "archive/zip" + "bytes" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestArchiveFromMarkdownInfersMetadata(t *testing.T) { + archive, err := archiveFromMarkdown([]byte("# 小说转分镜\n\n把小说段落拆成可拍摄的镜头。\n"), "", "") + if err != nil { + t.Fatal(err) + } + if archive.Metadata.Name != "小说转分镜" || archive.Metadata.Description != "把小说段落拆成可拍摄的镜头。" { + t.Fatalf("metadata = %#v", archive.Metadata) + } + if string(archive.Files["SKILL.md"]) == "" || archive.ContentHash == "" { + t.Fatal("archive did not preserve the entry file or compute a hash") + } +} + +func TestArchiveFromZipNormalizesWrapperAndNestedFiles(t *testing.T) { + data := skillZip(t, map[string]string{ + "director-main/SKILL.md": "---\nname: AI 导演\ndescription: 导演工作流\nmetadata:\n version: 2.1\n---\n", + "director-main/references/camera.md": "# Camera", + "director-main/scripts/check.js": "export default true", + }) + archive, err := archiveFromZip(data, "") + if err != nil { + t.Fatal(err) + } + if archive.Metadata.Version != "2.1" || len(archive.Files) != 3 { + t.Fatalf("archive = %#v", archive) + } + if string(archive.Files["references/camera.md"]) != "# Camera" { + t.Fatalf("nested file missing: %#v", archive.Files) + } +} + +func TestArchiveFromZipRejectsTraversalAndMultipleSkills(t *testing.T) { + for name, files := range map[string]map[string]string{ + "traversal": {"../SKILL.md": "# Bad"}, + "multiple": { + "one/SKILL.md": "# One\n\nFirst", + "two/SKILL.md": "# Two\n\nSecond", + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := archiveFromZip(skillZip(t, files), ""); err == nil { + t.Fatal("expected package validation error") + } + }) + } +} + +func TestParseGitHubSkillURL(t *testing.T) { + spec, err := parseGitHubSkillURL("https://github.com/ddcat-ai/open-ai-canvas/tree/main/plugins/yingce", "", "") + if err != nil { + t.Fatal(err) + } + if spec.Owner != "ddcat-ai" || spec.Repo != "open-ai-canvas" || spec.Ref != "main" || spec.Subdir != "plugins/yingce" { + t.Fatalf("spec = %#v", spec) + } + if _, err := parseGitHubSkillURL("https://github.com/ddcat-ai/open-ai-canvas/blob/main/SKILL.md", "", ""); err == nil { + t.Fatal("expected blob URL to be rejected") + } +} + +func TestEnsureSkillPackagesMigratesAndRefreshesBuiltinSkills(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Skill{}, &model.SkillVersion{}, &model.SkillFile{}); err != nil { + t.Fatal(err) + } + svc := New(repository.New(db), t.TempDir()) + builtin := model.Skill{ID: newID(), Name: "内置导演", Description: "内置工作流", Instruction: "# 内置导演\n\n第一版", Status: skillStatusEnabled, Source: 3} + userSkill := model.Skill{ID: newID(), Name: "用户技能", Description: "用户工作流", Instruction: "# 用户技能\n\n第一版", Status: skillStatusEnabled, Source: skillSourceUser} + if err := db.Create(&builtin).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&userSkill).Error; err != nil { + t.Fatal(err) + } + + if err := svc.EnsureSkillPackages(); err != nil { + t.Fatal(err) + } + assertSkillVersionCount(t, db, builtin.ID, 1) + assertSkillVersionCount(t, db, userSkill.ID, 1) + + if err := svc.EnsureSkillPackages(); err != nil { + t.Fatal(err) + } + assertSkillVersionCount(t, db, builtin.ID, 1) + assertSkillVersionCount(t, db, userSkill.ID, 1) + + if err := db.Model(&model.Skill{}).Where("id = ?", builtin.ID).Update("instruction", "# 内置导演\n\n第二版").Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Skill{}).Where("id = ?", userSkill.ID).Update("instruction", "# 用户技能\n\n不应在启动时重建").Error; err != nil { + t.Fatal(err) + } + if err := svc.EnsureSkillPackages(); err != nil { + t.Fatal(err) + } + assertSkillVersionCount(t, db, builtin.ID, 2) + assertSkillVersionCount(t, db, userSkill.ID, 1) + + var refreshed model.Skill + if err := db.First(&refreshed, "id = ?", builtin.ID).Error; err != nil { + t.Fatal(err) + } + wantArchive, err := archiveFromMarkdown([]byte(refreshed.Instruction), refreshed.Name, refreshed.Description) + if err != nil { + t.Fatal(err) + } + if refreshed.ContentHash != wantArchive.ContentHash || refreshed.CurrentVersionID == "" { + t.Fatalf("builtin package was not refreshed: %#v", refreshed) + } +} + +func assertSkillVersionCount(t *testing.T, db *gorm.DB, skillID string, want int64) { + t.Helper() + var got int64 + if err := db.Model(&model.SkillVersion{}).Where("skill_id = ?", skillID).Count(&got).Error; err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("skill %s version count = %d, want %d", skillID, got, want) + } +} + +func skillZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for filePath, content := range files { + entry, err := writer.Create(filePath) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/backend/internal/service/skills.go b/backend/internal/service/skills.go index 896635238..d7e291824 100644 --- a/backend/internal/service/skills.go +++ b/backend/internal/service/skills.go @@ -4,7 +4,10 @@ import ( "encoding/json" "errors" "net/url" + "os" + "path/filepath" "strings" + "time" "unicode/utf8" "infinite-canvas/backend/internal/model" @@ -43,6 +46,21 @@ type SkillItem struct { SkillName string `json:"skill_name"` Description string `json:"description"` Instruction string `json:"instruction,omitempty"` + VersionID string `json:"version_id"` + Version string `json:"version"` + ContentHash string `json:"content_hash"` + FileCount int `json:"file_count"` + TotalBytes int64 `json:"total_bytes"` + SourceType string `json:"source_type"` + SourceURL string `json:"source_url"` + SourceRef string `json:"source_ref"` + SourceSubdir string `json:"source_subdir"` + SourceCommit string `json:"source_commit"` + SyncStatus string `json:"sync_status"` + SyncError string `json:"sync_error,omitempty"` + AutoUpdate bool `json:"auto_update"` + LastCheckedAt int64 `json:"last_checked_at"` + LastSyncedAt int64 `json:"last_synced_at"` Status int `json:"status"` MarkdownURL string `json:"markdown_url"` CreateTime int64 `json:"create_time"` @@ -137,7 +155,7 @@ func (s *Service) AddedSkills(userID string) ([]SkillItem, error) { if err != nil { return nil, err } - return s.skillItems(userID, rows, true) + return s.skillItems(userID, rows, false) } func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) { @@ -153,26 +171,21 @@ func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) { } func (s *Service) CreateSkill(userID string, req SkillMutationRequest) (*SkillItem, error) { - normalized, mediaJSON, err := normalizeSkillMutationRequest(req) + normalized, mediaJSON, err := normalizeSkillMutationRequest(req, true) if err != nil { return nil, err } - skill := &model.Skill{ - ID: newID(), - OwnerID: userID, - Name: normalized.SkillName, - Description: normalized.Description, - Instruction: normalized.Instruction, - Status: skillStatusEnabled, - Source: skillSourceUser, - Tag: normalized.Tag, - IsPrivate: normalized.IsPrivate, - MarkdownURL: normalized.MarkdownURL, - ShowcaseMediaJSON: mediaJSON, - ExtraInfo: normalized.ExtraInfo, + created, err := s.createSingleMarkdownSkill(userID, normalized) + if err != nil { + return nil, err } - state := &model.UserSkillState{ID: newID(), UserID: userID, SkillID: skill.ID, Added: true} - if err := s.repo.CreateSkill(skill, state); err != nil { + skill, err := s.ownedSkill(userID, created.SkillID) + if err != nil { + return nil, err + } + skill.ShowcaseMediaJSON = mediaJSON + skill.ExtraInfo = normalized.ExtraInfo + if err := s.repo.SaveSkill(skill); err != nil { return nil, err } return s.SkillDetail(userID, skill.ID) @@ -183,19 +196,23 @@ func (s *Service) UpdateSkill(userID string, id string, req SkillMutationRequest if err != nil { return nil, err } - normalized, mediaJSON, err := normalizeSkillMutationRequest(req) + requireInstruction := skill.SourceType == "markdown" || skill.SourceType == "builtin" || skill.SourceType == "" + normalized, mediaJSON, err := normalizeSkillMutationRequest(req, requireInstruction) if err != nil { return nil, err } skill.Name = normalized.SkillName skill.Description = normalized.Description - skill.Instruction = normalized.Instruction skill.Tag = normalized.Tag skill.IsPrivate = normalized.IsPrivate skill.MarkdownURL = normalized.MarkdownURL skill.ShowcaseMediaJSON = mediaJSON skill.ExtraInfo = normalized.ExtraInfo - if err := s.repo.Save(skill); err != nil { + if skill.SourceType == "markdown" || skill.SourceType == "builtin" || skill.SourceType == "" { + if err := s.updateSingleMarkdownSkill(skill, normalized); err != nil { + return nil, err + } + } else if err := s.repo.SaveSkill(skill); err != nil { return nil, err } return s.SkillDetail(userID, skill.ID) @@ -206,7 +223,10 @@ func (s *Service) DeleteSkill(userID string, id string) error { if err != nil { return err } - return s.repo.DeleteSkill(skill.ID) + if err := s.repo.DeleteSkill(skill.ID); err != nil { + return err + } + return os.RemoveAll(filepath.Join(s.dataDir, "skill-packages", skill.ID)) } func (s *Service) SetSkillAdded(userID string, id string, added bool) (*SkillItem, error) { @@ -225,6 +245,8 @@ func (s *Service) SetSkillAdded(userID string, id string, added bool) (*SkillIte return nil, err } state.Added = added + state.InstalledVersionID = skill.CurrentVersionID + state.AutoUpdate = skill.AutoUpdate if err := s.repo.SetUserSkillAdded(state); err != nil { return nil, err } @@ -300,6 +322,9 @@ func (s *Service) skillItems(userID string, skills []model.Skill, includeInstruc } items = append(items, SkillItem{ SkillID: skill.ID, SkillName: skill.Name, Description: skill.Description, Instruction: instruction, + VersionID: skill.CurrentVersionID, Version: skill.VersionLabel, ContentHash: skill.ContentHash, FileCount: skill.FileCount, TotalBytes: skill.TotalBytes, + SourceType: skill.SourceType, SourceURL: skill.SourceURL, SourceRef: skill.SourceRef, SourceSubdir: skill.SourceSubdir, SourceCommit: skill.SourceCommit, + SyncStatus: skill.SyncStatus, SyncError: skill.SyncError, AutoUpdate: skill.AutoUpdate, LastCheckedAt: unixMillis(skill.LastCheckedAt), LastSyncedAt: unixMillis(skill.LastSyncedAt), Status: skill.Status, MarkdownURL: skill.MarkdownURL, CreateTime: skill.CreatedAt.UnixMilli(), UpdateTime: skill.UpdatedAt.UnixMilli(), Source: skill.Source, Tag: skill.Tag, SortWeight: skill.SortWeight, IsPrivate: skill.IsPrivate, LikeCount: metric.LikeCount, IsLike: state.Liked, OwnerUID: skill.OwnerID, @@ -381,7 +406,7 @@ func normalizeSkillListRequest(req SkillListRequest) SkillListRequest { return req } -func normalizeSkillMutationRequest(req SkillMutationRequest) (SkillMutationRequest, string, error) { +func normalizeSkillMutationRequest(req SkillMutationRequest, requireInstruction bool) (SkillMutationRequest, string, error) { req.SkillName = strings.TrimSpace(req.SkillName) req.Description = strings.TrimSpace(req.Description) req.Instruction = strings.TrimSpace(req.Instruction) @@ -394,7 +419,7 @@ func normalizeSkillMutationRequest(req SkillMutationRequest) (SkillMutationReque if req.Description == "" || utf8.RuneCountInString(req.Description) > 500 { return req, "", BadAuthRequest("技能简介必须为 1-500 个字符") } - if req.Instruction == "" || utf8.RuneCountInString(req.Instruction) > 100000 { + if (requireInstruction && req.Instruction == "") || utf8.RuneCountInString(req.Instruction) > 100000 { return req, "", BadAuthRequest("技能指令必须为 1-100000 个字符") } if _, ok := skillCategoryLabels[req.Tag]; !ok { @@ -445,3 +470,10 @@ func skillCategories() []SkillCategory { {Value: "others", Label: skillCategoryLabels["others"]}, } } + +func unixMillis(value *time.Time) int64 { + if value == nil { + return 0 + } + return value.UnixMilli() +} diff --git a/backend/internal/service/skills_seed.go b/backend/internal/service/skills_seed.go index 1853991cc..8ca61c694 100644 --- a/backend/internal/service/skills_seed.go +++ b/backend/internal/service/skills_seed.go @@ -71,7 +71,7 @@ func (s *Service) EnsureBuiltinSkills() error { SkillName: definition.SkillName, Description: definition.Description, Instruction: definition.Instruction, Tag: definition.Tag, IsPrivate: definition.IsPrivate, MarkdownURL: definition.MarkdownURL, ShowcaseMedia: definition.ShowcaseMedia, ExtraInfo: definition.ExtraInfo, - }) + }, true) if err != nil { return fmt.Errorf("内置技能 %s 数据无效: %w", id, err) } diff --git a/backend/internal/service/storyboard.go b/backend/internal/service/storyboard.go index 88445a71f..17bc87701 100644 --- a/backend/internal/service/storyboard.go +++ b/backend/internal/service/storyboard.go @@ -65,28 +65,30 @@ type agentStoryboardPlan struct { } type agentStoryboardShot struct { - Title string `json:"title"` - Description string `json:"description"` - Duration int `json:"durationSeconds"` - Dialogue string `json:"dialogue"` - ShotSize string `json:"shotSize"` - Emotion string `json:"emotion"` - Lighting string `json:"lightingAndAtmosphere"` - AudioEffects string `json:"audioEffects"` - VisualPrompt string `json:"visualPrompt"` - VideoPrompt string `json:"videoPrompt"` - Camera string `json:"camera"` - Motion string `json:"motion"` - TimeBeats string `json:"timeBeats"` - Negative string `json:"negativePrompt"` - AssetRefs []storyboardAssetRef `json:"assetRefs"` - CharacterIDs []string `json:"characterIds"` - Intent string `json:"narrativeIntent"` - ViewerPOV string `json:"viewerPOV"` - Performance string `json:"performanceBlocking"` - MustHave []string `json:"mustHave"` - Optional []string `json:"optionalDetails"` - ContinuityOut string `json:"continuityOut"` + Title string `json:"title"` + Description string `json:"description"` + Duration int `json:"durationSeconds"` + Dialogue string `json:"dialogue"` + ShotSize string `json:"shotSize"` + Emotion string `json:"emotion"` + Lighting string `json:"lightingAndAtmosphere"` + AudioEffects string `json:"audioEffects"` + VisualPrompt string `json:"visualPrompt"` + VideoPrompt string `json:"videoPrompt"` + Camera string `json:"camera"` + Motion string `json:"motion"` + TimeBeats string `json:"timeBeats"` + Negative string `json:"negativePrompt"` + AssetRefs []storyboardAssetRef `json:"assetRefs"` + CharacterIDs []string `json:"characterIds"` + CharacterNames []string `json:"-"` + CharacterLabels []string `json:"-"` + Intent string `json:"narrativeIntent"` + ViewerPOV string `json:"viewerPOV"` + Performance string `json:"performanceBlocking"` + MustHave []string `json:"mustHave"` + Optional []string `json:"optionalDetails"` + ContinuityOut string `json:"continuityOut"` } func parseAgentStoryboardPlan(raw string) (agentStoryboardPlan, error) { @@ -184,8 +186,13 @@ func validateStoryboardContext(projectStyle storyboardProjectStyle, characters [ } } for _, character := range characters { - if strings.TrimSpace(character.AssetID) == "" || strings.TrimSpace(character.VersionID) == "" || strings.TrimSpace(character.Name) == "" { - return errors.New("角色卡缺少当前资产版本,请刷新角色资产后再生成分镜") + if strings.TrimSpace(character.Name) == "" { + return errors.New("角色卡缺少角色名称,请刷新角色信息后再生成分镜") + } + hasAssetID := strings.TrimSpace(character.AssetID) != "" + hasVersionID := strings.TrimSpace(character.VersionID) != "" + if hasAssetID != hasVersionID { + return fmt.Errorf("角色 %s 的资产 ID 与版本 ID 必须同时提供", strings.TrimSpace(character.Name)) } } return nil @@ -206,20 +213,21 @@ func validateStoryboardShotDuration(plan agentStoryboardPlan, target int) error return nil } -func validateStoryboardPlan(plan agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard, assets []storyboardAsset) error { +func validateStoryboardPlan(plan *agentStoryboardPlan, shotDuration int, shotCount int, characters []storyboardCharacterCard, assets []storyboardAsset) error { + if plan == nil { + return errors.New("分镜方案不能为空") + } if utf8.RuneCountInString(strings.TrimSpace(plan.StyleGuide)) > 120 { return errors.New("styleGuide 最多 120 个中文字符") } - if err := validateStoryboardShotDuration(plan, shotDuration); err != nil { + if err := validateStoryboardShotDuration(*plan, shotDuration); err != nil { return err } - if err := validateStoryboardShotCount(plan, shotCount); err != nil { + if err := validateStoryboardShotCount(*plan, shotCount); err != nil { return err } - if err := validateStoryboardCharacterIDs(plan, characters); err != nil { - return err - } - if err := validateStoryboardAssetRefs(plan, assets); err != nil { + normalizeStoryboardCharacterReferences(plan, characters) + if err := validateStoryboardAssetRefs(*plan, assets); err != nil { return err } return nil @@ -276,8 +284,9 @@ func validateStoryboardComplexity(plan agentStoryboardPlan) error { issues := make([]string, 0) for index, shot := range plan.Shots { shotNumber := index + 1 - if len(shot.CharacterIDs) > 2 { - issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, len(shot.CharacterIDs))) + characterCount := len(shot.CharacterIDs) + len(shot.CharacterNames) + if characterCount > 2 { + issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 名主要角色,最多 2 名", shotNumber, characterCount)) } if len(shot.MustHave) > 3 { issues = append(issues, fmt.Sprintf("镜头 %d 有 %d 个必须完成项,最多 3 个", shotNumber, len(shot.MustHave))) @@ -311,41 +320,130 @@ func normalizeAutomaticStoryboardDurations(plan *agentStoryboardPlan, target int } } -func validateStoryboardCharacterIDs(plan agentStoryboardPlan, characters []storyboardCharacterCard) error { - allowed := make(map[string]bool, len(characters)) - for _, character := range characters { - allowed[character.AssetID] = true +func normalizeStoryboardCharacterReferences(plan *agentStoryboardPlan, characters []storyboardCharacterCard) { + if plan == nil { + return } - for index, shot := range plan.Shots { - for _, assetID := range shot.CharacterIDs { - if !allowed[assetID] { - return fmt.Errorf("镜头 %d 引用了不存在或非当前版本的角色 assetId:%s", index+1, assetID) - } + byID := make(map[string]storyboardCharacterCard, len(characters)) + byName := make(map[string]storyboardCharacterCard, len(characters)) + for _, character := range characters { + character.AssetID = strings.TrimSpace(character.AssetID) + character.VersionID = strings.TrimSpace(character.VersionID) + character.Name = strings.TrimSpace(character.Name) + if character.AssetID != "" && character.VersionID != "" { + byID[character.AssetID] = character + } + nameKey := normalizeStoryboardCharacterName(character.Name) + if nameKey == "" { + continue + } + current, exists := byName[nameKey] + if !exists || (current.AssetID == "" && character.AssetID != "") { + byName[nameKey] = character } } - return nil + for shotIndex := range plan.Shots { + shot := &plan.Shots[shotIndex] + references := append(append([]string{}, shot.CharacterIDs...), shot.CharacterNames...) + resolvedIDs := make([]string, 0, len(references)) + resolvedNames := make([]string, 0, len(references)) + seen := make(map[string]bool, len(references)) + seenLabels := make(map[string]bool, len(references)) + labels := make([]string, 0, len(references)) + for _, rawReference := range references { + reference := strings.TrimSpace(rawReference) + if reference == "" { + continue + } + if character, ok := byID[reference]; ok { + appendStoryboardCharacterID(&resolvedIDs, seen, character.AssetID) + appendStoryboardCharacterLabel(&labels, seenLabels, character.Name) + continue + } + if character, ok := byName[normalizeStoryboardCharacterName(reference)]; ok { + if character.AssetID != "" && character.VersionID != "" { + appendStoryboardCharacterID(&resolvedIDs, seen, character.AssetID) + } else { + appendStoryboardCharacterName(&resolvedNames, seen, character.Name) + } + appendStoryboardCharacterLabel(&labels, seenLabels, character.Name) + continue + } + appendStoryboardCharacterName(&resolvedNames, seen, reference) + appendStoryboardCharacterLabel(&labels, seenLabels, reference) + } + shot.CharacterIDs = resolvedIDs + shot.CharacterNames = resolvedNames + shot.CharacterLabels = labels + } } func storyboardRowCharacters(shot agentStoryboardShot, characters []storyboardCharacterCard) []map[string]any { byID := make(map[string]storyboardCharacterCard, len(characters)) for _, character := range characters { - byID[character.AssetID] = character + if strings.TrimSpace(character.AssetID) != "" && strings.TrimSpace(character.VersionID) != "" { + byID[strings.TrimSpace(character.AssetID)] = character + } } - result := make([]map[string]any, 0, len(shot.CharacterIDs)) + result := make([]map[string]any, 0, len(shot.CharacterIDs)+len(shot.CharacterNames)) + seenNames := make(map[string]bool, len(shot.CharacterIDs)+len(shot.CharacterNames)) for _, assetID := range shot.CharacterIDs { character, ok := byID[assetID] if !ok { continue } + seenNames[normalizeStoryboardCharacterName(character.Name)] = true result = append(result, map[string]any{ "characterName": character.Name, "characterAssetId": character.AssetID, "characterVersionId": character.VersionID, }) } + for _, name := range shot.CharacterNames { + name = strings.TrimSpace(name) + nameKey := normalizeStoryboardCharacterName(name) + if nameKey == "" || seenNames[nameKey] { + continue + } + seenNames[nameKey] = true + result = append(result, map[string]any{"characterName": name}) + } return result } +func appendStoryboardCharacterID(values *[]string, seen map[string]bool, assetID string) { + key := "id:" + assetID + if seen[key] { + return + } + seen[key] = true + *values = append(*values, assetID) +} + +func appendStoryboardCharacterName(values *[]string, seen map[string]bool, name string) { + name = strings.TrimSpace(name) + key := normalizeStoryboardCharacterName(name) + if key == "" || seen["name:"+key] { + return + } + seen["name:"+key] = true + *values = append(*values, name) +} + +func appendStoryboardCharacterLabel(values *[]string, seen map[string]bool, name string) { + name = strings.TrimSpace(name) + key := normalizeStoryboardCharacterName(name) + if key == "" || seen[key] { + return + } + seen[key] = true + *values = append(*values, name) +} + +func normalizeStoryboardCharacterName(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + func storyboardBeatCount(value string) int { parts := strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == ';' || r == '\n' }) count := 0 @@ -597,7 +695,7 @@ func storyboardImagePromptValues(projectStyle string, styleGuide string, shot ag negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") return map[string]string{ "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), + "首帧构图": compactPromptText(storyboardCharacterPromptPrefix(shot)+shot.VisualPrompt+";光影:"+shot.Lighting, 360), "表演起始状态": compactPromptText(shot.Performance, 180), "负面要求": compactPromptText(negative, 140), } @@ -617,7 +715,7 @@ func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot ag values := map[string]string{ "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), + "首帧构图": compactPromptText(storyboardCharacterPromptPrefix(shot)+shot.VisualPrompt+";光影:"+shot.Lighting, 280), "表演与调度": compactPromptText(shot.Performance, 180), "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), "时间节拍": compactPromptText(timeBeats, 240), @@ -635,6 +733,13 @@ func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot ag return values } +func storyboardCharacterPromptPrefix(shot agentStoryboardShot) string { + if len(shot.CharacterLabels) == 0 { + return "" + } + return "镜头角色:" + strings.Join(shot.CharacterLabels, "、") + ";" +} + func buildStoryboardVideoPrompt(projectStyle string, styleGuide string, shot agentStoryboardShot) string { definition, _ := promptDefinition(promptOperationStoryboardVideo) prompt, _ := renderPromptTemplate(definition, definition.DefaultContent, storyboardVideoPromptValues(projectStyle, styleGuide, shot)) diff --git a/backend/internal/service/storyboard_prompt_test.go b/backend/internal/service/storyboard_prompt_test.go index 2e6f7566c..25b3829f2 100644 --- a/backend/internal/service/storyboard_prompt_test.go +++ b/backend/internal/service/storyboard_prompt_test.go @@ -106,13 +106,65 @@ func TestValidateStoryboardPlanTreatsComplexityAsAdvisory(t *testing.T) { MustHave: []string{"一", "二", "三", "四"}, Motion: "航拍推进并升降", }}} - characters := []storyboardCharacterCard{{AssetID: "a"}, {AssetID: "b"}, {AssetID: "c"}} + characters := []storyboardCharacterCard{{AssetID: "a", VersionID: "va", Name: "甲"}, {AssetID: "b", VersionID: "vb", Name: "乙"}, {AssetID: "c", VersionID: "vc", Name: "丙"}} - if err := validateStoryboardPlan(plan, 0, 0, characters, nil); err != nil { + if err := validateStoryboardPlan(&plan, 0, 0, characters, nil); err != nil { t.Fatalf("complexity should be advisory for an otherwise valid plan: %v", err) } } +func TestValidateStoryboardContextAcceptsNameOnlyCharacter(t *testing.T) { + style := storyboardProjectStyle{PresetID: "style", Title: "都市", Prompt: "写实电影"} + characters := []storyboardCharacterCard{{Name: "张天昊", Definition: map[string]any{"role": "主角"}}} + if err := validateStoryboardContext(style, characters); err != nil { + t.Fatalf("name-only character should be accepted: %v", err) + } +} + +func TestValidateStoryboardContextRejectsPartialAssetIdentity(t *testing.T) { + style := storyboardProjectStyle{PresetID: "style", Title: "都市", Prompt: "写实电影"} + characters := []storyboardCharacterCard{{AssetID: "character-1", Name: "张天昊"}} + if err := validateStoryboardContext(style, characters); err == nil { + t.Fatal("character with only asset id should be rejected") + } +} + +func TestNormalizeStoryboardCharacterReferencesFallsBackToNames(t *testing.T) { + plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{CharacterIDs: []string{"character-1", "张振天", "张天昊", "路人甲", "张天昊"}}}} + characters := []storyboardCharacterCard{ + {AssetID: "character-1", VersionID: "version-1", Name: "张振天"}, + {Name: "张天昊"}, + } + + if err := validateStoryboardPlan(&plan, 0, 0, characters, nil); err != nil { + t.Fatalf("character names should not invalidate the storyboard plan: %v", err) + } + + shot := plan.Shots[0] + if len(shot.CharacterIDs) != 1 || shot.CharacterIDs[0] != "character-1" { + t.Fatalf("confirmed character should stay bound to asset: %#v", shot.CharacterIDs) + } + if len(shot.CharacterNames) != 2 || shot.CharacterNames[0] != "张天昊" || shot.CharacterNames[1] != "路人甲" { + t.Fatalf("pending and unknown characters should fall back to names: %#v", shot.CharacterNames) + } + if prompt := buildStoryboardImagePrompt("写实电影", "都市夜景", shot); !strings.Contains(prompt, "镜头角色:张振天、张天昊、路人甲") { + t.Fatalf("image prompt should keep character names after fallback: %s", prompt) + } + if prompt := buildStoryboardVideoPrompt("写实电影", "都市夜景", shot); !strings.Contains(prompt, "镜头角色:张振天、张天昊、路人甲") { + t.Fatalf("video prompt should keep character names after fallback: %s", prompt) + } + rows := storyboardRowCharacters(shot, characters) + if len(rows) != 3 { + t.Fatalf("expected three character references, got %#v", rows) + } + if rows[0]["characterAssetId"] != "character-1" || rows[1]["characterName"] != "张天昊" || rows[2]["characterName"] != "路人甲" { + t.Fatalf("unexpected character rows: %#v", rows) + } + if _, exists := rows[1]["characterAssetId"]; exists { + t.Fatalf("name-only character must not fabricate an asset id: %#v", rows[1]) + } +} + func TestValidateStoryboardComplexityAcceptsSingleDirectedMove(t *testing.T) { plan := agentStoryboardPlan{Shots: []agentStoryboardShot{{ Duration: 8, CharacterIDs: []string{"a", "b"}, MustHave: []string{"身份稳定", "动作落点", "结尾状态"}, TimeBeats: "0-2秒:建立;2-6秒:动作;6-8秒:反应", Motion: "缓慢推近后停住", Dialogue: "你终于来了。", diff --git a/backend/internal/service/storyboard_workflow.go b/backend/internal/service/storyboard_workflow.go index 0be6e6fa5..f92df3cb7 100644 --- a/backend/internal/service/storyboard_workflow.go +++ b/backend/internal/service/storyboard_workflow.go @@ -92,7 +92,7 @@ func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, inp plan, parseErr := parseAgentStoryboardPlan(repairedText) if parseErr == nil { normalizeAutomaticStoryboardDurations(&plan, shotDuration) - parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters, input.CanvasAssets) + parseErr = validateStoryboardPlan(&plan, shotDuration, shotCount, input.Characters, input.CanvasAssets) } if parseErr == nil { return plan, nil @@ -148,7 +148,7 @@ func (s *Service) generateStoryboardPlan(ctx context.Context, task model.Task, i plan, err := parseAgentStoryboardPlan(text) if err == nil { normalizeAutomaticStoryboardDurations(&plan, shotDuration) - err = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters, assets) + err = validateStoryboardPlan(&plan, shotDuration, shotCount, input.Characters, assets) } if err != nil { plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, shotDuration, shotCount) diff --git a/backend/internal/service/task_output.go b/backend/internal/service/task_output.go index 720551cf3..a2c4845d8 100644 --- a/backend/internal/service/task_output.go +++ b/backend/internal/service/task_output.go @@ -41,10 +41,16 @@ type TaskSummary struct { } type TaskClientContext struct { - ConversationID string `json:"conversationId"` - MessageID string `json:"messageId"` - BatchIndex int `json:"batchIndex,omitempty"` - BatchCount int `json:"batchCount,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + MessageID string `json:"messageId,omitempty"` + BatchIndex int `json:"batchIndex,omitempty"` + BatchCount int `json:"batchCount,omitempty"` + DomainProjectID string `json:"domainProjectId,omitempty"` + ChapterID string `json:"chapterId,omitempty"` + ChapterOperation string `json:"chapterOperation,omitempty"` + ShotID string `json:"shotId,omitempty"` + WorkflowStepID string `json:"workflowStepId,omitempty"` + ArtifactType string `json:"artifactType,omitempty"` } type TaskBillingSummary struct { @@ -123,25 +129,51 @@ func taskSummaryForOutput(task model.Task) TaskSummary { } } -// 列表只暴露创作页恢复所需的关联 ID,不下发完整任务输入或其他 metadata。 +// 列表只暴露页面恢复所需的非敏感关联 ID,不下发完整任务输入或其他 metadata。 func taskClientContext(raw string) *TaskClientContext { var input struct { Metadata struct { - Source string `json:"source"` - ConversationID string `json:"conversationId"` - MessageID string `json:"messageId"` - BatchIndex int `json:"batchIndex"` - BatchCount int `json:"batchCount"` + Source string `json:"source"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + BatchIndex int `json:"batchIndex"` + BatchCount int `json:"batchCount"` + DomainProjectID string `json:"domainProjectId"` + ChapterID string `json:"chapterId"` + Operation string `json:"operation"` + ShotID string `json:"shotId"` + WorkflowStepID string `json:"workflowStepId"` + ArtifactType string `json:"artifactType"` } `json:"metadata"` } - if json.Unmarshal([]byte(raw), &input) != nil || input.Metadata.Source != "create-page" || input.Metadata.ConversationID == "" || input.Metadata.MessageID == "" { + if json.Unmarshal([]byte(raw), &input) != nil { + return nil + } + metadata := input.Metadata + if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" { + return &TaskClientContext{ + ConversationID: metadata.ConversationID, + MessageID: metadata.MessageID, + BatchIndex: metadata.BatchIndex, + BatchCount: metadata.BatchCount, + } + } + if metadata.ShotID != "" && metadata.WorkflowStepID != "" { + return &TaskClientContext{DomainProjectID: metadata.DomainProjectID, ShotID: metadata.ShotID, WorkflowStepID: metadata.WorkflowStepID, ArtifactType: metadata.ArtifactType} + } + chapterOperation := "" + if metadata.Operation == "chapter_character_breakdown" { + chapterOperation = "characters" + } else if metadata.Source == "short-drama-chapter-storyboard" { + chapterOperation = "storyboard" + } + if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" { return nil } return &TaskClientContext{ - ConversationID: input.Metadata.ConversationID, - MessageID: input.Metadata.MessageID, - BatchIndex: input.Metadata.BatchIndex, - BatchCount: input.Metadata.BatchCount, + DomainProjectID: metadata.DomainProjectID, + ChapterID: metadata.ChapterID, + ChapterOperation: chapterOperation, } } diff --git a/backend/internal/service/task_output_test.go b/backend/internal/service/task_output_test.go index 52fe6b540..3d34056d9 100644 --- a/backend/internal/service/task_output_test.go +++ b/backend/internal/service/task_output_test.go @@ -50,3 +50,31 @@ func TestTaskClientContextRequiresCreatePageMetadata(t *testing.T) { t.Fatalf("unexpected context for non-create-page task: %+v", context) } } + +func TestTaskClientContextProjectsChapterOperations(t *testing.T) { + characters := taskClientContext(`{"metadata":{"domainProjectId":"project-1","chapterId":"chapter-1","operation":"chapter_character_breakdown"}}`) + if characters == nil || characters.DomainProjectID != "project-1" || characters.ChapterID != "chapter-1" || characters.ChapterOperation != "characters" { + t.Fatalf("character task context was not decoded: %+v", characters) + } + storyboard := taskClientContext(`{"metadata":{"source":"short-drama-chapter-storyboard","domainProjectId":"project-1","chapterId":"chapter-2"}}`) + if storyboard == nil || storyboard.ChapterID != "chapter-2" || storyboard.ChapterOperation != "storyboard" { + t.Fatalf("storyboard task context was not decoded: %+v", storyboard) + } + if context := taskClientContext(`{"metadata":{"domainProjectId":"project-1","chapterId":"chapter-1","operation":"unrelated"}}`); context != nil { + t.Fatalf("unexpected context for unrelated project task: %+v", context) + } +} + +func TestTaskClientContextProjectsShotWorkflow(t *testing.T) { + context := taskClientContext(`{"metadata":{"domainProjectId":"project-1","shotId":"shot-1","workflowStepId":"step-1","artifactType":"video"}}`) + if context == nil || context.DomainProjectID != "project-1" || context.ShotID != "shot-1" || context.WorkflowStepID != "step-1" || context.ArtifactType != "video" { + t.Fatalf("shot task context was not decoded: %+v", context) + } +} + +func TestTaskOutputResourceReadsPersistedMedia(t *testing.T) { + id, mediaType := taskOutputResource(`{"mode":"video","video":{"resourceId":"resource-1","storageKey":"resource:resource-1"}}`, "canvas_video") + if id != "resource-1" || mediaType != "video" { + t.Fatalf("unexpected task output resource: id=%q mediaType=%q", id, mediaType) + } +} diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go index 57c1e960b..001bda61f 100644 --- a/backend/internal/service/task_worker.go +++ b/backend/internal/service/task_worker.go @@ -118,6 +118,12 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error { task.Stage = "调用生成模型" task.Progress = 35 + if taskUsesUpstreamReportedProgress(task.Type) { + // 图片/视频百分比只能来自供应商状态响应。连接和提交阶段只展示文案, + // 不能再用统一的 35% 冒充真实生成进度。 + task.Stage = "正在连接上游" + task.Progress = 0 + } if err := s.repo.UpdateTaskProgress(task.ID, task.Stage, task.Progress); err != nil { return fmt.Errorf("更新任务进度失败,任务暂未调用上游:%w", err) } @@ -188,6 +194,10 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error { return terminal.handleSuccess(task) } +func taskUsesUpstreamReportedProgress(taskType string) bool { + return taskType == "canvas_image" || taskType == "canvas_video" || strings.HasPrefix(taskType, "video_") +} + func taskFailureMessage(err error) string { if err == nil { return "任务处理失败" diff --git a/canvas-agent/README.md b/canvas-agent/README.md index bcdf72100..8e8ed83b5 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -196,7 +196,9 @@ default_tools_approval_mode = "approve" 侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。 -侧边栏 Composer 中显式提及的网页技能不会被拼接进用户 Prompt。网页把技能 bundle 传给本机 Runtime,Runtime 为当前 turn 临时生成受限的 `SKILL.md`,并通过 Codex app-server 的原生 `skill` 输入项加载;技能不会被复制进文本输入,也不会添加 `$skill-name` 伪标记,turn 完成后删除临时文件。未被用户提及的技能不会进入该 turn。 +侧边栏 Composer 中显式提及的网页技能不会被拼接进用户 Prompt。网页只为本轮提及的技能获取 bundle,本机 Runtime 将 `SKILL.md`、`references/`、`scripts/`、`assets/` 等目录完整写入当前 turn 的临时技能目录,并通过 Codex app-server 的原生 `skill` 输入项加载;技能不会被复制进文本输入,也不会添加 `$skill-name` 伪标记,turn 完成后删除整个临时目录。未被用户提及的技能不会传给本机 Runtime。 + +网页内置在线 Agent 使用另一条渐进式路径:系统上下文只列技能名称、简介、版本和文件数;模型先读取入口 `SKILL.md`,再按入口引用调用文件清单、搜索或单文件读取工具,不会一次性加载整个技能包。 ## Claude Code diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts index e19e8f4b5..57ef9a31a 100644 --- a/canvas-agent/src/agents.ts +++ b/canvas-agent/src/agents.ts @@ -13,7 +13,8 @@ import type { AgentAttachment, AgentEmit } from "./types.js"; type Json = Record; type AgentEvent = Json & { type: string; usage?: unknown }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; -export type AgentSkillReference = { skillId?: string; name: string; description?: string; instruction?: string }; +export type AgentSkillFile = { path: string; mimeType?: string; contentBase64: string }; +export type AgentSkillReference = { skillId?: string; name: string; description?: string; version?: string; files?: AgentSkillFile[]; instruction?: string }; export type CodexSkillInput = { type: "skill"; name: string; path: string }; type CodexRunOptions = { threadId?: string; cwd?: string; skills?: AgentSkillReference[]; onThreadId?: (threadId: string) => void }; type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string }; @@ -471,17 +472,32 @@ export async function writeSkillFiles(skills: AgentSkillReference[]) { const usedNames = new Set(); try { for (const skill of skills.slice(0, 8)) { - const instruction = String(skill.instruction || "").trim().slice(0, 24_000); - if (!instruction) continue; const baseName = `canvas-${safeSkillSegment(skill.skillId || skill.name)}`; const name = uniqueSkillName(baseName, usedNames); usedNames.add(name); - const directory = await fs.mkdtemp(path.join(os.tmpdir(), "infinite-canvas-skill-")); - directories.push(directory); + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "infinite-canvas-skill-")); + directories.push(temporaryRoot); + const directory = path.join(temporaryRoot, name); + await fs.mkdir(directory, { recursive: true }); + const packageFiles = Array.isArray(skill.files) ? skill.files : []; + if (packageFiles.length) { + for (const item of packageFiles) { + const relativePath = safeSkillFilePath(item.path); + const target = path.join(directory, ...relativePath.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, Buffer.from(item.contentBase64, "base64")); + } + } else { + const instruction = String(skill.instruction || "").trim().slice(0, 24_000); + if (!instruction) continue; + await fs.writeFile(path.join(directory, "SKILL.md"), skillMarkdown(name, skill.name, skill.description, instruction), "utf8"); + } const file = path.join(directory, "SKILL.md"); - const description = String(skill.description || skill.name).trim().slice(0, 500).replace(/[\r\n]+/g, " "); - const body = [`---`, `name: ${name}`, `description: ${JSON.stringify(description)}`, `---`, ``, `# ${skill.name}`, ``, instruction, ``].join("\n"); - await fs.writeFile(file, body, "utf8"); + let entry = await fs.readFile(file, "utf8"); + if (!hasSkillFrontmatter(entry)) { + entry = skillMarkdown(name, skill.name, skill.description, entry); + await fs.writeFile(file, entry, "utf8"); + } inputs.push({ type: "skill", name, path: file }); } return { directories, inputs }; @@ -491,6 +507,29 @@ export async function writeSkillFiles(skills: AgentSkillReference[]) { } } +function skillMarkdown(name: string, title: string, description: string | undefined, instruction: string) { + const summary = String(description || title).trim().slice(0, 500).replace(/[\r\n]+/g, " "); + return [`---`, `name: ${name}`, `description: ${JSON.stringify(summary)}`, `---`, ``, `# ${title}`, ``, instruction, ``].join("\n"); +} + +function hasSkillFrontmatter(value: string) { + const normalized = value.replace(/^\uFEFF/, ""); + if (!normalized.startsWith("---\n") && !normalized.startsWith("---\r\n")) return false; + const end = normalized.indexOf("\n---", 4); + if (end < 0) return false; + const frontmatter = normalized.slice(0, end); + return /^name\s*:/m.test(frontmatter) && /^description\s*:/m.test(frontmatter); +} + +function safeSkillFilePath(value: string) { + const normalized = String(value || "").trim().replace(/\\/g, "/"); + const segments = normalized.split("/"); + if (!normalized || normalized.startsWith("/") || segments.some((segment) => !segment || segment === "." || segment === "..") || normalized.includes("\0")) { + throw new Error(`技能文件路径无效:${normalized || "空路径"}`); + } + return normalized; +} + function uniqueSkillName(baseName: string, usedNames: Set) { if (!usedNames.has(baseName)) return baseName; let suffix = 2; diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts index fc9e81e4c..02397a569 100644 --- a/canvas-agent/src/http-server.ts +++ b/canvas-agent/src/http-server.ts @@ -1,3 +1,5 @@ +import type { Express } from "express"; + import { CanvasSession } from "./canvas-session.js"; import { CONFIG_DIR, type LocalRuntimeConfig } from "./config.js"; import { createLocalRuntimeApp } from "./local-runtime.js"; @@ -21,7 +23,7 @@ type CanvasAgentHttpOptions = { dependencies: CanvasAgentHttpDependencies; }; -export function startHttpServer(options?: CanvasAgentHttpOptions) { +export function startHttpServer(options?: CanvasAgentHttpOptions): ReturnType | Express { if (!options) return startLocalRuntime(); return createHttpApp(options.config, options.session, options.dependencies); } @@ -30,7 +32,7 @@ export function createHttpApp( config: LocalRuntimeConfig, session: CanvasAgentSession = new CanvasSession(), dependencies: CanvasAgentHttpDependencies = {}, -) { +): Express { const endpoint = config.url; const manager = new LocalRuntimeSessionManager({ endpoint, diff --git a/canvas-agent/src/local-runtime-host.ts b/canvas-agent/src/local-runtime-host.ts index d4930592a..1ce839336 100644 --- a/canvas-agent/src/local-runtime-host.ts +++ b/canvas-agent/src/local-runtime-host.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { createServer, type Server } from "node:http"; +import type { Express } from "express"; import { CONFIG_DIR, @@ -25,6 +26,14 @@ export type StartLocalRuntimeOptions = { persistConfig?: (config: LocalRuntimeConfig) => void; }; +export type LocalRuntimeHandle = { + app: Express; + server: Server; + sessions: LocalRuntimeSessionManager; + ready: Promise; + close: () => Promise; +}; + export function createDefaultLocalRuntimeModules(config: LocalRuntimeConfig): LocalRuntimeModule[] { return [ createCanvasAgentHttpModule(config), @@ -40,7 +49,7 @@ export function createDefaultLocalRuntimeModules(config: LocalRuntimeConfig): Lo ]; } -export function startLocalRuntime(options: StartLocalRuntimeOptions = {}) { +export function startLocalRuntime(options: StartLocalRuntimeOptions = {}): LocalRuntimeHandle { const config = options.config ?? loadConfig(true); const persistConfig = options.persistConfig ?? saveConfig; const requestedPort = options.port ?? ( diff --git a/canvas-agent/src/local-runtime.ts b/canvas-agent/src/local-runtime.ts index 61e893e34..65f08d029 100644 --- a/canvas-agent/src/local-runtime.ts +++ b/canvas-agent/src/local-runtime.ts @@ -1,4 +1,4 @@ -import type { Request, RequestHandler, Response } from "express"; +import type { Express, Request, RequestHandler, Response } from "express"; import express from "express"; import { @@ -55,14 +55,14 @@ export type CreateLocalRuntimeAppOptions = { legacyOrigins?: readonly string[]; }; -export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions) { +export function createLocalRuntimeApp(options: CreateLocalRuntimeAppOptions): Express { const modules = validateModules(options.modules); const policies = corsPolicies(modules, options.legacyOrigins ?? []); const app = express(); app.disable("x-powered-by"); app.use(express.raw({ type: "application/json", - limit: "30mb", + limit: "64mb", })); app.use(noStore); app.use(exactAuthorityGuard(options.authority)); diff --git a/canvas-agent/src/modules/canvas-agent-http.ts b/canvas-agent/src/modules/canvas-agent-http.ts index a20f203ae..3a6098066 100644 --- a/canvas-agent/src/modules/canvas-agent-http.ts +++ b/canvas-agent/src/modules/canvas-agent-http.ts @@ -221,16 +221,54 @@ export function parseAgentSkills(value: unknown) { const input = item as Record; const name = typeof input.name === "string" ? input.name.trim().slice(0, 120) : ""; const instruction = typeof input.instruction === "string" ? input.instruction.trim().slice(0, 24_000) : ""; - if (!name || !instruction) return []; + const files = parseAgentSkillFiles(input.files); + if (!name || (!instruction && !files.length)) return []; return [{ ...(typeof input.skillId === "string" ? { skillId: input.skillId.trim().slice(0, 120) } : {}), name, ...(typeof input.description === "string" ? { description: input.description.trim().slice(0, 500) } : {}), - instruction, + ...(typeof input.version === "string" ? { version: input.version.trim().slice(0, 120) } : {}), + ...(files.length ? { files } : { instruction }), }]; }); } +function parseAgentSkillFiles(value: unknown) { + if (!Array.isArray(value)) return []; + const files: Array<{ path: string; mimeType?: string; contentBase64: string }> = []; + const paths = new Set(); + let totalBytes = 0; + for (const item of value.slice(0, 512)) { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const input = item as Record; + const filePath = typeof input.path === "string" ? normalizeAgentSkillPath(input.path) : ""; + const contentBase64 = typeof input.contentBase64 === "string" ? input.contentBase64.trim() : ""; + if (!filePath || paths.has(filePath) || !validBase64(contentBase64)) return []; + const size = Buffer.from(contentBase64, "base64").byteLength; + if (size > 8 * 1024 * 1024) return []; + totalBytes += size; + if (totalBytes > 20 * 1024 * 1024) return []; + paths.add(filePath); + files.push({ + path: filePath, + ...(typeof input.mimeType === "string" ? { mimeType: input.mimeType.trim().slice(0, 255) } : {}), + contentBase64, + }); + } + return files.some((file) => file.path === "SKILL.md") ? files : []; +} + +function normalizeAgentSkillPath(value: string) { + const normalized = value.trim().replace(/\\/g, "/"); + const segments = normalized.split("/"); + if (!normalized || normalized.startsWith("/") || normalized.length > 1000 || segments.some((segment) => !segment || segment === "." || segment === "..") || normalized.includes("\0")) return ""; + return normalized; +} + +function validBase64(value: string) { + return value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value); +} + function queryValue(req: Request, key: string) { const value = req.query[key]; return Array.isArray(value) ? String(value[0] ?? "") : String(value ?? ""); diff --git a/canvas-agent/test/skills.test.ts b/canvas-agent/test/skills.test.ts index 119efced6..eb1f1e697 100644 --- a/canvas-agent/test/skills.test.ts +++ b/canvas-agent/test/skills.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; +import path from "node:path"; import test from "node:test"; import { codexInput, writeSkillFiles } from "../src/agents.js"; @@ -24,19 +25,31 @@ test("writeSkillFiles creates Codex-compatible SKILL.md inputs and unique names" const prepared = await writeSkillFiles([ { skillId: "same", name: "第一个技能", description: "first", instruction: "do first" }, { skillId: "same", name: "第二个技能", description: "second", instruction: "do second" }, + { + skillId: "package", + name: "目录技能", + description: "package", + files: [ + { path: "SKILL.md", contentBase64: Buffer.from("# 目录技能\n\n按需读取参考文件。", "utf8").toString("base64") }, + { path: "references/guide.md", contentBase64: Buffer.from("# Guide", "utf8").toString("base64") }, + ], + }, { skillId: "empty", name: "空技能", instruction: " " }, ]); try { - assert.equal(prepared.inputs.length, 2); - assert.deepEqual(prepared.inputs.map((item) => item.type), ["skill", "skill"]); - assert.deepEqual(prepared.inputs.map((item) => item.name), ["canvas-same", "canvas-same-2"]); - for (const item of prepared.inputs) { + assert.equal(prepared.inputs.length, 3); + assert.deepEqual(prepared.inputs.map((item) => item.type), ["skill", "skill", "skill"]); + assert.deepEqual(prepared.inputs.map((item) => item.name), ["canvas-same", "canvas-same-2", "canvas-package"]); + for (const item of prepared.inputs.slice(0, 2)) { assert.equal(item.path.endsWith("/SKILL.md"), true); const body = await fs.readFile(item.path, "utf8"); assert.match(body, /^---\nname: canvas-same(?:-2)?\ndescription: /); assert.match(body, /\n---\n\n#/); } + const packageInput = prepared.inputs[2]; + assert.match(await fs.readFile(packageInput.path, "utf8"), /^---\nname: canvas-package\ndescription: /); + assert.equal(await fs.readFile(path.join(path.dirname(packageInput.path), "references", "guide.md"), "utf8"), "# Guide"); } finally { await Promise.all(prepared.directories.map((directory) => fs.rm(directory, { recursive: true, force: true }))); } @@ -44,14 +57,26 @@ test("writeSkillFiles creates Codex-compatible SKILL.md inputs and unique names" test("parseAgentSkills validates and bounds browser skill bundles", () => { const input = [ - { skillId: "safe", name: " safe ", description: " desc ", instruction: " instruction " }, + { + skillId: "safe", + name: " safe ", + description: " desc ", + version: " v2 ", + files: [ + { path: "SKILL.md", mimeType: "text/markdown", contentBase64: Buffer.from("# Safe").toString("base64") }, + { path: "references/a.md", mimeType: "text/markdown", contentBase64: Buffer.from("A").toString("base64") }, + ], + }, + { skillId: "legacy", name: "legacy", instruction: " instruction " }, { name: "missing instruction" }, null, - { name: "too long", instruction: "x".repeat(25_000) }, + { name: "unsafe", files: [{ path: "../SKILL.md", contentBase64: Buffer.from("bad").toString("base64") }] }, ]; const parsed = parseAgentSkills(input); assert.equal(parsed.length, 2); - assert.deepEqual(parsed[0], { skillId: "safe", name: "safe", description: "desc", instruction: "instruction" }); - assert.equal(parsed[1].instruction.length, 24_000); + assert.equal(parsed[0].name, "safe"); + assert.equal(parsed[0].version, "v2"); + assert.deepEqual(parsed[0].files?.map((file) => file.path), ["SKILL.md", "references/a.md"]); + assert.deepEqual(parsed[1], { skillId: "legacy", name: "legacy", instruction: "instruction" }); }); diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 0df85e13c..36a78a67a 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -7,10 +7,23 @@ description: 影策后端核心持久化表与状态边界 后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 +## 技能包与版本 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `skills` | 保存技能公开元数据、当前版本指针、来源和 GitHub 同步状态 | `instruction` 仅保留入口 `SKILL.md` 的兼容投影;目录文件不展开写入该字段 | +| `skill_versions` | 保存每次安装、编辑或同步形成的不可变技能版本 | 记录内容哈希、入口路径、规范化 ZIP 的存储键、文件数、总大小和 GitHub commit | +| `skill_files` | 保存某个版本的文件索引 | `(skill_version_id, path)` 唯一;只保存路径、类型、MIME、大小和 SHA-256,不保存正文 | +| `user_skill_states` | 保存用户的加入、收藏、安装版本和自动更新选择 | 技能正文读取仍经过公开性和归属校验,不能通过文件接口绕过私有技能权限 | + +规范化技能包保存在后端数据目录的 `skill-packages//.zip`,数据库只保存索引和版本指针。上传 ZIP 时强制校验 `SKILL.md`、路径越界、软链接、重复路径、文件数量和解压大小;GitHub 技能保存公开仓库、分支、子目录和提交 SHA,后台每 6 小时检查一次开启自动更新的技能,并记录最近检查、同步时间及失败状态。 + ## 短剧分镜生产 | 表 | 用途 | 关键边界 | | --- | --- | --- | +| `projects` | 保存短剧项目基础设置、画幅、画风快照和项目主图 | `cover_resource_id` 只能引用当前用户已就绪的图片资源;资源删除检查会阻止仍被项目主图使用的图片被回收 | +| `project_units` | 保存项目章节或集数的正文、排序和状态 | `word_count` 在正文创建、导入和更新时同步维护,项目详情只返回摘要和字数,不批量返回长篇正文;升级时会为旧正文回填字数 | | `shots` | 保存章节内稳定的镜头身份、排序和当前版本指针 | 不保存完整生成历史;`current_revision_id` 指向当前采用的分镜脚本版本 | | `shot_revisions` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 | | `shot_asset_references` | 将镜头绑定到具体资产版本 | 修改引用时只使该镜头的下游产物过期,不影响其他镜头 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 94e8df90f..4d24be562 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,17 +5,48 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 多文件技能包库 + +- [x] 后端新增技能版本与文件索引,支持单个 Markdown、ZIP 和公开 GitHub 仓库安装;ZIP 路径越界、软链接、重复路径、文件数、单文件大小和总解压大小均在写入前校验,后端全量测试通过。 +- [x] 技能详情改为约 82vw × 82vh 的弹窗,左侧支持多层文件树,右侧支持 Markdown 预览、源码、代码/文本及媒体文件查看;前端生产构建通过。 +- [x] 在线 Agent 只在系统上下文保留技能元数据,并通过入口、文件清单、单文件读取和搜索工具渐进式加载;本机 Agent 只把用户本轮显式提及的完整技能包写入临时目录,321 项 Agent 测试与 TypeScript 构建通过。 +- [x] 新增统一 Skill Runtime:画布、创作页和短剧工作台共用同一技能选择、引用解析、版本/文件来源记录与上下文预算;普通生成只加载 `SKILL.md` 和入口直接引用的相关文本,在线 Agent 使用渐进工具,本机 Agent 使用原生完整包,专项测试与前端类型检查通过。 +- [ ] 分别安装无 Frontmatter 的单个 Markdown、两个用户提供的示例 ZIP,确认名称/简介推断、文件树层级、中文路径、预览/源码切换和相对 Markdown 链接正确。 +- [ ] 使用公开 GitHub 仓库的默认分支、指定分支和子目录安装技能,修改仓库后手动同步并等待一次后台检查,确认 commit、版本、检查时间、同步时间和失败信息准确。 +- [ ] 在浅色、深色、常规桌面、窄屏下检查大弹窗尺寸、双栏滚动、文件筛选、长路径、省略文本、键盘焦点和媒体预览;确认弹窗不会触发画布缩放或快捷键。 +- [ ] 在线 Agent 使用示例技能完成一次任务,确认先读 `SKILL.md`,仅在入口引用时读取对应 reference;本机 Agent 确认 scripts/assets 保持目录结构、turn 完成后临时目录被清理,且聊天历史不出现完整技能正文。 +- [ ] 分别在画布图片/视频/文本生成、创作页文本/图片/视频、短剧分镜图/动作预演/镜头视频中选择示例技能,确认任务只记录本轮技能版本和实际读取文件,不重复注入历史消息,也不加载 `scripts/`、`assets/` 或整个技能包。 +- [x] 画布、创作页和 Agent 共用的 `@` 引用菜单改为真实光标定位,输入框或页面滚动、尺寸变化时同步更新;向上弹出按实际内容高度贴边。技能候选改为工作流图标、名称、说明、版本和文件数的统一信息行,专项测试和前端生产构建通过。 +- [ ] 登录后分别在高输入框首行、末行、滚动后的中间行输入 `@`,确认菜单与光标保持约 8px 间距;在浅色/深色及窄屏下确认技能说明、版本、文件数和键盘选中态清晰可读。 + ## 短剧分镜工作流 v2 - [x] 后端新增镜头版本、镜头产物和生产任务上下文;分镜修改与资产版本变更会局部将旧产物标记为 `stale`,专项测试通过。 - [x] 短剧工作流调整为“剧情与章节 → 资产拆分 → 分镜脚本 → 黑白动作预演 → 视频生成 → 交付与打包”,旧 v1 实例保留,新章节使用 v2。 - [x] 项目工作台新增独立分镜工作流路由,支持初始化章节流程、新增镜头、保存分镜版本、绑定资产版本、查看预演/视频版本和交付缺失状态;前端生产构建通过。 +- [x] 镜头轨道按当前制作阶段显示待生成、生成中、已生成和生成失败状态,状态标签固定在卡片右下角;横向镜头只在轨道内部滚动,不再撑宽脚本区或挤出产物预览。制作概览六个流程节点改为独立可点击卡片;前端类型检查、生产构建与相关专项测试通过。 - [ ] 在真实项目中初始化一个已有章节,确认不会误用旧 v1 工作流,刷新和切换章节后阶段、镜头和当前版本保持一致。 +- [ ] 在同一章节准备待生成、生成中、已生成和生成失败镜头,确认灰、蓝、绿、红标签始终显示在卡片右下角;增加到几十个镜头后确认只有底部轨道横向滚动,中间脚本和右侧产物预览仍完整可见。 +- [ ] 在浅色、深色、常规桌面和窄屏下检查制作概览六张流程卡片的边界、间距、完成态、悬停和键盘焦点,确认跳转目标正确。 - [ ] 连续保存同一镜头多个版本并绑定不同资产版本,确认只有该镜头的动作预演和视频转为过期,其他镜头不受影响。 - [ ] 使用画布任务和项目任务分别登记镜头产物,确认 `production_task_links` 正确记录 Project、Canvas、Unit、Shot 和 WorkflowStep,且账号归属校验有效。 - [ ] 在浅色、深色、窄屏和常规桌面验证阶段导航、镜头列表、表单、检查器、媒体预览和长章节内容滚动。 - [ ] 当前“资产拆分 AI、多宫格生成提交、批量视频任务、后端交付包”仍是后续阶段,界面不得显示为已完成或可用。 +## 角色声音素材绑定 + +- [x] 角色卡声音绑定移除无效的预设声音下拉框,改为从素材库选择或实时上传声音样本;支持 MP3、WAV、M4A/AAC、FLAC、OGG/Opus 和 WebM 音频。前后端共同校验资源归属、完成状态、资源类型和格式白名单,画布生成引用会保留资源真实 MIME 与文件扩展名。 +- [ ] 在角色卡分别选择素材库已有音频并上传 MP3、WAV、M4A/AAC、FLAC、OGG/Opus、WebM 样本,确认选择器格式标签、浏览器试听、绑定、版本历史和刷新后的声音状态一致;再验证不支持格式、伪装扩展名、视频 WebM、未完成资源和其他账号资源均被拒绝。 +- [ ] 使用绑定了不同格式声音样本的角色执行画布生成,确认请求中的参考音频使用资源真实 MIME 和对应文件扩展名,不再统一伪装成 MP3;生成成功、失败及刷新恢复后引用关系不丢失。 + +## 短剧章节后台生成与项目头部 + +- [x] “提取角色”和“生成到分镜制作”继续使用后端任务中心,确认配置后立即关闭弹窗;章节按钮按任务真实开始时间每秒显示“已运行 X分钟XX秒”,任务完成后恢复可点击并显示“已生成”。刷新页面后会按章节从任务中心恢复运行计时和完成态,并接管尚未写回的角色或分镜结果。 +- [x] 项目名称、状态、六个项目导航、章节选择和新建画布操作已注册到全局公用头部,项目内容区不再重复占用一整行头部;相关专项测试、TypeScript 检查与前端生产构建通过。 +- [ ] 在真实慢任务中分别启动角色提取和章节分镜,确认弹窗立即关闭、按钮计时连续增长、可编辑正文和切换章节,完成后按钮显示“已生成”且仍可再次运行。 +- [ ] 在任务运行中和任务刚完成尚未写回结果时分别刷新项目,确认对应章节继续显示累计计时或“已生成”,成功结果会自动写回;再检查无新角色、生成失败和重新生成替换确认场景的按钮恢复、错误提示和旧分镜保护。 +- [ ] 在浅色、深色、常规桌面和窄屏下检查全局头部:项目名、状态、项目导航、右侧全局操作和新建画布/章节选择不重叠,导航可横向滚动,内容区高度确实增加。 + ## 画布生成结果命名 - [x] 快捷键复制节点生成图片后保留 `_copyN`,参数变体生成图片后保留 `A/B/C/D` 版本标识;批量输出追加结果序号。 @@ -23,6 +54,14 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 分别使用快捷键复制和“创建参数变体”生成图片,确认生成中、生成完成和刷新画布后标题标识不丢失。 - [ ] 下载原图、复制结果、多个参数变体和同批多图结果,确认浏览器不会再提示同名文件覆盖。 +## 画布生成进度与全屏视频控件 + +- [x] 移除图片、视频任务进入模型调用时统一写入的假 35%;排队、后端接管和连接上游阶段只显示真实阶段文案,不再显示伪造百分比。 +- [x] 后端从图片、视频上游成功的创建与轮询响应中识别 `progress`、`percent`、`percentage`、`completion` 及常见百分比变体,支持数字、百分数字符串和 0~1 小数;进度单调写回任务,前端既有 2 秒轮询会同步到画布节点。 +- [x] 画布视频节点保留紧凑控件;进入全屏后恢复完整顶部控制组,底部按钮调整为 52px 操作目标,同时放大图标、时间文字、进度轨道和拖动滑块。 +- [ ] 分别使用会返回整数百分比、小数进度、百分数字符串和不返回进度的图片/视频渠道生成,确认前三者按上游实时增长,不返回进度的渠道仅显示阶段文案且不会卡在固定百分比;刷新页面后继续从后端任务恢复最新进度。 +- [ ] 在桌面、窄屏和触屏设备全屏播放横版、竖版视频,确认播放/暂停、音量、时间、画中画、设置、全屏退出和进度拖动尺寸清晰且不会遮挡字幕。 + ## 视频画面与片段提取 - [x] 视频节点“尾帧截取”已合并为“提取画面”,支持首帧、当前帧、尾帧和最多 30 个自选时间点;批量结果保留来源节点与时间点元数据。 @@ -468,7 +507,41 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] 镜头编辑器改为脚本优先顺序:镜头名称与画面、镜头资产、表演与动作、对白/旁白优先展示,生成规格和模型补充保持折叠;字段统一为画幅、镜头时长、机位角度、运镜方式、排除内容和接戏备注等生产术语。 - [x] 编辑器标题实时反映镜头名称与“已保存/有未保存修改/保存中”状态;无修改时禁止重复保存,切换或新增镜头前会拦截未保存修改。浏览器交互验证通过。 - [x] 镜头保存使用真实版本写接口;资产点击绑定具体版本;生成统一进入后端任务生命周期,成功后登记对应镜头产物;下载使用带登录凭据的资源接口。 +- [x] “生成设置”新增技能库多选,分镜图、动作预演和镜头视频统一通过 Skill Runtime 解析,任务与镜头产物记录技能版本和实际引用文件。 +- [x] 镜头资产支持再次点击或从已绑定标签取消引用;解绑使用归属校验的独立写接口,并使该镜头旧产物失效。动态提示词复用画布 `@` 引用编辑器,已绑定图片和角色会按实际参考图顺序解析后提交;后端全量测试、前端专项测试和生产构建通过。 +- [x] 镜头编辑器底部在“保存脚本”左侧新增“删除镜头”;二次确认后原子清理该镜头的脚本版本、资产引用、生成产物、候选资产和生产任务关联,压紧剩余镜头顺序并自动切换到相邻镜头。 - [ ] 使用包含角色、场景、道具和画风的真实项目,确认左侧分类、绑定状态、参考素材标签和镜头切换保持一致。 +- [ ] 分别删除首个、中间、末尾和唯一镜头,确认二次提示、相邻镜头切换、空态、镜头编号、总镜数以及刷新后的数据一致;有未保存修改时确认提示明确且取消不会丢失内容。 +- [ ] 在同一镜头绑定角色和场景后输入 `@`,确认菜单、行内缩略图和生成请求中的“图片 N”顺序一致;分别从左侧资产行和已绑定标签取消引用,确认刷新后状态一致,旧 token 会阻止生成并提示重新选择或删除引用。 - [ ] 配置一个图片模型和一个视频模型,分别生成分镜预览、动作预演与视频,确认任务中心、镜头历史、最新预览和阶段状态同步更新。 - [ ] 在浅色/深色主题及 1280px、1050px、760px 三个断点检查项目导航、表单滚动、右侧预览和底部镜头轨道,确认没有遮挡或不可达控件。 - [ ] 验证下载视频、复制生成参数、将任务物化产物加入项目素材库、章节切换与新增分镜;确认失败任务展示真实错误且不会伪造产物成功。 + +## 章节分镜生成到分镜制作 + +剧情章节的分镜入口已从“在画布中分镜”纠正为“生成到分镜制作”;生成结果直接写入镜头脚本,并在本章已有分镜时执行显式替换确认。以下场景仍需使用真实项目与已安装技能确认: + +- [x] 章节正文、项目画风、当前角色版本和项目资产统一进入 `agent_storyboard_rows` 任务;分镜生成继续经过 `Skill Runtime` 的 `shortDrama` profile,并记录实际技能版本和引用文件。 +- [x] 本章待确认角色也会进入分镜上下文;模型在 `characterIds` 中返回角色名称时,已确认角色自动映射到真实资产版本,待确认或未知角色降级为仅名称引用,不再触发结构修复请求或报废整份分镜。后端专项测试、前端映射测试与生产构建通过。 +- [x] 分镜任务结果映射到镜头画面、动作、对白、景别、机位、运镜、图片/动态提示词与接戏备注;模型返回的项目资产关联随镜头在同一替换事务中保存。 +- [x] 本章无分镜时直接写入“分镜制作”并跳转到分镜图阶段;已有分镜时展示镜头、脚本版本、资产引用、生成产物和候选资产影响数量,生成失败不修改旧数据。 +- [x] 技能多选框占满弹窗内容宽度;专项测试、TypeScript 检查、后端服务测试和前端生产构建通过。 +- [ ] 选择 1 至 4 个已安装技能生成真实章节,确认任务中心中的技能版本、文件来源和生成结果一致,完成后进入对应章节的“分镜制作 / 分镜图”。 +- [ ] 使用已提取但尚未确认资产的“张振天、张天昊”重新生成真实章节,确认镜头正常落库、提示词保留角色名称;确认角色资产后再次生成,确认同名角色自动带上真实资产与版本 ID。 +- [ ] 对已有镜头、历史脚本、资产引用和生成产物的章节重新生成,分别验证取消替换、模型生成失败和确认替换:前两者保留旧数据,确认替换后旧关联被移除且新资产引用可用。 +- [ ] 在 560px 及更窄视口打开设置弹窗和技能下拉框,确认选择器、选项面板、长技能名称和替换警告没有横向溢出。 + +## 短剧项目导航与制作概览 + +项目设置、章节导航、制作概览、镜头轨道和画布新增入口已按长篇短剧工作流重构;以下场景仍需使用登录态真实项目确认: + +- [x] 项目新增 `cover_resource_id`,服务端校验资源归属、就绪状态和图片类型;项目主图被资源删除流程识别为直接引用。章节新增持久化 `word_count`,新建、导入、更新和旧数据迁移均维护字数摘要;后端全量测试通过。 +- [x] 项目设置可从个人图片素材、项目图片素材或实时上传中设置主图;项目列表优先显示主图,未设置时回退画风示意图。分镜制作头部改为上一章、搜索定位、下一章的虚拟章节导航;前端生产构建通过。 +- [x] 修复项目更新仓储遗漏 `cover_resource_id`,避免接口返回“主图已更新”但数据库字段仍为空;回归测试同时校验设置与移除主图后的数据库重读结果,后端全量测试通过。 +- [x] 剧情章节列表去除装饰性侧边表达并显示状态、字数、画布数;制作概览新增六阶段标准流程、当前指标、制作缺口和可点击快速入口;底部镜头卡增加画面、加粗时长、镜头参数、产物版本与绿色成功状态。 +- [x] 画布空白区域双击改为直接打开已有新增节点面板,并保留双击位置作为节点落点;原“适配内容”能力继续由缩放控制区提供。 +- [ ] 在项目设置分别从个人素材、项目素材和上传图片设置主图,刷新项目列表后确认图片一致;替换、移除主图后确认回退逻辑正确,尝试删除当前主图资源时确认显示项目引用来源。 +- [ ] 使用 2500 章项目搜索章节序号和标题,连续切换上一章、下一章并刷新页面,确认当前位置、总章数、字数和路由一致,弹窗滚动流畅且不会一次渲染全部章节。 +- [ ] 在制作概览点击剧情、资产、分镜、预演、视频、交付和三个快速入口,确认跳转到正确章节及阶段,真实镜头产物数量与完成状态一致。 +- [ ] 在浅色、深色主题和 1280px、1680px 宽度检查镜头卡,确认时长明显、标题与镜头参数不溢出、“已生成”为绿色且选中态仍使用工作台强调色。 +- [ ] 在画布不同缩放和平移位置双击空白处,确认新增节点面板直接打开,选择文本、图片、视频和扩展节点后落点接近双击位置;双击节点、弹窗和其他浮层不应触发新增。 diff --git a/web/src/components/canvas/asset-picker-modal.tsx b/web/src/components/canvas/asset-picker-modal.tsx index 12a812ef5..801dc150c 100644 --- a/web/src/components/canvas/asset-picker-modal.tsx +++ b/web/src/components/canvas/asset-picker-modal.tsx @@ -34,7 +34,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P category: asset.category || "other", kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", asset, - searchText: asset.tags.join(" "), + searchText: (asset.tags || []).join(" "), })), ...externalAssetSources.items, ], [externalAssetSources.items, insertableAssets]); diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 88ec5da65..b927aaa83 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -34,15 +34,15 @@ import { canvasAgentPostconditionMessage, previewCanvasAgentOps, summarizeCanvas import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context"; import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache"; import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context"; -import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions"; import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow"; import { listAddedSkills, type Skill } from "@/services/api/skills"; +import { buildSkillMentionReferences, SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime"; export const CANVAS_AGENT_PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; -const ONLINE_AGENT_MAX_STEPS = 4; +const ONLINE_AGENT_MAX_STEPS = 8; const ONLINE_AGENT_PROMPT = - "你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。技能不是被拼进用户消息的提示词:用户提及技能时,先用 canvas_get_skill 按 id 或名称加载该技能,再按工具返回的技能契约执行。"; + `你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`; const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true }; const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false }; const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false }; @@ -91,7 +91,7 @@ const CANVAS_OP_SCHEMA = { required: ["type"], additionalProperties: false, }; -const ONLINE_READ_TOOLS = new Set(["canvas_list_skills", "canvas_get_skill", "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]); +const ONLINE_READ_TOOLS = new Set([...skillRuntime.agentToolNames("onlineAgent"), "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]); function toolDefinition(name: string, description: string, properties: Record, required: string[] = [], strict = false): ResponseFunctionTool { return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } }; @@ -116,8 +116,7 @@ function generationToolDefinition(name: string, description: string, mode?: "tex } const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [ - toolDefinition("canvas_list_skills", "列出当前用户已加入、可按需加载的画布技能;只返回元数据,不返回完整指令。", {}), - toolDefinition("canvas_get_skill", "按 skillId 或技能名称按需加载一个画布技能的完整契约。技能正文通过工具结果提供,不会自动注入每条用户消息。", { skillId: { type: "string" }, name: { type: "string" } }), + ...skillRuntime.agentTools("onlineAgent"), toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}), toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}), toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }), @@ -779,21 +778,8 @@ export function CanvasAssistantPanel({ if (expectedRevision !== undefined && expectedRevision !== (current.revision ?? 0)) return { ok: false, message: "画布 revision 已变化,请重新读取 canvas_get_context 后再执行写操作。" }; const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : ""; if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" }; - if (name === "canvas_list_skills") { - const data = composerSkills.filter((skill) => skill.is_added).map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, tag: skill.tag })); - return { ok: true, message: data.length ? "已列出当前可用技能。" : "当前没有已加入技能。", data }; - } - if (name === "canvas_get_skill") { - const skillId = typeof args.skillId === "string" ? args.skillId : ""; - const nameQuery = typeof args.name === "string" ? args.name.trim().toLocaleLowerCase() : ""; - const skill = composerSkills.find((item) => item.is_added && (item.skill_id === skillId || item.skill_name.toLocaleLowerCase() === nameQuery)); - if (!skill) return { ok: false, message: "未找到已加入的技能,请先调用 canvas_list_skills。" }; - return { - ok: true, - message: `已按需加载技能「${skill.skill_name}」。`, - data: { skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description, version: skill.update_time }, - }; - } + const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, composerSkills); + if (skillToolResult) return skillToolResult; if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) }; if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) }; if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters[1]) }; @@ -1701,7 +1687,10 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent function toolCallLabel(name: string) { if (name === "canvas_list_skills") return "列出技能"; - if (name === "canvas_get_skill") return "加载技能"; + if (name === "canvas_get_skill") return "读取技能入口"; + if (name === "canvas_list_skill_files") return "列出技能文件"; + if (name === "canvas_read_skill_file") return "读取技能文件"; + if (name === "canvas_search_skill_files") return "搜索技能文件"; if (name === "canvas_apply_ops") return "画布操作"; if (name === "canvas_get_state") return "读取画布"; if (name === "canvas_get_context") return "读取上下文"; @@ -1913,7 +1902,7 @@ async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: Ca const skillCatalog = skills .filter((skill) => skill.is_added) .slice(0, 40) - .map((skill) => `- ${skill.skill_name}(${skill.skill_id}):${skill.description}`) + .map((skill) => `- ${skill.skill_name}(${skill.skill_id},v${skill.version || "1"},${skill.file_count || 1} 文件):${skill.description}`) .join("\n"); const systemContent = [ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n"); return [ diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 32a6b1927..82ff60693 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -116,9 +116,9 @@ export function CanvasNodeContextMenu({ }, [categoryOpen, onClose]); useEffect(() => { - setAddOpen(false); + setAddOpen(menu.type === "canvas" && Boolean(menu.createOpen)); setCategoryOpen(false); - }, [menu.type, menu.x, menu.y]); + }, [menu]); const runAction = (action: () => void) => { action(); diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 181d3fd3d..1c3c7398b 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -26,8 +26,8 @@ import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvas import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context"; import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references"; import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup"; -import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { listAddedSkills, type Skill } from "@/services/api/skills"; +import { skillRuntime } from "@/services/skill-runtime"; import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools"; import { AgentChatComposer, AgentChatMessage, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; @@ -36,6 +36,7 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome"; const PANEL_MOTION_SECONDS = 0.5; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; +const MAX_AGENT_TURN_PAYLOAD_BYTES = 56 * 1024 * 1024; type AgentEventPayload = { agent?: string; type?: string; @@ -57,7 +58,7 @@ type AgentTurnPayload = { canvasId: string; threadId?: string; attachments: Array>; - skills: Array<{ skillId: string; name: string; description: string; instruction: string }>; + skills: Array<{ skillId: string; name: string; description: string; version: string; files: Array<{ path: string; mimeType: string; contentBase64: string }> }>; }; export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ @@ -386,20 +387,31 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ const sendPrompt = async (overrideText?: string) => { const text = (overrideText ?? prompt).trim(); const files = attachments; - const mentionedSkills = resolveSkillMentions(text, composerSkills); - const requestPrompt = promptWithAttachments(text, files); - if (!connected || !requestPrompt || sending || waiting) return; + if (!connected || !promptWithAttachments(text, files) || sending || waiting) return; if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) { addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); return; } + let skillExecution: Awaited>>; + try { + skillExecution = await skillRuntime.prepare({ profile: "localAgent", prompt: text, skills: composerSkills }); + } catch (error) { + addMessage({ role: "error", title: "技能加载失败", text: error instanceof Error ? error.message : "无法读取技能包" }); + return; + } + const requestPrompt = promptWithAttachments(skillExecution.prompt, files); + const skillBundles: AgentTurnPayload["skills"] = skillExecution.skills; + if (attachmentPayloadBytes(files) + skillBundlePayloadBytes(skillBundles) > MAX_AGENT_TURN_PAYLOAD_BYTES) { + addMessage({ role: "error", title: "本轮内容过大", text: "图片与技能包合计超过本地 Agent 单轮限制,请减少附件或只选择一个技能。" }); + return; + } await submitTurn({ text, prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })), - skills: mentionedSkills.map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description })), + skills: skillBundles, }, true); }; @@ -1385,6 +1397,10 @@ function attachmentPayloadBytes(attachments: AgentAttachment[]) { return attachments.reduce((total, item) => total + item.dataUrl.length, 0); } +function skillBundlePayloadBytes(skills: AgentTurnPayload["skills"]) { + return skills.reduce((total, skill) => total + skill.files.reduce((fileTotal, file) => fileTotal + file.contentBase64.length, 0), 0); +} + function formatBytes(bytes: number) { return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`; } diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index 81ac4afaa..616ef58a0 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -11,6 +11,7 @@ import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-refer import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference"; import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation"; import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; +import { audioFileExtension } from "@/lib/character-voice-formats"; import type { ModelReferenceLimits } from "@/lib/model-selection"; import type { Asset } from "@/stores/use-asset-store"; @@ -433,7 +434,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex if (!context.characterReferences.length) return { ...context, referenceImages }; if (!domainProjectId) throw new Error("角色引用未关联短剧项目,无法解析角色版本"); const { getProjectCharacter } = await import("@/services/api/projects"); - const { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources"); + const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources"); const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId))); const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length); const selected = details.flatMap((detail) => { @@ -491,13 +492,17 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex } const maxAudios = referenceLimits?.maxAudios ?? 3; if (context.referenceAudios.length + voiceSamples.length > maxAudios) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`); - const characterVoiceAudios = voiceSamples.map((voice) => ({ - id: `character-voice-${voice.assetId}`, - name: `${voice.characterName}-声音样本.mp3`, - type: "audio/mpeg", - url: resourceFileUrl(voice.sampleResourceId!), - storageKey: resourceStorageKey(voice.sampleResourceId!), - } satisfies ReferenceAudio)); + const characterVoiceAudios = await Promise.all(voiceSamples.map(async (voice) => { + const resource = await getResource(voice.sampleResourceId!); + const extension = audioFileExtension(resource.mimeType, resource.objectKey); + return { + id: `character-voice-${voice.assetId}`, + name: `${voice.characterName}-声音样本.${extension}`, + type: resource.mimeType || "audio/mpeg", + url: resourceFileUrl(voice.sampleResourceId!), + storageKey: resourceStorageKey(voice.sampleResourceId!), + } satisfies ReferenceAudio; + })); const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios]; const voiceBlocks = mode === "video" ? resolvedCharacterVoices.map(compileResolvedVoicePrompt) : []; return { diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx index d9ba2e033..fcc197a7a 100644 --- a/web/src/components/canvas/canvas-project-asset-modal.tsx +++ b/web/src/components/canvas/canvas-project-asset-modal.tsx @@ -49,7 +49,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined, imageFit: character ? "contain" : "cover", description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText, - searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "), + searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "), }; }), [items]); const pickerItems = useMemo(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]); diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index 956f6fcf8..724b6685e 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -1,7 +1,7 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react"; import { createPortal } from "react-dom"; -import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, Sparkles, UserRound, Video } from "lucide-react"; +import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -234,8 +234,8 @@ export const CanvasResourceMentionTextarea = forwardRef= 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined} - theme={theme} preferredWidth={mentionMenuWidth} onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)} onClose={closeMention} @@ -507,14 +507,14 @@ const ASSET_CATEGORY_LABELS: Record = { other: "其他", }; -function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, activeReferenceId, theme, preferredWidth, onQueryChange, onClose, onSelect }: { +function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: { anchor: HTMLElement; connectedReferences: CanvasResourceReference[]; assetReferences: CanvasResourceReference[]; filteredReferences: CanvasResourceReference[]; query: string; + cursorOffset: number; activeReferenceId?: string; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; preferredWidth: number; onQueryChange: (query: string) => void; onClose: () => void; @@ -523,14 +523,26 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef const menuRef = useRef(null); const selectedRef = useRef(false); const [category, setCategory] = useState(null); - const rect = anchor.getBoundingClientRect(); - const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 }; - const menuWidth = Math.min(preferredWidth, Math.max(0, boundary.right - boundary.left - 16)); - const maxMenuHeight = Math.min(320, Math.max(0, boundary.bottom - boundary.top - 16)); - const gap = 6; - const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8); - const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top; - const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8); + const [position, setPosition] = useState(() => mentionMenuPosition(anchor, cursorOffset, preferredWidth)); + + useLayoutEffect(() => { + let frame = 0; + const updatePosition = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => setPosition(mentionMenuPosition(anchor, cursorOffset, preferredWidth))); + }; + updatePosition(); + const observer = new ResizeObserver(updatePosition); + observer.observe(anchor); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [anchor, cursorOffset, preferredWidth]); const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => { event.stopPropagation(); @@ -566,7 +578,8 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef ref={menuRef} data-canvas-resource-mention-menu="true" className="canvas-resource-mention-menu fixed z-[var(--z-tooltip)]" - style={{ left, top, width: menuWidth, maxHeight: maxMenuHeight, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} + data-placement={position.showAbove ? "top" : "bottom"} + style={{ left: position.left, top: position.top, width: position.width, maxHeight: position.maxHeight, transform: position.showAbove ? "translateY(-100%)" : undefined }} onPointerDown={stopCanvasInteraction} onMouseDown={stopCanvasInteraction} onClick={(event) => event.stopPropagation()} @@ -575,7 +588,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef onQueryChange(event.target.value)} onPointerDown={(event) => event.stopPropagation()} @@ -595,7 +608,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
{query ? ( - + ) : category ? ( <> - + ) : ( <> {connectedNodes.length ? (
-

已连接节点

- +

已连接节点{connectedNodes.length}

+
) : null} {skillReferences.length ? (
-

技能库

- +

技能库{skillReferences.length}

+
) : null} {categoryItems.length ? (
-

素材库

+

素材库{assetReferences.length}

{categoryItems.map((item) => ( )); } function ReferencePreview({ reference }: { reference: CanvasResourceReference }) { - if (reference.kind === "image" && reference.previewUrl) return ; - if (reference.kind === "video" && reference.previewUrl) return
@@ -354,11 +410,16 @@ export default function WorkflowProductionWorkbench(props: Props) {
- + ); } +function formatTaskElapsed(startedAt: number, now: number) { + const totalSeconds = Math.max(0, Math.floor((now - (Number.isFinite(startedAt) ? startedAt : now)) / 1_000)); + return `${Math.floor(totalSeconds / 60)}分钟${String(totalSeconds % 60).padStart(2, "0")}秒`; +} + function WorkflowDisclosure({ icon, title, description, summary, className = "", children }: { icon: ReactNode; title: string; description: string; summary: ReactNode; className?: string; children: ReactNode }) { return (
@@ -371,14 +432,32 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "", ); } -function AssetLibrary({ detail, referencedVersionIds, binding, onBind }: { detail: ProjectDetail; referencedVersionIds: Set; binding: boolean; onBind: (asset: ProjectAsset) => void }) { +function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map; changing: boolean; onToggle: (asset: ProjectAsset, reference?: ShotAssetReference) => void }) { const groups = useMemo(() => { const map = new Map(); detail.assets.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset])); return Array.from(map.entries()); }, [detail.assets]); if (!groups.length) return ; - return
{groups.map(([category, assets]) =>

{assetCategoryLabel(category)} ({assets.length})

{assets.map((asset) => { const active = Boolean(asset.primaryVersionId && referencedVersionIds.has(asset.primaryVersionId)); const previewUrl = assetPreviewUrl(asset); return ; })}
)}
; + return
{groups.map(([category, assets]) =>

{assetCategoryLabel(category)} ({assets.length})

{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return ; })}
)}
; +} + +function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType["mentionReferences"]; variant?: "scene" | "motion" }) { + const isScene = variant === "scene"; + return ( + + ); } function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { detail: ProjectDetail; activeUnitId: string; projectId: string; activeStage: ShortDramaWorkflowStage }) { @@ -389,18 +468,26 @@ function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: return
{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return ; })}
; } -function BoundAssets({ detail, shotId }: { detail: ProjectDetail; shotId: string }) { +function BoundAssets({ detail, shotId, changing, onUnlink }: { detail: ProjectDetail; shotId: string; changing: boolean; onUnlink: (reference: ShotAssetReference) => void }) { const references = (detail.shotReferences || []).filter((item) => item.shotId === shotId); const assetByVersionId = useMemo(() => new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset])), [detail.assets]); return (
镜头资产{references.length ? `已绑定 ${references.length} 项` : "从左侧资产栏点击绑定"}
-
- {references.length ? references.map((reference) => { - const asset = assetByVersionId.get(reference.assetVersionId); - return {asset ? assetCategoryLabel(asset.category) : "历史"}{asset?.title || "历史资产版本"}; - }) : 尚未绑定角色、场景或道具} -
+ +
+ {references.length ? references.map((reference) => { + const asset = assetByVersionId.get(reference.assetVersionId); + const title = asset?.title || "历史资产版本"; + const previewUrl = asset ? assetPreviewUrl(asset) : ""; + return
+ {previewUrl ? {`${title}预览`} : } + {asset ? assetCategoryLabel(asset.category) : "历史"}{title} + +
; + }) : 尚未绑定角色、场景或道具} +
+
); } @@ -417,16 +504,31 @@ function ArtifactHistory({ artifacts, activeId, onSelect, compact = false }: { a return
历史版本
{artifacts.map((artifact) => )}
; } -function ShotTimeline({ detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) { - return
{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}
共 {shots.length} 镜
{shots.map((shot, index) => onSelectShot(shot.id)} />)}
; +function ShotTimeline({ activeStage, detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { activeStage: ShortDramaWorkflowStage; detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) { + const artifactType = artifactTypeForStage(activeStage); + const latestTaskByShotId = useMemo(() => { + const tasks = new Map(); + for (const task of detail.tasks || []) { + const shotId = task.clientContext?.shotId; + if (!shotId || task.clientContext?.artifactType !== artifactType) continue; + const current = tasks.get(shotId); + if (!current || task.updatedAt > current.updatedAt) tasks.set(shotId, task); + } + return tasks; + }, [artifactType, detail.tasks]); + return
{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}
共 {shots.length} 镜
{shots.map((shot, index) => onSelectShot(shot.id)} />)}
; } -function TimelineShot({ detail, shot, index, selected, onSelect }: { detail: ProjectDetail; shot: ProjectShot; index: number; selected: boolean; onSelect: () => void }) { +function TimelineShot({ artifactType, detail, shot, task, index, selected, onSelect }: { artifactType: string; detail: ProjectDetail; shot: ProjectShot; task?: GenerationTask; index: number; selected: boolean; onSelect: () => void }) { const video = currentArtifact(detail, shot.id, "video"); const previz = currentArtifact(detail, shot.id, "action_board"); - const preview = video?.resourceId ? video : previz?.resourceId ? previz : undefined; - const stateArtifact = video || previz; - return ; + const storyboard = currentArtifact(detail, shot.id, "storyboard"); + const preview = video?.resourceId ? video : previz?.resourceId ? previz : storyboard?.resourceId ? storyboard : undefined; + const stateArtifact = artifactType === "video" ? video : artifactType === "action_board" ? previz : storyboard; + const revision = currentRevision(detail, shot); + const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面"; + const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数"; + return ; } function revisionInput(values: ShotEditorValues): ShotRevisionInput { @@ -445,14 +547,6 @@ function revisionInput(values: ShotEditorValues): ShotRevisionInput { }; } -function shotReferenceImages(detail: ProjectDetail, shotId: string): ReferenceImage[] { - const versionIds = new Set((detail.shotReferences || []).filter((item) => item.shotId === shotId).map((item) => item.assetVersionId)); - return detail.assets.filter((asset) => asset.primaryVersionId && versionIds.has(asset.primaryVersionId) && asset.mediaType === "image" && asset.storageKey).map((asset) => { - const resourceId = resourceIdFromStorageKey(asset.storageKey); - return { id: asset.id, name: asset.title, type: "image/*", dataUrl: "", ...(resourceId ? { url: resourceFileUrl(resourceId) } : {}), storageKey: asset.storageKey }; - }); -} - async function downloadArtifact(artifact: ShotArtifact, shotTitle: string, onError: (content: string) => void) { if (!artifact.resourceId) return; try { diff --git a/web/src/pages/projects/detail/workflow-shared.tsx b/web/src/pages/projects/detail/workflow-shared.tsx index f626a5696..6111ab4d6 100644 --- a/web/src/pages/projects/detail/workflow-shared.tsx +++ b/web/src/pages/projects/detail/workflow-shared.tsx @@ -4,6 +4,7 @@ import { Check, CircleAlert } from "lucide-react"; import { Link } from "react-router"; import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects"; +import type { TaskStatus } from "@/services/api/task-center"; export type ShortDramaWorkflowStage = "story" | "assets" | "storyboard" | "previz" | "video" | "delivery"; @@ -38,11 +39,17 @@ export function MetricCard({ icon, label, value }: { icon: ReactNode; label: str return
{icon}
{value}
{label}
; } -export function ArtifactStatus({ artifact, compact = false }: { artifact?: ShotArtifact; compact?: boolean }) { - if (!artifact) return 待生成; - const color = artifact.status === "ready" ? "green" : artifact.status === "failed" ? "red" : artifact.status === "stale" ? "orange" : "blue"; - const label = artifact.status === "ready" ? "已生成" : artifact.status === "failed" ? "失败" : artifact.status === "stale" ? "已过期" : "生成中"; - return {label}; +export function ArtifactStatus({ artifact, taskStatus, compact = false }: { artifact?: ShotArtifact; taskStatus?: TaskStatus; compact?: boolean }) { + const className = `artifact-status-tag ${compact ? "!m-0" : ""}`; + if (taskStatus === "queued" || taskStatus === "running" || (taskStatus === "succeeded" && !artifact)) { + return 生成中; + } + if (taskStatus === "failed") return 生成失败; + if (!artifact) return 待生成; + const color = artifact.status === "ready" ? "success" : artifact.status === "failed" ? "error" : artifact.status === "stale" ? "warning" : "processing"; + const label = artifact.status === "ready" ? "已生成" : artifact.status === "failed" ? "生成失败" : artifact.status === "stale" ? "已过期" : "生成中"; + const tone = artifact.status === "ready" ? "ready" : artifact.status === "failed" ? "failed" : artifact.status === "stale" ? "stale" : "running"; + return {label}; } export function currentRevision(detail: ProjectDetail, shot?: ProjectShot): ShotRevision | undefined { diff --git a/web/src/pages/projects/detail/workflow-shot-references.ts b/web/src/pages/projects/detail/workflow-shot-references.ts new file mode 100644 index 000000000..16165671d --- /dev/null +++ b/web/src/pages/projects/detail/workflow-shot-references.ts @@ -0,0 +1,89 @@ +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources"; +import type { ProjectAsset, ProjectDetail } from "@/services/api/projects"; +import type { AssetCategory } from "@/stores/use-asset-store"; +import type { ReferenceImage } from "@/types/image"; + +export type ShotAssetReferenceContext = { + mentionReferences: CanvasResourceReference[]; + referenceImages: ReferenceImage[]; +}; + +const assetCategories = new Set(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]); + +export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext { + const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset])); + const seenAssetIds = new Set(); + const entries = (detail.shotReferences || []).flatMap((reference) => { + if (reference.shotId !== shotId || reference.status !== "linked") return []; + const asset = assetByVersionId.get(reference.assetVersionId); + if (!asset || seenAssetIds.has(asset.id)) return []; + const image = projectAssetReferenceImage(asset); + if (!image) return []; + seenAssetIds.add(asset.id); + return [{ asset, image }]; + }); + + return { + mentionReferences: entries.map(({ asset, image }) => ({ + id: `project-asset:${asset.id}`, + nodeId: "", + assetId: asset.id, + kind: asset.character ? "character" : "image", + label: asset.title, + title: asset.title, + previewUrl: image.url, + storageKey: image.storageKey, + active: true, + category: projectAssetCategory(asset.category), + })), + referenceImages: entries.map(({ image }) => image), + }; +} + +export function resolveShotAssetMentionPrompt(prompt: string, context: ShotAssetReferenceContext) { + const imageLabelByAssetId = new Map(context.mentionReferences.map((reference, index) => [reference.assetId, `图片${index + 1}`])); + const unresolved = new Set(); + const resolved = prompt.replace(/@\[asset:([^\]]+)\]/g, (token, assetId: string) => { + const label = imageLabelByAssetId.get(assetId); + if (!label) { + unresolved.add(token); + return token; + } + return label; + }); + if (unresolved.size) throw new Error(`提示词中的 ${Array.from(unresolved).join("、")} 未绑定到当前镜头,请重新选择资产或删除引用`); + return resolved; +} + +function projectAssetReferenceImage(asset: ProjectAsset): ReferenceImage | undefined { + const representation = asset.character + ? asset.character.representations.find((item) => item.role === "turnaround_sheet") + || asset.character.representations.find((item) => item.role === "primary") + || asset.character.representations.find((item) => item.role === "front") + : undefined; + if (representation) { + return { + id: asset.id, + name: asset.title, + type: "image/*", + dataUrl: "", + url: resourceFileUrl(representation.resourceId), + storageKey: resourceStorageKey(representation.resourceId), + }; + } + if (asset.mediaType !== "image" || !asset.storageKey) return undefined; + const resourceId = resourceIdFromStorageKey(asset.storageKey); + return { + id: asset.id, + name: asset.title, + type: "image/*", + dataUrl: "", + ...(resourceId ? { url: resourceFileUrl(resourceId) } : {}), + storageKey: asset.storageKey, + }; +} + +function projectAssetCategory(value: string): AssetCategory { + return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other"; +} diff --git a/web/src/pages/projects/detail/workflow.css b/web/src/pages/projects/detail/workflow.css index 2c2f9e108..44ef155a1 100644 --- a/web/src/pages/projects/detail/workflow.css +++ b/web/src/pages/projects/detail/workflow.css @@ -1,12 +1,19 @@ .workflow-page-root { display: flex; + width: 100%; height: 100%; + max-width: 100%; + min-width: 0; min-height: 0; flex-direction: column; + overflow: hidden; background: var(--workspace-content); } .workflow-stage-content { + width: 100%; + max-width: 100%; + min-width: 0; min-height: 0; flex: 1 1 auto; overflow: hidden; @@ -20,16 +27,24 @@ .workflow-production-shell { display: grid; + width: 100%; height: 100%; + max-width: 100%; + min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr) clamp(168px, 20vh, 210px); + overflow: hidden; background: var(--workspace-content); } .workflow-production-main { display: grid; + width: 100%; + max-width: 100%; + min-width: 0; min-height: 0; - grid-template-columns: 220px minmax(500px, 1fr) 370px; + grid-template-columns: 220px minmax(0, 1fr) minmax(300px, 370px); + overflow: hidden; border-bottom: 1px solid var(--workspace-border); } @@ -379,7 +394,7 @@ min-height: 48px; flex-wrap: wrap; align-items: center; - gap: 6px; + gap: 8px; margin-top: 5px; border: 1px dashed var(--workspace-border-strong); border-radius: var(--r-md); @@ -392,8 +407,10 @@ .workflow-bound-assets-content:hover { border-color: var(--workspace-border-strong); } .workflow-bound-asset-chip { - display: inline-flex; - min-width: 0; + display: flex; + min-width: 180px; + max-width: 280px; + flex: 1 1 220px; align-items: center; overflow: hidden; border: 1px solid var(--workspace-border); @@ -403,23 +420,111 @@ font-size: var(--fs-tiny); } -.workflow-bound-asset-chip em { - align-self: stretch; - padding: 4px 6px; - background: var(--workspace-accent-soft); +.workflow-bound-asset-preview { + display: grid; + width: 40px; + height: 40px; + flex: 0 0 40px; + place-items: center; + overflow: hidden; + border-right: 1px solid var(--workspace-border); + background: color-mix(in srgb, var(--workspace-surface-strong) 92%, var(--workspace-accent) 8%); + color: color-mix(in srgb, var(--foreground) 42%, transparent); +} + +.workflow-bound-asset-preview .ant-image, +.workflow-bound-asset-preview .ant-image-img { display: block; width: 40px !important; height: 40px !important; } +.workflow-bound-asset-preview .ant-image-img { object-fit: cover; } +.workflow-bound-asset-preview .ant-image-mask { font-size: var(--fs-micro); } +.workflow-bound-asset-preview > svg { width: 16px; height: 16px; } + +.workflow-bound-asset-copy { + display: grid; + min-width: 0; + flex: 1 1 auto; + gap: 2px; + padding: 5px 8px; +} + +.workflow-bound-asset-copy em { color: color-mix(in srgb, var(--foreground) 54%, transparent); font-size: var(--fs-micro); font-style: normal; font-weight: 650; } -.workflow-bound-asset-chip > span { +.workflow-bound-asset-copy strong { overflow: hidden; - padding: 4px 7px; + color: color-mix(in srgb, var(--foreground) 76%, transparent); + font-size: var(--fs-tiny); + font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } +.workflow-bound-asset-chip > button { + display: grid; + width: 25px; + height: 40px; + flex: 0 0 25px; + place-items: center; + border-left: 1px solid var(--workspace-border); + color: color-mix(in srgb, var(--foreground) 42%, transparent); +} + +.workflow-bound-asset-chip > button:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--foreground); +} + +.workflow-bound-asset-chip > button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: -2px; +} + +.workflow-bound-asset-chip > button > svg { width: 13px; height: 13px; } + +.workflow-shot-mention-container { + min-height: 82px; + overflow: visible; + border: 1px solid var(--workspace-border-strong); + border-radius: var(--r-md); + background: var(--workspace-surface); + transition: border-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out; +} + +.workflow-shot-mention-container.is-scene { min-height: 116px; } + +.workflow-shot-mention-container:focus-within { + border-color: var(--workspace-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 14%, transparent); +} + +.workflow-shot-mention-editor { + display: block; + width: 100%; + min-height: 80px; + max-height: 144px; + resize: vertical; + overflow-y: auto; + border: 0; + background: transparent; + padding: 8px 11px; + color: var(--foreground); + font-size: var(--fs-body); + line-height: 1.55; + outline: 0; +} + +.workflow-shot-mention-editor.is-scene { + min-height: 114px; + max-height: 220px; +} + +.workflow-shot-mention-editor:empty::before { + color: color-mix(in srgb, var(--foreground) 32%, transparent); +} + .workflow-editor-actions { display: flex; min-height: 58px; @@ -522,8 +627,12 @@ .workflow-shot-timeline { display: flex; + width: 100%; + max-width: 100%; + min-width: 0; min-height: 0; flex-direction: column; + overflow: hidden; background: var(--workspace-surface); } @@ -541,17 +650,22 @@ .workflow-shot-track { display: flex; + width: 100%; + max-width: 100%; + min-width: 0; min-height: 0; + box-sizing: border-box; flex: 1 1 auto; gap: 8px; overflow-x: auto; + overflow-y: hidden; padding: 0 14px 12px; } .workflow-timeline-shot, .workflow-add-shot-card { - width: 148px; - min-width: 148px; + width: 184px; + min-width: 184px; overflow: hidden; border: 1px solid var(--workspace-border); border-radius: var(--r-md); @@ -560,6 +674,8 @@ transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out; } +.workflow-timeline-shot { position: relative; display: flex; flex-direction: column; } + .workflow-timeline-shot:hover, .workflow-timeline-shot:focus-visible { border-color: var(--workspace-border-strong); background: var(--surface-hover); } .workflow-timeline-shot:focus-visible, @@ -568,7 +684,8 @@ .workflow-timeline-media { display: grid; - height: 62px; + height: 72px; + flex: 0 0 72px; place-items: center; overflow: hidden; background: color-mix(in srgb, var(--foreground) 5%, var(--workspace-surface)); @@ -577,11 +694,20 @@ .workflow-timeline-media img, .workflow-timeline-media video { width: 100%; height: 100%; object-fit: cover; } .workflow-timeline-media > svg { width: 20px; height: 20px; } -.workflow-timeline-copy { display: block; padding: 7px 9px 8px; } -.workflow-timeline-copy > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 6px; } -.workflow-timeline-copy strong { font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums; } -.workflow-timeline-copy small { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: 10px; } -.workflow-timeline-copy em { display: block; overflow: hidden; margin: 3px 0 6px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-micro); font-style: normal; text-overflow: ellipsis; white-space: nowrap; } +.workflow-timeline-copy { display: flex; min-height: 0; flex: 1 1 auto; flex-direction: column; padding: 8px 10px 34px; } +.workflow-timeline-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.workflow-timeline-heading strong { font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums; } +.workflow-timeline-heading b { color: var(--foreground); font-size: var(--fs-caption); font-weight: 750; font-variant-numeric: tabular-nums; } +.workflow-timeline-title { display: block; min-height: 16px; overflow: hidden; margin-top: 5px; color: color-mix(in srgb, var(--foreground) 72%, transparent); font-size: var(--fs-label); font-style: normal; font-weight: 550; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; } +.workflow-timeline-meta { display: block; overflow: hidden; margin-top: 4px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; } +.workflow-timeline-status { position: absolute; right: 10px; bottom: 8px; left: 10px; display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 6px; } +.workflow-timeline-status > span:first-child { min-width: 0; overflow: hidden; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; } +.artifact-status-tag.ant-tag { display: inline-flex; min-height: 20px; flex: 0 0 auto; align-items: center; border-radius: var(--r-full); padding-inline: 7px; font-size: var(--fs-micro); font-weight: 600; line-height: 18px; white-space: nowrap; } +.artifact-status-tag.is-pending.ant-tag { border-color: color-mix(in srgb, var(--foreground) 18%, transparent) !important; background: color-mix(in srgb, var(--foreground) 7%, transparent) !important; color: color-mix(in srgb, var(--foreground) 62%, transparent) !important; } +.artifact-status-tag.is-running.ant-tag { border-color: color-mix(in srgb, var(--palette-status-loading) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-loading) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-loading) 82%, var(--foreground)) !important; } +.artifact-status-tag.is-ready.ant-tag { border-color: color-mix(in srgb, var(--palette-status-success) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-success) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-success) 82%, var(--foreground)) !important; } +.artifact-status-tag.is-failed.ant-tag { border-color: color-mix(in srgb, var(--palette-status-error) 45%, transparent) !important; background: color-mix(in srgb, var(--palette-status-error) 13%, transparent) !important; color: color-mix(in srgb, var(--palette-status-error) 82%, var(--foreground)) !important; } +.artifact-status-tag.is-stale.ant-tag { border-color: color-mix(in srgb, var(--palette-status-warning) 42%, transparent) !important; background: color-mix(in srgb, var(--palette-status-warning) 12%, transparent) !important; color: color-mix(in srgb, var(--palette-status-warning) 80%, var(--foreground)) !important; } .workflow-add-shot-card { display: grid; @@ -624,12 +750,12 @@ } @media (max-width: 1280px) { - .workflow-production-main { grid-template-columns: 204px minmax(420px, 1fr) 320px; } + .workflow-production-main { grid-template-columns: 204px minmax(0, 1fr) minmax(280px, 320px); } .workflow-disclosure-summary > span.is-model { display: none; } } @media (max-width: 1050px) { - .workflow-production-main { grid-template-columns: minmax(420px, 1fr) 300px; } + .workflow-production-main { grid-template-columns: minmax(0, 1fr) 300px; } .workflow-library-panel { display: none; } } @@ -644,6 +770,12 @@ .workflow-shot-form-scroll { padding-inline: 14px; } .workflow-timeline-shot, .workflow-add-shot-card { width: 138px; min-width: 138px; } + .workflow-timeline-media { height: 52px; flex-basis: 52px; } + .workflow-timeline-title, + .workflow-timeline-meta, + .workflow-timeline-status > span:first-child { display: none; } + .workflow-timeline-copy { padding: 6px 8px 30px; } + .workflow-timeline-status { right: 8px; bottom: 6px; left: 8px; justify-content: flex-end; } .workflow-overview-scroll { padding: 20px 16px 48px; } } diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index 7532a68e5..93015a2f0 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from "react-router"; import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; import { CanvasStylePickerModal, resolveCanvasStylePreset, resolveProjectCanvasStyle, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; +import { resourceFileUrl } from "@/services/api/resources"; import { ModelPicker } from "@/components/model-picker"; import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile"; import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; @@ -373,7 +374,7 @@ function ProjectRow({ row, onDelete }: { row: ProjectSummary; onDelete: () => vo const stage = projectSummaryStage(row); const projectStyle = resolveProjectCanvasStyle(row.project.stylePresetId, row.project.styleProfileJson); const styleTitle = projectStyle?.title || parseStyleProfile(row.project.styleProfileJson)?.title || resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风"); - const coverUrl = projectStyle?.imageUrl; + const coverUrl = row.project.coverResourceId ? resourceFileUrl(row.project.coverResourceId) : projectStyle?.imageUrl; return ( diff --git a/web/src/pages/skills/index.tsx b/web/src/pages/skills/index.tsx index 15dcdee78..ebb9af89c 100644 --- a/web/src/pages/skills/index.tsx +++ b/web/src/pages/skills/index.tsx @@ -3,13 +3,14 @@ import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, Mo import type { LucideIcon } from "lucide-react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; -import { ListToolbar, PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page"; +import { ListToolbar, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLabel } from "@/pages/skills/skill-catalog"; -import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer"; +import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer"; import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer"; -import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills"; +import { SkillInstallModal } from "@/pages/skills/skill-install-modal"; +import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills"; const scopeOptions = [ { label: "技能广场", value: "public", icon: Sparkles }, @@ -64,6 +65,7 @@ export default function SkillsPage() { const [detailLoading, setDetailLoading] = useState(false); const [mutatingID, setMutatingID] = useState(""); const [editorOpen, setEditorOpen] = useState(false); + const [installOpen, setInstallOpen] = useState(false); const [editingSkill, setEditingSkill] = useState(null); const reload = useCallback(() => setReloadKey((value) => value + 1), []); @@ -163,6 +165,20 @@ export default function SkillsPage() { } }; + const synchronizeSkill = async (skill: Skill) => { + setMutatingID(skill.skill_id); + try { + const result = await syncSkill(skill.skill_id); + patchSkill(result.skill); + message.success(result.skill.version_id === skill.version_id ? "已是最新版本" : "已同步最新版本"); + reload(); + } catch (error) { + message.error(error instanceof Error ? error.message : "GitHub 技能同步失败"); + } finally { + setMutatingID(""); + } + }; + const confirmDelete = (skill: Skill) => { modal.confirm({ title: `删除“${skill.skill_name}”?`, @@ -239,7 +255,7 @@ export default function SkillsPage() { {group.skills.length} 个
- {groupedSkills[0] === group ? : null} + {groupedSkills[0] === group ? : null} {group.skills.map((skill, index) => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
@@ -256,7 +272,7 @@ export default function SkillsPage() { action={filtersActive ? : (scope === "created" || scope === "public") - ? + ? : undefined} /> )} @@ -264,7 +280,8 @@ export default function SkillsPage() { { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> - setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} /> + setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} /> + setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} /> setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} /> ); diff --git a/web/src/pages/skills/skill-detail-drawer.tsx b/web/src/pages/skills/skill-detail-drawer.tsx index 826cbac0b..c5a8c3114 100644 --- a/web/src/pages/skills/skill-detail-drawer.tsx +++ b/web/src/pages/skills/skill-detail-drawer.tsx @@ -1,57 +1,276 @@ -import { Button, Drawer, Skeleton, Tooltip } from "antd"; -import { Check, ExternalLink, Heart, Pencil, Plus, Users } from "lucide-react"; +import { Button, Input, Modal, Segmented, Skeleton, Tooltip, Tree } from "antd"; +import type { DataNode } from "antd/es/tree"; +import { Check, ChevronRight, Code2, ExternalLink, File, FileArchive, FileCode2, FileImage, FileText, Folder, FolderOpen, Heart, Pencil, Plus, RefreshCw, Users } from "lucide-react"; +import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { formatSkillCount, formatSkillDate, skillCategoryLabel } from "@/pages/skills/skill-catalog"; -import type { Skill, SkillCategory } from "@/services/api/skills"; +import { getSkillFile, listSkillFiles, skillFileRawURL, type Skill, type SkillCategory, type SkillPackageFile, type SkillPackageFileContent } from "@/services/api/skills"; + +type PreviewMode = "preview" | "source"; + +export function SkillDetailModal({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit, onSync }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void; onSync: (skill: Skill) => void }) { + const [files, setFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(false); + const [filesError, setFilesError] = useState(""); + const [activePath, setActivePath] = useState("SKILL.md"); + const [content, setContent] = useState(null); + const [contentLoading, setContentLoading] = useState(false); + const [contentError, setContentError] = useState(""); + const [previewMode, setPreviewMode] = useState("preview"); + const [pathFilter, setPathFilter] = useState(""); + + useEffect(() => { + if (!skill) { + setFiles([]); + setContent(null); + return; + } + let cancelled = false; + setFilesLoading(true); + setFilesError(""); + setPathFilter(""); + listSkillFiles(skill.skill_id) + .then((result) => { + if (cancelled) return; + setFiles(result.files); + const first = result.files.find((file) => file.path === "SKILL.md")?.path || result.files[0]?.path || ""; + setActivePath(first); + }) + .catch((error) => { + if (cancelled) return; + setFiles([]); + setFilesError(error instanceof Error ? error.message : "技能文件加载失败"); + }) + .finally(() => { + if (!cancelled) setFilesLoading(false); + }); + return () => { cancelled = true; }; + }, [skill?.skill_id, skill?.version_id]); + + useEffect(() => { + if (!skill || !activePath) { + setContent(null); + return; + } + let cancelled = false; + setContentLoading(true); + setContentError(""); + getSkillFile(skill.skill_id, activePath) + .then((result) => { if (!cancelled) setContent(result.file); }) + .catch((error) => { + if (cancelled) return; + setContent(null); + setContentError(error instanceof Error ? error.message : "文件读取失败"); + }) + .finally(() => { if (!cancelled) setContentLoading(false); }); + return () => { cancelled = true; }; + }, [activePath, skill?.skill_id, skill?.version_id]); + + useEffect(() => { + setPreviewMode("preview"); + }, [activePath]); + + const visibleFiles = useMemo(() => { + const needle = pathFilter.trim().toLowerCase(); + return needle ? files.filter((file) => file.path.toLowerCase().includes(needle)) : files; + }, [files, pathFilter]); + const treeData = useMemo(() => buildSkillTree(visibleFiles), [visibleFiles]); + const filePaths = useMemo(() => new Set(files.map((file) => file.path)), [files]); + const selectedFile = files.find((file) => file.path === activePath); + const canPreviewMarkdown = selectedFile?.kind === "markdown" && !content?.binary; -export function SkillDetailDrawer({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void }) { return ( - } onClick={() => onEdit(skill)}>编辑 : undefined}> + {skill ? ( -
-
-
- {skillCategoryLabel(skill.tag, categories)}{skill.is_private ? "仅自己可见" : "公开技能"}更新于 {formatSkillDate(skill.update_time)} +
+
+
+
+ {skillCategoryLabel(skill.tag, categories)}{sourceLabel(skill.source_type)}v{skill.version || "1"}更新于 {formatSkillDate(skill.update_time)} +
+

{skill.skill_name}

+

{skill.description}

-

{skill.description}

-
- +
+ {skill.source_type === "github" && skill.is_owner ? : null} + {skill.is_owner ? : null} - {formatSkillCount(skill.added_count)} 人加入 +
- {skill.showcase_media.length ? : null} +
+ -
-
-

技能指令

- {skill.markdown_url ? : null} -
- {loading ? :
{skill.instruction || "暂无技能指令"}
} -
- -
-
作者:{skill.effective_user.name || "未知用户"}
- {skill.extra_info ?
{skill.extra_info}
: null} -
+
+
+
{activePath || "请选择文件"}
+ {canPreviewMarkdown ? setPreviewMode(value as PreviewMode)} options={[{ value: "preview", label: 预览 }, { value: "source", label: 源码 }]} /> : null} + {activePath ? : null} +
+
+ {contentLoading ? : contentError ?
{contentError}
: content && selectedFile ? ( + + ) :
从左侧选择一个文件
} +
+
+
) : null} - + ); } -function SkillMediaGallery({ skill }: { skill: Skill }) { +function SkillFilePreview({ skill, file, content, mode, filePaths, onNavigate }: { skill: Skill; file: SkillPackageFile; content: SkillPackageFileContent; mode: PreviewMode; filePaths: Set; onNavigate: (path: string) => void }) { + const rawURL = skillFileRawURL(skill.skill_id, file.path); + if (content.binary) { + if (file.kind === "image") return
{file.path}
; + if (file.kind === "video") return
; + if (file.kind === "audio") return
; + return
该文件不支持在线预览
打开原始文件
; + } + if (file.kind === "markdown" && mode === "preview") { + return ; + } + return
{content.content}
; +} + +function SkillMarkdown({ source, currentPath, filePaths, onNavigate }: { source: string; currentPath: string; filePaths: Set; onNavigate: (path: string) => void }) { + const navigate = (event: MouseEvent, href?: string) => { + const target = resolveSkillPath(currentPath, href || ""); + if (!target || !filePaths.has(target)) return; + event.preventDefault(); + onNavigate(target); + }; return ( -
-

展示案例

-
- {skill.showcase_media.map((media, index) => ( -
- {media.type === "video" ?
- ))} -
-
+
+ navigate(event, href)}>{children}, + table: ({ children }) =>
{children}
, + img: ({ src, alt }) => { + if (isExternalURL(src)) return {alt; + const target = resolveSkillPath(currentPath, src || ""); + if (target && filePaths.has(target)) return ; + return {alt || src || "图片"}; + }, + }} + >{source}
+
); } + +function buildSkillTree(files: SkillPackageFile[]): DataNode[] { + type MutableNode = DataNode & { children?: MutableNode[]; file?: SkillPackageFile }; + const roots: MutableNode[] = []; + const folders = new Map(); + for (const file of files) { + const parts = file.path.split("/"); + let children = roots; + let prefix = ""; + parts.forEach((part, index) => { + const isLeaf = index === parts.length - 1; + prefix = prefix ? `${prefix}/${part}` : part; + if (isLeaf) { + children.push({ key: file.path, title: treeTitle(part, file), icon: fileIcon(file), isLeaf: true, file }); + return; + } + let folder = folders.get(prefix); + if (!folder) { + folder = { + key: `folder:${prefix}`, + title: {part}, + icon: ({ expanded }) => expanded ?