feat: add canvas agent progress timeline

This commit is contained in:
134 2026-07-07 15:42:54 +08:00
parent 610d2aed9b
commit f88ada378e
3 changed files with 282 additions and 34 deletions

View File

@ -144,15 +144,16 @@ import {
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import {
AGENT_PROGRESS_STEPS,
type AgentProgressStreamPayload,
type AgentTargetLockRefPreview,
type AgentTargetLockStreamPayload,
buildAgentProgressNodeSteps,
type CanvasOperationStreamPayload,
type CanvasViewOperationStreamPayload,
type CanvasMediaStreamPayload,
filterCanvasMediaPayloadForUnhandledItems,
getAgentProgressFallbackTitle,
getAgentProgressStepLabel,
getAgentTargetLockTitle,
getAgentTargetResolutionLabel,
getCanvasMediaPayloadHandledItemIds,
@ -1623,6 +1624,130 @@ function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) {
);
}
function CanvasAgentProgressTimeline({
progress,
}: {
progress?: AgentProgressStreamPayload | null;
}) {
const steps = buildAgentProgressNodeSteps(progress);
const title = progress?.title?.trim() || getAgentProgressFallbackTitle(progress);
const isFailed = progress?.stage === "failed";
const isCompleted = progress?.stage === "completed";
const isClarify = progress?.stage === "clarify";
return (
<div className="flex w-full justify-start">
<div
aria-label={title}
className={cn(
"w-full max-w-[min(92%,860px)] rounded-[22px] border px-4 py-3 shadow-[0_16px_44px_-32px_rgba(15,23,42,0.65)] backdrop-blur-sm",
isFailed
? "border-rose-300/40 bg-rose-50/70 dark:border-rose-400/20 dark:bg-rose-950/20"
: isClarify
? "border-amber-300/40 bg-amber-50/70 dark:border-amber-400/20 dark:bg-amber-950/20"
: "border-[#d8deea]/70 bg-white/72 dark:border-white/10 dark:bg-white/6"
)}
>
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
isFailed
? "bg-rose-500/12 text-rose-600 dark:text-rose-300"
: isClarify
? "bg-amber-500/12 text-amber-600 dark:text-amber-300"
: isCompleted
? "bg-emerald-500/12 text-emerald-600 dark:text-emerald-300"
: "bg-sky-500/12 text-sky-600 dark:text-sky-300"
)}
>
{isFailed ? (
<AlertCircle className="h-4 w-4" />
) : isCompleted ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
)}
</span>
<span className="text-[15px] font-medium text-[#5d6675] dark:text-zinc-300">
{title}
</span>
</div>
<div className="mt-4 overflow-x-auto pb-1 scrollbar-hide">
<div className="flex min-w-max items-center">
{steps.map((step, index) => {
const isStepCompleted = step.state === "completed";
const isStepCurrent = step.state === "current";
const isStepFailed = step.state === "failed";
const lineIsActive =
isStepCompleted || isStepCurrent || isStepFailed;
return (
<div key={step.key} className="flex items-center">
<div className="flex min-w-max items-center gap-2">
<span
className={cn(
"relative inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
isStepFailed
? "border-rose-500 bg-rose-500 text-white"
: isStepCompleted
? "border-emerald-500 bg-emerald-500 text-white"
: isStepCurrent
? "border-sky-500 bg-white text-sky-500 dark:bg-[#111318]"
: "border-[#cfd6e2] bg-white text-transparent dark:border-white/18 dark:bg-white/8"
)}
>
{isStepCompleted ? (
<Check className="h-2.5 w-2.5" />
) : isStepFailed ? (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
) : isStepCurrent ? (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
{isStepCurrent ? (
<span className="absolute inset-[-5px] rounded-full border border-sky-400/35" />
) : null}
</span>
<span
className={cn(
"whitespace-nowrap text-[14px] font-medium leading-5",
isStepFailed
? "text-rose-600 dark:text-rose-300"
: isStepCompleted
? "text-emerald-600 dark:text-emerald-300"
: isStepCurrent
? "text-sky-600 dark:text-sky-300"
: "text-[#9aa3b2] dark:text-zinc-500"
)}
>
{step.label}
</span>
</div>
{index < steps.length - 1 ? (
<span
className={cn(
"mx-3 h-px w-9 shrink-0 transition-colors",
lineIsActive
? isStepFailed
? "bg-rose-300 dark:bg-rose-400/35"
: "bg-sky-300 dark:bg-sky-400/35"
: "bg-[#d8dee9] dark:bg-white/10"
)}
/>
) : null}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function CanvasComposerUploadProgressDialog({
progress,
}: {
@ -12318,26 +12443,10 @@ export function CanvasChatPanel({
activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress);
const agentProgressMessage =
activeAgentProgress?.message?.trim() || "我正在根据当前上下文持续推进这一步。";
const agentProgressSteps = useMemo(() => {
const currentStepIndex = Math.max(
0,
AGENT_PROGRESS_STEPS.findIndex((step) => step.key === activeAgentProgress?.currentStep)
);
return AGENT_PROGRESS_STEPS.map((step, index) => {
let state: "upcoming" | "current" | "completed" | "failed" = "upcoming";
if (activeAgentProgress?.stage === "failed") {
state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "failed" : "upcoming";
} else if (activeAgentProgress?.stage === "completed") {
state = "completed";
} else {
state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "current" : "upcoming";
}
return {
...step,
state,
};
});
}, [activeAgentProgress]);
const agentProgressSteps = useMemo(
() => buildAgentProgressNodeSteps(activeAgentProgress),
[activeAgentProgress]
);
const renderModelSelectContent = () => (
<SelectContent
@ -13552,12 +13661,7 @@ export function CanvasChatPanel({
) : null}
{isLoading && !shouldRenderImageLoading && (
<div className="flex w-full justify-start">
<div className="flex items-center gap-2 text-[#8e97a5]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[16px]">分析中</span>
</div>
</div>
<CanvasAgentProgressTimeline progress={activeAgentProgress} />
)}
<div ref={messagesEndRef} />
@ -14660,9 +14764,10 @@ export function CanvasChatPanel({
{agentProgressTitle}
</span>
<span className="inline-flex items-center rounded-full bg-white/70 px-2 py-0.5 text-[11px] text-[#5b6472] dark:bg-white/10 dark:text-zinc-300">
{AGENT_PROGRESS_STEPS.find(
(step) => step.key === activeAgentProgress.currentStep
)?.label || "处理中"}
{getAgentProgressStepLabel(
activeAgentProgress.currentStep || "lock_target",
activeAgentProgress
)}
</span>
</div>
<div className="mt-1 text-xs leading-5 text-[#5b6472] dark:text-zinc-300">

View File

@ -206,14 +206,26 @@ export type AgentProgressStreamPayload = {
capability?: string;
};
export type AgentProgressStepState =
| "upcoming"
| "current"
| "completed"
| "failed";
export type AgentProgressNodeStep = {
key: NonNullable<AgentProgressStreamPayload["currentStep"]>;
label: string;
state: AgentProgressStepState;
};
export const AGENT_PROGRESS_STEPS: Array<{
key: NonNullable<AgentProgressStreamPayload["currentStep"]>;
label: string;
}> = [
{ key: "lock_target", label: "整理素材" },
{ key: "analyze", label: "理解需求" },
{ key: "select_tool", label: "优化提示" },
{ key: "execute", label: "提交生成" },
{ key: "lock_target", label: "理解用户需求" },
{ key: "analyze", label: "分析使用工具" },
{ key: "select_tool", label: "确认执行工具" },
{ key: "execute", label: "执行中" },
{ key: "finalize", label: "整理结果" },
];
@ -260,3 +272,94 @@ export function getAgentProgressFallbackTitle(payload?: AgentProgressStreamPaylo
return "正在理解需求";
}
}
export function getAgentProgressActionLabel(
payload?: AgentProgressStreamPayload | null
) {
switch (payload?.capability) {
case "analyze":
return "图片分析";
case "generic_edit":
return "修图";
case "remove_background":
return "抠图";
case "remove_watermark":
return "去水印";
case "enhance":
return "图像增强";
case "resize":
return "尺寸调整";
case "extend":
return "扩图";
case "angle_variant":
return "多视角";
case "local_edit":
return "局部编辑";
case "box_cutout":
return "框选抠图";
case "generate_image":
return "生图";
case "generate_video":
return "视频生成";
default:
return "工具";
}
}
export function getAgentProgressStepLabel(
stepKey: NonNullable<AgentProgressStreamPayload["currentStep"]>,
payload?: AgentProgressStreamPayload | null
) {
const actionLabel = getAgentProgressActionLabel(payload);
switch (stepKey) {
case "lock_target":
return "理解用户需求";
case "analyze":
return payload?.capability === "analyze" ? "分析图片" : "分析使用工具";
case "select_tool":
return actionLabel === "工具" ? "确认执行工具" : `确认${actionLabel}工具`;
case "execute":
return actionLabel === "工具" ? "执行中" : `${actionLabel}中`;
case "finalize":
return "整理结果";
default:
return "处理中";
}
}
export function buildAgentProgressNodeSteps(
payload?: AgentProgressStreamPayload | null
): AgentProgressNodeStep[] {
const currentStep = payload?.currentStep || "lock_target";
const currentStepIndex = Math.max(
0,
AGENT_PROGRESS_STEPS.findIndex((step) => step.key === currentStep)
);
return AGENT_PROGRESS_STEPS.map((step, index) => {
let state: AgentProgressStepState = "upcoming";
if (payload?.stage === "failed") {
state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "failed"
: "upcoming";
} else if (payload?.stage === "completed") {
state = "completed";
} else {
state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "upcoming";
}
return {
key: step.key,
label: getAgentProgressStepLabel(step.key, payload),
state,
};
});
}

View File

@ -9,8 +9,10 @@ import {
} from "@/components/canvas/chat-panel/canvas-chat-media";
import {
filterCanvasMediaPayloadForUnhandledItems,
buildAgentProgressNodeSteps,
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
getAgentProgressStepLabel,
isCanvasAgentViewOperationClientEnabled,
normalizeCanvasAgentClientBoolean,
shouldRetryCanvasViewOperationAfterRefresh,
@ -1210,6 +1212,44 @@ test.describe("canvas chat pure helpers", () => {
expect(editText).toContain("替换背景");
});
test("builds capability-aware agent progress node labels", () => {
const selectingSteps = buildAgentProgressNodeSteps({
stage: "planning",
currentStep: "select_tool",
capability: "remove_background",
});
expect(selectingSteps.map((step) => step.label)).toEqual([
"理解用户需求",
"分析使用工具",
"确认抠图工具",
"抠图中",
"整理结果",
]);
expect(selectingSteps.map((step) => step.state)).toEqual([
"completed",
"completed",
"current",
"upcoming",
"upcoming",
]);
const executingSteps = buildAgentProgressNodeSteps({
stage: "executing",
currentStep: "execute",
capability: "remove_background",
});
expect(executingSteps[2]?.state).toBe("completed");
expect(executingSteps[3]).toMatchObject({
label: "抠图中",
state: "current",
});
expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe(
"视频生成中"
);
});
test("resolves deictic canvas edit requests to latest results and direct tools", () => {
const latestResultUrl = "https://cdn.example.com/latest-cat-result.png";
const requestPolicy = resolveCanvasAgentRequestPolicy({