Files
ttstd-infinite-canvas/backend/internal/canvas/capability/builtin.go
ddcat666 e0a2697c60 fix(canvas): 画布背板 - 明确背板能力说明并允许拖动 Markdown 节点
背板能力卡改为说明可移动、可折叠的视觉分区用途与边界,Agent 能据此判断何时用背板归组。Markdown 节点正文不再拦截 mousedown,拖动节点和拖入背板恢复正常。
2026-09-30 03:42:36 +08:00

188 lines
12 KiB
Go

package capability
const (
maxAgentNodeTitleRunes = 240
maxAgentNodeContentRunes = 16000
// 坐标绝对值上限:与 app 侧整理工具收敛几何值的范围一致。
maxAgentNodeCoordLimit = 1e6
)
func BuiltinRegistry() *Registry {
registry, err := NewRegistry([]Descriptor{
{
Type: "text", Version: "1", Label: "文本", DefaultWidth: 340, DefaultHeight: 240,
Purpose: "承载普通说明、创意草稿和单段提示词。",
GoodFor: []string{"单个创意", "一次性提示词", "临时备注", "快速试验"},
NotIdealFor: []string{"多镜头脚本", "需要逐镜修改的内容", "需要镜头级资产关系的内容"},
Tradeoffs: []string{"创建和编辑最轻量", "没有镜头级字段和逐镜维护能力"},
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
PatchFields: editableNodeFields("metadata.content", "正文", "节点正文"),
CreateMetadata: func(content string) map[string]any {
return map[string]any{"content": content, "status": "idle", "fontSize": float64(14)}
},
},
{
Type: "markdown", Version: "1", Label: "Markdown", DefaultWidth: 420, DefaultHeight: 320,
Purpose: "承载面向人阅读的方案、脚本草稿和格式化文档。",
GoodFor: []string{"创意方案", "脚本草稿", "交付文档", "需要排版的长文本"},
NotIdealFor: []string{"需要逐镜生成或审核的多镜头内容", "需要绑定媒体资产的结构化流程"},
Tradeoffs: []string{"适合阅读和导出", "结构化程度低,不能替代可维护的分镜表"},
InputKind: "text", Connection: ConnectionPolicy{CanSource: true}, CanUpdate: true,
SummaryFields: []string{"content"}, DetailFields: []string{"content"},
PatchFields: editableNodeFields("metadata.content", "Markdown 正文", "Markdown 正文"),
},
generatedMediaDescriptor("image", "2", "图片", 720, 405, "image", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image"},
}),
generatedMediaDescriptor("video", "2", "视频", 720, 405, "video", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, AcceptedInputKinds: []string{"text", "image", "video", "audio"},
}),
generatedMediaDescriptor("audio", "2", "音频", 340, 120, "audio", ConnectionPolicy{
CanSource: true, CanTarget: true, CanReference: true, MaxInputCount: 1, AcceptedInputKinds: []string{"text"},
}),
{
Type: "frame", Version: "1", Label: "背板", DefaultWidth: 760, DefaultHeight: 520,
Purpose: "在画布上建立可移动、可折叠的视觉分区,用来归组相关节点;背板本身不承载创作正文或生成结果。",
GoodFor: []string{"按场景或镜头组归拢脚本、参考图和生成结果", "按前期策划、制作、交付等阶段划分工作区", "为大型画布建立清晰分区,便于移动或折叠整组内容"},
NotIdealFor: []string{"承载结构化镜头数据(应使用分镜脚本节点)", "存放需要 Agent 单独读写的正文(应使用文本或 Markdown 节点)", "替代具体业务节点或媒体生成节点"},
Tradeoffs: []string{"改善空间组织但不增加内容结构或生成能力"},
SummaryFields: []string{"label"}, DetailFields: []string{"label"},
CreateMetadata: func(string) map[string]any {
return map[string]any{"frame": map[string]any{"collapsed": false, "expandedWidth": float64(760), "expandedHeight": float64(520)}}
},
},
{
Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 1280, DefaultHeight: 560,
Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,也可设置全局提示词覆盖各行,并追踪生成结果。",
GoodFor: []string{"商品与模特批量换装", "同一商品多场景创意图", "多组参考图组合生成", "批量结果追踪与失败重试"},
NotIdealFor: []string{"通用数据库或库存管理", "单张图片快速试验", "多镜头叙事连续性"},
Tradeoffs: []string{"参考图必须先作为图片节点进入画布", "批量提交会产生多项生成任务,执行前必须确认模型、数量和费用"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt", "preview_batch_generation"},
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}},
CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable",
PatchFields: func() map[string]PatchField {
fields := map[string]PatchField{
"title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}(),
CreateMetadata: func(string) map[string]any {
return map[string]any{
"status": "idle",
"batchTable": map[string]any{
"operation": "try_on", "concurrency": float64(10),
"referenceColumns": []any{
map[string]any{"id": "reference-1", "label": "参考图 1"},
map[string]any{"id": "reference-2", "label": "参考图 2"},
map[string]any{"id": "reference-3", "label": "参考图 3"},
},
"rows": []any{},
},
}
},
},
{
Type: "script", Version: "1", Label: "分镜脚本", DefaultWidth: 920, DefaultHeight: 360,
Purpose: "维护结构化的多镜头脚本,支持镜头级审查、修改和媒体关联。",
GoodFor: []string{"多镜头规划", "镜头连续性", "逐镜审查和微调", "逐镜生成图片或视频", "需要他人接手维护的内容"},
NotIdealFor: []string{"只有一个画面的快速试验", "一次性临时提示词", "仅需要阅读排版的普通文档"},
Tradeoffs: []string{"前期录入成本高于文本节点", "但能保留镜头级结构、资产关系和后续维护能力"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "generate_storyboard"},
SummaryFields: []string{"storyboard"}, DetailFields: []string{"storyboard"}, ProjectionKind: "storyboard", ProjectionField: "storyboard",
CreateMetadata: func(string) map[string]any {
return map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}}
},
},
})
if err != nil {
panic(err)
}
return registry
}
func generatedMediaDescriptor(nodeType, version, label string, width, height float64, generationMode string, connection ConnectionPolicy) Descriptor {
semantics := generatedMediaSemantics(nodeType)
return Descriptor{
Type: nodeType, Version: version, Label: label, DefaultWidth: width, DefaultHeight: height,
Purpose: semantics.Purpose, GoodFor: semantics.GoodFor, NotIdealFor: semantics.NotIdealFor,
Tradeoffs: semantics.Tradeoffs, Actions: semantics.Actions,
InputKind: nodeType, GenerationMode: generationMode, Connection: connection, CanUpdate: true,
SummaryFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
DetailFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
PatchFields: editableNodeFields("metadata.composerContent", "下一版提示词", "下次生成使用的提示词草稿;不覆盖已提交提示词或媒体结果"),
CreateMetadata: generatedMetadata,
}
}
type generatedMediaCapabilitySemantics struct {
Purpose string
GoodFor []string
NotIdealFor []string
Tradeoffs []string
Actions []string
}
func generatedMediaSemantics(nodeType string) generatedMediaCapabilitySemantics {
switch nodeType {
case "image":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个静态画面,并作为后续图片或视频生成的真实参考素材。",
GoodFor: []string{"单张图片生成", "有参考图的图片生成", "分镜首帧和关键帧", "需要复用的视觉素材"},
NotIdealFor: []string{"承载多镜头脚本结构", "表达镜头运动或时间变化", "代替分镜表维护镜头连续性"},
Tradeoffs: []string{"一个节点对应一个静态媒体目标", "参考图数量和生成方式必须再由模型目录能力匹配"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
case "video":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个连续视频片段;可按实际参考素材执行文生视频、图生视频或多图生视频。",
GoodFor: []string{"单镜头文生视频", "单图生视频", "多图参考视频", "已有视频或音频参与的视频生成"},
NotIdealFor: []string{"承载整部多镜头脚本", "用一个节点代替逐镜审查和维护", "未查询模型能力就假定支持任意参考数量"},
Tradeoffs: []string{"一个节点通常对应一个可独立生成和审核的视频片段", "参考类型、数量、时长、画幅、音频和价格受当前模型目录约束"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
case "audio":
return generatedMediaCapabilitySemantics{
Purpose: "生成或承载一个音频素材,用于配音、音乐、音效或视频参考输入。",
GoodFor: []string{"文本转语音", "配音", "音乐或音效素材", "为视频提供音频参考"},
NotIdealFor: []string{"承载分镜结构", "表达画面构图或镜头运动", "代替视频节点"},
Tradeoffs: []string{"一个节点对应一个音频目标且最多接收一个文本输入", "音频类型、时长和价格仍以模型目录及任务准入为准"},
Actions: []string{"generate_media", "update_prompt", "use_as_reference"},
}
default:
return generatedMediaCapabilitySemantics{}
}
}
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
fields := map[string]PatchField{
"title": {
Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes,
},
"content": {
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
},
}
for key, field := range positionPatchFields() {
fields[key] = field
}
return fields
}
// positionPatchFields 是坐标字段:模型可以直接指定节点位置(微调),批量整理走 canvas_arrange_nodes。
// 坐标上限与整理侧的收敛范围一致,避免写进离谱的几何值。
func positionPatchFields() map[string]PatchField {
return map[string]PatchField{
"x": {Path: "position.x", Kind: patchKindNumber, Label: "横坐标", Order: 30, Limit: maxAgentNodeCoordLimit, Description: "画布横坐标(像素);与 y 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
"y": {Path: "position.y", Kind: patchKindNumber, Label: "纵坐标", Order: 31, Limit: maxAgentNodeCoordLimit, Description: "画布纵坐标(像素);与 x 一起移动节点,通常用 canvas_arrange_nodes 批量整理"},
}
}
func generatedMetadata(prompt string) map[string]any {
return map[string]any{"content": "", "prompt": prompt, "composerContent": prompt, "status": "idle"}
}