diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
index 265d0c566..0b3913bd4 100644
--- a/.agents/plugins/marketplace.json
+++ b/.agents/plugins/marketplace.json
@@ -1,7 +1,7 @@
{
"name": "infinite-canvas-local",
"interface": {
- "displayName": "Infinite Canvas Local"
+ "displayName": "影策 Local"
},
"plugins": [
{
diff --git a/.gitignore b/.gitignore
index 05e611f22..b824d1466 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ docs/
!.env.example
data/
.local/
+/.local/docker-compose.postgres.yml
coverage/
dist/
*.log
diff --git a/AGENTS.md b/AGENTS.md
index 8e39c7e73..2527c3dad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,7 @@
- 默认推进到可交付状态,但只改任务相关文件,不回滚或覆盖已有用户修改。
- 优先小而完整的实现,不为假想兼容场景增加分支,不引入无实际收益的抽象。
- 项目尚未上线;除非用户要求,不为旧字段和旧数据编写迁移兼容层。
+- 本工作区本地启动后端时必须复用 `.local/project-workbench-debug`,通过 `CANVAS_BACKEND_DATA_DIR` 显式指定;启动前先确认该目录中的现有数据库,禁止直接使用 `backend/data` 创建或切换到另一套本地账号数据。
- 按用户要求,写完代码不执行语法检查、测试或构建;交付时明确说明未运行验证。
- 反复出现的项目约束应补充到本文件,规则要具体、可执行。
@@ -39,6 +40,9 @@
- 页面私有组件和 hook 放在页面目录内;只有真实跨页面复用的能力才上移到全局目录。
- 已在全局 store 或 hook 中的状态直接读取,不通过多层 props 透传。复制、下载、确认等跨页面 UI 副作用优先收敛为全局 hook。
- UI 图标优先使用 `lucide-react` 或项目已有 Ant Design 图标;页面文案使用中文。
+- 页面身份图标统一使用 `WorkspaceSignalIcon`,导航与操作图标统一使用 `lucide-react`;同一角色不得并行创造另一套图标容器或视觉语言。
+- 页面级分类导航统一使用至少 44px 高的“图标 + 明确文字”入口和一致选中态;项目设置等重要入口不得只藏在无文字图标中。
+- 可编辑的工作区、画布和对象名称必须常显铅笔图标并支持单击编辑;双击和右键只能作为快捷方式,不能作为唯一发现路径。画布顶栏名称采用纯文字原位编辑,只显示文本光标,不使用大面积输入框、下划线或自动焦点框破坏标题层级。
- 私有样式优先用 Tailwind 或少量内联样式;`globals.css` 只放变量、重置、通用样式和必要的第三方覆盖。
- 业务列表、生成记录、媒体和大 JSON 使用 `localforage`;`localStorage` 只保存极小配置。
diff --git a/README.md b/README.md
index b4eea2aac..cc623abba 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-
+
-无限画布
+影策
-面向 AI 影视创作的开源画布工作台
+让一个故事,从文字走向银幕
@@ -12,7 +12,9 @@
-无限画布把节点编排、图片/视频/音频生成、结构化分镜、3D 导演台、素材库、异步任务和 Agent 会话放在同一个创作空间中,适合连续探索视觉方案并沉淀影视资产。
+一个故事也许始于一页小说、一个人物,或一句还没写完的对白。影策从章节中梳理角色与情节,让人物的外观、声音和气质成为可复用的角色资产,再把分镜、图片、视频和音频组织在同一张画布上。从最初的文字到可以被看见、被听见的镜头,创作者始终掌握故事的方向。
+
+影策是一款面向 AI 影视与短剧创作的开源工作台,集成自由画布、结构化分镜、角色卡、3D 导演台、素材库、异步生成任务和 Agent 协作能力。
> 项目仍在快速开发,数据结构可能直接调整。当前更适合个人、本地或可信环境部署,不建议未经安全配置直接开放公网多人使用。
@@ -23,7 +25,7 @@
## 主要功能
-- **无限画布**:多项目、节点与连线、框选布局、撤销重做、小地图、导入导出和公开只读分享。
+- **自由画布**:多项目、节点与连线、框选布局、撤销重做、小地图、导入导出和公开只读分享。
- **AI 生成**:支持文本、图片、视频和音频任务,以及参考图编辑、首尾帧、运镜、视频续写和局部修改。
- **影视工作流**:结构化分镜脚本、角色卡、批量镜头节点、3D 导演台和控制图回写。
- **任务与素材**:后端异步队列、任务日志、失败重试、素材库及登录后的后端同步。
@@ -64,7 +66,7 @@
Issue 反馈、技术讨论和产品升级建议都可以在 QQ 群中沟通。群内还会不定期组织 AI 学习与培训交流会。
-
+
## 新服务器一键部署(推荐)
diff --git a/assets/prototypes/project-canvas-ui-interactions.drawio b/assets/prototypes/project-canvas-ui-interactions.drawio
new file mode 100644
index 000000000..173fc1ad6
--- /dev/null
+++ b/assets/prototypes/project-canvas-ui-interactions.drawio
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 9c7055c52..a95e7420a 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -48,6 +48,9 @@ func main() {
if err := svc.EnsureDefaultStoryboardPromptTemplate(); err != nil {
log.Fatal(err)
}
+ if err := svc.EnsureBuiltinProjectWorkflowTemplate(); 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 != "" {
@@ -79,10 +82,11 @@ func main() {
handler.RegisterSessionRoutes(api, svc)
handler.RegisterSkillRoutes(api, svc)
handler.RegisterUserDataRoutes(api, svc)
+ handler.RegisterProjectRoutes(api, svc)
handler.RegisterCanvasShareRoutes(api, svc)
addr := env("CANVAS_BACKEND_ADDR", ":8080")
- log.Printf("Infinite Canvas backend listening on %s", addr)
+ log.Printf("影策 backend listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatal(err)
}
diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go
index fe2f80375..a598b3323 100644
--- a/backend/internal/database/schema.go
+++ b/backend/internal/database/schema.go
@@ -31,6 +31,21 @@ func Models() []any {
&model.UserSkillState{},
&model.Resource{},
&model.Asset{},
+ &model.ProjectAssetLink{},
+ &model.ProjectAssetCandidate{},
+ &model.AssetVersion{},
+ &model.AssetRepresentation{},
+ &model.VoiceProfile{},
+ &model.CharacterVoiceBinding{},
+ &model.Project{},
+ &model.ProjectUnit{},
+ &model.CanvasUnitLink{},
+ &model.Shot{},
+ &model.ShotAssetReference{},
+ &model.WorkflowTemplateVersion{},
+ &model.WorkflowInstance{},
+ &model.WorkflowStepInstance{},
+ &model.WorkflowStepTask{},
&model.CanvasProject{},
&model.CanvasShare{},
&model.StoryboardPromptTemplate{},
@@ -49,6 +64,10 @@ func MigrateSchema(db *gorm.DB) error {
if err := db.AutoMigrate(Models()...); err != nil {
return err
}
+ // 逻辑删除后的同名模型允许重新添加,旧唯一索引不能继续覆盖已删除记录。
+ if err := db.Exec("DROP INDEX IF EXISTS idx_channel_model_key").Error; err != nil {
+ return err
+ }
if err := db.Exec("DROP INDEX IF EXISTS idx_users_email").Error; err != nil {
return err
}
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index db176eb9b..5e4378a99 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -623,7 +623,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo:
capability = "video"
}
- order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"))
+ order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body))
if err != nil {
failService(c, err)
return
diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go
index 55a363962..bc0aef265 100644
--- a/backend/internal/handler/finance.go
+++ b/backend/internal/handler/finance.go
@@ -234,7 +234,7 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
- if err := svc.DisableAdminChannelModel(user, c.Param("id"), c.Param("modelId")); err != nil {
+ if err := svc.DeleteAdminChannelModel(user, c.Param("id"), c.Param("modelId")); err != nil {
failService(c, err)
return
}
diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go
new file mode 100644
index 000000000..91e54cf97
--- /dev/null
+++ b/backend/internal/handler/project.go
@@ -0,0 +1,635 @@
+package handler
+
+import (
+ "net/http"
+
+ "infinite-canvas/backend/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
+ r.GET("/voice-profiles", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ profiles, err := svc.ListVoiceProfiles(user.ID)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"profiles": profiles})
+ })
+ r.GET("/projects", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ projects, err := svc.ListProjects(user.ID)
+ if err != nil {
+ fail(c, http.StatusInternalServerError, err)
+ return
+ }
+ ok(c, gin.H{"projects": projects})
+ })
+ r.POST("/projects", 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, 64<<10)
+ var req service.CreateProjectRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ project, err := svc.CreateProject(user.ID, req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"project": project})
+ })
+ r.GET("/projects/:id", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ detail, err := svc.ProjectDetail(user.ID, c.Param("id"))
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ fail(c, http.StatusInternalServerError, err)
+ return
+ }
+ ok(c, detail)
+ })
+ r.PATCH("/projects/:id", 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, 64<<10)
+ var req service.UpdateProjectRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ project, err := svc.UpdateProject(user.ID, c.Param("id"), req)
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"project": project})
+ })
+ r.DELETE("/projects/:id", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.DeleteProject(user.ID, c.Param("id")); err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"id": c.Param("id")})
+ })
+ r.POST("/projects/:id/units", 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, 2<<20)
+ var req service.CreateProjectUnitRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ unit, err := svc.CreateProjectUnit(user.ID, c.Param("id"), req)
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"unit": unit})
+ })
+ r.GET("/projects/:id/units/:unitId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ unit, err := svc.GetProjectUnit(user.ID, c.Param("id"), c.Param("unitId"))
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"unit": unit})
+ })
+ r.POST("/projects/:id/units/import", 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<<20)
+ var req service.ImportProjectUnitsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ units, err := svc.ImportProjectUnits(user.ID, c.Param("id"), req)
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ // 导入响应只需返回新章节标识与摘要,正文不再原样回传一次。
+ for index := range units {
+ units[index].SourceText = ""
+ }
+ ok(c, gin.H{"units": units})
+ })
+ r.PATCH("/projects/:id/units/reorder", 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, 256<<10)
+ var req service.ReorderProjectUnitsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ if err := svc.ReorderProjectUnits(user.ID, c.Param("id"), req); err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"unitIds": req.UnitIDs})
+ })
+ r.PATCH("/projects/:id/units/:unitId", 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, 2<<20)
+ var req service.UpdateProjectUnitRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ unit, err := svc.UpdateProjectUnit(user.ID, c.Param("id"), c.Param("unitId"), req)
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"unit": unit})
+ })
+ r.DELETE("/projects/:id/units/:unitId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.DeleteProjectUnit(user.ID, c.Param("id"), c.Param("unitId")); err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"id": c.Param("unitId")})
+ })
+ r.POST("/projects/:id/canvas-links", 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, 64<<10)
+ var req service.LinkCanvasUnitRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ link, err := svc.LinkCanvasUnit(user.ID, c.Param("id"), req)
+ if err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"link": link})
+ })
+ r.DELETE("/projects/:id/canvas-links/:canvasId/units/:unitId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.UnlinkCanvasUnit(user.ID, c.Param("id"), c.Param("canvasId"), c.Param("unitId")); err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"canvasId": c.Param("canvasId"), "unitId": c.Param("unitId")})
+ })
+ r.DELETE("/projects/:id/canvases/:canvasId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.UnlinkCanvasProject(user.ID, c.Param("id"), c.Param("canvasId")); err != nil {
+ if service.IsProjectNotFound(err) {
+ fail(c, http.StatusNotFound, err)
+ return
+ }
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"canvasId": c.Param("canvasId")})
+ })
+ r.GET("/projects/:id/assets", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ assets, err := svc.FilterProjectAssets(user.ID, c.Param("id"), service.ProjectAssetFilter{Category: c.Query("category"), MediaType: c.Query("mediaType"), Status: c.Query("status"), Usage: c.Query("usage")})
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"assets": assets})
+ })
+ r.POST("/projects/:id/characters", 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, 256<<10)
+ var req service.CreateProjectCharacterRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ character, err := svc.CreateProjectCharacter(user.ID, c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.GET("/projects/:id/characters/:assetId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ character, err := svc.ProjectCharacter(user.ID, c.Param("id"), c.Param("assetId"))
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.PATCH("/projects/:id/characters/:assetId", 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, 256<<10)
+ var req service.UpdateProjectCharacterRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ character, err := svc.UpdateProjectCharacter(user.ID, c.Param("id"), c.Param("assetId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.PUT("/projects/:id/characters/:assetId/representations", 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, 128<<10)
+ var req service.ReplaceCharacterRepresentationsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ character, err := svc.ReplaceProjectCharacterRepresentations(user.ID, c.Param("id"), c.Param("assetId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.PUT("/projects/:id/characters/:assetId/voice", 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, 64<<10)
+ var req service.BindCharacterVoiceRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ character, err := svc.BindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.DELETE("/projects/:id/characters/:assetId/voice", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ character, err := svc.UnbindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId"))
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, character)
+ })
+ r.POST("/projects/:id/assets", 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, 64<<10)
+ var req service.LinkProjectAssetRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ asset, err := svc.LinkProjectAsset(user.ID, c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"asset": asset})
+ })
+ r.DELETE("/projects/:id/assets/:assetId", func(c *gin.Context) {
+ user, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if err := svc.UnlinkProjectAsset(user.ID, c.Param("id"), c.Param("assetId")); err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"id": c.Param("assetId")})
+ })
+ r.PATCH("/projects/:id/assets/:assetId", 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, 64<<10)
+ var req service.UpdateProjectAssetCategoryRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ asset, err := svc.UpdateProjectAssetCategory(user.ID, c.Param("id"), c.Param("assetId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"asset": asset})
+ })
+ r.POST("/projects/:id/assets/:assetId/versions", 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, 256<<10)
+ var req service.CreateAssetVersionRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ version, err := svc.CreateProjectAssetVersion(user.ID, c.Param("id"), c.Param("assetId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"version": version})
+ })
+ r.POST("/projects/:id/workflows", 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, 64<<10)
+ var req struct {
+ UnitID string `json:"unitId"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ workflow, err := svc.CreateUnitWorkflow(user.ID, c.Param("id"), req.UnitID)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"workflow": workflow})
+ })
+ r.PATCH("/projects/:id/workflow-steps/:stepId", 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, 256<<10)
+ var req service.UpdateWorkflowStepRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ step, err := svc.UpdateWorkflowStep(user.ID, c.Param("id"), c.Param("stepId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"step": step})
+ })
+ r.POST("/projects/:id/workflow-steps/:stepId/task-output", 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, 256<<10)
+ var req service.RegisterTaskOutputRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ step, err := svc.RegisterTaskOutput(user.ID, c.Param("id"), c.Param("stepId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"step": step})
+ })
+ r.POST("/projects/:id/shots", 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, 256<<10)
+ var req service.CreateProjectShotRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ shot, err := svc.CreateProjectShot(user.ID, c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"shot": shot})
+ })
+ r.PUT("/projects/:id/units/:unitId/shots", 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, 2<<20)
+ var req service.ReplaceProjectUnitShotsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ shots, err := svc.ReplaceProjectUnitShots(user.ID, c.Param("id"), c.Param("unitId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"shots": shots})
+ })
+ r.POST("/projects/:id/shots/:shotId/assets", 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, 64<<10)
+ var req service.LinkShotAssetRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ reference, err := svc.LinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"reference": reference})
+ })
+ r.POST("/projects/:id/asset-candidates", 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, 512<<10)
+ var req service.CreateAssetCandidatesRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ candidates, err := svc.CreateProjectAssetCandidates(user.ID, c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"candidates": candidates})
+ })
+ r.POST("/projects/:id/asset-candidates/:candidateId/confirm", 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, 64<<10)
+ var req service.ConfirmProjectAssetCandidateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ asset, err := svc.ConfirmProjectAssetCandidate(user.ID, c.Param("id"), c.Param("candidateId"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"asset": asset})
+ })
+}
diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go
index 080a9c83d..ed9479103 100644
--- a/backend/internal/handler/security.go
+++ b/backend/internal/handler/security.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
+ "fmt"
"io"
"mime"
"mime/multipart"
@@ -11,6 +12,7 @@ import (
"net/url"
"path"
"regexp"
+ "strconv"
"strings"
"time"
@@ -221,3 +223,35 @@ func proxyRequestModel(contentType string, body []byte) string {
modelName, _ := payload["model"].(string)
return strings.TrimSpace(modelName)
}
+
+func proxyRequestVideoSeconds(contentType string, body []byte) int64 {
+ mediaType, params, _ := mime.ParseMediaType(contentType)
+ if strings.HasPrefix(mediaType, "multipart/") {
+ reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
+ for {
+ part, err := reader.NextPart()
+ if err != nil {
+ return 0
+ }
+ if part.FormName() == "seconds" || part.FormName() == "duration" {
+ value, _ := io.ReadAll(io.LimitReader(part, 32))
+ seconds, _ := strconv.ParseInt(strings.TrimSpace(string(value)), 10, 64)
+ return seconds
+ }
+ _ = part.Close()
+ }
+ }
+ var payload map[string]any
+ if json.Unmarshal(body, &payload) != nil {
+ return 0
+ }
+ for _, key := range []string{"seconds", "duration"} {
+ value, exists := payload[key]
+ if !exists {
+ continue
+ }
+ seconds, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
+ return seconds
+ }
+ return 0
+}
diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go
index 1936b7cde..694f26da2 100644
--- a/backend/internal/model/models.go
+++ b/backend/internal/model/models.go
@@ -1,6 +1,10 @@
package model
-import "time"
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
type TaskStatus string
type SessionStatus string
@@ -15,6 +19,13 @@ type CreditLedgerType string
type RedeemCodeStatus string
type AnnouncementStatus string
type AnnouncementLevel string
+type ProjectStatus string
+type ProjectUnitKind string
+type ProjectUnitStatus string
+type AssetCategory string
+type AssetVersionStatus string
+type WorkflowStatus string
+type WorkflowStepStatus string
// AdminAuditEvent 只允许追加,用于还原管理员写操作,禁止作为可编辑业务状态使用。
type AdminAuditEvent struct {
@@ -90,6 +101,41 @@ const (
AnnouncementLevelSuccess AnnouncementLevel = "success"
AnnouncementLevelWarning AnnouncementLevel = "warning"
AnnouncementLevelCritical AnnouncementLevel = "critical"
+
+ ProjectStatusActive ProjectStatus = "active"
+ ProjectStatusArchived ProjectStatus = "archived"
+
+ ProjectUnitKindChapter ProjectUnitKind = "chapter"
+ ProjectUnitKindEpisode ProjectUnitKind = "episode"
+
+ ProjectUnitStatusDraft ProjectUnitStatus = "draft"
+ ProjectUnitStatusReady ProjectUnitStatus = "ready"
+ ProjectUnitStatusCompleted ProjectUnitStatus = "completed"
+
+ AssetCategoryCharacter AssetCategory = "character"
+ AssetCategoryEnvironment AssetCategory = "environment"
+ AssetCategoryWardrobe AssetCategory = "wardrobe"
+ AssetCategoryProp AssetCategory = "prop"
+ AssetCategoryWeapon AssetCategory = "weapon"
+ AssetCategoryStyle AssetCategory = "style"
+ AssetCategoryOther AssetCategory = "other"
+
+ AssetVersionStatusDraft AssetVersionStatus = "draft"
+ AssetVersionStatusReview AssetVersionStatus = "review"
+ AssetVersionStatusConfirmed AssetVersionStatus = "confirmed"
+ AssetVersionStatusArchived AssetVersionStatus = "archived"
+
+ WorkflowStatusActive WorkflowStatus = "active"
+ WorkflowStatusCompleted WorkflowStatus = "completed"
+ WorkflowStatusFailed WorkflowStatus = "failed"
+
+ WorkflowStepStatusPending WorkflowStepStatus = "pending"
+ WorkflowStepStatusReady WorkflowStepStatus = "ready"
+ WorkflowStepStatusRunning WorkflowStepStatus = "running"
+ WorkflowStepStatusReview WorkflowStepStatus = "review"
+ WorkflowStepStatusCompleted WorkflowStepStatus = "completed"
+ WorkflowStepStatusFailed WorkflowStepStatus = "failed"
+ WorkflowStepStatusSkipped WorkflowStepStatus = "skipped"
)
type User struct {
@@ -160,21 +206,23 @@ type ModelChannel struct {
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ChannelModel struct {
- ID string `json:"id" gorm:"primaryKey;size:36"`
- ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key,priority:1"`
- ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key,priority:2"`
- DisplayName string `json:"displayName" gorm:"size:160"`
- Capability string `json:"capability" gorm:"size:32;index"`
- BillingMode string `json:"billingMode" gorm:"size:32"`
- UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
- PriceConfigured bool `json:"priceConfigured" gorm:"index"`
- Enabled bool `json:"enabled" gorm:"index"`
- PriceVersion int64 `json:"priceVersion"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"`
+ ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"`
+ DisplayName string `json:"displayName" gorm:"size:160"`
+ Capability string `json:"capability" gorm:"size:32;index"`
+ BillingMode string `json:"billingMode" gorm:"size:32"`
+ UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
+ PriceConfigured bool `json:"priceConfigured" gorm:"index"`
+ Enabled bool `json:"enabled" gorm:"index"`
+ PriceVersion int64 `json:"priceVersion"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type ApiCallLog struct {
@@ -392,18 +440,196 @@ type Resource struct {
}
type Asset struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"`
+ Kind string `json:"kind" gorm:"index;size:24"`
+ Category AssetCategory `json:"category" gorm:"index;size:32"`
+ Status AssetVersionStatus `json:"status" gorm:"index;size:24"`
+ PrimaryVersionID string `json:"primaryVersionId,omitempty" gorm:"index;size:36"`
+ Title string `json:"title" gorm:"size:240"`
+ PayloadJSON string `json:"payloadJson" gorm:"type:text"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"`
+}
+
+type ProjectAssetLink struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"`
+ AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type ProjectAssetCandidate struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ProjectID string `json:"projectId" gorm:"index;size:36"`
+ UnitID string `json:"unitId,omitempty" gorm:"index;size:36"`
+ ShotID string `json:"shotId,omitempty" gorm:"index;size:36"`
+ Name string `json:"name" gorm:"size:240"`
+ Category AssetCategory `json:"category" gorm:"index;size:32"`
+ Status string `json:"status" gorm:"index;size:32"`
+ DetailsJSON string `json:"detailsJson" gorm:"type:text"`
+ ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type AssetVersion struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"`
+ Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"`
+ Status AssetVersionStatus `json:"status" gorm:"index;size:24"`
+ DefinitionJSON string `json:"definitionJson" gorm:"type:text"`
+ Prompt string `json:"prompt" gorm:"type:text"`
+ Note string `json:"note" gorm:"size:500"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type AssetRepresentation struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ TaskID string `json:"taskId,omitempty" gorm:"index;size:36;uniqueIndex:idx_asset_representations_task_role,priority:1"`
+ AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_asset_representations_version_role,priority:1"`
+ ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"`
+ MediaType string `json:"mediaType" gorm:"index;size:24"`
+ Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_asset_representations_task_role,priority:2;uniqueIndex:idx_asset_representations_version_role,priority:2"`
+ MetadataJSON string `json:"metadataJson" gorm:"type:text"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// VoiceProfile 是可复用的声音身份;试听音频只是表现资源,不等同于声音本身。
+type VoiceProfile struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_voice_profiles_user_provider_key,priority:1"`
+ Name string `json:"name" gorm:"size:160"`
+ Provider string `json:"provider" gorm:"size:48;uniqueIndex:idx_voice_profiles_user_provider_key,priority:2"`
+ VoiceKey string `json:"voiceKey" gorm:"size:160;uniqueIndex:idx_voice_profiles_user_provider_key,priority:3"`
+ Language string `json:"language" gorm:"size:80"`
+ Timbre string `json:"timbre" gorm:"size:240"`
+ SampleResourceID string `json:"sampleResourceId,omitempty" gorm:"index;size:36"`
+ CompatibleModelsJSON string `json:"compatibleModelsJson" gorm:"type:text"`
+ Status string `json:"status" gorm:"index;size:24"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type CharacterVoiceBinding struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"`
+ VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"`
+ Instructions string `json:"instructions" gorm:"type:text"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。
+type Project struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"`
+ Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"`
+ Type string `json:"type" gorm:"size:32;index"`
+ AspectRatio string `json:"aspectRatio" gorm:"size:16"`
+ SourceType string `json:"sourceType" gorm:"size:32"`
+ Description string `json:"description" gorm:"type:text"`
+ StylePresetID string `json:"stylePresetId" gorm:"size:64"`
+ ActiveTaskLimit int `json:"activeTaskLimit" gorm:"default:3"`
+ Status ProjectStatus `json:"status" gorm:"index;size:24"`
+ Revision int64 `json:"revision"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
+}
+
+type ProjectUnit struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ProjectID string `json:"projectId" gorm:"index;size:36"`
+ ParentID string `json:"parentId,omitempty" gorm:"index;size:36"`
+ Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"`
+ Title string `json:"title" gorm:"size:240"`
+ SourceText string `json:"sourceText" gorm:"type:text"`
+ Status ProjectUnitStatus `json:"status" gorm:"index;size:24"`
+ Position int `json:"position"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type CanvasUnitLink struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"`
+ CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"`
+ UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"`
+ Role string `json:"role" gorm:"size:32"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type Shot struct {
ID string `json:"id" gorm:"primaryKey;size:36"`
- UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"`
- Kind string `json:"kind" gorm:"index;size:24"`
+ ProjectID string `json:"projectId" gorm:"index;size:36"`
+ UnitID string `json:"unitId" gorm:"index;size:36"`
Title string `json:"title" gorm:"size:240"`
- PayloadJSON string `json:"payloadJson" gorm:"type:text"`
+ Description string `json:"description" gorm:"type:text"`
+ Position int `json:"position"`
+ DurationMs int64 `json:"durationMs"`
+ Status string `json:"status" gorm:"index;size:24"`
CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type ShotAssetReference struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:1"`
+ AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:2"`
+ Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_shot_asset_reference_unique,priority:3"`
+ Status string `json:"status" gorm:"index;size:24"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type WorkflowTemplateVersion struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ TemplateKey string `json:"templateKey" gorm:"size:80;uniqueIndex:idx_workflow_template_version,priority:1"`
+ Name string `json:"name" gorm:"size:160"`
+ Version int `json:"version" gorm:"uniqueIndex:idx_workflow_template_version,priority:2"`
+ DefinitionJSON string `json:"definitionJson" gorm:"type:text"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type WorkflowInstance struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:1"`
+ UnitID string `json:"unitId,omitempty" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:2"`
+ TemplateVersionID string `json:"templateVersionId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:3"`
+ Scope string `json:"scope" gorm:"index;size:24"`
+ Status WorkflowStatus `json:"status" gorm:"index;size:24"`
+ Revision int64 `json:"revision"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type WorkflowStepInstance struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ WorkflowInstanceID string `json:"workflowInstanceId" gorm:"index;size:36;uniqueIndex:idx_workflow_steps_instance_key,priority:1"`
+ StepKey string `json:"stepKey" gorm:"size:80;uniqueIndex:idx_workflow_steps_instance_key,priority:2"`
+ Name string `json:"name" gorm:"size:160"`
+ Position int `json:"position"`
+ Status WorkflowStepStatus `json:"status" gorm:"index;size:24"`
+ InputJSON string `json:"inputJson" gorm:"type:text"`
+ OutputJSON string `json:"outputJson" gorm:"type:text"`
+ Error string `json:"error" gorm:"type:text"`
+ StartedAt *time.Time `json:"startedAt"`
+ CompletedAt *time.Time `json:"completedAt"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type WorkflowStepTask struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ WorkflowStepID string `json:"workflowStepId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:1"`
+ TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:2"`
+ CreatedAt time.Time `json:"createdAt"`
}
type CanvasProject struct {
ID string `json:"id" gorm:"primaryKey;size:80"`
UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"`
+ ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"`
Title string `json:"title" gorm:"size:240"`
PayloadJSON string `json:"payloadJson" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go
index be7ecb2b1..d4e0379fc 100644
--- a/backend/internal/repository/finance.go
+++ b/backend/internal/repository/finance.go
@@ -17,6 +17,7 @@ var (
ErrInsufficientCredits = errors.New("insufficient credits")
ErrRedeemCodeInvalid = errors.New("redeem code invalid")
ErrActiveTaskLimit = errors.New("active task limit reached")
+ ErrProjectTaskLimit = errors.New("project active task limit reached")
ErrTaskNotRetryable = errors.New("task is not retryable")
ErrBillingStateConflict = errors.New("billing state conflict")
)
@@ -73,6 +74,12 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo
return items, query.Find(&items).Error
}
+func (r *Repository) ChannelModelsIncludingDeleted(channelID string) ([]model.ChannelModel, error) {
+ var items []model.ChannelModel
+ err := r.db.Unscoped().Where("channel_id = ?", channelID).Order("created_at asc").Find(&items).Error
+ return items, err
+}
+
func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil {
@@ -93,12 +100,39 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
return r.db.Save(item).Error
}
+func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ result := tx.Model(&model.ChannelModel{}).
+ Where("id = ? AND channel_id = ?", id, channelID).
+ Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ if err := tx.Where("id = ? AND channel_id = ?", id, channelID).Delete(&model.ChannelModel{}).Error; err != nil {
+ return err
+ }
+ channelResult := tx.Model(&model.ModelChannel{}).
+ Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem).
+ Updates(map[string]any{"models_json": modelsJSON, "updated_at": now})
+ if channelResult.Error != nil {
+ return channelResult.Error
+ }
+ if channelResult.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+ })
+}
+
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
if len(items) == 0 {
return 0, nil
}
// 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。
- result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_key"}}, DoNothing: true}).Create(&items)
+ result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items)
return result.RowsAffected, result.Error
}
@@ -153,11 +187,14 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err
return count > 0, err
}
-func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error {
+func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
+ if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil {
+ return err
+ }
if err := reserveBillingOrder(tx, order); err != nil {
return err
}
@@ -165,21 +202,27 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo
})
}
-func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error {
+func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
+ if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil {
+ return err
+ }
return tx.Create(task).Error
})
}
-func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int) (*model.Task, error) {
+func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) (*model.Task, error) {
var task model.Task
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, userID, activeTaskLimit); err != nil {
return err
}
+ if err := enforceProjectTaskLimit(tx, userID, domainProjectID, projectTaskLimit); err != nil {
+ return err
+ }
if order != nil {
if err := reserveBillingOrder(tx, order); err != nil {
return err
@@ -217,6 +260,23 @@ func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) err
return nil
}
+func enforceProjectTaskLimit(tx *gorm.DB, userID string, projectID string, limit int) error {
+ if projectID == "" || limit <= 0 {
+ return nil
+ }
+ canvasIDs := tx.Model(&model.CanvasProject{}).Select("id").Where("user_id = ? AND project_id = ?", userID, projectID)
+ var count int64
+ if err := tx.Model(&model.Task{}).
+ Where("user_id = ? AND status IN ? AND (project_id = ? OR project_id IN (?))", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}, projectID, canvasIDs).
+ Count(&count).Error; err != nil {
+ return err
+ }
+ if count >= int64(limit) {
+ return ErrProjectTaskLimit
+ }
+ return nil
+}
+
func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return reserveBillingOrder(tx, order)
diff --git a/backend/internal/repository/project_character.go b/backend/internal/repository/project_character.go
new file mode 100644
index 000000000..cb53c35c7
--- /dev/null
+++ b/backend/internal/repository/project_character.go
@@ -0,0 +1,143 @@
+package repository
+
+import (
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func (r *Repository) ProjectCharacterAsset(userID string, projectID string, assetID string) (*model.Asset, error) {
+ var asset model.Asset
+ err := r.db.Table("assets").Select("assets.*").
+ Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").
+ Where("assets.id = ? AND assets.user_id = ? AND assets.category = ? AND project_asset_links.project_id = ?", assetID, userID, model.AssetCategoryCharacter, projectID).
+ First(&asset).Error
+ if err != nil {
+ return nil, err
+ }
+ return &asset, nil
+}
+
+func (r *Repository) AssetVersion(id string) (*model.AssetVersion, error) {
+ var version model.AssetVersion
+ if err := r.db.First(&version, "id = ?", id).Error; err != nil {
+ return nil, err
+ }
+ return &version, nil
+}
+
+func (r *Repository) AssetRepresentations(assetVersionID string) ([]model.AssetRepresentation, error) {
+ var representations []model.AssetRepresentation
+ err := r.db.Where("asset_version_id = ?", assetVersionID).Order("role asc, created_at asc").Find(&representations).Error
+ return representations, err
+}
+
+func (r *Repository) CharacterVoiceBinding(assetVersionID string) (*model.CharacterVoiceBinding, error) {
+ var binding model.CharacterVoiceBinding
+ if err := r.db.First(&binding, "asset_version_id = ?", assetVersionID).Error; err != nil {
+ return nil, err
+ }
+ return &binding, nil
+}
+
+func (r *Repository) VoiceProfileForUser(userID string, id string) (*model.VoiceProfile, error) {
+ var profile model.VoiceProfile
+ if err := r.db.First(&profile, "id = ? AND user_id = ?", id, userID).Error; err != nil {
+ return nil, err
+ }
+ return &profile, nil
+}
+
+func (r *Repository) VoiceProfiles(userID string) ([]model.VoiceProfile, error) {
+ var profiles []model.VoiceProfile
+ err := r.db.Where("user_id = ? AND status = ?", userID, "active").Order("created_at asc").Find(&profiles).Error
+ return profiles, err
+}
+
+func (r *Repository) EnsureVoiceProfiles(profiles []model.VoiceProfile) error {
+ if len(profiles) == 0 {
+ return nil
+ }
+ return r.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "provider"}, {Name: "voice_key"}},
+ DoNothing: true,
+ }).Create(&profiles).Error
+}
+
+// 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 {
+ if err := tx.Create(asset).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(version).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(link).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).
+ Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": asset.UpdatedAt}).Error
+ })
+}
+
+// SaveCharacterVersion 通过不可变版本替换角色当前状态,旧镜头仍可继续引用历史版本。
+func (r *Repository) SaveCharacterVersion(projectID string, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := saveCharacterVersion(tx, asset, version, representations, voice); err != nil {
+ return err
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).
+ Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+}
+
+// ConfirmProjectCharacterCandidate 保证角色归并版本与候选确认同时生效,失败时不会留下孤立版本。
+func (r *Repository) ConfirmProjectCharacterCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := saveCharacterVersion(tx, asset, version, representations, voice); err != nil {
+ return err
+ }
+ result := tx.Model(&model.ProjectAssetCandidate{}).
+ Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation").
+ Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID).
+ Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error
+ })
+}
+
+func saveCharacterVersion(tx *gorm.DB, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
+ if err := tx.Create(version).Error; err != nil {
+ return err
+ }
+ if len(representations) > 0 {
+ if err := tx.Create(&representations).Error; err != nil {
+ return err
+ }
+ }
+ if voice != nil {
+ if err := tx.Create(voice).Error; err != nil {
+ return err
+ }
+ }
+ result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{
+ "kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID,
+ "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt,
+ })
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+}
diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go
index 236579c14..6b154c443 100644
--- a/backend/internal/repository/repository.go
+++ b/backend/internal/repository/repository.go
@@ -2,6 +2,7 @@ package repository
import (
"errors"
+ "sort"
"strings"
"time"
@@ -387,7 +388,7 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl
if limit <= 0 || limit > 100 {
limit = 50
}
- query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at").
+ query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "result_json", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at").
Where("user_id = ?", userID)
if strings.TrimSpace(projectID) != "" {
query = query.Where("project_id = ?", strings.TrimSpace(projectID))
@@ -460,6 +461,12 @@ func (r *Repository) SystemChannels(includeDisabled bool) ([]model.ModelChannel,
return channels, err
}
+func (r *Repository) HistoricalSystemChannelReferences() ([]model.ModelChannel, error) {
+ var channels []model.ModelChannel
+ err := r.db.Unscoped().Select("id", "name").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error
+ return channels, err
+}
+
func (r *Repository) AdminSystemChannels(keyword string, interfaceType string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) {
var channels []model.ModelChannel
var total int64
@@ -507,6 +514,28 @@ func (r *Repository) AdminSystemChannel(id string) (*model.ModelChannel, error)
return &channel, nil
}
+func (r *Repository) DeleteSystemChannel(id string) error {
+ now := time.Now()
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ channelResult := tx.Model(&model.ModelChannel{}).
+ Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).
+ Updates(map[string]any{"api_key": "", "enabled": false, "updated_at": now})
+ if channelResult.Error != nil {
+ return channelResult.Error
+ }
+ if channelResult.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ if err := tx.Model(&model.ChannelModel{}).Where("channel_id = ?", id).Updates(map[string]any{"enabled": false, "updated_at": now}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("channel_id = ?", id).Delete(&model.ChannelModel{}).Error; err != nil {
+ return err
+ }
+ return tx.Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).Delete(&model.ModelChannel{}).Error
+ })
+}
+
func (r *Repository) ApiCallLogs(userID string, admin bool, limit int) ([]model.ApiCallLog, error) {
var logs []model.ApiCallLog
if limit <= 0 || limit > 200 {
@@ -634,7 +663,7 @@ func (r *Repository) Assets(userID string) ([]model.Asset, error) {
func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) {
var assets []model.Asset
- err := r.db.Select("id", "kind", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
+ err := r.db.Select("id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
return assets, err
}
@@ -649,7 +678,7 @@ func (r *Repository) AssetForUser(userID string, id string) (*model.Asset, error
func (r *Repository) UpsertAsset(asset *model.Asset) error {
result := r.db.Model(&model.Asset{}).
Where("id = ? AND user_id = ?", asset.ID, asset.UserID).
- Updates(map[string]any{"kind": asset.Kind, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
+ Updates(map[string]any{"kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
@@ -657,7 +686,19 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error {
}
func (r *Repository) DeleteAsset(userID string, id string) error {
- return r.db.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", id)
+ if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("asset_id = ?", id).Delete(&model.AssetVersion{}).Error; err != nil {
+ return err
+ }
+ return tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error
+ })
}
func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error {
@@ -695,7 +736,7 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv
func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
result := r.db.Model(&model.CanvasProject{}).
Where("id = ? AND user_id = ?", project.ID, project.UserID).
- Updates(map[string]any{"title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt})
+ Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
@@ -706,6 +747,623 @@ func (r *Repository) DeleteCanvasProject(userID string, id string) error {
return r.db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error
}
+func (r *Repository) Projects(userID string) ([]model.Project, error) {
+ var projects []model.Project
+ err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error
+ return projects, err
+}
+
+func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, error) {
+ var project model.Project
+ if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil {
+ return nil, err
+ }
+ return &project, nil
+}
+
+func (r *Repository) CreateProject(project *model.Project) error {
+ return r.db.Create(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, "active_task_limit": project.ActiveTaskLimit,
+ "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt,
+ }).Error
+}
+
+func (r *Repository) DeleteProject(userID string, id string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil {
+ return err
+ }
+ shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id)
+ if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.Shot{}).Error; err != nil {
+ return err
+ }
+ instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ?", id)
+ stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs)
+ if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.WorkflowInstance{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ?", id).Delete(&model.ProjectUnit{}).Error; err != nil {
+ return err
+ }
+ return tx.Delete(&model.Project{}, "id = ? AND user_id = ?", id, userID).Error
+ })
+}
+
+func (r *Repository) BumpProjectRevision(projectID string) error {
+ return r.db.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+}
+
+func (r *Repository) ProjectUnits(projectID string) ([]model.ProjectUnit, error) {
+ var units []model.ProjectUnit
+ err := r.db.Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error
+ return units, err
+}
+
+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
+ return units, err
+}
+
+func (r *Repository) ProjectAssetCount(projectID string) (int64, error) {
+ var count int64
+ err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ?", projectID).Count(&count).Error
+ return count, err
+}
+
+func (r *Repository) CreateProjectUnit(unit *model.ProjectUnit) error {
+ return r.db.Create(unit).Error
+}
+
+func (r *Repository) ImportProjectUnits(projectID string, units []model.ProjectUnit) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ // 分批写入仍处于同一事务,避免两千章导入超过 SQLite/PostgreSQL 单语句参数上限。
+ if err := tx.CreateInBatches(&units, 100).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+}
+
+func (r *Repository) ReorderProjectUnits(projectID string, unitIDs []string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ now := time.Now()
+ for position, unitID := range unitIDs {
+ result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unitID, projectID).Updates(map[string]any{"position": position, "updated_at": now})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": now}).Error
+ })
+}
+
+func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUnit, error) {
+ var unit model.ProjectUnit
+ if err := r.db.First(&unit, "id = ? AND project_id = ?", id, projectID).Error; err != nil {
+ return nil, err
+ }
+ return &unit, nil
+}
+
+func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit) error {
+ return r.db.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,
+ }).Error
+}
+
+func (r *Repository) DeleteProjectUnit(projectID string, id string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.CanvasUnitLink{}).Error; err != nil {
+ return err
+ }
+ shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id)
+ if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.Shot{}).Error; err != nil {
+ return err
+ }
+ instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id)
+ stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs)
+ if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.WorkflowInstance{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
+ return err
+ }
+ result := tx.Delete(&model.ProjectUnit{}, "id = ? AND project_id = ?", id, projectID)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+}
+
+func (r *Repository) CanvasUnitLink(projectID string, canvasID string, unitID string) (*model.CanvasUnitLink, error) {
+ var link model.CanvasUnitLink
+ if err := r.db.First(&link, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID).Error; err != nil {
+ return nil, err
+ }
+ return &link, nil
+}
+
+func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error {
+ result := r.db.Model(&model.CanvasUnitLink{}).Where("project_id = ? AND canvas_id = ? AND unit_id = ?", link.ProjectID, link.CanvasID, link.UnitID).Updates(map[string]any{"role": link.Role})
+ if result.Error != nil || result.RowsAffected > 0 {
+ return result.Error
+ }
+ return r.db.Create(link).Error
+}
+
+func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) {
+ var canvases []model.CanvasProject
+ err := r.db.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error
+ return canvases, err
+}
+
+func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) {
+ var canvases []model.CanvasProject
+ err := r.db.Select("id", "title", "payload_json").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error
+ return canvases, err
+}
+
+func (r *Repository) ProjectCanvasUnitLinks(projectID string) ([]model.CanvasUnitLink, error) {
+ var links []model.CanvasUnitLink
+ err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&links).Error
+ return links, err
+}
+
+func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, unitID string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ result := tx.Delete(&model.CanvasUnitLink{}, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+}
+
+func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error {
+ return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Update("project_id", projectID).Error
+}
+
+func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil {
+ return err
+ }
+ result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ?", canvasID, userID, projectID).Updates(map[string]any{
+ "project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt,
+ })
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error
+ })
+}
+
+func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Asset, error) {
+ var assets []model.Asset
+ err := r.db.Table("assets").Select("assets.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID).Order("assets.updated_at desc").Scan(&assets).Error
+ return assets, err
+}
+
+// LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。
+func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) {
+ createdLink := false
+ err := r.db.Transaction(func(tx *gorm.DB) error {
+ created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "project_id"}, {Name: "asset_id"}}, DoNothing: true}).Create(link)
+ if created.Error != nil {
+ return created.Error
+ }
+ if created.RowsAffected == 0 {
+ return nil
+ }
+ createdLink = true
+ if version != nil {
+ if err := tx.Create(version).Error; err != nil {
+ return err
+ }
+ }
+ result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{
+ "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt,
+ })
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", link.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+ return createdLink, err
+}
+
+func (r *Repository) DeleteProjectAssetLink(projectID string, assetID string) error {
+ return r.db.Delete(&model.ProjectAssetLink{}, "project_id = ? AND asset_id = ?", projectID, assetID).Error
+}
+
+func (r *Repository) ProjectAssetShotReferenceCount(projectID string, assetID string) (int64, error) {
+ var count int64
+ err := r.db.Table("shot_asset_references").
+ Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
+ Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").
+ Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
+ Count(&count).Error
+ return count, err
+}
+
+func (r *Repository) ProjectAssetLinked(projectID string, assetID string) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND asset_id = ?", projectID, assetID).Count(&count).Error
+ return count > 0, err
+}
+
+func (r *Repository) AssetReferenceCount(assetID string) (int64, error) {
+ var projectLinks int64
+ if err := r.db.Model(&model.ProjectAssetLink{}).Where("asset_id = ?", assetID).Count(&projectLinks).Error; err != nil {
+ return 0, err
+ }
+ var shotLinks int64
+ err := r.db.Table("shot_asset_references").Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").Where("asset_versions.asset_id = ?", assetID).Count(&shotLinks).Error
+ return projectLinks + shotLinks, err
+}
+
+func (r *Repository) UpdateAssetDomain(asset *model.Asset) error {
+ return r.db.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{"category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt}).Error
+}
+
+func (r *Repository) AssetVersions(assetID string) ([]model.AssetVersion, error) {
+ var versions []model.AssetVersion
+ err := r.db.Where("asset_id = ?", assetID).Order("version desc").Find(&versions).Error
+ return versions, err
+}
+
+func (r *Repository) ProjectAssetUsageRoles(projectID string, assetID string) ([]string, error) {
+ var shotRoles []string
+ if err := r.db.Table("shot_asset_references").
+ Distinct("shot_asset_references.role").
+ Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
+ Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").
+ Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
+ Order("shot_asset_references.role asc").
+ Pluck("shot_asset_references.role", &shotRoles).Error; err != nil {
+ return nil, err
+ }
+ var representationRoles []string
+ if err := r.db.Table("asset_representations").
+ Distinct("asset_representations.role").
+ Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id").
+ Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").
+ Where("project_asset_links.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
+ Pluck("asset_representations.role", &representationRoles).Error; err != nil {
+ return nil, err
+ }
+ seen := make(map[string]struct{}, len(shotRoles)+len(representationRoles))
+ for _, role := range append(shotRoles, representationRoles...) {
+ if role != "" {
+ seen[role] = struct{}{}
+ }
+ }
+ roles := make([]string, 0, len(seen))
+ for role := range seen {
+ roles = append(roles, role)
+ }
+ sort.Strings(roles)
+ return roles, nil
+}
+
+func (r *Repository) AssetVersionForProject(projectID string, versionID string) (*model.AssetVersion, error) {
+ var version model.AssetVersion
+ err := r.db.Table("asset_versions").Select("asset_versions.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").Where("project_asset_links.project_id = ? AND asset_versions.id = ?", projectID, versionID).First(&version).Error
+ if err != nil {
+ return nil, err
+ }
+ return &version, nil
+}
+
+func (r *Repository) CreateAssetVersion(version *model.AssetVersion) error {
+ return r.db.Create(version).Error
+}
+
+func (r *Repository) ProjectShots(projectID string) ([]model.Shot, error) {
+ var shots []model.Shot
+ err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, position asc").Find(&shots).Error
+ return shots, err
+}
+
+func (r *Repository) SaveShot(shot *model.Shot, create bool) error {
+ if create {
+ return r.db.Create(shot).Error
+ }
+ return r.db.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{
+ "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position,
+ "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt,
+ }).Error
+}
+
+func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID)
+ if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND shot_id IN (?)", projectID, shotIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.Shot{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(&shots).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
+ })
+}
+
+func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Shot, error) {
+ var shot model.Shot
+ if err := r.db.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil {
+ return nil, err
+ }
+ return &shot, nil
+}
+
+func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReference) error {
+ result := r.db.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status})
+ if result.Error != nil || result.RowsAffected > 0 {
+ return result.Error
+ }
+ return r.db.Create(reference).Error
+}
+
+func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) {
+ var references []model.ShotAssetReference
+ err := r.db.Table("shot_asset_references").Select("shot_asset_references.*").
+ Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
+ Where("shots.project_id = ?", projectID).
+ Order("shot_asset_references.created_at asc").Scan(&references).Error
+ return references, err
+}
+
+func (r *Repository) ProjectAssetCandidates(projectID string) ([]model.ProjectAssetCandidate, error) {
+ var candidates []model.ProjectAssetCandidate
+ err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&candidates).Error
+ return candidates, err
+}
+
+func (r *Repository) ProjectAssetCandidate(projectID string, candidateID string) (*model.ProjectAssetCandidate, error) {
+ var candidate model.ProjectAssetCandidate
+ if err := r.db.First(&candidate, "id = ? AND project_id = ?", candidateID, projectID).Error; err != nil {
+ return nil, err
+ }
+ return &candidate, nil
+}
+
+func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAssetCandidate) error {
+ if len(candidates) == 0 {
+ return nil
+ }
+ return r.db.Create(&candidates).Error
+}
+
+// ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。
+func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if createAsset {
+ if err := tx.Create(asset).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(version).Error; err != nil {
+ return err
+ }
+ } else if err := tx.First(&model.Asset{}, "id = ? AND user_id = ?", asset.ID, asset.UserID).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("project_id = ? AND asset_id = ?", link.ProjectID, link.AssetID).FirstOrCreate(link).Error; err != nil {
+ return err
+ }
+ result := tx.Model(&model.ProjectAssetCandidate{}).
+ Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation").
+ Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID).
+ Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error
+ })
+}
+
+func (r *Repository) WorkflowTemplateVersion(templateKey string, version int) (*model.WorkflowTemplateVersion, error) {
+ var template model.WorkflowTemplateVersion
+ if err := r.db.First(&template, "template_key = ? AND version = ?", templateKey, version).Error; err != nil {
+ return nil, err
+ }
+ return &template, nil
+}
+
+func (r *Repository) CreateWorkflowTemplateVersion(template *model.WorkflowTemplateVersion) error {
+ return r.db.Create(template).Error
+}
+
+func (r *Repository) ProjectWorkflowInstances(projectID string) ([]model.WorkflowInstance, error) {
+ var instances []model.WorkflowInstance
+ err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&instances).Error
+ return instances, err
+}
+
+func (r *Repository) WorkflowInstanceForScope(projectID string, unitID string, templateVersionID string) (*model.WorkflowInstance, error) {
+ var instance model.WorkflowInstance
+ if err := r.db.First(&instance, "project_id = ? AND unit_id = ? AND template_version_id = ?", projectID, unitID, templateVersionID).Error; err != nil {
+ return nil, err
+ }
+ return &instance, nil
+}
+
+func (r *Repository) WorkflowInstance(id string) (*model.WorkflowInstance, error) {
+ var instance model.WorkflowInstance
+ if err := r.db.First(&instance, "id = ?", id).Error; err != nil {
+ return nil, err
+ }
+ return &instance, nil
+}
+
+func (r *Repository) WorkflowSteps(instanceID string) ([]model.WorkflowStepInstance, error) {
+ var steps []model.WorkflowStepInstance
+ err := r.db.Where("workflow_instance_id = ?", instanceID).Order("position asc").Find(&steps).Error
+ return steps, err
+}
+
+func (r *Repository) NextWorkflowStep(instanceID string, position int) (*model.WorkflowStepInstance, error) {
+ var step model.WorkflowStepInstance
+ if err := r.db.Where("workflow_instance_id = ? AND position > ?", instanceID, position).Order("position asc").First(&step).Error; err != nil {
+ return nil, err
+ }
+ return &step, nil
+}
+
+func (r *Repository) CreateWorkflowInstance(instance *model.WorkflowInstance, steps []model.WorkflowStepInstance) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(instance).Error; err != nil {
+ return err
+ }
+ if len(steps) == 0 {
+ return nil
+ }
+ return tx.Create(&steps).Error
+ })
+}
+
+func (r *Repository) WorkflowStepForProject(projectID string, stepID string) (*model.WorkflowStepInstance, error) {
+ var step model.WorkflowStepInstance
+ err := r.db.Table("workflow_step_instances").Select("workflow_step_instances.*").Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id").Where("workflow_instances.project_id = ? AND workflow_step_instances.id = ?", projectID, stepID).First(&step).Error
+ if err != nil {
+ return nil, err
+ }
+ return &step, nil
+}
+
+func (r *Repository) UpdateWorkflowStep(step *model.WorkflowStepInstance) error {
+ return r.db.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt}).Error
+}
+
+// UpdateWorkflowProgress 原子保存当前步骤、下一步骤和实例状态,确保刷新后流程依赖仍可恢复。
+func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{
+ "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt,
+ "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt,
+ }).Error; err != nil {
+ return err
+ }
+ if next != nil {
+ if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).
+ Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}).Error; err != nil {
+ return err
+ }
+ }
+ if err := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).
+ Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.Project{}).Where("id = ?", projectID).
+ Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}).Error
+ })
+}
+
+// RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。
+func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation) 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 {
+ return err
+ }
+ if representation != nil {
+ if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).FirstOrCreate(representation).Error; err != nil {
+ return err
+ }
+ }
+ stepResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{
+ "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt,
+ "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt,
+ })
+ if stepResult.Error != nil {
+ return stepResult.Error
+ }
+ if stepResult.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ if next != nil {
+ nextResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt})
+ if nextResult.Error != nil {
+ return nextResult.Error
+ }
+ if nextResult.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ }
+ instanceResult := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt})
+ if instanceResult.Error != nil {
+ return instanceResult.Error
+ }
+ if instanceResult.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ projectResult := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt})
+ if projectResult.Error != nil {
+ return projectResult.Error
+ }
+ if projectResult.RowsAffected != 1 {
+ return gorm.ErrInvalidData
+ }
+ return nil
+ })
+}
+
func (r *Repository) CanvasShareForProject(userID string, projectID string) (*model.CanvasShare, error) {
var share model.CanvasShare
if err := r.db.First(&share, "user_id = ? AND project_id = ?", userID, projectID).Error; err != nil {
diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go
index a3f22158e..ba3c6e334 100644
--- a/backend/internal/service/admin.go
+++ b/backend/internal/service/admin.go
@@ -105,6 +105,7 @@ type PublicModelChannel struct {
type PublicChannelModelPrice struct {
Model string `json:"model"`
+ DisplayName string `json:"displayName"`
Capability string `json:"capability"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
@@ -451,12 +452,17 @@ func (s *Service) DeleteSystemChannel(actor *model.User, id string) error {
}
channel, err := s.repo.AdminSystemChannel(id)
if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("系统渠道不存在或已删除")
+ }
return err
}
- // 历史账单和调用日志需要保留渠道主体,删除入口仅停用。
- channel.Enabled = false
- channel.UpdatedAt = time.Now()
- return s.repo.Save(channel)
+ // 保留主体供历史账单和调用日志关联,但从所有业务查询中隐藏并清除密钥。
+ err = s.repo.DeleteSystemChannel(channel.ID)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("系统渠道不存在或已删除")
+ }
+ return err
}
func (s *Service) LogAPICall(log model.ApiCallLog) error {
@@ -607,7 +613,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
}
models = append(models, item.ModelKey)
if item.Enabled && item.PriceConfigured {
- modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, Capability: item.Capability, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits})
+ modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits})
}
}
if len(models) == 0 {
diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go
index d3b51884d..18c089130 100644
--- a/backend/internal/service/analytics.go
+++ b/backend/internal/service/analytics.go
@@ -192,7 +192,8 @@ func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*A
if err != nil {
return nil, err
}
- channels, err := s.repo.SystemChannels(true)
+ // 历史日志允许读取逻辑删除渠道的名称,但不读取或返回渠道密钥。
+ channels, err := s.repo.HistoricalSystemChannelReferences()
if err != nil {
return nil, err
}
diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go
index 5ddf40700..1d425501a 100644
--- a/backend/internal/service/channel_models.go
+++ b/backend/internal/service/channel_models.go
@@ -3,9 +3,13 @@ package service
import (
"context"
"encoding/json"
+ "errors"
"strings"
+ "time"
"infinite-canvas/backend/internal/model"
+
+ "gorm.io/gorm"
)
type ChannelModelRequest struct {
@@ -66,7 +70,8 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
if err != nil {
return nil, err
}
- existing, err := s.repo.ChannelModels(channelID, true)
+ // 删除项作为 tombstone 参与去重,避免再次拉取上游目录时自动恢复到页面。
+ existing, err := s.repo.ChannelModelsIncludingDeleted(channelID)
if err != nil {
return nil, err
}
@@ -112,8 +117,11 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if billingMode == "" {
billingMode = "fixed_request"
}
- if billingMode != "fixed_request" {
- return nil, BadAuthRequest("当前版本只支持按次固定计费")
+ if billingMode != "fixed_request" && billingMode != "per_second" {
+ return nil, BadAuthRequest("模型计费方式仅支持按次或按秒")
+ }
+ if billingMode == "per_second" && capability != "video" {
+ return nil, BadAuthRequest("只有视频模型可以按秒计费")
}
if req.UnitPriceMicrocredits < 0 {
return nil, BadAuthRequest("模型积分价格不能小于 0")
@@ -147,24 +155,42 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return item, nil
}
-func (s *Service) DisableAdminChannelModel(actor *model.User, channelID string, id string) error {
+func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
- item, err := s.repo.ChannelModelByID(channelID, id)
+ if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("系统渠道不存在或已删除")
+ }
+ return err
+ }
+ if _, err := s.repo.ChannelModelByID(channelID, id); err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("渠道模型不存在或已删除")
+ }
+ return err
+ }
+ items, err := s.repo.ChannelModels(channelID, false)
if err != nil {
return err
}
- item.Enabled = false
- item.PriceVersion++
- if err := s.repo.SaveChannelModel(item); err != nil {
- return err
+ names := make([]string, 0, len(items))
+ for _, item := range items {
+ if item.ID != id {
+ names = append(names, item.ModelKey)
+ }
}
- channel, err := s.repo.AdminSystemChannel(channelID)
+ encoded, err := json.Marshal(names)
if err != nil {
return err
}
- return s.syncChannelModelNames(channel)
+ // 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。
+ err = s.repo.DeleteChannelModel(channelID, id, string(encoded), time.Now())
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BadAuthRequest("渠道模型不存在或已删除")
+ }
+ return err
}
func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error {
diff --git a/backend/internal/service/credit_policy.go b/backend/internal/service/credit_policy.go
index 2207000a9..ef2b8cba5 100644
--- a/backend/internal/service/credit_policy.go
+++ b/backend/internal/service/credit_policy.go
@@ -141,12 +141,16 @@ func (s *Service) publicCreditPolicy(userID string) (PublicCreditPolicy, error)
return PublicCreditPolicy{SignupBonusMicrocredits: policy.SignupBonusMicrocredits, CheckinBonusMicrocredits: policy.CheckinBonusMicrocredits, CheckedInToday: checked}, err
}
-// 全程使用整数基点并向上取整,避免浮点误差造成少扣积分。
-func creditAmount(base int64, multiplierBPS int64) (int64, error) {
- if base < 0 || multiplierBPS <= 0 {
+// 单价、数量和倍率全程使用整数并向上取整,避免浮点误差造成少扣积分。
+func creditAmount(unitPrice int64, quantity int64, multiplierBPS int64) (int64, error) {
+ if unitPrice < 0 || quantity <= 0 || multiplierBPS <= 0 {
return 0, errors.New("积分计费参数无效")
}
- if base > (1<<63-1)/multiplierBPS {
+ if unitPrice > (1<<63-1)/quantity {
+ return 0, errors.New("积分计费金额溢出")
+ }
+ base := unitPrice * quantity
+ if base > ((1<<63-1)-9_999)/multiplierBPS {
return 0, errors.New("积分计费金额溢出")
}
amount := (base*multiplierBPS + 9_999) / 10_000
diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go
index 851095db6..9041d9e47 100644
--- a/backend/internal/service/email.go
+++ b/backend/internal/service/email.go
@@ -167,7 +167,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
if err := s.repo.Create(&record); err != nil {
return err
}
- if err := sendSMTPMail(setting, email, "无限画布注册验证码", registrationEmailBody(code)); err != nil {
+ if err := sendSMTPMail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil {
_ = s.repo.DeleteEmailVerificationCode(record.ID)
return fmt.Errorf("发送注册邮件失败:%w", err)
}
@@ -270,7 +270,7 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue {
value.Encryption = "starttls"
}
if value.FromName == "" {
- value.FromName = "无限画布"
+ value.FromName = "影策"
}
return value
}
@@ -348,5 +348,5 @@ func randomNumericCode(length int) (string, error) {
}
func registrationEmailBody(code string) string {
- return "你正在注册无限画布。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件。"
+ return "你正在注册影策。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件。"
}
diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go
index cdfe3c32d..a577145ab 100644
--- a/backend/internal/service/finance.go
+++ b/backend/internal/service/finance.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strconv"
"strings"
"time"
@@ -364,14 +365,14 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st
capability = capabilityFromTaskType(task.Type)
}
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
- return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene)
+ return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]))
}
-func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string) (*model.BillingOrder, error) {
+func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) {
if strings.TrimSpace(idempotencyKey) == "" {
idempotencyKey = newID()
}
- order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"))
+ order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"), quantity)
if err != nil {
return nil, err
}
@@ -384,7 +385,7 @@ func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey
return order, nil
}
-func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string) (*model.BillingOrder, error) {
+func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64) (*model.BillingOrder, error) {
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("当前系统渠道模型未配置或已停用")
@@ -395,7 +396,18 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
if !item.PriceConfigured {
return nil, BadAuthRequest("当前模型尚未配置用户积分价格")
}
- if item.BillingMode != "fixed_request" {
+ quantity := int64(1)
+ switch item.BillingMode {
+ case "fixed_request":
+ case "per_second":
+ if item.Capability != "video" || capability != "video" {
+ return nil, BadAuthRequest("按秒计费仅适用于视频生成")
+ }
+ if requestedQuantity <= 0 {
+ return nil, BadAuthRequest("视频生成时长无效,无法按秒计费")
+ }
+ quantity = requestedQuantity
+ default:
return nil, BadAuthRequest("当前模型计费方式暂不支持")
}
policy, err := s.creditPolicy()
@@ -406,7 +418,7 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 {
multiplierBPS = configured
}
- amount, err := creditAmount(item.UnitPriceMicrocredits, multiplierBPS)
+ amount, err := creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS)
if err != nil {
return nil, err
}
@@ -414,11 +426,22 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID,
ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability,
Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion,
- UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: 1, AmountMicrocredits: amount,
+ UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount,
Status: model.BillingStatusReserved,
}, nil
}
+func billingQuantity(capability string, value any) int64 {
+ if capability != "video" {
+ return 1
+ }
+ quantity, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
+ if err != nil || quantity <= 0 {
+ return 0
+ }
+ return quantity
+}
+
func (s *Service) MarkBillingRunning(orderID string) error {
if orderID == "" {
return nil
diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go
new file mode 100644
index 000000000..8f5b61aaa
--- /dev/null
+++ b/backend/internal/service/project.go
@@ -0,0 +1,516 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+
+ "gorm.io/gorm"
+)
+
+type CreateProjectRequest struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ AspectRatio string `json:"aspectRatio"`
+ SourceType string `json:"sourceType"`
+ Description string `json:"description"`
+ StylePresetID string `json:"stylePresetId"`
+ ActiveTaskLimit int `json:"activeTaskLimit"`
+}
+
+type UpdateProjectRequest struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ AspectRatio string `json:"aspectRatio"`
+ SourceType string `json:"sourceType"`
+ Description *string `json:"description"`
+ StylePresetID *string `json:"stylePresetId"`
+ ActiveTaskLimit *int `json:"activeTaskLimit"`
+ Status string `json:"status"`
+}
+
+type CreateProjectUnitRequest struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ SourceText string `json:"sourceText"`
+ Position int `json:"position"`
+}
+
+type UpdateProjectUnitRequest struct {
+ Title string `json:"title"`
+ SourceText string `json:"sourceText"`
+ Status string `json:"status"`
+}
+
+type ImportProjectUnitsRequest struct {
+ Units []CreateProjectUnitRequest `json:"units"`
+}
+
+type ReorderProjectUnitsRequest struct {
+ UnitIDs []string `json:"unitIds"`
+}
+
+type LinkCanvasUnitRequest struct {
+ CanvasID string `json:"canvasId"`
+ UnitID string `json:"unitId"`
+ Role string `json:"role"`
+}
+
+type ProjectSummary struct {
+ Project model.Project `json:"project"`
+ CanvasCount int `json:"canvasCount"`
+ AssetCount int64 `json:"assetCount"`
+ UnitCount int `json:"unitCount"`
+ CompletedUnitCount int `json:"completedUnitCount"`
+}
+
+type ProjectDetail struct {
+ Project model.Project `json:"project"`
+ Units []model.ProjectUnit `json:"units"`
+ Canvases []model.CanvasProject `json:"canvases"`
+ CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"`
+ Assets []ProjectAssetSummary `json:"assets"`
+ Workflows []ProjectWorkflowDetail `json:"workflows"`
+ Shots []model.Shot `json:"shots"`
+ ShotReferences []model.ShotAssetReference `json:"shotReferences"`
+ AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
+}
+
+func (s *Service) ListProjects(userID string) ([]ProjectSummary, error) {
+ projects, err := s.repo.Projects(userID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ProjectSummary, 0, len(projects))
+ for _, project := range projects {
+ units, unitsErr := s.repo.ProjectUnitSummaries(project.ID)
+ if unitsErr != nil {
+ return nil, unitsErr
+ }
+ canvases, canvasesErr := s.repo.ProjectCanvasSummaries(userID, project.ID)
+ if canvasesErr != nil {
+ return nil, canvasesErr
+ }
+ assetCount, assetCountErr := s.repo.ProjectAssetCount(project.ID)
+ if assetCountErr != nil {
+ return nil, assetCountErr
+ }
+ completed := 0
+ for _, unit := range units {
+ if unit.Status == model.ProjectUnitStatusCompleted {
+ completed++
+ }
+ }
+ result = append(result, ProjectSummary{Project: project, CanvasCount: len(canvases), AssetCount: assetCount, UnitCount: len(units), CompletedUnitCount: completed})
+ }
+ return result, nil
+}
+
+func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) {
+ project, err := s.repo.ProjectForUser(userID, id)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ // 项目工作台只返回章节摘要,长篇小说正文由单章接口按需读取。
+ units, err := s.repo.ProjectUnitSummaries(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ canvases, err := s.repo.ProjectCanvasSummaries(userID, project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ canvasUnitLinks, err := s.repo.ProjectCanvasUnitLinks(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ assets, err := s.ProjectAssets(userID, project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ workflows, err := s.ProjectWorkflows(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ shots, err := s.repo.ProjectShots(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ shotReferences, err := s.repo.ProjectShotAssetReferences(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ candidates, err := s.repo.ProjectAssetCandidates(project.ID)
+ if err != nil {
+ return ProjectDetail{}, err
+ }
+ if project.ActiveTaskLimit <= 0 {
+ project.ActiveTaskLimit = 3
+ }
+ return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil
+}
+
+func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) {
+ if err := s.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
+ return model.Project{}, err
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return model.Project{}, BadAuthRequest("项目名称不能为空")
+ }
+ projectType := strings.TrimSpace(req.Type)
+ if projectType == "" {
+ projectType = "short-drama"
+ }
+ aspectRatio := strings.TrimSpace(req.AspectRatio)
+ if aspectRatio == "" {
+ aspectRatio = "9:16"
+ }
+ sourceType := strings.TrimSpace(req.SourceType)
+ if sourceType == "" {
+ sourceType = "blank"
+ }
+ now := time.Now()
+ activeTaskLimit := req.ActiveTaskLimit
+ if activeTaskLimit == 0 {
+ activeTaskLimit = 3
+ }
+ if activeTaskLimit < 1 || activeTaskLimit > 20 {
+ return model.Project{}, BadAuthRequest("项目活跃任务上限必须在 1 到 20 之间")
+ }
+ project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: strings.TrimSpace(req.StylePresetID), ActiveTaskLimit: activeTaskLimit, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
+ if err := s.repo.CreateProject(&project); err != nil {
+ return model.Project{}, err
+ }
+ if _, err := s.createProjectWorkflow(project.ID, "", "project"); err != nil {
+ _ = s.repo.DeleteProject(userID, project.ID)
+ return model.Project{}, err
+ }
+ project.Revision++
+ project.UpdatedAt = time.Now()
+ return project, nil
+}
+
+func (s *Service) UpdateProject(userID string, id string, req UpdateProjectRequest) (model.Project, error) {
+ project, err := s.repo.ProjectForUser(userID, id)
+ if err != nil {
+ return model.Project{}, err
+ }
+ if name := strings.TrimSpace(req.Name); name != "" {
+ project.Name = name
+ }
+ if value := strings.TrimSpace(req.Type); value != "" {
+ project.Type = value
+ }
+ if value := strings.TrimSpace(req.AspectRatio); value != "" {
+ project.AspectRatio = value
+ }
+ if value := strings.TrimSpace(req.SourceType); value != "" {
+ project.SourceType = value
+ }
+ if req.Description != nil {
+ project.Description = strings.TrimSpace(*req.Description)
+ }
+ if req.StylePresetID != nil {
+ project.StylePresetID = strings.TrimSpace(*req.StylePresetID)
+ }
+ if req.ActiveTaskLimit != nil {
+ if *req.ActiveTaskLimit < 1 || *req.ActiveTaskLimit > 20 {
+ return model.Project{}, BadAuthRequest("项目活跃任务上限必须在 1 到 20 之间")
+ }
+ project.ActiveTaskLimit = *req.ActiveTaskLimit
+ }
+ if status := model.ProjectStatus(strings.TrimSpace(req.Status)); status != "" {
+ if status != model.ProjectStatusActive && status != model.ProjectStatusArchived {
+ return model.Project{}, BadAuthRequest("不支持的项目状态")
+ }
+ project.Status = status
+ }
+ project.Revision++
+ project.UpdatedAt = time.Now()
+ if err := s.repo.UpdateProject(project); err != nil {
+ return model.Project{}, err
+ }
+ return *project, nil
+}
+
+func (s *Service) DeleteProject(userID string, id string) error {
+ if _, err := s.repo.ProjectForUser(userID, id); err != nil {
+ return err
+ }
+ return s.repo.DeleteProject(userID, id)
+}
+
+func (s *Service) CreateProjectUnit(userID string, projectID string, req CreateProjectUnitRequest) (model.ProjectUnit, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ unit, err := newProjectUnit(projectID, req, req.Position)
+ if err != nil {
+ return model.ProjectUnit{}, err
+ }
+ if err := s.repo.CreateProjectUnit(&unit); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ return unit, nil
+}
+
+func (s *Service) GetProjectUnit(userID string, projectID string, unitID string) (model.ProjectUnit, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ unit, err := s.repo.ProjectUnit(projectID, strings.TrimSpace(unitID))
+ if err != nil {
+ return model.ProjectUnit{}, err
+ }
+ return *unit, nil
+}
+
+func (s *Service) ImportProjectUnits(userID string, projectID string, req ImportProjectUnitsRequest) ([]model.ProjectUnit, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return nil, err
+ }
+ if len(req.Units) == 0 || len(req.Units) > 2500 {
+ return nil, BadAuthRequest("一次导入的章节数量必须在 1 到 2500 之间")
+ }
+ existing, err := s.repo.ProjectUnits(projectID)
+ if err != nil {
+ return nil, err
+ }
+ units := make([]model.ProjectUnit, 0, len(req.Units))
+ for index, input := range req.Units {
+ unit, unitErr := newProjectUnit(projectID, input, len(existing)+index)
+ if unitErr != nil {
+ return nil, unitErr
+ }
+ units = append(units, unit)
+ }
+ if err := s.repo.ImportProjectUnits(projectID, units); err != nil {
+ return nil, err
+ }
+ return units, nil
+}
+
+func (s *Service) ReorderProjectUnits(userID string, projectID string, req ReorderProjectUnitsRequest) error {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return err
+ }
+ units, err := s.repo.ProjectUnits(projectID)
+ if err != nil {
+ return err
+ }
+ if len(req.UnitIDs) != len(units) {
+ return BadAuthRequest("章节排序列表不完整")
+ }
+ existing := make(map[string]struct{}, len(units))
+ for _, unit := range units {
+ existing[unit.ID] = struct{}{}
+ }
+ seen := make(map[string]struct{}, len(req.UnitIDs))
+ normalizedIDs := make([]string, 0, len(req.UnitIDs))
+ for _, rawID := range req.UnitIDs {
+ id := strings.TrimSpace(rawID)
+ if _, ok := existing[id]; !ok {
+ return BadAuthRequest("章节排序包含无效章节")
+ }
+ if _, duplicate := seen[id]; duplicate {
+ return BadAuthRequest("章节排序包含重复章节")
+ }
+ seen[id] = struct{}{}
+ normalizedIDs = append(normalizedIDs, id)
+ }
+ return s.repo.ReorderProjectUnits(projectID, normalizedIDs)
+}
+
+func (s *Service) DeleteProjectUnit(userID string, projectID string, unitID string) error {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return err
+ }
+ if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
+ return err
+ }
+ return s.repo.DeleteProjectUnit(projectID, unitID)
+}
+
+func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int) (model.ProjectUnit, error) {
+ kind := model.ProjectUnitKind(strings.TrimSpace(req.Kind))
+ if kind == "" {
+ kind = model.ProjectUnitKindChapter
+ }
+ if kind != model.ProjectUnitKindChapter && kind != model.ProjectUnitKindEpisode {
+ return model.ProjectUnit{}, BadAuthRequest("不支持的项目单元类型")
+ }
+ title := strings.TrimSpace(req.Title)
+ if title == "" {
+ return model.ProjectUnit{}, BadAuthRequest("章节标题不能为空")
+ }
+ if position < 0 {
+ 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}
+ return unit, nil
+}
+
+func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID string, req UpdateProjectUnitRequest) (model.ProjectUnit, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ unit, err := s.repo.ProjectUnit(projectID, unitID)
+ if err != nil {
+ return model.ProjectUnit{}, err
+ }
+ if title := strings.TrimSpace(req.Title); title != "" {
+ unit.Title = title
+ }
+ unit.SourceText = 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("不支持的章节状态")
+ }
+ unit.Status = status
+ }
+ unit.UpdatedAt = time.Now()
+ if err := s.repo.UpdateProjectUnit(unit); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.ProjectUnit{}, err
+ }
+ return *unit, nil
+}
+
+func (s *Service) LinkCanvasUnit(userID string, projectID string, req LinkCanvasUnitRequest) (model.CanvasUnitLink, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ canvasID := strings.TrimSpace(req.CanvasID)
+ unitID := strings.TrimSpace(req.UnitID)
+ if canvasID == "" || unitID == "" {
+ return model.CanvasUnitLink{}, BadAuthRequest("画布和章节不能为空")
+ }
+ if _, err := s.repo.CanvasProjectForUser(userID, canvasID); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ if err := s.repo.AssignCanvasToProject(userID, canvasID, projectID); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ role := strings.TrimSpace(req.Role)
+ if role == "" {
+ role = "storyboard"
+ }
+ now := time.Now()
+ link := model.CanvasUnitLink{ID: newID(), ProjectID: projectID, CanvasID: canvasID, UnitID: unitID, Role: role, CreatedAt: now}
+ if err := s.repo.UpsertCanvasUnitLink(&link); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.CanvasUnitLink{}, err
+ }
+ return link, nil
+}
+
+func (s *Service) UnlinkCanvasUnit(userID string, projectID string, canvasID string, unitID string) error {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return err
+ }
+ canvas, err := s.repo.CanvasProjectForUser(userID, strings.TrimSpace(canvasID))
+ if err != nil {
+ return err
+ }
+ if canvas.ProjectID != projectID {
+ return BadAuthRequest("画布不属于当前项目")
+ }
+ if _, err := s.repo.CanvasUnitLink(projectID, canvas.ID, strings.TrimSpace(unitID)); err != nil {
+ return err
+ }
+ return s.repo.DeleteCanvasUnitLink(projectID, canvas.ID, strings.TrimSpace(unitID))
+}
+
+func (s *Service) UnlinkCanvasProject(userID string, projectID string, canvasID string) error {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return err
+ }
+ canvas, err := s.repo.CanvasProjectForUser(userID, strings.TrimSpace(canvasID))
+ if err != nil {
+ return err
+ }
+ if canvas.ProjectID != projectID {
+ return BadAuthRequest("画布不属于当前项目")
+ }
+ now := time.Now()
+ payloadJSON, err := canvasPayloadWithoutProject(canvas.PayloadJSON, now)
+ if err != nil {
+ return err
+ }
+ // 关系列、同步快照和更新时间必须原子更新,否则浏览器会用旧 projectId 把关系重新写回。
+ return s.repo.UnassignCanvasFromProject(userID, projectID, canvas.ID, payloadJSON, now)
+}
+
+func canvasPayloadWithoutProject(payloadJSON string, updatedAt time.Time) (string, error) {
+ var payload map[string]any
+ if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
+ return "", BadAuthRequest("画布数据格式错误,无法解除项目关系")
+ }
+ delete(payload, "projectId")
+ payload["updatedAt"] = updatedAt.Format(time.RFC3339Nano)
+ next, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+ return string(next), nil
+}
+
+func IsProjectNotFound(err error) bool {
+ return errors.Is(err, gorm.ErrRecordNotFound)
+}
+
+// 任务仍以画布 ID 作为 projectId;这里统一解析到业务项目,避免项目限额和画布主键语义混淆。
+func (s *Service) projectTaskLimit(userID string, canvasOrProjectID string) (string, int, error) {
+ id := strings.TrimSpace(canvasOrProjectID)
+ if id == "" {
+ return "", 0, nil
+ }
+ if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil {
+ if canvas.ProjectID == "" {
+ return "", 0, nil
+ }
+ project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID)
+ if projectErr != nil {
+ return "", 0, projectErr
+ }
+ if project.Status == model.ProjectStatusArchived {
+ return "", 0, BadAuthRequest("项目已归档,无法创建生成任务")
+ }
+ return project.ID, normalizedProjectTaskLimit(project.ActiveTaskLimit), nil
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", 0, err
+ }
+ project, err := s.repo.ProjectForUser(userID, id)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", 0, nil
+ }
+ return "", 0, err
+ }
+ if project.Status == model.ProjectStatusArchived {
+ return "", 0, BadAuthRequest("项目已归档,无法创建生成任务")
+ }
+ return project.ID, normalizedProjectTaskLimit(project.ActiveTaskLimit), nil
+}
+
+func normalizedProjectTaskLimit(limit int) int {
+ if limit <= 0 {
+ return 3
+ }
+ return limit
+}
diff --git a/backend/internal/service/project_asset.go b/backend/internal/service/project_asset.go
new file mode 100644
index 000000000..ca32b172d
--- /dev/null
+++ b/backend/internal/service/project_asset.go
@@ -0,0 +1,473 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+)
+
+type LinkProjectAssetRequest struct {
+ AssetID string `json:"assetId"`
+ Category string `json:"category"`
+}
+
+type UpdateProjectAssetCategoryRequest struct {
+ Category string `json:"category"`
+}
+
+type CreateAssetVersionRequest struct {
+ Prompt string `json:"prompt"`
+ DefinitionJSON string `json:"definitionJson"`
+ Note string `json:"note"`
+}
+
+type ProjectAssetSummary struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ MediaType string `json:"mediaType"`
+ Category model.AssetCategory `json:"category"`
+ Status model.AssetVersionStatus `json:"status"`
+ PrimaryVersionID string `json:"primaryVersionId,omitempty"`
+ VersionCount int `json:"versionCount"`
+ Usages []string `json:"usages"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ Character *CharacterCardSummary `json:"character,omitempty"`
+}
+
+type ProjectAssetFilter struct {
+ Category string
+ MediaType string
+ Status string
+ Usage string
+}
+
+type ConfirmProjectAssetCandidateRequest struct {
+ AssetID string `json:"assetId"`
+}
+
+func (s *Service) FilterProjectAssets(userID string, projectID string, filter ProjectAssetFilter) ([]ProjectAssetSummary, error) {
+ assets, err := s.ProjectAssets(userID, projectID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ProjectAssetSummary, 0, len(assets))
+ for _, asset := range assets {
+ if filter.Category != "" && string(asset.Category) != filter.Category {
+ continue
+ }
+ if filter.MediaType != "" && asset.MediaType != filter.MediaType {
+ continue
+ }
+ if filter.Status != "" && string(asset.Status) != filter.Status {
+ continue
+ }
+ if filter.Usage != "" && !containsString(asset.Usages, filter.Usage) {
+ continue
+ }
+ result = append(result, asset)
+ }
+ return result, nil
+}
+
+func (s *Service) ProjectAssets(userID string, projectID string) ([]ProjectAssetSummary, error) {
+ assets, err := s.repo.ProjectAssets(userID, projectID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ProjectAssetSummary, 0, len(assets))
+ for _, asset := range assets {
+ summary, summaryErr := s.projectAssetSummary(userID, projectID, &asset)
+ if summaryErr != nil {
+ return nil, summaryErr
+ }
+ result = append(result, summary)
+ }
+ return result, nil
+}
+
+func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProjectAssetRequest) (ProjectAssetSummary, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ assetID := strings.TrimSpace(req.AssetID)
+ asset, err := s.repo.AssetForUser(userID, assetID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ category := model.AssetCategory(strings.TrimSpace(req.Category))
+ if category == "" {
+ category = asset.Category
+ }
+ if !validAssetCategory(category) {
+ return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类")
+ }
+ linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ if linked {
+ versions, versionErr := s.repo.AssetVersions(asset.ID)
+ if versionErr != nil {
+ return ProjectAssetSummary{}, versionErr
+ }
+ usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
+ if usageErr != nil {
+ return ProjectAssetSummary{}, usageErr
+ }
+ return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil
+ }
+ now := time.Now()
+ asset.Category = category
+ if asset.Status == "" {
+ asset.Status = model.AssetVersionStatusConfirmed
+ }
+ versions, err := s.repo.AssetVersions(asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ var initialVersion *model.AssetVersion
+ if len(versions) == 0 {
+ version := model.AssetVersion{ID: newID(), AssetID: asset.ID, Version: 1, Status: asset.Status, DefinitionJSON: "{}", CreatedAt: now, UpdatedAt: now}
+ initialVersion = &version
+ asset.PrimaryVersionID = version.ID
+ versions = append(versions, version)
+ }
+ asset.UpdatedAt = now
+ link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, CreatedAt: now}
+ created, err := s.repo.LinkProjectAsset(asset, initialVersion, &link)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ if !created {
+ current, currentErr := s.repo.AssetForUser(userID, asset.ID)
+ if currentErr != nil {
+ return ProjectAssetSummary{}, currentErr
+ }
+ currentVersions, versionErr := s.repo.AssetVersions(asset.ID)
+ if versionErr != nil {
+ return ProjectAssetSummary{}, versionErr
+ }
+ usages, usageErr := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
+ if usageErr != nil {
+ return ProjectAssetSummary{}, usageErr
+ }
+ return ProjectAssetSummary{ID: current.ID, Title: current.Title, MediaType: current.Kind, Category: current.Category, Status: current.Status, PrimaryVersionID: current.PrimaryVersionID, VersionCount: len(currentVersions), Usages: usages, UpdatedAt: current.UpdatedAt}, nil
+ }
+ return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: []string{}, UpdatedAt: asset.UpdatedAt}, nil
+}
+
+func (s *Service) UnlinkProjectAsset(userID string, projectID string, assetID string) error {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return err
+ }
+ asset, err := s.repo.AssetForUser(userID, assetID)
+ if err != nil {
+ return err
+ }
+ if asset.Category == model.AssetCategoryCharacter {
+ canvases, canvasErr := s.repo.ProjectCanvasDocuments(userID, projectID)
+ if canvasErr != nil {
+ return canvasErr
+ }
+ for _, canvas := range canvases {
+ referenced, parseErr := canvasReferencesCharacterAsset(canvas.PayloadJSON, assetID)
+ if parseErr != nil {
+ return BadAuthRequest("画布数据格式错误,无法确认角色引用")
+ }
+ if referenced {
+ return BadAuthRequest("角色仍被项目画布引用,请先删除对应角色卡节点")
+ }
+ }
+ }
+ references, err := s.repo.ProjectAssetShotReferenceCount(projectID, assetID)
+ if err != nil {
+ return err
+ }
+ if references > 0 {
+ return BadAuthRequest("素材仍被项目镜头引用,请先解除镜头用途")
+ }
+ if err := s.repo.DeleteProjectAssetLink(projectID, assetID); err != nil {
+ return err
+ }
+ return s.repo.BumpProjectRevision(projectID)
+}
+
+func canvasReferencesCharacterAsset(payloadJSON string, assetID string) (bool, error) {
+ var payload struct {
+ Nodes []struct {
+ Metadata struct {
+ WorkflowKind string `json:"workflowKind"`
+ CharacterAssetID string `json:"characterAssetId"`
+ } `json:"metadata"`
+ } `json:"nodes"`
+ }
+ if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
+ return false, err
+ }
+ for _, node := range payload.Nodes {
+ if node.Metadata.WorkflowKind == "character" && node.Metadata.CharacterAssetID == assetID {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (s *Service) UpdateProjectAssetCategory(userID string, projectID string, assetID string, req UpdateProjectAssetCategoryRequest) (ProjectAssetSummary, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ asset, err := s.repo.AssetForUser(userID, strings.TrimSpace(assetID))
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ if !linked {
+ return ProjectAssetSummary{}, BadAuthRequest("素材尚未加入当前项目")
+ }
+ category := model.AssetCategory(strings.TrimSpace(req.Category))
+ if !validAssetCategory(category) {
+ return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类")
+ }
+ asset.Category = category
+ asset.UpdatedAt = time.Now()
+ if err := s.repo.UpdateAssetDomain(asset); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ versions, err := s.repo.AssetVersions(asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ return ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}, nil
+}
+
+func (s *Service) CreateProjectAssetVersion(userID string, projectID string, assetID string, req CreateAssetVersionRequest) (model.AssetVersion, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.AssetVersion{}, err
+ }
+ asset, err := s.repo.AssetForUser(userID, assetID)
+ if err != nil {
+ return model.AssetVersion{}, err
+ }
+ linked, err := s.repo.ProjectAssetLinked(projectID, assetID)
+ if err != nil {
+ return model.AssetVersion{}, err
+ }
+ if !linked {
+ return model.AssetVersion{}, BadAuthRequest("素材尚未加入当前项目")
+ }
+ versions, err := s.repo.AssetVersions(assetID)
+ if err != nil {
+ return model.AssetVersion{}, err
+ }
+ nextVersion := 1
+ if len(versions) > 0 {
+ nextVersion = versions[0].Version + 1
+ }
+ definition := strings.TrimSpace(req.DefinitionJSON)
+ if definition == "" {
+ definition = "{}"
+ }
+ if !json.Valid([]byte(definition)) {
+ return model.AssetVersion{}, BadAuthRequest("资产版本设定必须是有效 JSON")
+ }
+ now := time.Now()
+ version := model.AssetVersion{ID: newID(), AssetID: assetID, Version: nextVersion, Status: model.AssetVersionStatusDraft, DefinitionJSON: definition, Prompt: strings.TrimSpace(req.Prompt), Note: strings.TrimSpace(req.Note), CreatedAt: now, UpdatedAt: now}
+ if err := s.repo.CreateAssetVersion(&version); err != nil {
+ return model.AssetVersion{}, err
+ }
+ asset.PrimaryVersionID = version.ID
+ asset.Status = model.AssetVersionStatusDraft
+ asset.UpdatedAt = now
+ if err := s.repo.UpdateAssetDomain(asset); err != nil {
+ return model.AssetVersion{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.AssetVersion{}, err
+ }
+ return version, nil
+}
+
+func (s *Service) ConfirmProjectAssetCandidate(userID string, projectID string, candidateID string, req ConfirmProjectAssetCandidateRequest) (ProjectAssetSummary, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ candidate, err := s.repo.ProjectAssetCandidate(projectID, candidateID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ if candidate.Status != "pending_confirmation" {
+ return ProjectAssetSummary{}, BadAuthRequest("资产候选已处理")
+ }
+ now := time.Now()
+ assetID := strings.TrimSpace(req.AssetID)
+ if assetID != "" && candidate.Category == model.AssetCategoryCharacter {
+ asset, assetErr := s.repo.ProjectCharacterAsset(userID, projectID, assetID)
+ if assetErr != nil {
+ return ProjectAssetSummary{}, assetErr
+ }
+ current, versionErr := s.repo.AssetVersion(asset.PrimaryVersionID)
+ if versionErr != nil {
+ return ProjectAssetSummary{}, versionErr
+ }
+ definition, mergeErr := mergeCharacterCandidateDefinition(current.DefinitionJSON, candidate.DetailsJSON, asset.Title, candidate.Name)
+ if mergeErr != nil {
+ return ProjectAssetSummary{}, mergeErr
+ }
+ nextAsset, nextVersion, representations, voice, prepareErr := s.prepareNextCharacterVersion(asset, asset.Title, definition, nil, nil, false)
+ if prepareErr != nil {
+ return ProjectAssetSummary{}, prepareErr
+ }
+ candidate.Status = "confirmed"
+ candidate.ResolvedAssetID = asset.ID
+ candidate.UpdatedAt = now
+ if saveErr := s.repo.ConfirmProjectCharacterCandidate(candidate, &nextAsset, &nextVersion, representations, voice); saveErr != nil {
+ return ProjectAssetSummary{}, saveErr
+ }
+ return s.projectAssetSummary(userID, projectID, &nextAsset)
+ }
+ createAsset := assetID == ""
+ var asset model.Asset
+ var version model.AssetVersion
+ if createAsset {
+ assetID = newID()
+ versionID := newID()
+ kind := "text"
+ var payload []byte
+ var marshalErr error
+ if candidate.Category == model.AssetCategoryCharacter {
+ kind = "entity"
+ var characterPayload string
+ characterPayload, marshalErr = characterAssetPayload(assetID, versionID, candidate.Name, json.RawMessage(candidate.DetailsJSON), now, now)
+ payload = []byte(characterPayload)
+ } else {
+ payload, marshalErr = json.Marshal(map[string]any{
+ "id": assetID, "kind": kind, "category": candidate.Category, "status": model.AssetVersionStatusConfirmed,
+ "primaryVersionId": versionID, "title": candidate.Name, "tags": []string{}, "data": map[string]string{"content": ""},
+ "createdAt": now.Format(time.RFC3339Nano), "updatedAt": now.Format(time.RFC3339Nano),
+ })
+ }
+ if marshalErr != nil {
+ return ProjectAssetSummary{}, marshalErr
+ }
+ asset = model.Asset{ID: assetID, UserID: userID, Kind: kind, Category: candidate.Category, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: candidate.Name, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now}
+ version = model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: candidate.DetailsJSON, CreatedAt: now, UpdatedAt: now}
+ } else {
+ existing, assetErr := s.repo.AssetForUser(userID, assetID)
+ if assetErr != nil {
+ return ProjectAssetSummary{}, assetErr
+ }
+ asset = *existing
+ }
+ candidate.Status = "confirmed"
+ candidate.ResolvedAssetID = assetID
+ candidate.UpdatedAt = now
+ link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now}
+ if err := s.repo.ConfirmProjectAssetCandidate(candidate, &asset, &version, &link, createAsset); err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ return s.projectAssetSummary(userID, projectID, &asset)
+}
+
+func mergeCharacterCandidateDefinition(currentJSON string, candidateJSON string, currentName string, candidateName string) (string, error) {
+ current := map[string]any{}
+ candidate := map[string]any{}
+ if err := json.Unmarshal([]byte(currentJSON), ¤t); err != nil {
+ return "", err
+ }
+ if err := json.Unmarshal([]byte(candidateJSON), &candidate); err != nil {
+ return "", BadAuthRequest("角色候选设定格式无效")
+ }
+ for key, value := range candidate {
+ if key == "aliases" || !emptyCharacterDefinitionValue(current[key]) {
+ continue
+ }
+ current[key] = value
+ }
+ aliases := make([]string, 0)
+ seen := map[string]bool{}
+ appendAlias := func(value string) {
+ value = strings.TrimSpace(value)
+ key := strings.ToLower(value)
+ if value == "" || seen[key] || strings.EqualFold(value, strings.TrimSpace(currentName)) {
+ return
+ }
+ seen[key] = true
+ aliases = append(aliases, value)
+ }
+ for _, value := range []any{current["aliases"], candidate["aliases"]} {
+ if list, ok := value.([]any); ok {
+ for _, item := range list {
+ if text, valid := item.(string); valid {
+ appendAlias(text)
+ }
+ }
+ }
+ }
+ appendAlias(candidateName)
+ current["aliases"] = aliases
+ encoded, err := json.Marshal(current)
+ return string(encoded), err
+}
+
+func emptyCharacterDefinitionValue(value any) bool {
+ switch typed := value.(type) {
+ case nil:
+ return true
+ case string:
+ return strings.TrimSpace(typed) == ""
+ case []any:
+ return len(typed) == 0
+ default:
+ return false
+ }
+}
+
+func containsString(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+func validAssetCategory(category model.AssetCategory) bool {
+ switch category {
+ case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryWardrobe, model.AssetCategoryProp, model.AssetCategoryWeapon, model.AssetCategoryStyle, model.AssetCategoryOther:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *Service) projectAssetSummary(userID string, projectID string, asset *model.Asset) (ProjectAssetSummary, error) {
+ versions, err := s.repo.AssetVersions(asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
+ if err != nil {
+ return ProjectAssetSummary{}, err
+ }
+ summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt}
+ if asset.Category == model.AssetCategoryCharacter && asset.PrimaryVersionID != "" {
+ card, cardErr := s.characterCard(userID, asset)
+ if cardErr != nil {
+ return ProjectAssetSummary{}, cardErr
+ }
+ summary.Character = &card
+ }
+ return summary, nil
+}
diff --git a/backend/internal/service/project_character.go b/backend/internal/service/project_character.go
new file mode 100644
index 000000000..57c6209d2
--- /dev/null
+++ b/backend/internal/service/project_character.go
@@ -0,0 +1,395 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+
+ "gorm.io/gorm"
+)
+
+type CreateProjectCharacterRequest struct {
+ Name string `json:"name"`
+ Definition map[string]any `json:"definition"`
+}
+
+type UpdateProjectCharacterRequest struct {
+ Name string `json:"name"`
+ Definition map[string]any `json:"definition"`
+}
+
+type CharacterRepresentationInput struct {
+ Role string `json:"role"`
+ ResourceID string `json:"resourceId"`
+ Metadata any `json:"metadata"`
+}
+
+type ReplaceCharacterRepresentationsRequest struct {
+ Representations []CharacterRepresentationInput `json:"representations"`
+}
+
+type BindCharacterVoiceRequest struct {
+ VoiceProfileID string `json:"voiceProfileId"`
+ Instructions string `json:"instructions"`
+}
+
+type CharacterRepresentationSummary struct {
+ ID string `json:"id"`
+ ResourceID string `json:"resourceId"`
+ MediaType string `json:"mediaType"`
+ Role string `json:"role"`
+}
+
+type VoiceProfileSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ VoiceKey string `json:"voiceKey"`
+ Language string `json:"language"`
+ Timbre string `json:"timbre"`
+ SampleResourceID string `json:"sampleResourceId,omitempty"`
+ CompatibleModels []string `json:"compatibleModels"`
+ Status string `json:"status"`
+}
+
+type CharacterVoiceSummary struct {
+ Profile VoiceProfileSummary `json:"profile"`
+ Instructions string `json:"instructions"`
+}
+
+type CharacterCardSummary struct {
+ VersionID string `json:"versionId"`
+ Version int `json:"version"`
+ Definition map[string]any `json:"definition"`
+ Representations []CharacterRepresentationSummary `json:"representations"`
+ Voice *CharacterVoiceSummary `json:"voice,omitempty"`
+ VisualStatus string `json:"visualStatus"`
+ VoiceStatus string `json:"voiceStatus"`
+}
+
+type ProjectCharacterDetail struct {
+ Asset ProjectAssetSummary `json:"asset"`
+ Character CharacterCardSummary `json:"character"`
+}
+
+var builtinVoiceNames = []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar"}
+
+func (s *Service) ListVoiceProfiles(userID string) ([]VoiceProfileSummary, error) {
+ now := time.Now()
+ profiles := make([]model.VoiceProfile, 0, len(builtinVoiceNames))
+ for _, key := range builtinVoiceNames {
+ profiles = append(profiles, model.VoiceProfile{
+ ID: newID(), UserID: userID, Name: strings.ToUpper(key[:1]) + key[1:], Provider: "openai_compatible", VoiceKey: key,
+ Language: "多语言", CompatibleModelsJSON: "[]", Status: "active", CreatedAt: now, UpdatedAt: now,
+ })
+ }
+ if err := s.repo.EnsureVoiceProfiles(profiles); err != nil {
+ return nil, err
+ }
+ stored, err := s.repo.VoiceProfiles(userID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]VoiceProfileSummary, 0, len(stored))
+ for _, profile := range stored {
+ result = append(result, voiceProfileSummary(profile))
+ }
+ return result, nil
+}
+
+func (s *Service) CreateProjectCharacter(userID string, projectID string, req CreateProjectCharacterRequest) (ProjectCharacterDetail, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return ProjectCharacterDetail{}, BadAuthRequest("角色名称不能为空")
+ }
+ definition, err := normalizedCharacterDefinition(req.Definition)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ now := time.Now()
+ assetID := newID()
+ versionID := newID()
+ payload, err := characterAssetPayload(assetID, versionID, name, definition, now, now)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ asset := model.Asset{ID: assetID, UserID: userID, Kind: "entity", Category: model.AssetCategoryCharacter, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: name, PayloadJSON: payload, CreatedAt: now, UpdatedAt: now}
+ version := model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: string(definition), CreatedAt: now, UpdatedAt: now}
+ link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, CreatedAt: now}
+ if err := s.repo.CreateProjectCharacter(projectID, &asset, &version, &link); err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ return s.projectCharacterDetail(userID, projectID, &asset)
+}
+
+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
+ }
+ return s.projectCharacterDetail(userID, projectID, asset)
+}
+
+func (s *Service) UpdateProjectCharacter(userID string, projectID string, assetID string, req UpdateProjectCharacterRequest) (ProjectCharacterDetail, error) {
+ asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return ProjectCharacterDetail{}, BadAuthRequest("角色名称不能为空")
+ }
+ definition, err := normalizedCharacterDefinition(req.Definition)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ if _, err := s.createNextCharacterVersion(projectID, asset, name, string(definition), nil, nil, false); err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ return s.ProjectCharacter(userID, projectID, asset.ID)
+}
+
+func (s *Service) ReplaceProjectCharacterRepresentations(userID string, projectID string, assetID string, req ReplaceCharacterRepresentationsRequest) (ProjectCharacterDetail, error) {
+ asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID))
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ if len(req.Representations) == 0 || len(req.Representations) > 8 {
+ return ProjectCharacterDetail{}, BadAuthRequest("角色形象数量必须在 1 到 8 之间")
+ }
+ roles := make(map[string]struct{}, len(req.Representations))
+ representations := make([]model.AssetRepresentation, 0, len(req.Representations))
+ operationID := newID()
+ for _, input := range req.Representations {
+ role := strings.TrimSpace(input.Role)
+ if !validCharacterRepresentationRole(role) {
+ return ProjectCharacterDetail{}, BadAuthRequest("不支持的角色形象视角")
+ }
+ if _, exists := roles[role]; exists {
+ return ProjectCharacterDetail{}, BadAuthRequest("同一角色形象视角不能重复")
+ }
+ roles[role] = struct{}{}
+ resourceID := strings.TrimSpace(input.ResourceID)
+ resource, resourceErr := s.repo.ResourceForUser(userID, resourceID)
+ if resourceErr != nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady {
+ return ProjectCharacterDetail{}, BadAuthRequest("角色形象资源不可用")
+ }
+ metadata, marshalErr := json.Marshal(input.Metadata)
+ if marshalErr != nil {
+ return ProjectCharacterDetail{}, BadAuthRequest("角色形象元数据格式无效")
+ }
+ representations = append(representations, model.AssetRepresentation{ID: newID(), TaskID: operationID, ResourceID: resourceID, MediaType: "image", Role: role, MetadataJSON: string(metadata), CreatedAt: time.Now()})
+ }
+ if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", representations, nil, false); err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ return s.ProjectCharacter(userID, projectID, asset.ID)
+}
+
+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
+ }
+ profile, err := s.repo.VoiceProfileForUser(userID, strings.TrimSpace(req.VoiceProfileID))
+ if err != nil || profile.Status != "active" {
+ 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 {
+ return ProjectCharacterDetail{}, err
+ }
+ return s.ProjectCharacter(userID, projectID, asset.ID)
+}
+
+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 := s.createNextCharacterVersion(projectID, asset, asset.Title, "", nil, nil, true); err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ return s.ProjectCharacter(userID, projectID, asset.ID)
+}
+
+func (s *Service) createNextCharacterVersion(projectID string, asset *model.Asset, name string, definitionJSON string, replacementRepresentations []model.AssetRepresentation, replacementVoice *model.CharacterVoiceBinding, dropVoice bool) (model.AssetVersion, error) {
+ nextAsset, next, representations, voice, err := s.prepareNextCharacterVersion(asset, name, definitionJSON, replacementRepresentations, replacementVoice, dropVoice)
+ if err != nil {
+ return model.AssetVersion{}, err
+ }
+ if err := s.repo.SaveCharacterVersion(projectID, &nextAsset, &next, representations, voice); err != nil {
+ return model.AssetVersion{}, err
+ }
+ *asset = nextAsset
+ return next, nil
+}
+
+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) {
+ current, err := s.repo.AssetVersion(asset.PrimaryVersionID)
+ if err != nil {
+ return model.Asset{}, model.AssetVersion{}, nil, nil, err
+ }
+ versions, err := s.repo.AssetVersions(asset.ID)
+ if err != nil {
+ return model.Asset{}, model.AssetVersion{}, nil, nil, err
+ }
+ nextNumber := 1
+ if len(versions) > 0 {
+ nextNumber = versions[0].Version + 1
+ }
+ if strings.TrimSpace(definitionJSON) == "" {
+ definitionJSON = current.DefinitionJSON
+ }
+ now := time.Now()
+ next := model.AssetVersion{ID: newID(), AssetID: asset.ID, Version: nextNumber, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: definitionJSON, Prompt: current.Prompt, Note: current.Note, CreatedAt: now, UpdatedAt: now}
+ representations := replacementRepresentations
+ if representations == nil {
+ currentRepresentations, representationErr := s.repo.AssetRepresentations(current.ID)
+ if representationErr != nil {
+ return model.Asset{}, model.AssetVersion{}, nil, nil, representationErr
+ }
+ operationID := newID()
+ representations = make([]model.AssetRepresentation, 0, len(currentRepresentations))
+ for _, representation := range currentRepresentations {
+ representations = append(representations, model.AssetRepresentation{ID: newID(), TaskID: operationID, AssetVersionID: next.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role, MetadataJSON: representation.MetadataJSON, CreatedAt: now})
+ }
+ } else {
+ for index := range representations {
+ representations[index].AssetVersionID = next.ID
+ }
+ }
+ voice := replacementVoice
+ if voice == nil && !dropVoice {
+ currentVoice, voiceErr := s.repo.CharacterVoiceBinding(current.ID)
+ if voiceErr == nil {
+ voice = &model.CharacterVoiceBinding{ID: newID(), AssetVersionID: next.ID, VoiceProfileID: currentVoice.VoiceProfileID, Instructions: currentVoice.Instructions, CreatedAt: now, UpdatedAt: now}
+ } else if !errors.Is(voiceErr, gorm.ErrRecordNotFound) {
+ return model.Asset{}, model.AssetVersion{}, nil, nil, voiceErr
+ }
+ }
+ if voice != nil {
+ voice.ID = newID()
+ voice.AssetVersionID = next.ID
+ voice.CreatedAt = now
+ voice.UpdatedAt = now
+ }
+ payload, err := characterAssetPayload(asset.ID, next.ID, name, json.RawMessage(definitionJSON), asset.CreatedAt, now)
+ if err != nil {
+ return model.Asset{}, model.AssetVersion{}, nil, nil, err
+ }
+ nextAsset := *asset
+ nextAsset.Title = name
+ nextAsset.Kind = "entity"
+ nextAsset.Category = model.AssetCategoryCharacter
+ nextAsset.Status = model.AssetVersionStatusConfirmed
+ nextAsset.PrimaryVersionID = next.ID
+ nextAsset.PayloadJSON = payload
+ nextAsset.UpdatedAt = now
+ return nextAsset, next, representations, voice, nil
+}
+
+func (s *Service) projectCharacterDetail(userID string, projectID string, asset *model.Asset) (ProjectCharacterDetail, error) {
+ versions, err := s.repo.AssetVersions(asset.ID)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ card, err := s.characterCard(userID, asset)
+ if err != nil {
+ return ProjectCharacterDetail{}, err
+ }
+ summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, UpdatedAt: asset.UpdatedAt, Character: &card}
+ return ProjectCharacterDetail{Asset: summary, Character: card}, nil
+}
+
+func (s *Service) characterCard(userID string, asset *model.Asset) (CharacterCardSummary, error) {
+ version, err := s.repo.AssetVersion(asset.PrimaryVersionID)
+ if err != nil {
+ return CharacterCardSummary{}, err
+ }
+ definition := map[string]any{}
+ if err := json.Unmarshal([]byte(version.DefinitionJSON), &definition); err != nil {
+ return CharacterCardSummary{}, err
+ }
+ stored, err := s.repo.AssetRepresentations(version.ID)
+ if err != nil {
+ return CharacterCardSummary{}, err
+ }
+ representations := make([]CharacterRepresentationSummary, 0, len(stored))
+ roles := make(map[string]bool, len(stored))
+ for _, representation := range stored {
+ representations = append(representations, CharacterRepresentationSummary{ID: representation.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role})
+ roles[representation.Role] = true
+ }
+ visualStatus := "missing"
+ // 新角色只需要一张完整的三视图设定图;旧版本的三张视图仍按原规则兼容读取。
+ if roles["turnaround_sheet"] || (roles["front"] && roles["side"] && roles["back"]) {
+ visualStatus = "ready"
+ } else if len(representations) > 0 {
+ visualStatus = "partial"
+ }
+ var voice *CharacterVoiceSummary
+ voiceStatus := "missing"
+ binding, bindingErr := s.repo.CharacterVoiceBinding(version.ID)
+ if bindingErr == nil {
+ profile, profileErr := s.repo.VoiceProfileForUser(userID, binding.VoiceProfileID)
+ if profileErr == nil && profile.Status == "active" {
+ voice = &CharacterVoiceSummary{Profile: voiceProfileSummary(*profile), Instructions: binding.Instructions}
+ voiceStatus = "ready"
+ } else {
+ voiceStatus = "unavailable"
+ }
+ } else if !errors.Is(bindingErr, gorm.ErrRecordNotFound) {
+ return CharacterCardSummary{}, bindingErr
+ }
+ return CharacterCardSummary{VersionID: version.ID, Version: version.Version, Definition: definition, Representations: representations, Voice: voice, VisualStatus: visualStatus, VoiceStatus: voiceStatus}, nil
+}
+
+func normalizedCharacterDefinition(value map[string]any) (json.RawMessage, error) {
+ if value == nil {
+ value = map[string]any{}
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ return nil, BadAuthRequest("角色设定格式无效")
+ }
+ return encoded, nil
+}
+
+func characterAssetPayload(assetID string, versionID string, name string, definition json.RawMessage, createdAt time.Time, updatedAt time.Time) (string, error) {
+ var value map[string]any
+ if err := json.Unmarshal(definition, &value); err != nil {
+ return "", err
+ }
+ payload, err := json.Marshal(map[string]any{
+ "id": assetID, "kind": "entity", "category": model.AssetCategoryCharacter, "status": model.AssetVersionStatusConfirmed,
+ "primaryVersionId": versionID, "title": name, "coverUrl": "", "tags": []string{}, "data": map[string]any{"definition": value},
+ "createdAt": createdAt.Format(time.RFC3339Nano), "updatedAt": updatedAt.Format(time.RFC3339Nano),
+ })
+ return string(payload), err
+}
+
+func voiceProfileSummary(profile model.VoiceProfile) VoiceProfileSummary {
+ compatible := []string{}
+ _ = json.Unmarshal([]byte(profile.CompatibleModelsJSON), &compatible)
+ return VoiceProfileSummary{ID: profile.ID, Name: profile.Name, Provider: profile.Provider, VoiceKey: profile.VoiceKey, Language: profile.Language, Timbre: profile.Timbre, SampleResourceID: profile.SampleResourceID, CompatibleModels: compatible, Status: profile.Status}
+}
+
+func validCharacterRepresentationRole(role string) bool {
+ switch role {
+ case "primary", "front", "side", "back", "turnaround_sheet", "expression_sheet":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/service/project_shot.go b/backend/internal/service/project_shot.go
new file mode 100644
index 000000000..490427eef
--- /dev/null
+++ b/backend/internal/service/project_shot.go
@@ -0,0 +1,230 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+)
+
+type CreateProjectShotRequest struct {
+ ID string `json:"id"`
+ UnitID string `json:"unitId"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Position int `json:"position"`
+ DurationMs int64 `json:"durationMs"`
+ Status string `json:"status"`
+}
+
+type ReplaceProjectUnitShotsRequest struct {
+ Shots []CreateProjectShotRequest `json:"shots"`
+}
+
+type LinkShotAssetRequest struct {
+ AssetVersionID string `json:"assetVersionId"`
+ Role string `json:"role"`
+}
+
+type AssetCandidateInput struct {
+ UnitID string `json:"unitId"`
+ ShotID string `json:"shotId"`
+ Name string `json:"name"`
+ Category string `json:"category"`
+ Details map[string]any `json:"details"`
+}
+
+type CreateAssetCandidatesRequest struct {
+ Candidates []AssetCandidateInput `json:"candidates"`
+}
+
+func (s *Service) CreateProjectShot(userID string, projectID string, req CreateProjectShotRequest) (model.Shot, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.Shot{}, err
+ }
+ unitID := strings.TrimSpace(req.UnitID)
+ if unitID != "" {
+ if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
+ return model.Shot{}, err
+ }
+ }
+ title := strings.TrimSpace(req.Title)
+ if title == "" {
+ return model.Shot{}, BadAuthRequest("镜头标题不能为空")
+ }
+ if req.Position < 0 || req.DurationMs < 0 {
+ return model.Shot{}, BadAuthRequest("镜头顺序和时长不能为负数")
+ }
+ now := time.Now()
+ shotID := strings.TrimSpace(req.ID)
+ create := shotID == ""
+ status := strings.TrimSpace(req.Status)
+ if create {
+ shotID = newID()
+ if status == "" {
+ status = "draft"
+ }
+ } else {
+ existing, err := s.repo.ShotForProject(projectID, shotID)
+ if err != nil {
+ return model.Shot{}, err
+ }
+ if status == "" {
+ status = existing.Status
+ }
+ now = existing.CreatedAt
+ }
+ if !validShotStatus(status) {
+ return model.Shot{}, BadAuthRequest("不支持的镜头状态")
+ }
+ shot := model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, Title: title, Description: strings.TrimSpace(req.Description), Position: req.Position, DurationMs: req.DurationMs, Status: status, CreatedAt: now, UpdatedAt: time.Now()}
+ if err := s.repo.SaveShot(&shot, create); err != nil {
+ return model.Shot{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.Shot{}, err
+ }
+ return shot, nil
+}
+
+func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitID string, req ReplaceProjectUnitShotsRequest) ([]model.Shot, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return nil, err
+ }
+ unitID = strings.TrimSpace(unitID)
+ if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
+ return nil, err
+ }
+ if len(req.Shots) == 0 || len(req.Shots) > 200 {
+ return nil, BadAuthRequest("章节分镜数量必须在 1 到 200 之间")
+ }
+ now := time.Now()
+ shots := make([]model.Shot, 0, len(req.Shots))
+ for position, input := range req.Shots {
+ title := strings.TrimSpace(input.Title)
+ description := strings.TrimSpace(input.Description)
+ if title == "" || description == "" || input.DurationMs < 0 {
+ return nil, BadAuthRequest("分镜标题、描述或时长无效")
+ }
+ shots = append(shots, model.Shot{ID: newID(), ProjectID: projectID, UnitID: unitID, Title: title, Description: description, Position: position, DurationMs: input.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now})
+ }
+ // 章节级重生成是一个整体写操作,旧镜头与引用必须和新镜头在同一事务中替换。
+ if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots); err != nil {
+ return nil, err
+ }
+ return shots, nil
+}
+
+func validShotStatus(status string) bool {
+ switch status {
+ case "draft", "ready", "running", "review", "completed", "failed":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *Service) LinkShotAsset(userID string, projectID string, shotID string, req LinkShotAssetRequest) (model.ShotAssetReference, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.ShotAssetReference{}, err
+ }
+ if _, err := s.repo.ShotForProject(projectID, shotID); err != nil {
+ return model.ShotAssetReference{}, err
+ }
+ versionID := strings.TrimSpace(req.AssetVersionID)
+ if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil {
+ return model.ShotAssetReference{}, err
+ }
+ role := strings.TrimSpace(req.Role)
+ if !validShotAssetRole(role) {
+ return model.ShotAssetReference{}, BadAuthRequest("不支持的镜头素材用途")
+ }
+ reference := model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: role, Status: "linked", CreatedAt: time.Now()}
+ if err := s.repo.UpsertShotAssetReference(&reference); err != nil {
+ return model.ShotAssetReference{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return model.ShotAssetReference{}, err
+ }
+ return reference, nil
+}
+
+func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, req CreateAssetCandidatesRequest) ([]model.ProjectAssetCandidate, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return nil, err
+ }
+ if len(req.Candidates) == 0 || len(req.Candidates) > 100 {
+ return nil, BadAuthRequest("资产候选数量必须在 1 到 100 之间")
+ }
+ now := time.Now()
+ candidates := make([]model.ProjectAssetCandidate, 0, len(req.Candidates))
+ for _, input := range req.Candidates {
+ name := strings.TrimSpace(input.Name)
+ category := model.AssetCategory(strings.TrimSpace(input.Category))
+ if name == "" || !validAssetCategory(category) {
+ return nil, BadAuthRequest("资产候选名称或分类无效")
+ }
+ if input.UnitID != "" {
+ if _, err := s.repo.ProjectUnit(projectID, input.UnitID); err != nil {
+ return nil, err
+ }
+ }
+ if input.ShotID != "" {
+ if _, err := s.repo.ShotForProject(projectID, input.ShotID); err != nil {
+ return nil, err
+ }
+ }
+ detailsJSON, err := marshalProjectDetails(input.Details)
+ if err != nil {
+ return nil, BadAuthRequest("资产候选详情格式无效")
+ }
+ if category == model.AssetCategoryCharacter {
+ if err := validateCharacterCandidateDetails(input.Details); err != nil {
+ return nil, err
+ }
+ }
+ candidates = append(candidates, model.ProjectAssetCandidate{ID: newID(), ProjectID: projectID, UnitID: strings.TrimSpace(input.UnitID), ShotID: strings.TrimSpace(input.ShotID), Name: name, Category: category, Status: "pending_confirmation", DetailsJSON: detailsJSON, CreatedAt: now, UpdatedAt: now})
+ }
+ if err := s.repo.CreateProjectAssetCandidates(candidates); err != nil {
+ return nil, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return nil, err
+ }
+ return candidates, nil
+}
+
+func validShotAssetRole(role string) bool {
+ switch role {
+ case "reference", "start_frame", "end_frame", "keyframe", "storyboard", "output":
+ return true
+ default:
+ return false
+ }
+}
+
+func marshalProjectDetails(value map[string]any) (string, error) {
+ if value == nil {
+ return "{}", nil
+ }
+ encoded, err := json.Marshal(value)
+ return string(encoded), err
+}
+
+func validateCharacterCandidateDetails(details map[string]any) error {
+ text := func(key string) string {
+ value, _ := details[key].(string)
+ return strings.TrimSpace(value)
+ }
+ descriptiveCount := 0
+ for _, key := range []string{"appearance", "clothing", "physique", "personality", "consistencyPrompt", "multiViewPrompt"} {
+ if text(key) != "" {
+ descriptiveCount++
+ }
+ }
+ if text("role") == "" || descriptiveCount < 3 || text("voiceLanguage") == "" || text("voiceAge") == "" || text("voiceTimbre") == "" {
+ return BadAuthRequest("角色候选必须包含剧情定位、稳定设定和声音画像")
+ }
+ return nil
+}
diff --git a/backend/internal/service/project_workflow.go b/backend/internal/service/project_workflow.go
new file mode 100644
index 000000000..c036ada1f
--- /dev/null
+++ b/backend/internal/service/project_workflow.go
@@ -0,0 +1,349 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+
+ "gorm.io/gorm"
+)
+
+const builtinShortDramaWorkflowKey = "short-drama-production"
+const builtinShortDramaWorkflowVersion = 1
+
+type workflowStepDefinition struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+}
+
+var builtinShortDramaSteps = []workflowStepDefinition{
+ {Key: "style", Name: "选择画风"},
+ {Key: "story", Name: "输入故事"},
+ {Key: "storyboard", Name: "生成分镜"},
+ {Key: "assets", Name: "准备资产"},
+ {Key: "video", Name: "生成视频"},
+ {Key: "review", Name: "审核与重试"},
+ {Key: "delivery", Name: "合并成片"},
+}
+
+type ProjectWorkflowDetail struct {
+ Instance model.WorkflowInstance `json:"instance"`
+ Steps []model.WorkflowStepInstance `json:"steps"`
+}
+
+type UpdateWorkflowStepRequest struct {
+ Status string `json:"status"`
+ OutputJSON string `json:"outputJson"`
+ Error string `json:"error"`
+}
+
+type RegisterTaskOutputRequest struct {
+ TaskID string `json:"taskId"`
+ AssetVersionID string `json:"assetVersionId"`
+ ResourceID string `json:"resourceId"`
+ MediaType string `json:"mediaType"`
+ Role string `json:"role"`
+ MetadataJSON string `json:"metadataJson"`
+ OutputJSON string `json:"outputJson"`
+}
+
+func (s *Service) EnsureBuiltinProjectWorkflowTemplate() error {
+ if _, err := s.repo.WorkflowTemplateVersion(builtinShortDramaWorkflowKey, builtinShortDramaWorkflowVersion); err == nil {
+ return nil
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ definition, err := json.Marshal(map[string]any{"scope": []string{"project", "unit"}, "steps": builtinShortDramaSteps})
+ if err != nil {
+ return err
+ }
+ template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧标准制作流程", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()}
+ return s.repo.CreateWorkflowTemplateVersion(&template)
+}
+
+func (s *Service) createProjectWorkflow(projectID string, unitID string, scope string) (ProjectWorkflowDetail, error) {
+ template, err := s.repo.WorkflowTemplateVersion(builtinShortDramaWorkflowKey, builtinShortDramaWorkflowVersion)
+ if err != nil {
+ return ProjectWorkflowDetail{}, err
+ }
+ if existing, existingErr := s.repo.WorkflowInstanceForScope(projectID, unitID, template.ID); existingErr == nil {
+ steps, stepsErr := s.repo.WorkflowSteps(existing.ID)
+ return ProjectWorkflowDetail{Instance: *existing, Steps: steps}, stepsErr
+ } else if !errors.Is(existingErr, gorm.ErrRecordNotFound) {
+ return ProjectWorkflowDetail{}, existingErr
+ }
+ now := time.Now()
+ instance := model.WorkflowInstance{ID: newID(), ProjectID: projectID, UnitID: unitID, TemplateVersionID: template.ID, Scope: scope, Status: model.WorkflowStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
+ steps := make([]model.WorkflowStepInstance, 0, len(builtinShortDramaSteps))
+ for index, definition := range builtinShortDramaSteps {
+ status := model.WorkflowStepStatusPending
+ if index == 0 {
+ status = model.WorkflowStepStatusReady
+ }
+ steps = append(steps, model.WorkflowStepInstance{ID: newID(), WorkflowInstanceID: instance.ID, StepKey: definition.Key, Name: definition.Name, Position: index, Status: status, InputJSON: "{}", OutputJSON: "{}", CreatedAt: now, UpdatedAt: now})
+ }
+ if err := s.repo.CreateWorkflowInstance(&instance, steps); err != nil {
+ return ProjectWorkflowDetail{}, err
+ }
+ if err := s.repo.BumpProjectRevision(projectID); err != nil {
+ return ProjectWorkflowDetail{}, err
+ }
+ return ProjectWorkflowDetail{Instance: instance, Steps: steps}, nil
+}
+
+func (s *Service) ProjectWorkflows(projectID string) ([]ProjectWorkflowDetail, error) {
+ instances, err := s.repo.ProjectWorkflowInstances(projectID)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ProjectWorkflowDetail, 0, len(instances))
+ for _, instance := range instances {
+ steps, stepsErr := s.repo.WorkflowSteps(instance.ID)
+ if stepsErr != nil {
+ return nil, stepsErr
+ }
+ result = append(result, ProjectWorkflowDetail{Instance: instance, Steps: steps})
+ }
+ return result, nil
+}
+
+func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID string) (ProjectWorkflowDetail, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return ProjectWorkflowDetail{}, err
+ }
+ if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
+ return ProjectWorkflowDetail{}, err
+ }
+ return s.createProjectWorkflow(projectID, unitID, "unit")
+}
+
+func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID string, req UpdateWorkflowStepRequest) (model.WorkflowStepInstance, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ step, err := s.repo.WorkflowStepForProject(projectID, stepID)
+ if err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ status := model.WorkflowStepStatus(strings.TrimSpace(req.Status))
+ if !validWorkflowStepStatus(status) {
+ return model.WorkflowStepInstance{}, BadAuthRequest("不支持的工作流步骤状态")
+ }
+ if !canTransitionWorkflowStep(step.Status, status) {
+ return model.WorkflowStepInstance{}, BadAuthRequest("当前工作流步骤不能直接切换到目标状态")
+ }
+ now := time.Now()
+ step.Status = status
+ step.OutputJSON = req.OutputJSON
+ if strings.TrimSpace(step.OutputJSON) == "" {
+ step.OutputJSON = "{}"
+ }
+ step.Error = strings.TrimSpace(req.Error)
+ if status == model.WorkflowStepStatusRunning && step.StartedAt == nil {
+ step.StartedAt = &now
+ }
+ if status == model.WorkflowStepStatusCompleted || status == model.WorkflowStepStatusSkipped {
+ step.CompletedAt = &now
+ } else {
+ step.CompletedAt = nil
+ }
+ step.UpdatedAt = now
+ instance, err := s.repo.WorkflowInstance(step.WorkflowInstanceID)
+ if err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ instance.Status = model.WorkflowStatusActive
+ instance.Revision++
+ instance.UpdatedAt = now
+ var next *model.WorkflowStepInstance
+ if status == model.WorkflowStepStatusCompleted || status == model.WorkflowStepStatusSkipped {
+ next, err = s.repo.NextWorkflowStep(step.WorkflowInstanceID, step.Position)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ next = nil
+ instance.Status = model.WorkflowStatusCompleted
+ } else if err != nil {
+ return model.WorkflowStepInstance{}, err
+ } else if next.Status == model.WorkflowStepStatusPending {
+ next.Status = model.WorkflowStepStatusReady
+ next.UpdatedAt = now
+ }
+ } else if status == model.WorkflowStepStatusFailed {
+ instance.Status = model.WorkflowStatusFailed
+ }
+ if err := s.repo.UpdateWorkflowProgress(step, next, instance, projectID); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ return *step, nil
+}
+
+func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID string, req RegisterTaskOutputRequest) (model.WorkflowStepInstance, error) {
+ if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ task, err := s.repo.TaskForUser(userID, strings.TrimSpace(req.TaskID))
+ if err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ if task.ProjectID != projectID {
+ return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目")
+ }
+ if task.Status != model.TaskStatusSucceeded {
+ return model.WorkflowStepInstance{}, BadAuthRequest("只有成功任务才能登记产物")
+ }
+ step, err := s.repo.WorkflowStepForProject(projectID, stepID)
+ if err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ if step.Status == model.WorkflowStepStatusFailed {
+ return model.WorkflowStepInstance{}, BadAuthRequest("失败步骤不能登记成功产物")
+ }
+ if versionID := strings.TrimSpace(req.AssetVersionID); versionID != "" {
+ if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ }
+ if resourceID := strings.TrimSpace(req.ResourceID); resourceID != "" {
+ if _, err := s.repo.ResourceForUser(userID, resourceID); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ }
+ metadata := strings.TrimSpace(req.MetadataJSON)
+ if metadata == "" {
+ metadata = "{}"
+ }
+ if !json.Valid([]byte(metadata)) {
+ return model.WorkflowStepInstance{}, BadAuthRequest("产物元数据必须是有效 JSON")
+ }
+ now := time.Now()
+ step.Status = model.WorkflowStepStatusCompleted
+ step.OutputJSON = strings.TrimSpace(req.OutputJSON)
+ if step.OutputJSON == "" {
+ step.OutputJSON = task.ResultJSON
+ }
+ if strings.TrimSpace(step.OutputJSON) == "" {
+ step.OutputJSON = "{}"
+ }
+ step.Error = ""
+ step.CompletedAt = &now
+ step.UpdatedAt = now
+ instance, err := s.repo.WorkflowInstance(step.WorkflowInstanceID)
+ if err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ instance.Revision++
+ instance.Status = model.WorkflowStatusActive
+ instance.UpdatedAt = now
+ next, nextErr := s.repo.NextWorkflowStep(step.WorkflowInstanceID, step.Position)
+ if errors.Is(nextErr, gorm.ErrRecordNotFound) {
+ instance.Status = model.WorkflowStatusCompleted
+ next = nil
+ } else if nextErr != nil {
+ return model.WorkflowStepInstance{}, nextErr
+ } else if next.Status == model.WorkflowStepStatusPending {
+ next.Status = model.WorkflowStepStatusReady
+ next.UpdatedAt = now
+ }
+ var representation *model.AssetRepresentation
+ if strings.TrimSpace(req.AssetVersionID) != "" {
+ role := strings.TrimSpace(req.Role)
+ if role == "" {
+ role = "output"
+ }
+ if !validShotAssetRole(role) {
+ return model.WorkflowStepInstance{}, BadAuthRequest("不支持的产物用途")
+ }
+ representation = &model.AssetRepresentation{ID: newID(), TaskID: task.ID, AssetVersionID: strings.TrimSpace(req.AssetVersionID), ResourceID: strings.TrimSpace(req.ResourceID), MediaType: strings.TrimSpace(req.MediaType), Role: role, MetadataJSON: metadata, CreatedAt: now}
+ }
+ link := &model.WorkflowStepTask{ID: newID(), WorkflowStepID: step.ID, TaskID: task.ID, CreatedAt: now}
+ if err := s.repo.RegisterWorkflowTaskOutput(step, next, instance, projectID, link, representation); err != nil {
+ return model.WorkflowStepInstance{}, err
+ }
+ return *step, nil
+}
+
+func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
+ if strings.TrimSpace(task.ProjectID) == "" || task.Status != model.TaskStatusSucceeded {
+ return nil
+ }
+ if strings.TrimSpace(task.InputJSON) == "" {
+ return nil
+ }
+ decrypted, err := s.decryptTaskInputJSON(task.InputJSON)
+ if err != nil {
+ return err
+ }
+ var input struct {
+ WorkflowStepID string `json:"workflowStepId"`
+ DomainProjectID string `json:"domainProjectId"`
+ AssetVersionID string `json:"assetVersionId"`
+ ResourceID string `json:"resourceId"`
+ MediaType string `json:"mediaType"`
+ Role string `json:"role"`
+ Metadata map[string]any `json:"metadata"`
+ }
+ if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
+ return err
+ }
+ if input.Metadata != nil {
+ if input.WorkflowStepID == "" {
+ input.WorkflowStepID, _ = input.Metadata["workflowStepId"].(string)
+ }
+ if input.DomainProjectID == "" {
+ input.DomainProjectID, _ = input.Metadata["domainProjectId"].(string)
+ }
+ if input.AssetVersionID == "" {
+ input.AssetVersionID, _ = input.Metadata["assetVersionId"].(string)
+ }
+ if input.ResourceID == "" {
+ input.ResourceID, _ = input.Metadata["resourceId"].(string)
+ }
+ if input.MediaType == "" {
+ input.MediaType, _ = input.Metadata["mediaType"].(string)
+ }
+ if input.Role == "" {
+ input.Role, _ = input.Metadata["role"].(string)
+ }
+ }
+ if strings.TrimSpace(input.WorkflowStepID) == "" {
+ return nil
+ }
+ projectID := strings.TrimSpace(input.DomainProjectID)
+ if projectID == "" {
+ if _, projectErr := s.repo.ProjectForUser(task.UserID, task.ProjectID); projectErr == nil {
+ projectID = task.ProjectID
+ }
+ }
+ if projectID == "" {
+ return errors.New("任务未提供短剧项目 ID,无法登记产物")
+ }
+ _, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON})
+ return err
+}
+
+func validWorkflowStepStatus(status model.WorkflowStepStatus) bool {
+ switch status {
+ case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped:
+ return true
+ default:
+ return false
+ }
+}
+
+func canTransitionWorkflowStep(current model.WorkflowStepStatus, next model.WorkflowStepStatus) bool {
+ if current == next {
+ return true
+ }
+ allowed := map[model.WorkflowStepStatus]map[model.WorkflowStepStatus]bool{
+ model.WorkflowStepStatusPending: {model.WorkflowStepStatusReady: true, model.WorkflowStepStatusSkipped: true},
+ model.WorkflowStepStatusReady: {model.WorkflowStepStatusRunning: true, model.WorkflowStepStatusSkipped: true},
+ model.WorkflowStepStatusRunning: {model.WorkflowStepStatusReview: true, model.WorkflowStepStatusCompleted: true, model.WorkflowStepStatusFailed: true},
+ model.WorkflowStepStatusReview: {model.WorkflowStepStatusRunning: true, model.WorkflowStepStatusCompleted: true, model.WorkflowStepStatusFailed: true},
+ model.WorkflowStepStatusFailed: {model.WorkflowStepStatusReady: true, model.WorkflowStepStatusRunning: true},
+ model.WorkflowStepStatusCompleted: {model.WorkflowStepStatusRunning: true},
+ model.WorkflowStepStatusSkipped: {model.WorkflowStepStatusReady: true},
+ }
+ return allowed[current][next]
+}
diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go
index 390998de2..44acf6074 100644
--- a/backend/internal/service/provider.go
+++ b/backend/internal/service/provider.go
@@ -333,6 +333,15 @@ func systemChannelIDFromBaseURL(baseURL string) string {
func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
var payload imageResponse
+ if input.Mask != nil {
+ // 蒙版编辑是强校验写路径:协议能力不明确时必须失败,不能静默退化为整图重绘。
+ if strings.TrimSpace(input.Config.InterfaceType) != string(model.ChannelInterfaceOpenAIImage) {
+ return nil, errors.New("当前渠道未声明 OpenAI Images 编辑协议,已拒绝可能忽略蒙版的整图重绘")
+ }
+ if len(input.ReferenceImages) == 0 {
+ return nil, errors.New("蒙版编辑必须提供与蒙版同尺寸的源图片")
+ }
+ }
if len(input.ReferenceImages) > 0 || input.Mask != nil {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
@@ -548,6 +557,12 @@ func shouldFallbackTextToChat(err error) bool {
}
func runAudioTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
+ if resolved, ok := input.Metadata["resolvedCharacterVersions"].([]interface{}); ok && len(resolved) > 0 {
+ voiceKey := metadataString(input.Metadata, "resolvedCharacterVoiceKey")
+ if voiceKey == "" || strings.TrimSpace(input.Config.AudioVoice) != voiceKey {
+ return nil, errors.New("角色配音缺少已解析的声音绑定")
+ }
+ }
format := defaultString(input.Config.AudioFormat, "mp3")
body := map[string]interface{}{
"model": input.Config.Model,
diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go
index be76161d6..3a731e6f1 100644
--- a/backend/internal/service/service.go
+++ b/backend/internal/service/service.go
@@ -79,6 +79,8 @@ type TaskSummary struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
+ PreviewURL string `json:"previewUrl,omitempty"`
+ PreviewKind string `json:"previewKind,omitempty"`
Attempts int `json:"attempts"`
StartedAt *time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
@@ -290,6 +292,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
taskType = "video_image_to_video"
}
task := model.Task{ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID, Type: taskType, Status: model.TaskStatusQueued, Stage: "等待队列调度", Progress: 5, Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: req.Model}
+ domainProjectID, projectTaskLimit, err := s.projectTaskLimit(userID, req.ProjectID)
+ if err != nil {
+ return nil, err
+ }
billingOrder, err := s.taskBillingOrder(userID, &task, normalizedInput)
if err != nil {
return nil, err
@@ -302,10 +308,13 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
if billingOrder != nil {
task.BillingOrderID = billingOrder.ID
}
- err = s.createTaskWithinStorageQuota(&task, billingOrder, policy)
+ err = s.createTaskWithinStorageQuota(&task, billingOrder, policy, domainProjectID, projectTaskLimit)
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
+ if errors.Is(err, repository.ErrProjectTaskLimit) {
+ return nil, BadAuthRequest(fmt.Sprintf("当前项目同时排队或运行的任务最多 %d 个", projectTaskLimit))
+ }
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
@@ -410,13 +419,20 @@ func (s *Service) RetryTask(userID string, id string) (*model.Task, error) {
if err != nil {
return nil, err
}
- task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder, policy.Task.ActiveTaskLimit)
+ domainProjectID, projectTaskLimit, err := s.projectTaskLimit(userID, task.ProjectID)
+ if err != nil {
+ return nil, err
+ }
+ task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit)
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
+ if errors.Is(err, repository.ErrProjectTaskLimit) {
+ return nil, BadAuthRequest(fmt.Sprintf("当前项目同时排队或运行的任务最多 %d 个", projectTaskLimit))
+ }
if errors.Is(err, repository.ErrTaskNotRetryable) {
return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试")
}
@@ -539,6 +555,7 @@ func taskSummaryForOutput(task model.Task) TaskSummary {
if isContentModerationFailure(task.Error) {
errorCode = contentModerationErrorCode
}
+ previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type)
return TaskSummary{
ID: task.ID,
SessionID: task.SessionID,
@@ -552,6 +569,8 @@ func taskSummaryForOutput(task model.Task) TaskSummary {
Provider: task.Provider,
Model: task.Model,
ErrorCode: errorCode,
+ PreviewURL: previewURL,
+ PreviewKind: previewKind,
Attempts: task.Attempts,
StartedAt: task.StartedAt,
CompletedAt: task.CompletedAt,
@@ -560,6 +579,63 @@ func taskSummaryForOutput(task model.Task) TaskSummary {
}
}
+// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。
+func taskMediaPreview(raw string, taskType string) (string, string) {
+ if strings.TrimSpace(raw) == "" {
+ return "", ""
+ }
+ var payload any
+ if json.Unmarshal([]byte(raw), &payload) != nil {
+ return "", ""
+ }
+ defaultKind := "image"
+ if strings.Contains(strings.ToLower(taskType), "video") {
+ defaultKind = "video"
+ }
+ return findTaskMediaPreview(payload, defaultKind)
+}
+
+func findTaskMediaPreview(value any, hint string) (string, string) {
+ switch item := value.(type) {
+ case string:
+ text := strings.TrimSpace(item)
+ if !strings.HasPrefix(text, "/api/resources/") && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") {
+ return "", ""
+ }
+ kind := hint
+ lower := strings.ToLower(text)
+ if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") {
+ kind = "video"
+ } else if kind != "video" {
+ kind = "image"
+ }
+ return text, kind
+ case []any:
+ for _, child := range item {
+ if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" {
+ return previewURL, previewKind
+ }
+ }
+ case map[string]any:
+ for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} {
+ child, exists := item[key]
+ if !exists {
+ continue
+ }
+ childHint := hint
+ if key == "video" {
+ childHint = "video"
+ } else if key == "images" || key == "image" {
+ childHint = "image"
+ }
+ if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" {
+ return previewURL, previewKind
+ }
+ }
+ }
+ return "", ""
+}
+
func truncateRunes(value string, limit int) string {
text := []rune(value)
if len(text) <= limit {
@@ -582,7 +658,8 @@ func publicTaskInputJSON(raw string) string {
return ""
}
public := map[string]any{}
- for _, key := range []string{"mode", "metadata"} {
+ // 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。
+ for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} {
if value, ok := input[key]; ok {
public[key] = value
}
@@ -781,6 +858,12 @@ func (s *Service) processClaimedTask(task *model.Task) error {
_ = s.log(task.UserID, task.ID, "error", "任务结果保存失败", task.Error)
return err
}
+ if completedTask, fetchErr := s.repo.Task(task.ID); fetchErr == nil {
+ if registerErr := s.RegisterTaskOutputFromTask(*completedTask); registerErr != nil {
+ // 任务成功与产物登记分开记账;登记失败保持步骤异常,允许项目页幂等补登记。
+ _ = s.log(task.UserID, task.ID, "error", "任务成功但项目产物登记失败", registerErr.Error())
+ }
+ }
if err := s.SettleBilling(task.BillingOrderID, ""); err != nil {
_ = s.MarkBillingUncertain(task.BillingOrderID, "生成成功但积分结算失败:"+err.Error())
_ = s.log(task.UserID, task.ID, "error", "积分结算失败,已进入待核对", err.Error())
diff --git a/backend/internal/service/storage_quota.go b/backend/internal/service/storage_quota.go
index 19f6a33d7..ac38c4587 100644
--- a/backend/internal/service/storage_quota.go
+++ b/backend/internal/service/storage_quota.go
@@ -74,7 +74,7 @@ func validateStructuredReplacementQuotaWithPolicy(usage repository.UserStorageUs
return validateStructuredStorageQuotaWithPolicy(usage, kind, false, deltaBytes, policy)
}
-func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error {
+func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting, domainProjectID string, projectTaskLimit int) error {
s.storageMu.Lock()
defer s.storageMu.Unlock()
usage, err := s.repo.UserStorageUsage(task.UserID)
@@ -86,9 +86,9 @@ func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *m
return err
}
if billingOrder != nil {
- return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit)
+ return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit)
}
- return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit)
+ return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit, domainProjectID, projectTaskLimit)
}
// 任务完成会同时扩张任务历史和 Agent 会话数据,必须在同一临界区核算并原子写入。
diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go
index 2ea3555a9..d539bf52b 100644
--- a/backend/internal/service/user_data.go
+++ b/backend/internal/service/user_data.go
@@ -22,6 +22,8 @@ type CanvasProjectsSyncRequest struct {
type UserDataSummary struct {
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
+ Category string `json:"category,omitempty"`
+ Status string `json:"status,omitempty"`
Title string `json:"title"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -34,7 +36,7 @@ func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) {
}
result := make([]UserDataSummary, 0, len(assets))
for _, asset := range assets {
- result = append(result, UserDataSummary{ID: asset.ID, Kind: asset.Kind, Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
+ result = append(result, UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt})
}
return result, nil
}
@@ -79,10 +81,20 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
if existingErr != nil {
s.recordActivity(userID, "asset", 1)
}
- return UserDataSummary{ID: asset.ID, Kind: asset.Kind, Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
+ return UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil
}
func (s *Service) DeleteUserAsset(userID string, id string) error {
+ if _, err := s.repo.AssetForUser(userID, id); err != nil {
+ return err
+ }
+ references, err := s.repo.AssetReferenceCount(id)
+ if err != nil {
+ return err
+ }
+ if references > 0 {
+ return BadAuthRequest("素材仍被项目或镜头引用,请先解除引用")
+ }
return s.repo.DeleteAsset(userID, id)
}
@@ -247,11 +259,14 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
return model.Asset{}, err
}
var payload struct {
- ID string `json:"id"`
- Kind string `json:"kind"`
- Title string `json:"title"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Category string `json:"category"`
+ Status string `json:"status"`
+ PrimaryVersionID string `json:"primaryVersionId"`
+ Title string `json:"title"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return model.Asset{}, BadAuthRequest("素材数据格式错误")
@@ -263,14 +278,25 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) {
if id == "" {
id = newID()
}
+ category := model.AssetCategory(strings.TrimSpace(payload.Category))
+ if category == "" {
+ category = model.AssetCategoryOther
+ }
+ status := model.AssetVersionStatus(strings.TrimSpace(payload.Status))
+ if status == "" {
+ status = model.AssetVersionStatusConfirmed
+ }
return model.Asset{
- ID: id,
- UserID: userID,
- Kind: strings.TrimSpace(payload.Kind),
- Title: strings.TrimSpace(payload.Title),
- PayloadJSON: string(raw),
- CreatedAt: createdAt,
- UpdatedAt: updatedAt,
+ ID: id,
+ UserID: userID,
+ Kind: strings.TrimSpace(payload.Kind),
+ Category: category,
+ Status: status,
+ PrimaryVersionID: strings.TrimSpace(payload.PrimaryVersionID),
+ Title: strings.TrimSpace(payload.Title),
+ PayloadJSON: string(raw),
+ CreatedAt: createdAt,
+ UpdatedAt: updatedAt,
}, nil
}
@@ -281,6 +307,7 @@ func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProj
var payload struct {
ID string `json:"id"`
Title string `json:"title"`
+ ProjectID string `json:"projectId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
@@ -297,6 +324,7 @@ func canvasProjectFromJSON(userID string, raw json.RawMessage) (model.CanvasProj
return model.CanvasProject{
ID: id,
UserID: userID,
+ ProjectID: strings.TrimSpace(payload.ProjectID),
Title: strings.TrimSpace(payload.Title),
PayloadJSON: string(raw),
CreatedAt: createdAt,
diff --git a/canvas-agent/README.md b/canvas-agent/README.md
index 4fef50d05..87231aaaa 100644
--- a/canvas-agent/README.md
+++ b/canvas-agent/README.md
@@ -1,4 +1,4 @@
-# Infinite Canvas Agent
+# 影策 Canvas Agent
本地 Canvas Agent 用来连接画布网页和用户电脑上的 Codex / Claude Code。本地开发时优先连接 `http://localhost:3000`,不需要先使用线上站点。
@@ -58,7 +58,7 @@ codex plugin add infinite-canvas@infinite-canvas-local
npx -y @ddcat666/open-ai-canvas-agent mcp
```
-使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会优先启动本地画布和本地 Agent,读取 Local URL 和 Connect token,然后直接打开画布网页地址新建并连接画布。如果自动连接失败,再检查本地画布服务和 Canvas Agent 是否都已启动。
+使用时可以直接在 Codex 里说“打开影策”,插件会优先启动本地画布和本地 Agent,读取 Local URL 和 Connect token,然后直接打开画布网页地址新建并连接画布。如果自动连接失败,再检查本地画布服务和 Canvas Agent 是否都已启动。
Canvas Agent 启动后,给 Codex 添加 MCP:
diff --git a/canvas-agent/src/agents.ts b/canvas-agent/src/agents.ts
index eba908112..fb03e3c32 100644
--- a/canvas-agent/src/agents.ts
+++ b/canvas-agent/src/agents.ts
@@ -127,7 +127,7 @@ class CodexAppClient {
codexThreadId = "";
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
});
- await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
+ await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "影策 Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
client.notify("initialized");
return client;
}
diff --git a/canvas-agent/src/canvas-session.ts b/canvas-agent/src/canvas-session.ts
index 29b5f0c08..181d260f0 100644
--- a/canvas-agent/src/canvas-session.ts
+++ b/canvas-agent/src/canvas-session.ts
@@ -48,6 +48,13 @@ export class CanvasSession {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
let tool: ToolName = name;
let input = parseToolInput(tool, rawInput) as Record;
+ const projectTool = tool.startsWith("project_");
+ if (projectTool) {
+ if (!this.clients.size || !this.canvasState) throw new Error("当前没有已连接画布");
+ if (!input.projectId && this.canvasState.domainProjectId) input.projectId = this.canvasState.domainProjectId;
+ if (!input.projectId) throw new Error("当前画布没有关联短剧项目");
+ return await this.requestCanvasTool(tool, input);
+ }
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
diff --git a/canvas-agent/src/config.ts b/canvas-agent/src/config.ts
index 48d3a772d..63554ea2b 100644
--- a/canvas-agent/src/config.ts
+++ b/canvas-agent/src/config.ts
@@ -7,7 +7,7 @@ export const DEFAULT_PORT = 17371;
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion();
-export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
+export const AGENT_PROMPT = "你正在帮助用户操作影策网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record };
diff --git a/canvas-agent/src/http-server.ts b/canvas-agent/src/http-server.ts
index d0e6ca768..31a53bc4e 100644
--- a/canvas-agent/src/http-server.ts
+++ b/canvas-agent/src/http-server.ts
@@ -100,7 +100,7 @@ export function startHttpServer() {
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => res.status(500).json({ ok: false, error: error.message }));
app.listen(port, "127.0.0.1", () => {
- console.log("Infinite Canvas Agent");
+ console.log("影策 Canvas Agent");
console.log(`Local URL: ${config.url}`);
console.log(`Connect token: ${config.token}`);
console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y @ddcat666/open-ai-canvas-agent mcp");
diff --git a/canvas-agent/src/schemas.ts b/canvas-agent/src/schemas.ts
index e8880077e..d8c8102e1 100644
--- a/canvas-agent/src/schemas.ts
+++ b/canvas-agent/src/schemas.ts
@@ -5,6 +5,9 @@ const positionSchema = z.object({ x: z.number(), y: z.number() });
const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() });
const nodeTypeSchema = z.enum(["image", "text", "script", "config", "video", "audio", "frame"]);
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
+const projectIdSchema = z.string().min(1).optional();
+const projectCandidateSchema = z.object({ unitId: z.string().optional(), shotId: z.string().optional(), name: z.string().min(1), category: z.string().min(1), details: recordSchema.optional() });
+const projectShotSchema = z.object({ id: z.string().optional(), unitId: z.string().optional(), title: z.string().min(1), description: z.string().optional(), position: z.number().int().min(0).optional(), durationMs: z.number().int().min(0).optional(), status: z.string().optional() });
export const toolNames = [
"canvas_get_state",
@@ -30,6 +33,16 @@ export const toolNames = [
"canvas_select_nodes",
"canvas_set_viewport",
"canvas_run_generation",
+ "project_get_context",
+ "project_list_units",
+ "project_extract_asset_candidates",
+ "project_confirm_asset_candidate",
+ "project_create_or_update_shots",
+ "project_link_shot_asset",
+ "project_start_workflow_step",
+ "project_link_asset",
+ "project_upsert_asset_version",
+ "project_register_task_output",
] as const;
export type ToolName = (typeof toolNames)[number];
@@ -100,6 +113,16 @@ export const toolInputSchemas = {
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
canvas_set_viewport: z.object({ viewport: viewportSchema }),
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
+ project_get_context: z.object({ projectId: projectIdSchema }),
+ project_list_units: z.object({ projectId: projectIdSchema, kind: z.string().optional(), status: z.string().optional() }),
+ project_extract_asset_candidates: z.object({ projectId: projectIdSchema, candidates: z.array(projectCandidateSchema).min(1).max(100) }),
+ project_confirm_asset_candidate: z.object({ projectId: projectIdSchema, candidateId: z.string().min(1), assetId: z.string().optional() }),
+ project_create_or_update_shots: z.object({ projectId: projectIdSchema, shots: z.array(projectShotSchema).min(1).max(100) }),
+ project_link_shot_asset: z.object({ projectId: projectIdSchema, shotId: z.string().min(1), assetVersionId: z.string().min(1), role: z.enum(["reference", "start_frame", "end_frame", "keyframe", "storyboard", "output"]) }),
+ project_start_workflow_step: z.object({ projectId: projectIdSchema, stepId: z.string().min(1) }),
+ project_link_asset: z.object({ projectId: projectIdSchema, assetId: z.string().min(1), category: z.string().min(1) }),
+ project_upsert_asset_version: z.object({ projectId: projectIdSchema, assetId: z.string().min(1), prompt: z.string().optional(), definitionJson: z.string().optional(), note: z.string().optional() }),
+ project_register_task_output: z.object({ projectId: projectIdSchema, stepId: z.string().min(1), taskId: z.string().min(1), assetVersionId: z.string().optional(), resourceId: z.string().optional(), mediaType: z.string().optional(), role: z.enum(["reference", "start_frame", "end_frame", "keyframe", "storyboard", "output"]).optional(), metadataJson: z.string().optional(), outputJson: z.string().optional() }),
} satisfies Record;
export const toolDescriptions: Record = {
@@ -126,4 +149,14 @@ export const toolDescriptions: Record = {
canvas_select_nodes: "设置当前选中节点。",
canvas_set_viewport: "调整画布视口。",
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
+ project_get_context: "读取当前短剧项目的章节、画布、资产、镜头、候选和工作流事实。",
+ project_list_units: "按类型或状态筛选当前短剧项目的章节/项目单元。",
+ project_extract_asset_candidates: "将分镜识别出的角色、场景、服饰、道具或武器需求登记为待确认资产候选。",
+ project_confirm_asset_candidate: "确认一个资产候选,创建正式资产或关联已有个人资产。",
+ project_create_or_update_shots: "创建或更新项目镜头业务数据,不把镜头状态写进画布 metadata。",
+ project_link_shot_asset: "将具体资产版本按首帧、尾帧或参考等用途关联到镜头。",
+ project_start_workflow_step: "启动项目或章节制作流程中的一个步骤。",
+ project_link_asset: "将个人资产引用到当前短剧项目,不复制媒体文件。",
+ project_upsert_asset_version: "为项目资产创建新的设定和提示词版本,保留历史版本。",
+ project_register_task_output: "将成功生成任务挂到流程步骤,并登记到具体资产版本和资源表示。",
};
diff --git a/canvas-agent/src/types.ts b/canvas-agent/src/types.ts
index fb29cf8d5..28490ab14 100644
--- a/canvas-agent/src/types.ts
+++ b/canvas-agent/src/types.ts
@@ -3,6 +3,6 @@ export type Viewport = { x: number; y: number; k: number };
export type CanvasNodeType = "image" | "text" | "script" | "config" | "video" | "audio" | "frame";
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; parentId?: string; metadata?: Record };
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string; fromHandleId?: string; toHandleId?: string };
-export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
+export type CanvasSnapshot = { projectId?: string; domainProjectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
export type AgentEmit = (type: string, payload: unknown) => void;
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
diff --git a/plugins/infinite-canvas/.codex-plugin/plugin.json b/plugins/infinite-canvas/.codex-plugin/plugin.json
index 3005c3022..53d21a94b 100644
--- a/plugins/infinite-canvas/.codex-plugin/plugin.json
+++ b/plugins/infinite-canvas/.codex-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "infinite-canvas",
"version": "0.1.0",
- "description": "Use Codex to read and operate Infinite Canvas through the local Canvas Agent MCP server.",
+ "description": "Use Codex to read and operate Yingce through the local Canvas Agent MCP server.",
"author": {
"name": "ddcat"
},
@@ -11,14 +11,14 @@
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
- "displayName": "Infinite Canvas",
- "shortDescription": "Use Codex to operate the current Infinite Canvas board.",
- "longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Infinite Canvas board, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.",
+ "displayName": "影策",
+ "shortDescription": "Use Codex to operate the current Yingce canvas.",
+ "longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Yingce canvas, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.",
"developerName": "ddcat",
"category": "Productivity",
"capabilities": ["Canvas", "MCP", "Creative Workflow"],
"defaultPrompt": [
- "打开 Infinite Canvas",
+ "打开影策",
"读取当前画布并总结节点结构",
"根据选中节点创建一组生图提示词"
],
diff --git a/plugins/infinite-canvas/README.md b/plugins/infinite-canvas/README.md
index bc230c1ca..1720543af 100644
--- a/plugins/infinite-canvas/README.md
+++ b/plugins/infinite-canvas/README.md
@@ -1,17 +1,17 @@
-# Infinite Canvas Codex Plugin
+# 影策 Codex 插件
-这个插件把 Infinite Canvas 的本地 Canvas Agent MCP 打包给 Codex app 使用,让 Codex 能打开本地画布、读取当前节点、创建内容并触发生成流程。
+这个插件把影策的本地 Canvas Agent MCP 打包给 Codex app 使用,让 Codex 能打开本地画布、读取当前节点、创建内容并触发生成流程。
## 安装
-> Infinite Canvas 尚未上架 Codex 公共插件目录,直接搜索不会显示。请从本仓库自带的 marketplace 安装。
+> 影策尚未上架 Codex 公共插件目录,直接搜索不会显示。请从本仓库自带的 marketplace 安装。
### AI 自动安装
把下面这段发给 Codex:
```text
-请从 https://github.com/ddcat-ai/open-ai-canvas.git 安装 Infinite Canvas Codex 插件。
+请从 https://github.com/ddcat-ai/open-ai-canvas.git 安装影策 Codex 插件。
请 clone 仓库到 ~/plugins/open-ai-canvas,确认 .agents/plugins/marketplace.json 和
plugins/infinite-canvas/.codex-plugin/plugin.json 都存在。然后运行
codex plugin marketplace add ~/plugins/open-ai-canvas,
@@ -39,7 +39,7 @@ codex plugin add infinite-canvas@infinite-canvas-local
### 本仓库开发调试
-如果你就在 Infinite Canvas 仓库中调试插件,可以直接添加当前仓库。建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败:
+如果你就在影策仓库中调试插件,可以直接添加当前仓库。建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败:
```bash
cd /path/to/infinite-canvas
@@ -49,15 +49,15 @@ codex plugin add infinite-canvas@infinite-canvas-local
## 使用
-1. 新建 Codex 线程后说“打开 Infinite Canvas”。
-2. 插件会确认当前仓库的本地画布服务是否已运行;端口被占用时会检查进程归属,不会把其他项目的 `3000` 当作 Infinite Canvas。
+1. 新建 Codex 线程后说“打开影策”。
+2. 插件会确认当前仓库的本地画布服务是否已运行;端口被占用时会检查进程归属,不会把其他项目的 `3000` 当作影策。
3. 确认或启动后,插件会直接打开新建画布 URL,并自动尝试连接本地 Agent。
4. 画布打开后,让 Codex 读取或操作当前画布。
常用提示:
```text
-打开 Infinite Canvas
+打开影策
读取当前画布并总结节点结构
根据选中节点创建一组生图提示词
```
diff --git a/plugins/infinite-canvas/skills/canvas/SKILL.md b/plugins/infinite-canvas/skills/canvas/SKILL.md
index a254d553c..63be462fe 100644
--- a/plugins/infinite-canvas/skills/canvas/SKILL.md
+++ b/plugins/infinite-canvas/skills/canvas/SKILL.md
@@ -1,15 +1,15 @@
---
name: canvas
-description: 操作 Infinite Canvas 当前网页画布,读取节点、选区、创建文本节点、创建生成流程、连接节点或触发生成。
+description: 操作影策当前网页画布,读取节点、选区、创建文本节点、创建生成流程、连接节点或触发生成。
---
-# Infinite Canvas
+# 影策画布
-你正在帮助用户操作 Infinite Canvas 网页画布。需要理解或改动画布时,优先使用已配置的 `infinite-canvas` MCP 工具;不要让用户手动复制 JSON、URL 或 token。
+你正在帮助用户操作影策网页画布。需要理解或改动画布时,优先使用已配置的 `infinite-canvas` MCP 工具;不要让用户手动复制 JSON、URL 或 token。
## 工作流
-- 如果用户还没有打开或连接网页画布,使用 `open-canvas` 技能打开 Infinite Canvas,不要要求用户手动复制 URL 或 token。
+- 如果用户还没有打开或连接网页画布,使用 `open-canvas` 技能打开影策,不要要求用户手动复制 URL 或 token。
- 操作前先用 `canvas_get_state` 读取当前画布;如果用户明确提到选中内容、当前节点或“这个”,先用 `canvas_get_selection`。
- 创建单个文本内容优先用 `canvas_create_text_node`。
- 创建生成内容优先用 `canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`。
diff --git a/plugins/infinite-canvas/skills/open-canvas/SKILL.md b/plugins/infinite-canvas/skills/open-canvas/SKILL.md
index 098351d98..26d4d48c2 100644
--- a/plugins/infinite-canvas/skills/open-canvas/SKILL.md
+++ b/plugins/infinite-canvas/skills/open-canvas/SKILL.md
@@ -1,11 +1,11 @@
---
name: open-canvas
-description: 打开 Infinite Canvas 网页画布并自动连接本地 Canvas Agent。用户要求打开、启动、进入、使用 Infinite Canvas 或画布时使用。
+description: 打开影策网页画布并自动连接本地 Canvas Agent。用户要求打开、启动、进入、使用影策或画布时使用。
---
-# Open Infinite Canvas
+# 打开影策画布
-当用户要求打开、启动、进入或使用 Infinite Canvas 时,不要把 URL 交给用户手动复制,不要通过浏览器点击“新建画布”。优先快速拉起本地画布和本地 Canvas Agent,然后直接打开带 `mode`、`agentUrl`、`agentToken` 的 URL,让网页自动创建或选择画布并连接 Agent。
+当用户要求打开、启动、进入或使用影策时,不要把 URL 交给用户手动复制,不要通过浏览器点击“新建画布”。优先快速拉起本地画布和本地 Canvas Agent,然后直接打开带 `mode`、`agentUrl`、`agentToken` 的 URL,让网页自动创建或选择画布并连接 Agent。
## 默认打开方式
@@ -17,7 +17,7 @@ description: 打开 Infinite Canvas 网页画布并自动连接本地 Canvas Age
## 工作流
-1. 如果当前仓库是 Infinite Canvas 项目,优先使用当前仓库的 `web/` 前端。
+1. 如果当前仓库是影策项目,优先使用当前仓库的 `web/` 前端。
2. 先检查本地端口归属:如果 `3000`、`3001` 等端口已被占用,必须用 `lsof`/`ps` 或服务输出确认监听进程的工作目录属于当前仓库的 `web/`,不能只因为端口存在就当成本地画布。
3. 如果已有当前仓库的 Next dev 服务,复用它并记录真实画布地址,例如 `http://localhost:3001`。
4. 如果没有当前仓库的服务,启动本地画布开发服务,默认在 `web/` 下运行 `bun run dev`;若默认端口被其他项目占用,改用空闲端口启动,例如 `bunx next dev --webpack -H 0.0.0.0 -p <空闲端口>`。不要执行构建或测试。
@@ -29,7 +29,7 @@ description: 打开 Infinite Canvas 网页画布并自动连接本地 Canvas Age
## 用户只安装插件时
-- 如果当前工作区不是 Infinite Canvas 源码仓库,优先提示用户先打开或启动 Infinite Canvas 网页,再连接本地 Agent。
+- 如果当前工作区不是影策源码仓库,优先提示用户先打开或启动影策网页,再连接本地 Agent。
- 可以使用线上画布地址或用户给出的本地地址作为 `<画布网页地址>`,但仍要通过本地 Canvas Agent 获取 token 后再打开最终 URL。
- 不要假设用户已经安装本仓库依赖;插件的 MCP 会通过 `npx -y @ddcat666/open-ai-canvas-agent mcp` 使用已发布的 Canvas Agent。
diff --git a/scripts/install-server.sh b/scripts/install-server.sh
index ac847767a..6fbaeebc5 100755
--- a/scripts/install-server.sh
+++ b/scripts/install-server.sh
@@ -64,7 +64,7 @@ install_docker() {
}
sync_source() {
- step "下载无限画布源码"
+ step "下载影策源码"
if [[ -d "$INSTALL_DIR/.git" ]]; then
cd "$INSTALL_DIR"
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "$INSTALL_DIR 存在本地代码改动,请先处理后再更新"
diff --git a/web/bun.lock b/web/bun.lock
index 4416ac96a..79bbc8791 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -9,10 +9,11 @@
"@codemirror/lang-json": "^6.0.2",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
- "@radix-ui/react-select": "^2.3.4",
+ "@mediapipe/tasks-vision": "^0.10.35",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@tanstack/react-query": "^5.101.2",
+ "@tanstack/react-virtual": "^3.14.8",
"@tiptap/extension-character-count": "^3.28.0",
"@tiptap/extension-color": "^3.28.0",
"@tiptap/extension-highlight": "^3.28.0",
@@ -45,6 +46,7 @@
"tailwindcss": "^4.3.3",
"three": "^0.185.1",
"three-stdlib": "^2.36.1",
+ "tldraw": "5.2.5",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.14",
},
@@ -214,7 +216,7 @@
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
- "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
+ "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.35", "", {}, "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
@@ -236,57 +238,125 @@
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
- "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
+ "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
- "@radix-ui/primitive": ["@radix-ui/primitive@1.1.6", "", {}, "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw=="],
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
- "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA=="],
+ "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.14", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H7rGoxUdGeM3My0fsIgcAAZQy4Hpxm05OF9qkgvL6O1iVt1NHvZ5toyuVgeYQ+icMiAdWLJ+oXIkbpFCp/xDzg=="],
- "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="],
+ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.19", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ir4jA6TYdFonIC6n6ngyvaNQ6ReOMTqDg7MK/+D7U9KfWJtRoyoGLYwD1umh6LXGxc7QkKH1nwTOy7mewQz42w=="],
- "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dialog": "1.1.22", "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-12pDOt9f0tPSjZYTsP3iZmgJnsTZAAJZBeulqME0N5Z8NmSc13ZeHUbXqpLymSsUUiYhcQaYx6d0YBUxTi0yjA=="],
- "@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5GcTjiRmUKHDdGz2Fwm+SbVfGDVg1DBCoxUsc1fUL5k84ZYpfwIOjmTLKwCjOw2ZcH3w47rWkkx/vY/VxKuslw=="],
- "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
+ "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E6MdCaK8gw4FZq4NSXAIQpnv7irFisYffZeMY765gzD4drX/jynITYTDINVKdvZonVaa5DT+VIJLAh2KyIPSAg=="],
- "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ=="],
+ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-is-hydrated": "0.1.2", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2Xr22Yl13BBwLK2/iI7DFS4Vl9B06KWfDlwkMuOtUM5xT7hsnirzUx5bW/tweMs1JgQG4HvrLkWnW8OVOmTIqQ=="],
- "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
+ "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-size": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-psrdSGrS0lJI3fJwqmREDrx43GTZ50MiET4qnrqUl2Wb3ABQ4oPXo3afeDTyGk6Ke7xW90blyN4nymEAqezNrQ=="],
- "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.13", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg=="],
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TbvE0YFHJDRQXk6UpczD72Jr0UCGkJtAz3IVHE6W5N10sfJ0ZYAfVbR3+GR0/2BLZWAjHnOAFNSpzLFjdpYJdQ=="],
- "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.14", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-slot": "1.3.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+n2VZftI+uAtyvmgn9mM33Gdc1gdvc9Fz6pjYm4qtQIXhvDR5oBQDYJCL/z053Q9nTIErpGEbt5H9K83W+mK1Q=="],
- "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.4", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng=="],
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA=="],
- "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw=="],
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.2.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg=="],
- "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.8", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA=="],
+ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-menu": "2.1.23", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I01uQI0mXgk7/N/Dzi3AKtlabjPIOGDQJqHfTrPMqWLvxM6B1dAyh/rKDQz9h6ydtVG07d3715HogVB17ehNgw=="],
- "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="],
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-focus-guards": "1.1.5", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZvXpCrhEH2OrVa1l8caiuWzrk6Qleq6PeZQ824lXxUxFu6mE4/XzRz1DliOvS18WuBl3A3clg2gHPNiPqz8BjA=="],
- "@radix-ui/react-select": ["@radix-ui/react-select@2.3.4", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.6", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.16", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.4", "@radix-ui/react-portal": "1.1.14", "@radix-ui/react-presence": "1.1.8", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.4", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.8", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg=="],
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-OdgAA/xb6WOQMKNn0mtbYoruMG6YMaBOnq+evWxSpMmiEMBdeFZawnIWRfPPeVXCRm+0lnN8jpJLjhD7/UIxWw=="],
- "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-effect-event": "0.0.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4tuTRonWG1Etcbc597++jlROMx7r34Otb0fATJhaK16Uq7XRotUS1aGyDX+yOBh668HQzhcpUpTLXFqMRVuFeA=="],
- "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-menu": "2.1.23", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-AvjxJ38zw1jHy1mI3yKdME+p06lSX7MQc/5nOyBqkzjWrZ7quO7rNs4w7S/JO/vr9OgFzjrLrEj2Nxa9PrFpBw=="],
- "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.6", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA=="],
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ=="],
- "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rKjyFmJRsUhrCg39GJ0DOMfrOxHbw8WaS/KMOUPhLLm80LfDffRQGzF8RdulfDP3QfOJPJj4Sma+EU3E/90ykw=="],
- "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
+ "@radix-ui/react-form": ["@radix-ui/react-form@0.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-label": "2.1.14", "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5oA2XkUseU4ebd/S4YbxdZwKPCzEON7XUARHhI71574NsqrrSzSMhOYysXlgl//bpMwj3oqmXyYNkJipxFEf3A=="],
- "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="],
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tyisdq0MIw8Da1rQBPQaFq2LPiKr0jG3E8hI1J1prUIT0zwtlb0rD+BL4/aEwYk3Ixy+rXTHbr4vDXfAcXwq5A=="],
- "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="],
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ=="],
- "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="],
+ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sm0XafqY3QtR9HGAGSo7okarO3wahvIY//IFzqRL3Vpy2K+2yb2BVIZcdJXHP60tpr3tFYZN+2Kyvby/ESA5Bg=="],
- "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg=="],
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-focus-guards": "1.1.5", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-use-callback-ref": "1.1.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-AQgza5xP+qwUIUiDr8SOf64tI5VPCMDYz9E3dRepuN8/hSGmmdek4k0xPvgMzRv5uvUIRqXOq2taVrjXzwEWuw=="],
- "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
+ "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-menu": "2.1.23", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OIxPI3/miUJfTzrUb2bq5Weh23v16NoeOCzrS+XsPn6Zo2XxN6Z/JVSWa+cm8tvRDeHcNrkyx5J6hsF11WYBZw=="],
+
+ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-use-previous": "1.1.3", "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Y5SmS1Qd0SSG724Siw7ie5i+XL8+h3UgWLJcC/HaPKhuOnZz9sWP11gu5I7ZAvJL+rYu7X/FRZujRdf+s1QXTg=="],
+
+ "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.15", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-effect-event": "0.0.4", "@radix-ui/react-use-is-hydrated": "0.1.2", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-LBZ1cYf3CAp6ySAkyTDUL4AO2Oel6Z45kco6SzomDQHwwcXeRwyHtKCKHogK4KfVVgoV/rdaHxekeg1V0WS7AA=="],
+
+ "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-effect-event": "0.0.4", "@radix-ui/react-use-is-hydrated": "0.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sPl2ZvssdcIwqztMEdGEH9L9Q7Vchvnnoj9t5TycDLXTfRKRDavW3kIL3XlPxulywLK+vZ04ZcQ9ycL5WsFVeg=="],
+
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-focus-guards": "1.1.5", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w5DCbDSP6TiQKmmfFPsZ+gNK/CN1OWx/vkTFFSH70aUPso6RbPNO+Rv08X4SYOblnLoN/x6N6jGsdEWfbeGLig=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.6", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-use-rect": "1.1.3", "@radix-ui/react-use-size": "1.1.3", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hMA05k4dWrsMLWey2njJmGOvBoRirwlilS1EaGF8zufJJUZiJ7dW96p48xVsIj1oJXPdtlcK7yG8W+JcDhFP7A=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Lml0Z1vVXni7Sk29TTltPF2+SYTUrTJONrlrfQMTDGNKzEkXHIdHqYDQ/TkHEqsKT9Igy2jDWP7AmFeDeimd2w=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.9", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.9", "", { "dependencies": { "@radix-ui/react-slot": "1.3.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aX5c84AYUD0EIodbQfpujB5XI0CbEaQ5U9du/wntNQK451i5iCJlE2JN6IN1LgQ3SnoBbnW/Jv1Z4rUowZV+hg=="],
+
+ "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.15", "", { "dependencies": { "@radix-ui/react-context": "1.2.1", "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MHmMj0rnLDlvexXLUnmIgkuQzJpsikZm/TjdqU4sg/K3x1HifFam14Iad1wud0KDQmR2PJyuaeKbzRz5oRzs4g=="],
+
+ "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-size": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WsWXp3ZZ8QcngOior5Wn1c2BuNuAmbB6tRsPDBZqsiWmedOf0J51aqW07fxcPUqV0s2P3uIizMyPoBCFH66zXQ=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-is-hydrated": "0.1.2", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TLnxyyaccLuJV5p9PnApzswNNL5Zn9YEvYeDKKbO0dsvqq6Pg1vnExNzp4HnQ5MjhN8jwAOkq7HsDBZ9q66V0A=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.17", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-LTyRyEyxDV8mMx8ef/N+AaigP/XuHggt59um/AgQdjO113VzCXOGzsBb6Jvh7el/Mz4XwZ/+UilkXFDSD/LGiw=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-focus-guards": "1.1.5", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-use-previous": "1.1.3", "@radix-ui/react-visually-hidden": "1.2.10", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rn4ofLbvwgJya8JnwX+vr9JjktGhmQu1nEpRwRCqp/7zJgbnh7leTehpjHQH1VDgRoTjLgtUxWe+uHRGKqKapQ=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.14", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HZG2ZiV45ReCH1EwBZ82FGUgLJQ3+ffYcmMMrVJ/PmLHnqqsiWEc3CvJgSVuHinOpSO28/Zj5x7SZ3OcAy8vEg=="],
+
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.6", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-use-previous": "1.1.3", "@radix-ui/react-use-size": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-q+4Jsju/ZZNXp8//LQNi6SM2AurbLg41Ekx3oO/ivDxjmzS7E1Y3dzlPA2K4iD7IjkFU1bYoCTPa/rK8vwNtAg=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-nLBh0hpzgQA16ioKAWTSQvGjRj7QcI3pTpngtYZ12b4RP9N8eGSj7ziceHyHe04zrd0bAcLyMijO0ws0DLmDWw=="],
+
+ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-size": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WwlhEcV4Vuz9kioD7SO09RUvvelhEUHneDcIvGCriCproSvfpojfMG053s4/+syN2y2Q9a7egF+ZWAz/7C5I6g=="],
+
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/isB6Z/sIgMtnpGTig99pgMU0F8C4AmP7Dk36WN0PZ49H2mNjTpiCqmzIOOOZLez5F26DiZlQDFtPfQNMYb6Mg=="],
+
+ "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UCQbQBIyyfGXCBmaKFLqwGiJ6/p3JazFv55iaDQ+5sQ3fOtRHGLOuFmnCq2KXd0Magmd75s4ylcumLCTmWNR/A=="],
+
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o0KHOVKJb9jM5XagJtChfL0BOsW+8CpIH9A8XCRO+MxOepUeC28Ze4TARY8xLsXEMnEN1l05JXs0QuPVgRFxBQ=="],
+
+ "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-toggle": "1.1.17", "@radix-ui/react-use-controllable-state": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4p7IUjzt0PLub00FTAlYy9D/hWkoZWfA0DO2nvaCHu0G1VVCjHqOK4Y5xm0iwlQwFjNNkk1XCyQoqWrYOe98cg=="],
+
+ "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-separator": "1.1.14", "@radix-ui/react-toggle-group": "1.1.18" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5u4a7ucjJD9vxQoEb2VrQms+9VmSmKwaCUzYHn76DqExMAumSiiM8NMqIftNt8oaWjZwj7TcOeCR4RepjBjqcA=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-id": "1.1.3", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-82lc3GhNiyvDnjLg6gFaTF3xcFaTAsJQR9dJyuHy9QRgR6o6c6Vnyv1LNmltvlEpUgkrOA4thVrtG1ZRr+l7PQ=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.4", "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.4", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-4MSb5SPbMH02uUn19tLkO5Bav66ZQg/zFbedo7FWLWGFg9r8Z9xSog7QGnq9ZceurzBVIAIwgzANg6ExWc8HkQ=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2+gAVu9uaSLbopCTvvuWX9MIgEOlyqXC1Ok+KLCO7cZPSHLENs7dL0KbFQUGFIcFKmDW/bmaQRJtd8E3cnMRUw=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kbefNKla5AGhgvMqrg/qS7mYVLXTEHQLqoFIS03nKN2dAfQQ5PK5Tp+2LxS7BV6a6l4HHv4jTg6rtV2Uajwe2w=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.3", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-j1q1Aw5zpNp/mlNSkXeQNsF4RhRd0g2KYgR/BeN20D+PGncx4NmWQ5sMriucY9hlYG3jVRW5jH9PCQShL+ZiDA=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
"@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="],
@@ -452,6 +522,10 @@
"@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="],
+ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.8", "", { "dependencies": { "@tanstack/virtual-core": "3.17.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw=="],
+
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.6", "", {}, "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw=="],
+
"@tiptap/core": ["@tiptap/core@3.28.0", "", { "peerDependencies": { "@tiptap/pm": "3.28.0" } }, "sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.28.0", "", { "peerDependencies": { "@tiptap/core": "3.28.0", "@tiptap/pm": "3.28.0" } }, "sha512-y8Xi6Z3AQLXedz0J8Z3kDsu7SKBmL50gKVXx3RK1Oo1cuCGhNChm3syChkAz3PLAbrPUM5rvJDSZdF2jUrDnng=="],
@@ -520,6 +594,22 @@
"@tiptap/starter-kit": ["@tiptap/starter-kit@3.28.0", "", { "dependencies": { "@tiptap/core": "^3.28.0", "@tiptap/extension-blockquote": "^3.28.0", "@tiptap/extension-bold": "^3.28.0", "@tiptap/extension-bullet-list": "^3.28.0", "@tiptap/extension-code": "^3.28.0", "@tiptap/extension-code-block": "^3.28.0", "@tiptap/extension-document": "^3.28.0", "@tiptap/extension-dropcursor": "^3.28.0", "@tiptap/extension-gapcursor": "^3.28.0", "@tiptap/extension-hard-break": "^3.28.0", "@tiptap/extension-heading": "^3.28.0", "@tiptap/extension-horizontal-rule": "^3.28.0", "@tiptap/extension-italic": "^3.28.0", "@tiptap/extension-link": "^3.28.0", "@tiptap/extension-list": "^3.28.0", "@tiptap/extension-list-item": "^3.28.0", "@tiptap/extension-list-keymap": "^3.28.0", "@tiptap/extension-ordered-list": "^3.28.0", "@tiptap/extension-paragraph": "^3.28.0", "@tiptap/extension-strike": "^3.28.0", "@tiptap/extension-text": "^3.28.0", "@tiptap/extension-underline": "^3.28.0", "@tiptap/extensions": "^3.28.0", "@tiptap/pm": "^3.28.0" } }, "sha512-pqvFpValV+ONtlu3l4s73ROm/tEjoGMmt6uHmSJaLbqTcMHkdWuR3flJ/5brr4IDHe1foxmNicMbRxlje7yBYw=="],
+ "@tldraw/driver": ["@tldraw/driver@5.2.5", "", { "dependencies": { "@tldraw/editor": "5.2.5", "@tldraw/utils": "5.2.5" } }, "sha512-smu4NnqTdIM59/hHTvuXxqJWQ5aLpFUR59ibS//o30P9pgMAoN4v/7NhYx9g+V7RAeOzIpDqREouz3hZdoEt9Q=="],
+
+ "@tldraw/editor": ["@tldraw/editor@5.2.5", "", { "dependencies": { "@tiptap/core": "^3.12.1", "@tiptap/pm": "^3.12.1", "@tiptap/react": "^3.12.1", "@tldraw/state": "5.2.5", "@tldraw/state-react": "5.2.5", "@tldraw/store": "5.2.5", "@tldraw/tlschema": "5.2.5", "@tldraw/utils": "5.2.5", "@tldraw/validate": "5.2.5", "classnames": "^2.5.1", "eventemitter3": "^4.0.7", "idb": "^7.1.1", "is-plain-object": "^5.0.0", "rbush": "^3.0.1" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-/2aIuOLik7+tIzdmnjZ5XuxPRfSzZnNXVbxqCiFIVgbwe1W4jwm2lr2K4EpGduzdnbTNNvh6nPDJGg/jamAvnA=="],
+
+ "@tldraw/state": ["@tldraw/state@5.2.5", "", { "dependencies": { "@tldraw/utils": "5.2.5" } }, "sha512-fTn6YG6+MMlc3Rshb0/9k2YQ5TnKjy9FLwiGD7ph5maj9quSdpWQJNRgYxk7o2tRd/m7j+NhgylIb/F+2iKwbw=="],
+
+ "@tldraw/state-react": ["@tldraw/state-react@5.2.5", "", { "dependencies": { "@tldraw/state": "5.2.5", "@tldraw/utils": "5.2.5" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-/DbTo2Oulg/ZlQwfqlyJq+rjpYBIdwl9iELB01ImQ3aDIfM5TEyabc65baj/eCW3UNlFwKVTRft9m5z4jlPbVw=="],
+
+ "@tldraw/store": ["@tldraw/store@5.2.5", "", { "dependencies": { "@tldraw/state": "5.2.5", "@tldraw/utils": "5.2.5" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1" } }, "sha512-d3sWGoFH1j9jip71VdbVYQC5WLebn2ydo2n1BG3J+PhRhv7DGP9B1bojPUd9lEPBlB4VaS6/BmP4zyk/sLmAnw=="],
+
+ "@tldraw/tlschema": ["@tldraw/tlschema@5.2.5", "", { "dependencies": { "@tldraw/state": "5.2.5", "@tldraw/store": "5.2.5", "@tldraw/utils": "5.2.5", "@tldraw/validate": "5.2.5" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-Oe9f1e0pcO6M42RVeBTAMqUXdWMJTeCFnM9DsBvJoY94ukIau5huUARAJ2mIV+oqUQS3pkDOrDz/L0KSN9+Eig=="],
+
+ "@tldraw/utils": ["@tldraw/utils@5.2.5", "", { "dependencies": { "lodash.isequal": "^4.5.0", "lodash.isequalwith": "^4.4.0", "lodash.throttle": "^4.1.1", "lodash.uniq": "^4.5.0" } }, "sha512-8f2KqXVsPdXhZsGC2c1c+V19hpct7BQIcKnsq1b+zKUqDf7B/lNSHm/S63NeRhmsOTgzEIeOXVQYHX7OYXo2Xg=="],
+
+ "@tldraw/validate": ["@tldraw/validate@5.2.5", "", { "dependencies": { "@tldraw/utils": "5.2.5" } }, "sha512-FgFbMuKVdFUWyZFq0wr+ojZ/zeXlm4/Ed47duvYfDWoGkQ5r/j2q4vnfeB0MVyGHxGbvVMqDOgGbWPbCJoDspA=="],
+
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
@@ -712,6 +802,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+
"cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-spinners": ["cli-spinners@2.9.2", "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
@@ -962,6 +1054,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+ "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1014,6 +1108,8 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
+
"is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
"is-regexp": ["is-regexp@3.1.0", "https://registry.npmmirror.com/is-regexp/-/is-regexp-3.1.0.tgz", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
@@ -1088,6 +1184,14 @@
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
+ "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
+
+ "lodash.isequalwith": ["lodash.isequalwith@4.4.0", "", {}, "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ=="],
+
+ "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
+
+ "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="],
+
"log-symbols": ["log-symbols@6.0.0", "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -1098,6 +1202,8 @@
"lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -1310,10 +1416,16 @@
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+ "quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="],
+
+ "radix-ui": ["radix-ui@1.6.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.14", "@radix-ui/react-accordion": "1.2.19", "@radix-ui/react-alert-dialog": "1.1.22", "@radix-ui/react-arrow": "1.1.14", "@radix-ui/react-aspect-ratio": "1.1.14", "@radix-ui/react-avatar": "1.2.5", "@radix-ui/react-checkbox": "1.3.10", "@radix-ui/react-collapsible": "1.1.19", "@radix-ui/react-collection": "1.1.14", "@radix-ui/react-compose-refs": "1.1.4", "@radix-ui/react-context": "1.2.1", "@radix-ui/react-context-menu": "2.3.6", "@radix-ui/react-dialog": "1.1.22", "@radix-ui/react-direction": "1.1.3", "@radix-ui/react-dismissable-layer": "1.1.18", "@radix-ui/react-dropdown-menu": "2.1.23", "@radix-ui/react-focus-guards": "1.1.5", "@radix-ui/react-focus-scope": "1.1.15", "@radix-ui/react-form": "0.1.15", "@radix-ui/react-hover-card": "1.1.22", "@radix-ui/react-label": "2.1.14", "@radix-ui/react-menu": "2.1.23", "@radix-ui/react-menubar": "1.1.23", "@radix-ui/react-navigation-menu": "1.2.21", "@radix-ui/react-one-time-password-field": "0.1.15", "@radix-ui/react-password-toggle-field": "0.1.10", "@radix-ui/react-popover": "1.1.22", "@radix-ui/react-popper": "1.3.6", "@radix-ui/react-portal": "1.1.16", "@radix-ui/react-presence": "1.1.9", "@radix-ui/react-primitive": "2.1.9", "@radix-ui/react-progress": "1.1.15", "@radix-ui/react-radio-group": "1.4.6", "@radix-ui/react-roving-focus": "1.1.18", "@radix-ui/react-scroll-area": "1.2.17", "@radix-ui/react-select": "2.3.6", "@radix-ui/react-separator": "1.1.14", "@radix-ui/react-slider": "1.4.6", "@radix-ui/react-slot": "1.3.2", "@radix-ui/react-switch": "1.3.6", "@radix-ui/react-tabs": "1.1.20", "@radix-ui/react-toast": "1.2.22", "@radix-ui/react-toggle": "1.1.17", "@radix-ui/react-toggle-group": "1.1.18", "@radix-ui/react-toolbar": "1.1.18", "@radix-ui/react-tooltip": "1.2.15", "@radix-ui/react-use-callback-ref": "1.1.3", "@radix-ui/react-use-controllable-state": "1.2.5", "@radix-ui/react-use-effect-event": "0.0.4", "@radix-ui/react-use-escape-keydown": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.2", "@radix-ui/react-use-layout-effect": "1.1.3", "@radix-ui/react-use-size": "1.1.3", "@radix-ui/react-visually-hidden": "1.2.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-REDFpIvbRURwF992W7I6XiyA6/+dO1QimUNZYpa2UU5QKAPSVtbj5oKUM0xUNxbSB8RsSS4vXr4BBftXqcDi9g=="],
+
"range-parser": ["range-parser@1.2.1", "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+ "rbush": ["rbush@3.0.1", "", { "dependencies": { "quickselect": "^2.0.0" } }, "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w=="],
+
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
@@ -1468,6 +1580,8 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+ "tldraw": ["tldraw@5.2.5", "", { "dependencies": { "@tiptap/core": "^3.12.1", "@tiptap/extension-code": "^3.12.1", "@tiptap/extension-highlight": "^3.12.1", "@tiptap/extension-list": "^3.12.1", "@tiptap/pm": "^3.12.1", "@tiptap/react": "^3.12.1", "@tiptap/starter-kit": "^3.12.1", "@tldraw/driver": "5.2.5", "@tldraw/editor": "5.2.5", "@tldraw/store": "5.2.5", "classnames": "^2.5.1", "idb": "^7.1.1", "lz-string": "^1.5.0", "radix-ui": "^1.4.2" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-bTnOkeZhi+RgdHXPjxp0/nG6kbXzLC8ZlwNMpmGjWjT2Oel6uaJWni7PxFPIK4p2zInmnRamdU06ArDStLvPag=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1696,6 +1810,8 @@
"@rc-component/virtual-list/@rc-component/util": ["@rc-component/util@1.12.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ=="],
+ "@react-three/drei/@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
+
"@react-three/drei/zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@react-three/fiber/zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
@@ -1712,6 +1828,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@tldraw/editor/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
+
"@types/mdast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"accepts/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
diff --git a/web/index.html b/web/index.html
index 53712b327..2cdf28263 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,9 +3,9 @@
-
+
- 无限画布
+ 影策