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 000000000..86ad26eec Binary files /dev/null and b/assets/user-rou.jpg differ 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 ?