feat(canvas): Agent 审批 - 节点直接生成时自动关闭同节点的待审批任务
用户在画布节点上直接提交生成后,同画布 Agent 对该节点等待中的 generate_media 审批关闭为 superseded_by_node,Agent 带着「用户已手动提交」的结果继续,不重复提交、不扣费。审批卡片识别目标节点正在生成时禁用「同意执行」并提示原因。
This commit is contained in:
@@ -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<string, unknown>; 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({
|
||||
<AgentChatMessage key={segment.key} item={segment.item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage} />
|
||||
),
|
||||
)}
|
||||
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onFocusNode={onFocusNode} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}
|
||||
{approval ? (
|
||||
<ApprovalCard
|
||||
key={approval.approvalId}
|
||||
approval={approval}
|
||||
theme={theme}
|
||||
submitting={approvalSubmitting}
|
||||
targetGenerating={approvalTargetGenerating}
|
||||
onFocusNode={onFocusNode}
|
||||
onReasonChange={onApprovalReasonChange}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
) : null}
|
||||
{busy && !approval ? <AgentWorkingMessage theme={theme} label="正在处理当前画布" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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({
|
||||
<ShieldCheck className="size-4" />
|
||||
</span>
|
||||
<h3>{action.title}</h3>
|
||||
<span className="canvas-agent-approval-badge">等待你的确认</span>
|
||||
<span className="canvas-agent-approval-badge">{targetGenerating ? "已在节点中生成" : "等待你的确认"}</span>
|
||||
</div>
|
||||
<p className="canvas-agent-approval-description" style={{ color: theme.node.muted }}>
|
||||
{action.description}
|
||||
@@ -1629,13 +1651,18 @@ function ApprovalCard({
|
||||
disabled={submitting}
|
||||
/>
|
||||
) : null}
|
||||
{targetGenerating ? (
|
||||
<p className="canvas-agent-approval-description" role="status" style={{ color: theme.node.muted }}>
|
||||
你已在节点《{targetGenerating.title || "未命名节点"}》上直接提交了生成,Agent 不会重复提交;本次审批会自动关闭。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="canvas-agent-approval-actions">
|
||||
<button type="button" className="canvas-agent-approval-reject" disabled={submitting} onClick={onReject}>
|
||||
暂不执行
|
||||
</button>
|
||||
<button type="button" className="canvas-agent-approval-approve" disabled={submitting} onClick={() => onApprove(mediaSettings)}>
|
||||
<button type="button" className="canvas-agent-approval-approve" disabled={submitting || Boolean(targetGenerating)} onClick={() => onApprove(mediaSettings)}>
|
||||
{submitting ? <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" /> : <Check className="size-4" aria-hidden="true" />}
|
||||
{submitting ? "正在提交" : "同意执行"}
|
||||
{submitting ? "正在提交" : targetGenerating ? "已在节点中生成" : "同意执行"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, unknown>): 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<string, unknown>, 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;
|
||||
}
|
||||
|
||||
@@ -2827,7 +2827,7 @@ function InfiniteCanvasPage() {
|
||||
</div>
|
||||
|
||||
<div className={versions.open ? "hidden" : "contents"}>
|
||||
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
|
||||
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} canvasNodes={nodes} runningNodeId={runningNodeId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
|
||||
const currentNodes = nodesRef.current;
|
||||
const target = currentNodes.find((node) => node.id === nodeId);
|
||||
if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }
|
||||
|
||||
Reference in New Issue
Block a user