feat: add canvas agent progress timeline
This commit is contained in:
parent
610d2aed9b
commit
f88ada378e
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user