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 | [cihai.sea@gmail.com](mailto:cihai.sea@gmail.com) | AI 界热于助人的拖油瓶 |
|
| QAyong
ID:QAyong
B站:QAyong | [2110491559@qq.com](mailto:2110491559@qq.com) | AI 短剧合规,资产确权 |
|
| _K37ix. | [2773843782@qq.com](mailto:2773843782@qq.com) | Making things that think |
+|
| 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