From 354d86a70b5f0e92e5c8d835c1b404020baa23ee Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:40:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20Agent=20=E5=AE=A1=E6=89=B9=20-?= =?UTF-8?q?=20=E8=8A=82=E7=82=B9=E7=9B=B4=E6=8E=A5=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=85=B3=E9=97=AD=E5=90=8C=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E5=BE=85=E5=AE=A1=E6=89=B9=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在画布节点上直接提交生成后,同画布 Agent 对该节点等待中的 generate_media 审批关闭为 superseded_by_node,Agent 带着「用户已手动提交」的结果继续,不重复提交、不扣费。审批卡片识别目标节点正在生成时禁用「同意执行」并提示原因。 --- .../cloud_agent_node_generation_supersede.go | 159 ++++++++++++++++++ ...ud_agent_node_generation_supersede_test.go | 135 +++++++++++++++ backend/internal/app/cloud_agent_runtime.go | 4 +- backend/internal/app/task_creation.go | 4 + backend/internal/repository/cloud_agent.go | 11 ++ .../canvas/canvas-cloud-agent-panel.tsx | 49 +++++- web/src/lib/canvas/agent-media-approval.ts | 29 ++++ web/src/pages/canvas/project.tsx | 2 +- web/test/agent-media-approval.test.ts | 28 ++- 9 files changed, 411 insertions(+), 10 deletions(-) create mode 100644 backend/internal/app/cloud_agent_node_generation_supersede.go create mode 100644 backend/internal/app/cloud_agent_node_generation_supersede_test.go diff --git a/backend/internal/app/cloud_agent_node_generation_supersede.go b/backend/internal/app/cloud_agent_node_generation_supersede.go new file mode 100644 index 000000000..2a861c354 --- /dev/null +++ b/backend/internal/app/cloud_agent_node_generation_supersede.go @@ -0,0 +1,159 @@ +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) +} diff --git a/backend/internal/app/cloud_agent_node_generation_supersede_test.go b/backend/internal/app/cloud_agent_node_generation_supersede_test.go new file mode 100644 index 000000000..e8729f9dd --- /dev/null +++ b/backend/internal/app/cloud_agent_node_generation_supersede_test.go @@ -0,0 +1,135 @@ +package app + +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +// waitingAgentMediaApproval 把 Agent 推进到“等待生成审批”,返回运行和审批 ID。 +func waitingAgentMediaApproval(t *testing.T, s *Service, a cloudAgentMediaArgs) (*model.CloudAgentExecution, string) { + t.Helper() + run, _ := agentMediaRun(t, s, a, "request_approval") + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + run, _ = s.repo.CloudAgent("user", run.ID) + state, err := cloudAgentDecode(run) + if err != nil || run.Status != "waiting_approval" || state.Approval == nil { + t.Fatalf("expected waiting approval: status=%s err=%v", run.Status, err) + } + return run, state.Approval.ID +} + +// userNodeTaskRequest 复用 Agent 同一份生成请求,但以普通画布节点生成的身份提交。 +func userNodeTaskRequest(t *testing.T, s *Service, run *model.CloudAgentExecution, metadata map[string]any) CreateTaskRequest { + t.Helper() + state, _ := cloudAgentDecode(run) + req, _, err := s.prepareCloudAgentMedia(run, &state, state.Calls[state.CallIndex]) + if err != nil { + t.Fatal(err) + } + req.Input["metadata"] = metadata + return req +} + +func TestNodeGenerationSupersedesMatchingAgentApproval(t *testing.T) { + s, db, a := agentMediaFixture(t) + run, approvalID := waitingAgentMediaApproval(t, s, a) + req := userNodeTaskRequest(t, s, run, map[string]any{"nodeId": a.NodeID, "source": "canvas"}) + task, err := s.CreateTask("user", req) + if err != nil { + t.Fatal(err) + } + + run, _ = s.repo.CloudAgent("user", run.ID) + state, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + if run.Status != "running" || state.Approval != nil || state.Decisions[approvalID] != cloudAgentApprovalSuperseded || state.CallIndex != 1 { + t.Fatalf("approval not superseded: status=%s approval=%+v decision=%q index=%d", run.Status, state.Approval, state.Decisions[approvalID], state.CallIndex) + } + decided := false + for _, event := range state.Events { + if event.Type == "approval_decided" && event.Payload["decision"] == cloudAgentApprovalSuperseded { + decided = event.Payload["approvalId"] == approvalID && event.Payload["taskId"] == task.ID && event.Payload["nodeId"] == a.NodeID + } + } + if !decided { + t.Fatal("missing superseded approval_decided event") + } + content, ok := cloudAgentToolMessage(&state, "media-call") + if !ok || !strings.Contains(content, "superseded_by_user") || !strings.Contains(content, task.ID) || cloudAgentToolContentIsError(content) { + t.Fatalf("tool result does not tell the Agent the user submitted: %q", content) + } + if state.MediaTaskID != "" || state.Generations != 0 { + t.Fatalf("Agent took over the user task: media=%s generations=%d", state.MediaTaskID, state.Generations) + } + var count, leases int64 + db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) + db.Model(&model.CloudAgentResourceLease{}).Where("owner_id = ?", approvalID).Count(&leases) + if count != 1 || leases != 0 { + t.Fatalf("expected only the user task and released leases: tasks=%d leases=%d", count, leases) + } + + // 卡片上的“同意/拒绝”晚到时不能再提交任务,并给出可读原因。 + for _, decision := range []string{"approve", "reject"} { + err = s.DecideCloudAgentApproval("user", run.ID, approvalID, decision, "") + if err == nil || !strings.Contains(err.Error(), "直接生成") { + t.Fatalf("late %s must be rejected with a superseded reason: %v", decision, err) + } + } + db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) + if count != 1 { + t.Fatalf("late approval submitted a duplicate task: %d", count) + } +} + +func TestNodeGenerationLeavesUnrelatedAgentApprovals(t *testing.T) { + for name, metadata := range map[string]map[string]any{ + "other node": {"nodeId": "hero", "source": "canvas"}, + "no node": {"source": "canvas"}, + "agent origin": {"nodeId": "video-shot-1", "source": "cloud_agent"}, + } { + t.Run(name, func(t *testing.T) { + s, _, a := agentMediaFixture(t) + run, approvalID := waitingAgentMediaApproval(t, s, a) + state, _ := cloudAgentDecode(run) + s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "user-task", ProjectID: "agent-canvas"}, map[string]any{"metadata": metadata}) + if name == "agent origin" { + // 显式带 AgentRunID 的任务同样跳过。 + s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "agent-task", ProjectID: "agent-canvas", AgentRunID: run.ID}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}}) + } + // 其他画布上的同名节点也不能关闭本画布的审批。 + s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "other-canvas-task", ProjectID: "other-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}}) + after, _ := s.repo.CloudAgent("user", run.ID) + afterState, _ := cloudAgentDecode(after) + if after.Status != "waiting_approval" || afterState.Approval == nil || afterState.Approval.ID != approvalID || afterState.CallIndex != state.CallIndex { + t.Fatalf("unrelated task changed the approval: status=%s approval=%+v", after.Status, afterState.Approval) + } + if err := s.DecideCloudAgentApproval("user", run.ID, approvalID, "reject", ""); err != nil { + t.Fatalf("approval should still be decidable: %v", err) + } + }) + } +} + +func TestNodeGenerationSupersedeViaSourceNode(t *testing.T) { + s, _, a := agentMediaFixture(t) + run, approvalID := waitingAgentMediaApproval(t, s, a) + // 批量图片的结果落在新子节点,sourceNodeId 才是被审批的那个节点。 + s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "batch-child-task", ProjectID: "agent-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": "batch-child", "sourceNodeId": a.NodeID}}) + after, _ := s.repo.CloudAgent("user", run.ID) + state, _ := cloudAgentDecode(after) + if state.Decisions[approvalID] != cloudAgentApprovalSuperseded || state.Approval != nil { + t.Fatalf("sourceNodeId did not supersede the approval: %+v", state.Approval) + } + // 重复通知是幂等的:不会再写第二条工具结果。 + s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "batch-child-task-2", ProjectID: "agent-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}}) + again, _ := s.repo.CloudAgent("user", run.ID) + if again.Revision != after.Revision { + t.Fatalf("repeated supersede mutated the run: %d -> %d", after.Revision, again.Revision) + } +} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 4466eea69..78ae69baf 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -2449,7 +2449,7 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { return nil } - return creationConflict("该审批已有不同决定") + return cloudAgentDecisionConflict(previous) } if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { return creationConflict("审批不存在或已过期") @@ -2472,7 +2472,7 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { return nil } - return creationConflict("该审批已有不同决定") + return cloudAgentDecisionConflict(previous) } if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { return creationConflict("审批不存在或已过期") diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 405e42303..68bef92af 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -209,6 +209,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, } s.recordActivity(userID, "task", 1) _ = s.log(userID, task.ID, "info", "任务已进入队列", "") + if req.admission == nil { + // 用户直接在节点上生成时,关闭 Agent 对同一节点仍在等待的生成审批。 + s.supersedeCloudAgentApprovalsForNodeTask(userID, &task, normalizedInput) + } return taskForOutput(task), nil } diff --git a/backend/internal/repository/cloud_agent.go b/backend/internal/repository/cloud_agent.go index 45252c1e0..001f281f9 100644 --- a/backend/internal/repository/cloud_agent.go +++ b/backend/internal/repository/cloud_agent.go @@ -149,6 +149,17 @@ func (r *Repository) CloudAgentForActiveTask(userID, taskID string) (*model.Clou }) return &run, err } + +// CloudAgentIDsWaitingApprovalForCanvas lists the caller's runs paused on an +// approval for one canvas. Callers reload each run before mutating it. +func (r *Repository) CloudAgentIDsWaitingApprovalForCanvas(userID, canvasID string) ([]string, error) { + var ids []string + err := r.db.Model(&model.CloudAgentExecution{}). + Where("user_id = ? AND canvas_id = ? AND status = ?", userID, canvasID, "waiting_approval"). + Order("id").Limit(20).Pluck("id", &ids).Error + return ids, err +} + func (r *Repository) CloudAgentRoots() ([]model.Task, error) { var tasks []model.Task err := r.db.Where("operation = ? AND id NOT IN (SELECT id FROM cloud_agent_executions)", "cloud_agent").Order("created_at").Limit(50).Find(&tasks).Error diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 936144d8c..49a2b5a8f 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -36,7 +36,8 @@ import { } from "@/services/api/agent"; import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation"; import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed"; -import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval"; +import { AGENT_APPROVAL_SUPERSEDED_BY_NODE, agentApprovalMatchesSettings, agentApprovalTargetGenerating, agentImageApproval } from "@/lib/canvas/agent-media-approval"; +import type { CanvasNodeData } from "@/types/canvas"; import type { AgentMediaSettings } from "@/services/api/agent"; import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings"; import { addSkill, listAddedSkills, listSkillLibraryCategories, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillPreset } from "@/services/api/skills"; @@ -94,11 +95,14 @@ type CloudAgentPanelProps = { onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void; + /** 画布节点快照:用于识别审批目标节点是否已被用户直接提交生成。 */ + canvasNodes?: readonly CanvasNodeData[]; + runningNodeId?: string | null; }; type ApprovalState = { approvalId: string; detail: Record; reason: string }; type AgentPanelView = "chat" | "history" | "settings"; -export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, selectedNodeIds, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { +export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, selectedNodeIds, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode, canvasNodes, runningNodeId }: CloudAgentPanelProps) { const userId = useUserStore((state) => state.user?.id); const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; const theme = canvasThemes[useActiveTheme()]; @@ -1013,6 +1017,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se references={[...references, ...buildSkillMentionReferences(installedSkills)]} busy={busy || running} approval={approval} + approvalTargetGenerating={approval ? agentApprovalTargetGenerating(approval.detail, canvasNodes, runningNodeId) : undefined} nodeCount={nodeCount} approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"} onChooseSkill={() => setSkillsOpen(true)} @@ -1418,6 +1423,7 @@ function AgentConversation({ references, busy, approval, + approvalTargetGenerating, approvalSubmitting, nodeCount, onChooseSkill, @@ -1432,6 +1438,7 @@ function AgentConversation({ references: CanvasResourceReference[]; busy: boolean; approval: ApprovalState | null; + approvalTargetGenerating?: CanvasNodeData; approvalSubmitting: boolean; nodeCount: number; onChooseSkill: () => void; @@ -1492,7 +1499,19 @@ function AgentConversation({ ), )} - {approval ? : null} + {approval ? ( + + ) : null} {busy && !approval ? : null} @@ -1569,6 +1588,7 @@ function ApprovalCard({ approval, theme, submitting, + targetGenerating, onFocusNode, onReasonChange, onApprove, @@ -1577,6 +1597,8 @@ function ApprovalCard({ approval: ApprovalState; theme: CanvasTheme; submitting: boolean; + /** 目标节点已在画布上直接生成:禁用“同意执行”,等待后端关闭本次审批。 */ + targetGenerating?: CanvasNodeData; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; @@ -1593,7 +1615,7 @@ function ApprovalCard({

