279 lines
11 KiB
Go
279 lines
11 KiB
Go
// 画布 Agent 的画布写操作:把工具参数转换成画布操作(增删节点、连线)并校验。
|
|
//
|
|
// 连线必须满足节点类型的输入约束(validateCloudAgentConnection),不合法的连线
|
|
// 直接拒绝并返回可读原因,由模型自行修正。
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"infinite-canvas/backend/internal/repository"
|
|
)
|
|
|
|
func cloudAgentSkillPage(version, path, content string, offset int) (any, error) {
|
|
runes := []rune(content)
|
|
if offset < 0 || offset > len(runes) {
|
|
return nil, BadAuthRequest("技能读取偏移超出文件范围")
|
|
}
|
|
end := offset + min(12000, len(runes)-offset)
|
|
return map[string]any{"version": version, "path": path, "content": string(runes[offset:end]), "offset": offset, "nextOffset": end, "hasMore": end < len(runes)}, nil
|
|
}
|
|
|
|
func validateCloudAgentID(value, label string, maxRunes int) error {
|
|
if value == "" || strings.TrimSpace(value) != value || !utf8.ValidString(value) {
|
|
return BadAuthRequest(label + "不能为空、不能包含首尾空白或无效字符")
|
|
}
|
|
if utf8.RuneCountInString(value) > maxRunes {
|
|
return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes))
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return BadAuthRequest(label + "不能包含控制字符")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type agentCanvasArgs struct {
|
|
SnapshotHash string `json:"snapshotHash"`
|
|
Ops []agentCanvasOp `json:"ops"`
|
|
}
|
|
|
|
type agentCanvasOp struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
NodeType string `json:"nodeType"`
|
|
Title *string `json:"title"`
|
|
Content *string `json:"content"`
|
|
Patch map[string]any `json:"patch"`
|
|
// X/Y 为指针:nil 表示模型没有指定坐标,服务端按画布内容自动落位(不再落到原点重叠)。
|
|
// 指针语义与 canvas/capability/builtin.go 的 positionPatchFields 一致(坐标是可选的数字)。
|
|
X *float64 `json:"x"`
|
|
Y *float64 `json:"y"`
|
|
FromNodeID string `json:"fromNodeId"`
|
|
ToNodeID string `json:"toNodeId"`
|
|
FromHandleID string `json:"fromHandleId"`
|
|
ToHandleID string `json:"toHandleId"`
|
|
}
|
|
|
|
// Explicit node creation and edges only; no generic metadata, media URL or deletion.
|
|
func applyCloudAgentCanvas(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
|
|
plan, err := prepareCloudAgentCanvasMutation(repo, userID, canvasID, call)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
|
|
return nil, err
|
|
}
|
|
appliedPreview := cloudAgentCanvasAppliedPreview(plan.Preview)
|
|
if len(recorder) > 0 && recorder[0] != nil {
|
|
if err := recorder[0](repo, cloudAgentMutationInput{
|
|
UserID: userID,
|
|
CanvasID: canvasID,
|
|
StepID: call.ID,
|
|
Operation: "canvas_apply_ops",
|
|
BeforeSnapshotHash: plan.BeforeSnapshotHash,
|
|
AfterSnapshotHash: cloudAgentCanvasHash(plan.Document),
|
|
BeforeJSON: plan.BeforeJSON,
|
|
Preview: &appliedPreview,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return map[string]any{"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "committed": true, "summary": fmt.Sprintf("已完成 %d 项节点/连线操作", len(plan.Args.Ops)), "preview": appliedPreview}, nil
|
|
}
|
|
|
|
func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, existingConnections ...[]map[string]any) error {
|
|
return validateCloudAgentConnectionWithHandles(nodes, fromID, toID, "", "", existingConnections...)
|
|
}
|
|
|
|
func validateCloudAgentConnectionWithHandles(nodes []map[string]any, fromID, toID, fromHandleID, toHandleID string, existingConnections ...[]map[string]any) error {
|
|
if err := validateCloudAgentID(fromID, "来源节点 ID", 80); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudAgentID(toID, "目标节点 ID", 80); err != nil {
|
|
return err
|
|
}
|
|
if fromID == toID {
|
|
return BadAuthRequest("连线不能指向自身")
|
|
}
|
|
var from, to map[string]any
|
|
for _, node := range nodes {
|
|
if stringValue(node["id"]) == fromID {
|
|
from = node
|
|
}
|
|
if stringValue(node["id"]) == toID {
|
|
to = node
|
|
}
|
|
}
|
|
if from == nil || to == nil {
|
|
return BadAuthRequest("连线端点不存在")
|
|
}
|
|
if err := validateCloudAgentStoryboardHandle(from, fromHandleID, "来源"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudAgentStoryboardHandle(to, toHandleID, "目标"); err != nil {
|
|
return err
|
|
}
|
|
fromCapability, fromKnown := cloudAgentNodeCapabilityForNode(from)
|
|
toCapability, toKnown := cloudAgentNodeCapabilityForNode(to)
|
|
if !fromKnown || !toKnown {
|
|
return BadAuthRequest("连线包含当前 Agent 不支持的节点类型")
|
|
}
|
|
fromKind := fromCapability.InputKind
|
|
if fromKind == "" || !fromCapability.Connection.CanSource {
|
|
return BadAuthRequest(fmt.Sprintf("来源节点类型 %s 不能作为生成输入;引用连线不能用于普通节点关联", fromCapability.Type))
|
|
}
|
|
if !toCapability.Connection.CanTarget {
|
|
return BadAuthRequest(fmt.Sprintf("目标节点类型 %s 不能接收生成输入;无需为文档归档建立引用连线", toCapability.Type))
|
|
}
|
|
connections := []map[string]any{}
|
|
if len(existingConnections) > 0 {
|
|
connections = existingConnections[0]
|
|
}
|
|
for _, edge := range connections {
|
|
if stringValue(edge["toNodeId"]) == toID && stringValue(edge["fromNodeId"]) == fromID && stringValue(edge["fromHandleId"]) == fromHandleID && stringValue(edge["toHandleId"]) == toHandleID {
|
|
return BadAuthRequest("连线重复")
|
|
}
|
|
}
|
|
if err := toCapability.ValidateConnection(fromKind); err != nil {
|
|
return BadAuthRequest(err.Error())
|
|
}
|
|
if maxInputs := toCapability.Connection.MaxInputCount; maxInputs > 0 {
|
|
inputIDs := map[string]bool{}
|
|
for _, edge := range connections {
|
|
if stringValue(edge["toNodeId"]) == toID {
|
|
inputIDs[stringValue(edge["fromNodeId"])] = true
|
|
}
|
|
}
|
|
inputIDs[fromID] = true
|
|
if len(inputIDs) > maxInputs {
|
|
return BadAuthRequest(fmt.Sprintf("%s最多连接 %d 个输入", toCapability.Label, maxInputs))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCloudAgentStoryboardHandle(node map[string]any, handleID, side string) error {
|
|
if handleID == "" {
|
|
return nil
|
|
}
|
|
if stringValue(node["type"]) != "script" {
|
|
return BadAuthRequest(fmt.Sprintf("只有分镜脚本节点可以指定%s handle", side))
|
|
}
|
|
if handleID == "storyboard:context" {
|
|
return nil
|
|
}
|
|
if !strings.HasPrefix(handleID, "row:") || strings.TrimSpace(strings.TrimPrefix(handleID, "row:")) == "" {
|
|
return BadAuthRequest(fmt.Sprintf("分镜%s handle 必须是 row:<rowId> 或 storyboard:context", side))
|
|
}
|
|
metadata, _ := node["metadata"].(map[string]any)
|
|
storyboard, _ := metadata["storyboard"].(map[string]any)
|
|
rowID := strings.TrimPrefix(handleID, "row:")
|
|
for _, row := range creationMaps(storyboard["rows"]) {
|
|
if stringValue(row["id"]) == rowID {
|
|
return nil
|
|
}
|
|
}
|
|
return BadAuthRequest(fmt.Sprintf("分镜%s handle 引用的 rowId 不存在,请先读取最新分镜", side))
|
|
}
|
|
|
|
func cloudAgentInputKindLabel(kind string) string {
|
|
switch kind {
|
|
case "image":
|
|
return "图片"
|
|
case "video":
|
|
return "视频"
|
|
case "audio":
|
|
return "音频"
|
|
default:
|
|
return "文本"
|
|
}
|
|
}
|
|
|
|
func creationMaps(value any) []map[string]any {
|
|
result := []map[string]any{}
|
|
switch items := value.(type) {
|
|
case []any:
|
|
for _, v := range items {
|
|
if m, ok := v.(map[string]any); ok {
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
case []map[string]any:
|
|
result = items
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Only server-registered canvas capabilities are exposed to the model. UI-only
|
|
// renderers are not a persistence or authorization contract.
|
|
func cloudAgentNodeTypes() map[string]any {
|
|
types := make([]map[string]any, 0, len(canvasCapabilityRegistry.List()))
|
|
for _, capability := range canvasCapabilityRegistry.List() {
|
|
item := map[string]any{
|
|
"type": capability.Type,
|
|
"label": capability.Label,
|
|
"purpose": capability.Purpose,
|
|
"defaultSize": map[string]any{"width": capability.DefaultWidth, "height": capability.DefaultHeight},
|
|
"canUpdate": capability.CanUpdate,
|
|
}
|
|
if variant := capability.Variant; variant != nil {
|
|
// 变体不能 add_node;画布里按 type+metadata.workflowKind 识别,读取结果以 kind 标出。
|
|
item["canvasNodeType"], item["workflowKind"], item["creatable"] = variant.BaseType, variant.WorkflowKind, false
|
|
}
|
|
if len(capability.GoodFor) > 0 {
|
|
item["goodFor"] = capability.GoodFor
|
|
}
|
|
if len(capability.NotIdealFor) > 0 {
|
|
item["notIdealFor"] = capability.NotIdealFor
|
|
}
|
|
if len(capability.Tradeoffs) > 0 {
|
|
item["tradeoffs"] = capability.Tradeoffs
|
|
}
|
|
if len(capability.Actions) > 0 {
|
|
item["actions"] = capability.Actions
|
|
}
|
|
if capability.CanUpdate {
|
|
fields := map[string]any{}
|
|
for key, field := range capability.PatchFields {
|
|
definition := map[string]any{"type": field.Kind, "label": field.Label, "displayOrder": field.Order, "maxCharacters": field.MaxRunes}
|
|
if field.Description != "" {
|
|
definition["description"] = field.Description
|
|
}
|
|
fields[key] = definition
|
|
}
|
|
item["updateFields"] = fields
|
|
}
|
|
if capability.InputKind != "" {
|
|
item["inputKind"] = capability.InputKind
|
|
}
|
|
if capability.GenerationMode != "" && cloudAgentGenerationModeSupported(capability.GenerationMode) {
|
|
item["generationMode"] = capability.GenerationMode
|
|
}
|
|
if len(capability.Connection.AcceptedInputKinds) > 0 {
|
|
item["acceptedInputKinds"] = capability.Connection.AcceptedInputKinds
|
|
}
|
|
if len(capability.Connection.RejectedInputKinds) > 0 {
|
|
item["rejectedInputKinds"] = capability.Connection.RejectedInputKinds
|
|
}
|
|
if capability.Connection.MaxInputCount > 0 {
|
|
item["maxInputCount"] = capability.Connection.MaxInputCount
|
|
}
|
|
item["canSource"] = capability.Connection.CanSource
|
|
item["canTarget"] = capability.Connection.CanTarget
|
|
item["canReference"] = capability.Connection.CanReference
|
|
types = append(types, item)
|
|
}
|
|
return map[string]any{"schemaVersion": 2, "nodes": types, "selectionGuide": []string{
|
|
"单个画面、一次性提示词或快速试验通常使用文本/Markdown与媒体节点更轻量。",
|
|
"多镜头、连续性、逐镜审查、逐镜生成或需要后续维护时,分镜脚本通常更合适。",
|
|
"媒体节点只承载单个生成目标,不替代多镜头结构;选择媒体节点后还要用 model_list 按生成模式和本次真实参考节点筛选模型。",
|
|
"节点选择由Agent结合用户目标决定;不要为了形式创建复杂节点,也不要用普通文本伪装成结构化分镜。",
|
|
}}
|
|
}
|