6b225a288c
BUG-360: stream thinking and tool activity as an ordered trace so later CoT opens under 正在整理 instead of filling the first 思考 block. Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { Check } from "lucide-react";
|
|
import type { OrbState } from "thinking-orbs";
|
|
|
|
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
|
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
|
|
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
|
|
|
|
const labels = {
|
|
working: "正在处理任务…",
|
|
searching: "正在搜索相关信息…",
|
|
solving: "正在分析问题…",
|
|
listening: "正在聆听…",
|
|
composing: "正在组织回答…",
|
|
shaping: "正在生成结果…",
|
|
} as const satisfies Record<OrbState, string>;
|
|
|
|
const importThinkingOrb = () => import("thinking-orbs");
|
|
|
|
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
|
|
loading: () => (
|
|
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
|
|
),
|
|
ssr: false,
|
|
});
|
|
|
|
prefetchOnIdle(importThinkingOrb);
|
|
|
|
export type AgentActivityState = OrbState;
|
|
|
|
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
const label = activityElapsedLabel(startedAt, now);
|
|
if (!label) return null;
|
|
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
|
|
}
|
|
|
|
function MessageThinkingTrace({
|
|
text,
|
|
hasAnswer,
|
|
}: Readonly<{
|
|
text: string;
|
|
hasAnswer: boolean;
|
|
}>) {
|
|
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
|
const open = userOpen ?? !hasAnswer;
|
|
if (!text.trim()) return null;
|
|
return (
|
|
<details
|
|
className="message-thinking"
|
|
open={open}
|
|
onToggle={(event) => {
|
|
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
|
|
}}
|
|
>
|
|
<summary>思考</summary>
|
|
<div className="message-thinking-body">{text}</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
export function AgentActivityStatus({
|
|
state,
|
|
label = labels[state],
|
|
startedAt,
|
|
completedTrail,
|
|
thinkingText,
|
|
activityTrace,
|
|
hasAnswer = false,
|
|
showLive = true,
|
|
}: Readonly<{
|
|
state: AgentActivityState;
|
|
label?: string;
|
|
startedAt?: number;
|
|
completedTrail?: string;
|
|
thinkingText?: string;
|
|
activityTrace?: readonly AgentActivityTraceItem[];
|
|
hasAnswer?: boolean;
|
|
showLive?: boolean;
|
|
}>) {
|
|
const completedSteps = activityCompletedSteps(completedTrail);
|
|
const live = showLive && !hasAnswer;
|
|
const trace = activityTrace ?? [];
|
|
if (trace.length > 0) {
|
|
return (
|
|
<div className="agent-thinking-panel agent-activity-status">
|
|
<ol className="agent-thinking-timeline">
|
|
{trace.map((item) => (
|
|
<TraceStep
|
|
key={item.id}
|
|
item={item}
|
|
state={state}
|
|
hasAnswer={hasAnswer}
|
|
/>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
);
|
|
}
|
|
if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null;
|
|
|
|
return (
|
|
<div className="agent-thinking-panel agent-activity-status">
|
|
{(live || completedSteps.length > 0) && (
|
|
<ol className="agent-thinking-timeline">
|
|
{completedSteps.map((step) => (
|
|
<li className="agent-thinking-step is-done" key={step}>
|
|
<span className="agent-thinking-marker" aria-hidden="true">
|
|
<Check />
|
|
</span>
|
|
<span>{step}</span>
|
|
</li>
|
|
))}
|
|
{live ? (
|
|
<li className="agent-thinking-step is-live">
|
|
<span className="agent-thinking-marker is-live-marker">
|
|
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
|
</span>
|
|
<span className="agent-activity-status__live" role="status">
|
|
<span key={label} className="agent-activity-status__text">{label}</span>
|
|
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
|
|
</span>
|
|
</li>
|
|
) : null}
|
|
</ol>
|
|
)}
|
|
{thinkingText ? <MessageThinkingTrace text={thinkingText} hasAnswer={hasAnswer} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TraceStep({
|
|
item,
|
|
state,
|
|
hasAnswer,
|
|
}: Readonly<{
|
|
item: AgentActivityTraceItem;
|
|
state: AgentActivityState;
|
|
hasAnswer: boolean;
|
|
}>) {
|
|
if (item.kind === "think") {
|
|
return (
|
|
<li className={`agent-thinking-step is-think is-${item.status}`}>
|
|
{item.text?.trim() ? (
|
|
<MessageThinkingTrace text={item.text} hasAnswer={hasAnswer || item.status === "done"} />
|
|
) : (
|
|
<>
|
|
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
|
|
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
|
|
</span>
|
|
<span>{item.label}</span>
|
|
</>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
return (
|
|
<li className={`agent-thinking-step is-${item.status}`}>
|
|
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
|
|
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
|
|
</span>
|
|
{item.status === "live" ? (
|
|
<span className="agent-activity-status__live" role="status">
|
|
<span key={item.label} className="agent-activity-status__text">{item.label}</span>
|
|
{item.startedAt ? <ActivityElapsed key={item.startedAt} startedAt={item.startedAt} /> : null}
|
|
</span>
|
|
) : (
|
|
<span>{item.label}</span>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|