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, type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config"; } from "@/lib/canvas-toolbar-config";
import { import {
AGENT_PROGRESS_STEPS,
type AgentProgressStreamPayload, type AgentProgressStreamPayload,
type AgentTargetLockRefPreview, type AgentTargetLockRefPreview,
type AgentTargetLockStreamPayload, type AgentTargetLockStreamPayload,
buildAgentProgressNodeSteps,
type CanvasOperationStreamPayload, type CanvasOperationStreamPayload,
type CanvasViewOperationStreamPayload, type CanvasViewOperationStreamPayload,
type CanvasMediaStreamPayload, type CanvasMediaStreamPayload,
filterCanvasMediaPayloadForUnhandledItems, filterCanvasMediaPayloadForUnhandledItems,
getAgentProgressFallbackTitle, getAgentProgressFallbackTitle,
getAgentProgressStepLabel,
getAgentTargetLockTitle, getAgentTargetLockTitle,
getAgentTargetResolutionLabel, getAgentTargetResolutionLabel,
getCanvasMediaPayloadHandledItemIds, 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({ function CanvasComposerUploadProgressDialog({
progress, progress,
}: { }: {
@ -12318,26 +12443,10 @@ export function CanvasChatPanel({
activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress); activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress);
const agentProgressMessage = const agentProgressMessage =
activeAgentProgress?.message?.trim() || "我正在根据当前上下文持续推进这一步。"; activeAgentProgress?.message?.trim() || "我正在根据当前上下文持续推进这一步。";
const agentProgressSteps = useMemo(() => { const agentProgressSteps = useMemo(
const currentStepIndex = Math.max( () => buildAgentProgressNodeSteps(activeAgentProgress),
0, [activeAgentProgress]
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 renderModelSelectContent = () => ( const renderModelSelectContent = () => (
<SelectContent <SelectContent
@ -13552,12 +13661,7 @@ export function CanvasChatPanel({
) : null} ) : null}
{isLoading && !shouldRenderImageLoading && ( {isLoading && !shouldRenderImageLoading && (
<div className="flex w-full justify-start"> <CanvasAgentProgressTimeline progress={activeAgentProgress} />
<div className="flex items-center gap-2 text-[#8e97a5]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[16px]">分析中</span>
</div>
</div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
@ -14660,9 +14764,10 @@ export function CanvasChatPanel({
{agentProgressTitle} {agentProgressTitle}
</span> </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"> <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( {getAgentProgressStepLabel(
(step) => step.key === activeAgentProgress.currentStep activeAgentProgress.currentStep || "lock_target",
)?.label || "处理中"} activeAgentProgress
)}
</span> </span>
</div> </div>
<div className="mt-1 text-xs leading-5 text-[#5b6472] dark:text-zinc-300"> <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; 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<{ export const AGENT_PROGRESS_STEPS: Array<{
key: NonNullable<AgentProgressStreamPayload["currentStep"]>; key: NonNullable<AgentProgressStreamPayload["currentStep"]>;
label: string; label: string;
}> = [ }> = [
{ key: "lock_target", label: "整理素材" }, { key: "lock_target", label: "理解用户需求" },
{ key: "analyze", label: "理解需求" }, { key: "analyze", label: "分析使用工具" },
{ key: "select_tool", label: "优化提示" }, { key: "select_tool", label: "确认执行工具" },
{ key: "execute", label: "提交生成" }, { key: "execute", label: "执行中" },
{ key: "finalize", label: "整理结果" }, { key: "finalize", label: "整理结果" },
]; ];
@ -260,3 +272,94 @@ export function getAgentProgressFallbackTitle(payload?: AgentProgressStreamPaylo
return "正在理解需求"; 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"; } from "@/components/canvas/chat-panel/canvas-chat-media";
import { import {
filterCanvasMediaPayloadForUnhandledItems, filterCanvasMediaPayloadForUnhandledItems,
buildAgentProgressNodeSteps,
getCanvasMediaPayloadHandledItemIds, getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds, getCanvasOperationHandledMediaItemIds,
getAgentProgressStepLabel,
isCanvasAgentViewOperationClientEnabled, isCanvasAgentViewOperationClientEnabled,
normalizeCanvasAgentClientBoolean, normalizeCanvasAgentClientBoolean,
shouldRetryCanvasViewOperationAfterRefresh, shouldRetryCanvasViewOperationAfterRefresh,
@ -1210,6 +1212,44 @@ test.describe("canvas chat pure helpers", () => {
expect(editText).toContain("替换背景"); 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", () => { test("resolves deictic canvas edit requests to latest results and direct tools", () => {
const latestResultUrl = "https://cdn.example.com/latest-cat-result.png"; const latestResultUrl = "https://cdn.example.com/latest-cat-result.png";
const requestPolicy = resolveCanvasAgentRequestPolicy({ const requestPolicy = resolveCanvasAgentRequestPolicy({