feat(agent): Agent 生成审批 - 支持改模型参数、忽略位置漂移并取消 8 轮硬限制

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-16 22:59:38 +08:00
parent f394cd7385
commit fc5548836f
21 changed files with 930 additions and 66 deletions

View File

@@ -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>;
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -0,0 +1,44 @@
/* Rare UI Fluid Orb inspired launcher: isolated from canvas wheel/pointer gestures. */
.fluid-orb {
position: relative;
display: inline-block;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--fluid-orb-color, #7164f6) 18%, white);
border-radius: 999px;
background:
radial-gradient(circle at 34% 20%, rgba(255, 255, 255, .96) 0 9%, rgba(255, 255, 255, .48) 18%, transparent 42%),
var(--fluid-orb-color, #7164f6);
box-shadow:
0 14px 32px color-mix(in srgb, var(--fluid-orb-color, #7164f6) 27%, transparent),
inset 0 1px 1px rgba(255, 255, 255, .82);
isolation: isolate;
}
.fluid-orb::after {
position: absolute;
inset: 5%;
border: 1px solid rgba(255, 255, 255, .28);
border-radius: inherit;
content: "";
pointer-events: none;
}
.fluid-orb canvas {
display: block;
width: 100%;
height: 100%;
}
.fluid-orb-fallback {
position: absolute;
inset: 0;
border-radius: inherit;
background:
radial-gradient(circle at 30% 22%, #fff 0 9%, transparent 27%),
radial-gradient(circle at 60% 78%, color-mix(in srgb, var(--fluid-orb-color, #7164f6) 82%, white), transparent 60%),
linear-gradient(145deg, color-mix(in srgb, var(--fluid-orb-color, #7164f6) 70%, #b7e8ff), var(--fluid-orb-color, #7164f6));
}
@media (prefers-reduced-motion: reduce) { .fluid-orb { transition: none !important; } }

View File

@@ -0,0 +1,209 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { cn } from "@/lib/utils";
import "./fluid-orb.css";
type FluidOrbProps = {
size?: number;
color?: string;
className?: string;
};
const VERTEX_SHADER = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const FRAGMENT_SHADER = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_color;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m *= m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
float fbm(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * snoise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv * 2.0 - 1.0;
p.x *= u_resolution.x / u_resolution.y;
float r = length(p);
if (r > 1.0) discard;
float y = uv.y;
float t = u_time * 0.18;
float n1 = fbm(vec3(p * 1.28, t));
float n2 = fbm(vec3(p * 2.35 + n1 * 0.55, t * 1.22));
float shape = smoothstep(1.0, 0.16, r + n2 * 0.2);
float topGlow = smoothstep(0.16, 0.92, y);
vec3 white = vec3(1.0);
vec3 color = mix(u_color * 0.66, u_color * 1.17, n1 * 0.5 + 0.5);
color = mix(color, white, topGlow * 0.96);
color += vec3(0.11, 0.06, 0.17) * max(n2, 0.0) * (1.0 - topGlow);
float rim = smoothstep(0.78, 0.99, r);
color = mix(color, white, rim * 0.2);
gl_FragColor = vec4(color, shape);
}`;
export function FluidOrb({ size = 56, color = "#6d5dfc", className }: FluidOrbProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", { alpha: true, antialias: true, premultipliedAlpha: true });
if (!gl) {
setFallback(true);
return;
}
const compile = (type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) throw new Error("无法创建 Fluid Orb shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const detail = gl.getShaderInfoLog(shader) || "未知 shader 错误";
gl.deleteShader(shader);
throw new Error(detail);
}
return shader;
};
let frame = 0;
let removeMotionListener = () => {};
let program: WebGLProgram | null = null;
let vertexShader: WebGLShader | null = null;
let fragmentShader: WebGLShader | null = null;
let buffer: WebGLBuffer | null = null;
try {
vertexShader = compile(gl.VERTEX_SHADER, VERTEX_SHADER);
fragmentShader = compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
program = gl.createProgram();
if (!program) throw new Error("无法创建 Fluid Orb program");
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || "Fluid Orb link 失败");
gl.useProgram(program);
buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const resolution = gl.getUniformLocation(program, "u_resolution");
const time = gl.getUniformLocation(program, "u_time");
const colorUniform = gl.getUniformLocation(program, "u_color");
const [red, green, blue] = hexToRgb(color);
gl.uniform3f(colorUniform, red / 255, green / 255, blue / 255);
const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
const startedAt = performance.now();
const render = (now: number) => {
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(resolution, width, height);
gl.uniform1f(time, motionPreference.matches ? 0.8 : (now - startedAt) / 1000);
gl.drawArrays(gl.TRIANGLES, 0, 6);
if (!motionPreference.matches) frame = window.requestAnimationFrame(render);
};
const updateMotion = () => { window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(render); };
motionPreference.addEventListener("change", updateMotion);
removeMotionListener = () => motionPreference.removeEventListener("change", updateMotion);
frame = window.requestAnimationFrame(render);
} catch (error) {
console.warn("Fluid Orb WebGL 初始化失败,已切换为 CSS 降级效果", error);
setFallback(true);
}
return () => {
window.cancelAnimationFrame(frame);
removeMotionListener();
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
if (vertexShader) gl.deleteShader(vertexShader);
if (fragmentShader) gl.deleteShader(fragmentShader);
};
}, [color]);
return <span className={cn("fluid-orb", fallback && "is-fallback", className)} style={{ width: size, height: size, "--fluid-orb-color": color } as CSSProperties} aria-hidden="true">
<canvas ref={canvasRef} />
{fallback ? <span className="fluid-orb-fallback" /> : null}
</span>;
}
function hexToRgb(value: string): [number, number, number] {
const normalized = value.trim().replace(/^#/, "");
const expanded = normalized.length === 3 ? normalized.split("").map((item) => item + item).join("") : normalized;
const parsed = Number.parseInt(expanded, 16);
if (!Number.isFinite(parsed) || expanded.length !== 6) return [109, 93, 252];
return [(parsed >> 16) & 255, (parsed >> 8) & 255, parsed & 255];
}

View File

@@ -0,0 +1,45 @@
import type { AgentMediaSettings } from "@/services/api/agent";
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] };
export function agentImageApproval(detail: Record<string, unknown>): AgentImageApproval | null {
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
if ((call?.function?.name || detail.toolName) !== "generate_media") return null;
const raw = call?.function?.arguments ?? detail.arguments;
try {
const args = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!args || args.mode !== "image" || typeof args.size !== "string" || typeof args.prompt !== "string") return null;
return {
logicalModelId: typeof args.logicalModelId === "string" ? args.logicalModelId : "",
channelId: typeof args.channelId === "string" ? args.channelId : "",
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
size: args.size,
quality: typeof args.quality === "string" ? args.quality : "",
prompt: args.prompt,
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
};
} catch {
return null;
}
}
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings): string {
return selectableModelsByCapability(config, "image").find((model) => {
if (settings.logicalModelId) return logicalModelIDForConfig({ ...config, model }) === settings.logicalModelId;
return resolveModelChannel(config, model).id === settings.channelId && modelOptionName(model) === settings.channelModelKey;
}) || "";
}
export function agentApprovalMatchesSettings(argumentsValue: unknown, settings: AgentMediaSettings): boolean {
const approved = agentImageApproval({ toolName: "generate_media", arguments: argumentsValue });
return Boolean(approved && (["logicalModelId", "channelId", "channelModelKey", "size", "quality"] as const).every((key) => (approved[key] || "") === (settings[key] || "")));
}
export function agentApprovalModelSelection(config: AiConfig, model: string): Pick<AgentMediaSettings, "logicalModelId" | "channelId" | "channelModelKey"> {
const logicalModelId = logicalModelIDForConfig({ ...config, model });
if (logicalModelId) return { logicalModelId };
const channel = resolveModelChannel(config, model);
if (channel.scope !== "system") throw new Error("Agent 生成仅支持平台模型");
return { channelId: channel.id, channelModelKey: modelOptionName(model) };
}

View File

@@ -3,6 +3,13 @@ import { consumeTaskTextStream, createTaskTextStreamParser } from "@/services/ap
export type AgentPermissionMode = "read_only" | "auto" | "request_approval";
export type AgentReasoningMode = "off" | "auto" | "deep";
export type AgentMediaSettings = {
logicalModelId?: string;
channelId?: string;
channelModelKey?: string;
size: string;
quality: string;
};
export type AgentProfileScope = "user" | "project" | "canvas";
export type AgentProfileLayer = {
@@ -167,8 +174,8 @@ export function undoAgentCanvasRun(runId: string, input: { stepId?: string; expe
return http.post<{ accepted: boolean; snapshotHash: string }>(`/agent/runs/${encodeURIComponent(runId)}/undo`, input, { signal });
}
export async function decideAgentApproval(runId: string, approvalId: string, decision: "approve" | "reject", reason?: string, signal?: AbortSignal) {
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}/decision`, { decision, reason: reason?.trim() || undefined }, { signal });
export async function decideAgentApproval(runId: string, approvalId: string, decision: "approve" | "reject", reason?: string, signal?: AbortSignal, mediaSettings?: AgentMediaSettings) {
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}/decision`, { decision, reason: reason?.trim() || undefined, ...(mediaSettings ? { mediaSettings } : {}) }, { signal });
}
export function subscribeAgentEvents(runId: string, onEvent: (event: AgentEvent) => void, options: { after?: number; onError?: (error: unknown) => void; onConnectionChange?: (status: "connecting" | "connected" | "reconnecting" | "disconnected") => void; timeoutMs?: number } = {}) {

View File

@@ -65,6 +65,14 @@ function observe(options: { timeoutMs?: number; after?: number } = {}) {
}
describe("Agent SSE recovery", () => {
it("sends the user's final image settings with the approval and keeps cancellation", async () => {
const requests: unknown[][] = [];
transport.http.post = async (...args: unknown[]) => { requests.push(args); return { accepted: true }; };
const signal = new AbortController().signal;
const mediaSettings = { logicalModelId: "chosen-image", size: "1536x1024", quality: "high" };
await api.decideAgentApproval("run/id", "approval/id", "approve", "", signal, mediaSettings);
expect(requests).toEqual([["/agent/runs/run%2Fid/approvals/approval%2Fid/decision", { decision: "approve", reason: undefined, mediaSettings }, { signal }]]);
});
it.each([401, 403, 404, 422])("stops immediately on definitive %s", async (status) => {
let requests = 0;
globalThis.fetch = (async () => { requests++; return new Response("", { status }); }) as typeof fetch;

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import { agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentImageApproval } from "../src/lib/canvas/agent-media-approval";
import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store";
describe("image generation approval settings", () => {
const args = { mode: "image", prompt: "保持参考图产品外观", size: "1024x1024", quality: "high", channelId: "platform", channelModelKey: "gpt-image-2", referenceNodeIds: ["product"] };
it("reads both SSE approval requests and restored run snapshots", () => {
const event = agentImageApproval({ toolName: "generate_media", arguments: args });
const restored = agentImageApproval({ call: { function: { name: "generate_media", arguments: JSON.stringify(args) } } });
expect(event).toEqual(restored);
expect(restored?.referenceNodeIds).toEqual(["product"]);
expect(restored?.quality).toBe("high");
});
it("does not enable image settings for other tools or invalid arguments", () => {
expect(agentImageApproval({ toolName: "canvas_apply_ops", arguments: args })).toBeNull();
expect(agentImageApproval({ toolName: "generate_media", arguments: { ...args, mode: "video" } })).toBeNull();
expect(agentImageApproval({ toolName: "generate_media", arguments: "{" })).toBeNull();
});
it("does not treat another tab's different approval settings as a successful retry", () => {
expect(agentApprovalMatchesSettings(args, args)).toBe(true);
expect(agentApprovalMatchesSettings(args, { ...args, quality: "low" })).toBe(false);
expect(agentApprovalMatchesSettings(args, { logicalModelId: "other", size: args.size, quality: args.quality })).toBe(false);
});
it("switches between logical and channel models without retaining the previous selector", () => {
const cost = { model: "gpt-image-2", capability: "image" as const, billingMode: "fixed_request" as const, unitPriceMicrocredits: 1, priceConfigured: true };
const config = { ...defaultConfig, channels: [
createModelChannel({ id: "platform", scope: "system", models: [cost.model], modelCosts: [cost] }),
createModelChannel({ id: "managed", scope: "system", models: [cost.model], modelCosts: [{ ...cost, logicalModelId: "logical-image" }] }),
createModelChannel({ id: "personal", scope: "custom", models: [cost.model], modelCosts: [cost] }),
] };
const logical = agentApprovalModelSelection(config, encodeChannelModel("managed", cost.model));
expect(logical).toEqual({ logicalModelId: "logical-image" });
expect(agentApprovalModel(config, { ...logical, size: args.size, quality: args.quality })).toBe(encodeChannelModel("managed", cost.model));
const channel = agentApprovalModelSelection(config, encodeChannelModel("platform", cost.model));
expect(channel).toEqual({ channelId: "platform", channelModelKey: cost.model });
expect(agentApprovalModel(config, { ...channel, size: args.size, quality: args.quality })).toBe(encodeChannelModel("platform", cost.model));
expect(() => agentApprovalModelSelection(config, encodeChannelModel("personal", cost.model))).toThrow("平台模型");
expect(agentApprovalModel(config, { logicalModelId: "removed", size: args.size, quality: args.quality })).toBe("");
});
});