feat(agent): Agent 生成审批 - 支持改模型参数、忽略位置漂移并取消 8 轮硬限制
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import { ImageSettingsPanel } from "@/components/image-settings-panel";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { agentApprovalModel, agentApprovalModelSelection, type AgentImageApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import type { AgentMediaSettings } from "@/services/api/agent";
|
||||
import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
export function CanvasAgentImageApprovalSettings({ initial, value, onChange, theme, disabled }: {
|
||||
initial: AgentImageApproval;
|
||||
value?: AgentMediaSettings;
|
||||
onChange: (value: AgentMediaSettings) => void;
|
||||
theme: CanvasTheme;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const effective = useEffectiveConfig();
|
||||
const config = useMemo(() => ({ ...effective, channels: effective.channels.filter((channel) => channel.scope === "system" || channel.id === PUBLIC_MODEL_CATALOG_ID) }), [effective]);
|
||||
const settings = value || initial;
|
||||
const model = agentApprovalModel(config, settings);
|
||||
const requirements: ModelRequirements = {
|
||||
capability: "image",
|
||||
input: { textCount: 1, imageCount: initial.referenceNodeIds.length, videoCount: 0, audioCount: 0, characterCount: 0 },
|
||||
imageSize: settings.size,
|
||||
options: { size: settings.size, ...(settings.quality ? { quality: settings.quality } : {}), count: 1 },
|
||||
};
|
||||
const changeModel = (next: string) => {
|
||||
if (disabled) return;
|
||||
const profile = modelCapabilityConfigFor(config, next).image;
|
||||
if (!profile) return;
|
||||
const normalized = normalizeImageValue(profile, settings);
|
||||
onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, quality: profile.quality.supported ? normalized.quality : "" });
|
||||
};
|
||||
const changeOption = (key: "quality" | "size" | "transparentBackground" | "count", next: string) => {
|
||||
if (disabled || (key !== "size" && key !== "quality")) return;
|
||||
const options = { size: settings.size, quality: settings.quality, [key]: next };
|
||||
const selected = resolveCompatibleModel(config, model, { ...requirements, imageSize: options.size, options: { ...options, count: 1 } });
|
||||
if (!selected) return;
|
||||
onChange({ ...agentApprovalModelSelection(config, selected), ...options });
|
||||
};
|
||||
return <fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="图片生成设置">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
|
||||
<ModelPicker config={config} capability="image" value={model} onChange={changeModel} requirements={requirements} fullWidth showOptionPrices placeholder="选择生成模型" />
|
||||
</div>
|
||||
{model ? <ImageSettingsPanel config={{ ...config, model, imageModel: model, size: settings.size, quality: settings.quality, count: "1" }} onConfigChange={changeOption} theme={theme} showTitle={false} showCount={false} showTransparent={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
|
||||
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}</p>
|
||||
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import { Button, Dropdown, Input } from "antd";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
|
||||
@@ -8,16 +8,21 @@ import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { FluidOrb } from "@/components/ui/fluid-orb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
|
||||
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
|
||||
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
|
||||
import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import type { AgentMediaSettings } from "@/services/api/agent";
|
||||
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
|
||||
import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory } from "@/services/api/skills";
|
||||
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
@@ -32,7 +37,7 @@ type ApprovalState = { approvalId: string; detail: Record<string, unknown>; reas
|
||||
type AgentPanelView = "chat" | "history" | "settings";
|
||||
|
||||
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -436,7 +441,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
}
|
||||
};
|
||||
|
||||
const submitApproval = async (decision: "approve" | "reject") => {
|
||||
const submitApproval = async (decision: "approve" | "reject", mediaSettings?: AgentMediaSettings) => {
|
||||
if (!run || !approval || connectionStatus !== "connected" || approvalRequestRef.current === approval.approvalId) return;
|
||||
const runId = run.id;
|
||||
const approvalId = approval.approvalId;
|
||||
@@ -444,7 +449,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
approvalRequestRef.current = approvalId;
|
||||
setApprovalSubmitting(true);
|
||||
try {
|
||||
await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000));
|
||||
if (decision === "approve") await saveRemoteUserDataNow();
|
||||
if (currentScope.current !== scope) return;
|
||||
await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000), mediaSettings);
|
||||
if (currentScope.current === scope) {
|
||||
setApproval((current) => current?.approvalId === approvalId ? null : current);
|
||||
setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: decision === "reject" ? "rejected" : "running", approval: undefined } : current);
|
||||
@@ -455,7 +462,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
// asking the user to retry. The backend treats identical decisions idempotently.
|
||||
try {
|
||||
const snapshot = await getAgentRun(runId, AbortSignal.timeout(5_000));
|
||||
const decided = snapshot.run.events?.some((event) => event.type === "approval_decided" && event.payload.approvalId === approvalId && event.payload.decision === decision);
|
||||
const decided = snapshot.run.events?.some((event) => event.type === "approval_decided" && event.payload.approvalId === approvalId && event.payload.decision === decision && (!mediaSettings || agentApprovalMatchesSettings(event.payload.arguments, mediaSettings)));
|
||||
if (decided) {
|
||||
setApproval((current) => current?.approvalId === approvalId ? null : current);
|
||||
setRun(snapshot.run);
|
||||
@@ -666,7 +673,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
nodeCount={nodeCount}
|
||||
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
|
||||
onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))}
|
||||
onApprove={() => void submitApproval("approve")}
|
||||
onApprove={(settings) => void submitApproval("approve", settings)}
|
||||
onReject={() => void submitApproval("reject")}
|
||||
/>
|
||||
<AgentChatComposer
|
||||
@@ -736,16 +743,18 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
|
||||
type="button"
|
||||
aria-label="打开云端 Agent"
|
||||
title={approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"}
|
||||
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] flex h-12 items-center gap-2.5 rounded-2xl px-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
|
||||
style={{ color: theme.node.text, background: theme.node.panel, border: `1px solid ${theme.toolbar.border}`, boxShadow: `0 8px 28px ${theme.spatial.shadow}` }}
|
||||
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
|
||||
style={{ color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
data-canvas-no-zoom
|
||||
onClick={onOpen}
|
||||
whileHover={reducedMotion ? undefined : { y: -2 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.98 }}
|
||||
whileHover={reducedMotion ? undefined : { y: -3, scale: 1.035 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
|
||||
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||
>
|
||||
<Bot className="size-5" />
|
||||
<span className="text-sm font-semibold">Agent</span>
|
||||
{approvalPending ? <span className="text-xs" style={{ color: statusColor }}>待审批</span> : null}
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
<span className="canvas-agent-launcher-label">Agent</span>
|
||||
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
|
||||
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -843,7 +852,7 @@ function AgentConversation({
|
||||
nodeCount: number;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
onApprovalReasonChange: (reason: string) => void;
|
||||
onApprove: () => void;
|
||||
onApprove: (settings?: AgentMediaSettings) => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -989,8 +998,10 @@ function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentRea
|
||||
}));
|
||||
}
|
||||
|
||||
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) {
|
||||
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void }) {
|
||||
const [showReason, setShowReason] = useState(Boolean(approval.reason));
|
||||
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();
|
||||
const imageApproval = agentImageApproval(approval.detail);
|
||||
const action = agentApprovalPresentation(approval.detail);
|
||||
return (
|
||||
<section className="canvas-agent-approval-card" aria-label={action.title}>
|
||||
@@ -1002,16 +1013,17 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange
|
||||
<p className="canvas-agent-approval-description" style={{ color: theme.node.muted }}>{action.description}</p>
|
||||
{action.items.length ? (
|
||||
<div className="canvas-agent-approval-items" aria-label="涉及节点">
|
||||
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={item} theme={theme} onFocusNode={onFocusNode} />)}
|
||||
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={imageApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item} theme={theme} onFocusNode={onFocusNode} />)}
|
||||
</div>
|
||||
) : <div className="canvas-agent-approval-empty" style={{ color: theme.node.muted }}>无法确认具体目标,继续前请重新读取画布。</div>}
|
||||
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
|
||||
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
|
||||
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
|
||||
</button>
|
||||
{showReason ? <Input.TextArea className="canvas-agent-approval-reason" value={approval.reason} onChange={(event) => onReasonChange(event.target.value)} placeholder="告诉 Agent 为什么暂不执行" autoSize={{ minRows: 2, maxRows: 3 }} maxLength={2000} disabled={submitting} /> : 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}>
|
||||
<button type="button" className="canvas-agent-approval-approve" disabled={submitting} 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 ? "正在提交" : "同意执行"}
|
||||
</button>
|
||||
|
||||
@@ -274,6 +274,86 @@
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher {
|
||||
display: grid;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 7px;
|
||||
box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent);
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher::before {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--background) 82%, transparent);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
content: "";
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-label {
|
||||
position: absolute;
|
||||
bottom: -17px;
|
||||
left: 50%;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-status {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 7px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--background);
|
||||
border-radius: 50%;
|
||||
background: #57b786;
|
||||
box-shadow: 0 2px 7px rgba(23, 90, 61, .28);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-status.is-pending {
|
||||
background: var(--canvas-agent-status-color, #df9b47);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--canvas-agent-status-color, #df9b47) 20%, transparent);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-badge {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 62px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||
padding: 5px 8px;
|
||||
color: var(--foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas-agent-launcher { transition: none !important; }
|
||||
}
|
||||
|
||||
.agent-composer-send-hint {
|
||||
color: color-mix(in srgb, currentColor 52%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
|
||||
Reference in New Issue
Block a user