Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_node_generation_supersede.go
ddcat666 10ea9d3fa0 feat(canvas): Agent 审批 - 节点直接生成时自动关闭同节点的待审批任务
用户在画布节点上直接提交生成后,同画布 Agent 对该节点等待中的 generate_media 审批关闭为 superseded_by_node,Agent 带着「用户已手动提交」的结果继续,不重复提交、不扣费。审批卡片识别目标节点正在生成时禁用「同意执行」并提示原因。
2026-09-30 03:40:14 +08:00

160 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// cloudAgentApprovalSuperseded 是审批的第三种决定:用户没有点 Agent 卡片里的
// “同意/拒绝”,而是直接在画布节点上提交了同一节点的生成。Agent 不再提交自己的
// 任务、不扣费,也不接管该任务的结果回写(画布自己的生成链路负责回写)。
const cloudAgentApprovalSuperseded = "superseded_by_node"
// cloudAgentDecisionConflict 给重复/冲突的审批决定一个用户能看懂的原因。
func cloudAgentDecisionConflict(previous string) error {
if previous == cloudAgentApprovalSuperseded {
return creationConflict("你已在画布节点上直接生成,本次审批已自动关闭")
}
return creationConflict("该审批已有不同决定")
}
// supersedeCloudAgentApprovalsForNodeTask 在普通画布生成任务创建成功后调用:
// 如果同一画布上有 Agent 正在等待对同一节点的 generate_media 审批,就把该审批
// 关闭为 superseded_by_node,并让 Agent 带着“用户已手动提交”的工具结果继续。
//
// 这是尽力而为的联动:任何失败只记日志,不影响用户已经创建成功的任务。
func (s *Service) supersedeCloudAgentApprovalsForNodeTask(userID string, task *model.Task, input map[string]any) {
if s == nil || s.repo == nil || task == nil || task.AgentRunID != "" || strings.TrimSpace(task.ProjectID) == "" {
return
}
metadata, _ := input["metadata"].(map[string]any)
nodeID := strings.TrimSpace(stringValue(metadata["nodeId"]))
if nodeID == "" || stringValue(metadata["source"]) == "cloud_agent" {
return
}
// 批量图片等场景结果落在新节点上,sourceNodeId 才是用户点击生成的节点。
nodeIDs := []string{nodeID}
if source := strings.TrimSpace(stringValue(metadata["sourceNodeId"])); source != "" && source != nodeID {
nodeIDs = append(nodeIDs, source)
}
runIDs, err := s.repo.CloudAgentIDsWaitingApprovalForCanvas(userID, task.ProjectID)
if err != nil {
slog.Warn("agent approval supersede lookup failed", "canvas", task.ProjectID, "task", task.ID, "error", err)
return
}
for _, runID := range runIDs {
target, err := s.supersedeCloudAgentApproval(userID, runID, nodeIDs, task)
if err != nil {
slog.Warn("agent approval supersede failed", "run", runID, "task", task.ID, "error", err)
continue
}
if target != "" {
s.resumeCloudAgentAfterSupersede(userID, runID, target, task.ID)
}
}
}
// cloudAgentApprovalTargetNode 返回待审批 generate_media 调用的目标节点。
// 其他工具(批量、删除、计划等)不参与联动。
func cloudAgentApprovalTargetNode(approval *cloudAgentApproval) (string, bool) {
if approval == nil || approval.Decision != "" || approval.Call.Function.Name != "generate_media" {
return "", false
}
var args struct {
NodeID string `json:"nodeId"`
}
if err := json.Unmarshal([]byte(approval.Call.Function.Arguments), &args); err != nil {
return "", false
}
nodeID := strings.TrimSpace(args.NodeID)
return nodeID, nodeID != ""
}
// supersedeCloudAgentApproval 返回被关闭审批的目标节点;不匹配时返回空串。
// 与 DecideCloudAgentApproval 一样按 revision CAS 重试:两边同时点击时先落库者生效,
// 后到的一方看到状态已变化直接退出,不会重复提交任务。
func (s *Service) supersedeCloudAgentApproval(userID, runID string, nodeIDs []string, task *model.Task) (string, error) {
for attempt := 0; attempt < 8; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 20 * time.Millisecond)
}
run, err := s.repo.CloudAgent(userID, runID)
if err != nil {
return "", err
}
if run.Status != "waiting_approval" {
return "", nil
}
state, err := cloudAgentDecodeForExecution(run)
if err != nil {
return "", err
}
target, ok := cloudAgentApprovalTargetNode(state.Approval)
if !ok || !cloudAgentContainsString(nodeIDs, target) || state.CallIndex >= len(state.Calls) {
return "", nil
}
approvalID := state.Approval.ID
if _, decided := state.Decisions[approvalID]; decided {
return "", nil
}
call := state.Calls[state.CallIndex]
s.storageMu.Lock()
err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
applyCloudAgentApprovalSupersede(current, &state, call, approvalID, target, task.ID)
if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil {
return err
}
return cloudAgentSave(current, &state)
})
s.storageMu.Unlock()
if errors.Is(err, repository.ErrCreationConflict) {
continue
}
if err != nil {
return "", err
}
return target, nil
}
return "", repository.ErrCreationConflict
}
// applyCloudAgentApprovalSupersede 在同一个检查点里记录决定、关闭审批,并把
// “用户已手动提交”作为本次工具调用的结果写入历史(同时推进到下一个调用)。
func applyCloudAgentApprovalSupersede(current *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall, approvalID, nodeID, taskID string) {
state.Decisions[approvalID] = cloudAgentApprovalSuperseded
state.Approval = nil
state.event(current.ID, "approval_decided", map[string]any{
"approvalId": approvalID,
"decision": cloudAgentApprovalSuperseded,
"nodeId": nodeID,
"taskId": taskID,
"text": "你已在画布节点上直接提交了生成,本次审批已自动关闭;Agent 不会重复提交或扣费,结果由画布回写。",
})
cloudAgentRecordToolResult(current, state, call, map[string]any{
"status": "superseded_by_user",
"taskSubmitted": false,
"nodeId": nodeID,
"userTaskId": taskID,
"summary": fmt.Sprintf("用户已在画布节点 %s 上直接提交该生成(taskId=%s)。Agent 未提交任务、未扣费;不要再为该节点调用 generate_media,结果由画布自动回写。", nodeID, taskID),
}, nil)
if current.Status == "waiting_approval" {
current.Status = "running"
}
}
func (s *Service) resumeCloudAgentAfterSupersede(userID, runID, nodeID, taskID string) {
prompt := fmt.Sprintf("用户没有使用审批卡片,而是直接在画布节点 %s 上提交了刚才等待审批的生成(taskId=%s)。该调用已记录为用户手动提交:Agent 没有提交任务、没有扣费,生成结果由画布回写。请继续剩余步骤,不要再次为该节点调用 generate_media。", nodeID, taskID)
if err := s.saveCloudAgentPiResumePrompt(userID, runID, prompt); err != nil {
slog.Warn("agent approval supersede resume prompt failed", "run", runID, "error", err)
return
}
s.resumeCloudAgentPi(userID, runID)
}