{action.title}

- 等待你的确认 + {targetGenerating ? "已在节点中生成" : "等待你的确认"}

{action.description} @@ -1629,13 +1651,18 @@ function ApprovalCard({ disabled={submitting} /> ) : null} + {targetGenerating ? ( +

+ 你已在节点《{targetGenerating.title || "未命名节点"}》上直接提交了生成,Agent 不会重复提交;本次审批会自动关闭。 +

+ ) : null}
-
@@ -1781,6 +1808,16 @@ function applyAgentEvent( } if (event.type === "approval_decided") { setApproval(null); + if (payload.decision === AGENT_APPROVAL_SUPERSEDED_BY_NODE) { + setMessages((current) => + appendUniqueMessage(current, { + id: event.eventId, + role: "system", + text: text || "你已在画布节点上直接提交了生成,本次审批已自动关闭;Agent 不会重复提交或扣费。", + }), + ); + return; + } if (payload.decision === "reject") { setMessages((current) => appendUniqueMessage(current, { diff --git a/web/src/lib/canvas/agent-media-approval.ts b/web/src/lib/canvas/agent-media-approval.ts index cd529b900..d398d72ef 100644 --- a/web/src/lib/canvas/agent-media-approval.ts +++ b/web/src/lib/canvas/agent-media-approval.ts @@ -1,4 +1,6 @@ import type { AgentMediaSettings } from "@/services/api/agent"; +import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; +import type { CanvasNodeData } from "@/types/canvas"; import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store"; export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] }; @@ -43,3 +45,30 @@ export function agentApprovalModelSelection(config: AiConfig, model: string): Pi if (channel.scope !== "system") throw new Error("Agent 生成仅支持平台模型"); return { channelId: channel.id, channelModelKey: modelOptionName(model) }; } + +/** 后端在用户直接从节点提交生成时写入的审批决定(与 Go 侧 cloudAgentApprovalSuperseded 一致)。 */ +export const AGENT_APPROVAL_SUPERSEDED_BY_NODE = "superseded_by_node"; + +/** 待审批 generate_media 的目标节点;其他工具返回空串。 */ +export function agentMediaApprovalTargetNodeId(detail: Record): string { + const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined; + if ((call?.function?.name || detail.toolName) !== "generate_media") return ""; + const raw = call?.function?.arguments ?? detail.arguments; + try { + const args = typeof raw === "string" ? JSON.parse(raw) : raw; + return args && typeof args.nodeId === "string" ? args.nodeId.trim() : ""; + } catch { + return ""; + } +} + +/** + * 审批的目标节点已经在画布上生成中(用户直接点了节点的生成)。 + * 后端会随后推送 superseded 决定;这里只负责在事件到达前挡住重复点击“同意执行”。 + */ +export function agentApprovalTargetGenerating(detail: Record, nodes: readonly CanvasNodeData[] | undefined, runningNodeId?: string | null): CanvasNodeData | undefined { + const nodeId = agentMediaApprovalTargetNodeId(detail); + if (!nodeId || !nodes) return undefined; + const target = nodes.find((node) => node.id === nodeId); + return target && isCanvasNodeGenerating(target, runningNodeId) ? target : undefined; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 0f27d311f..dfe857796 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2827,7 +2827,7 @@ function InfiniteCanvasPage() {
- { + { const currentNodes = nodesRef.current; const target = currentNodes.find((node) => node.id === nodeId); if (!target) { message.info("该节点已删除或尚未同步到画布"); return; } diff --git a/web/test/agent-media-approval.test.ts b/web/test/agent-media-approval.test.ts index 8b77fc11c..fa5f0eaba 100644 --- a/web/test/agent-media-approval.test.ts +++ b/web/test/agent-media-approval.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentImageApproval } from "../src/lib/canvas/agent-media-approval"; +import { AGENT_APPROVAL_SUPERSEDED_BY_NODE, agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentApprovalTargetGenerating, agentImageApproval, agentMediaApprovalTargetNodeId } from "../src/lib/canvas/agent-media-approval"; +import type { CanvasNodeData } from "../src/types/canvas"; import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store"; describe("image generation approval settings", () => { @@ -38,3 +39,28 @@ describe("image generation approval settings", () => { expect(agentApprovalModel(config, { logicalModelId: "removed", size: args.size, quality: args.quality })).toBe(""); }); }); + +describe("approval target already generating on the canvas", () => { + const detail = { toolName: "generate_media", arguments: JSON.stringify({ mode: "image", prompt: "p", size: "1:1", nodeId: "draft-1" }) }; + const node = (metadata: Record) => ({ id: "draft-1", type: "image", title: "开场分镜", position: { x: 0, y: 0 }, width: 200, height: 200, metadata }) as unknown as CanvasNodeData; + + it("reads the target node from SSE events and restored snapshots", () => { + expect(agentMediaApprovalTargetNodeId(detail)).toBe("draft-1"); + expect(agentMediaApprovalTargetNodeId({ call: { function: { name: "generate_media", arguments: detail.arguments } } })).toBe("draft-1"); + expect(agentMediaApprovalTargetNodeId({ toolName: "canvas_apply_ops", arguments: detail.arguments })).toBe(""); + expect(agentMediaApprovalTargetNodeId({ toolName: "generate_media", arguments: "{" })).toBe(""); + }); + + it("blocks approval only while the same node is generating", () => { + expect(agentApprovalTargetGenerating(detail, [node({ status: "idle" })])).toBeUndefined(); + expect(agentApprovalTargetGenerating(detail, [node({ status: "loading" })])?.id).toBe("draft-1"); + expect(agentApprovalTargetGenerating(detail, [node({ taskId: "t1", taskStatus: "running" })])?.id).toBe("draft-1"); + expect(agentApprovalTargetGenerating(detail, [node({ status: "idle" })], "draft-1")?.id).toBe("draft-1"); + expect(agentApprovalTargetGenerating(detail, [{ ...node({ status: "loading" }), id: "other" }])).toBeUndefined(); + expect(agentApprovalTargetGenerating(detail, undefined)).toBeUndefined(); + }); + + it("uses the same decision value as the backend", () => { + expect(AGENT_APPROVAL_SUPERSEDED_BY_NODE).toBe("superseded_by_node"); + }); +});