mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
fix(webui): preserve activity duration on replay
This commit is contained in:
parent
8fedee276b
commit
c4e2fcaf0c
@ -173,6 +173,8 @@ interface AgentActivityClusterProps {
|
|||||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||||
isTurnStreaming: boolean;
|
isTurnStreaming: boolean;
|
||||||
hasBodyBelow: boolean;
|
hasBodyBelow: boolean;
|
||||||
|
/** Persisted end-to-end turn latency from the assistant answer, used for history replay. */
|
||||||
|
turnLatencyMs?: number;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
}
|
}
|
||||||
@ -185,6 +187,7 @@ export function AgentActivityCluster({
|
|||||||
messages,
|
messages,
|
||||||
isTurnStreaming,
|
isTurnStreaming,
|
||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
|
turnLatencyMs,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
@ -242,12 +245,15 @@ export function AgentActivityCluster({
|
|||||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||||
const activityDuration = formatActivityDuration(activityDurationMs(messages, isTurnStreaming, now));
|
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
|
||||||
|
const activityDuration = formatActivityDuration(durationMs);
|
||||||
const thoughtLabel = isTurnStreaming
|
const thoughtLabel = isTurnStreaming
|
||||||
? t("message.activityThinkingFor", {
|
? t("message.activityThinkingFor", {
|
||||||
duration: activityDuration,
|
duration: activityDuration,
|
||||||
defaultValue: "Thinking for {{duration}}",
|
defaultValue: "Thinking for {{duration}}",
|
||||||
})
|
})
|
||||||
|
: durationMs <= 0
|
||||||
|
? t("message.activityThought", { defaultValue: "Thought" })
|
||||||
: t("message.activityThoughtFor", {
|
: t("message.activityThoughtFor", {
|
||||||
duration: activityDuration,
|
duration: activityDuration,
|
||||||
defaultValue: "Thought for {{duration}}",
|
defaultValue: "Thought for {{duration}}",
|
||||||
@ -500,7 +506,15 @@ function shortFileName(path: string): string {
|
|||||||
return path.split(/[\\/]/).pop() || path;
|
return path.split(/[\\/]/).pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityDurationMs(messages: UIMessage[], active: boolean, now: number): number {
|
function activityDurationMs(
|
||||||
|
messages: UIMessage[],
|
||||||
|
active: boolean,
|
||||||
|
now: number,
|
||||||
|
completedLatencyMs?: number,
|
||||||
|
): number {
|
||||||
|
if (!active && Number.isFinite(completedLatencyMs) && completedLatencyMs! >= 0) {
|
||||||
|
return Math.round(completedLatencyMs!);
|
||||||
|
}
|
||||||
const timestamps = messages
|
const timestamps = messages
|
||||||
.map((message) => message.createdAt)
|
.map((message) => message.createdAt)
|
||||||
.filter((value) => Number.isFinite(value));
|
.filter((value) => Number.isFinite(value));
|
||||||
@ -513,7 +527,7 @@ function activityDurationMs(messages: UIMessage[], active: boolean, now: number)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatActivityDuration(ms: number): string {
|
function formatActivityDuration(ms: number): string {
|
||||||
const seconds = Math.max(0, Math.round(ms / 1000));
|
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.round(ms / 1000));
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const rest = seconds % 60;
|
const rest = seconds % 60;
|
||||||
|
|||||||
@ -134,6 +134,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
|||||||
reasoningStreaming: message.reasoningStreaming,
|
reasoningStreaming: message.reasoningStreaming,
|
||||||
isStreaming: message.reasoningStreaming,
|
isStreaming: message.reasoningStreaming,
|
||||||
activitySegmentId: message.activitySegmentId,
|
activitySegmentId: message.activitySegmentId,
|
||||||
|
latencyMs: message.latencyMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,6 +205,8 @@ export function ThreadMessages({
|
|||||||
unit.type === "cluster"
|
unit.type === "cluster"
|
||||||
&& next?.type === "single"
|
&& next?.type === "single"
|
||||||
&& next.message.role === "assistant";
|
&& next.message.role === "assistant";
|
||||||
|
const turnLatencyMs =
|
||||||
|
unit.type === "cluster" ? activityClusterTurnLatencyMs(unit.messages, next) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={unitKey(unit, index)} className={marginTop}>
|
<div key={unitKey(unit, index)} className={marginTop}>
|
||||||
@ -212,6 +215,7 @@ export function ThreadMessages({
|
|||||||
messages={unit.messages}
|
messages={unit.messages}
|
||||||
isTurnStreaming={index === liveActivityClusterIndex}
|
isTurnStreaming={index === liveActivityClusterIndex}
|
||||||
hasBodyBelow={hasBodyBelow}
|
hasBodyBelow={hasBodyBelow}
|
||||||
|
turnLatencyMs={turnLatencyMs}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
/>
|
/>
|
||||||
@ -234,6 +238,28 @@ export function ThreadMessages({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activityClusterTurnLatencyMs(
|
||||||
|
messages: UIMessage[],
|
||||||
|
next: DisplayUnit | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||||
|
const latency = messages[i].latencyMs;
|
||||||
|
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
next?.type === "single"
|
||||||
|
&& next.message.role === "assistant"
|
||||||
|
&& typeof next.message.latencyMs === "number"
|
||||||
|
&& Number.isFinite(next.message.latencyMs)
|
||||||
|
&& next.message.latencyMs >= 0
|
||||||
|
) {
|
||||||
|
return next.message.latencyMs;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
function currentActivityClusterIndex(units: DisplayUnit[]): number {
|
||||||
const last = units.length - 1;
|
const last = units.length - 1;
|
||||||
return units[last]?.type === "cluster" ? last : -1;
|
return units[last]?.type === "cluster" ? last : -1;
|
||||||
|
|||||||
@ -619,6 +619,7 @@
|
|||||||
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
|
||||||
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
|
||||||
"activityThinkingFor": "Thinking for {{duration}}",
|
"activityThinkingFor": "Thinking for {{duration}}",
|
||||||
|
"activityThought": "Thought",
|
||||||
"activityThoughtFor": "Thought for {{duration}}",
|
"activityThoughtFor": "Thought for {{duration}}",
|
||||||
"cliActivityRunningOne": "Using @{{name}}",
|
"cliActivityRunningOne": "Using @{{name}}",
|
||||||
"cliActivityRanOne": "Used @{{name}}",
|
"cliActivityRanOne": "Used @{{name}}",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "Respuesta copiada",
|
"copiedReply": "Respuesta copiada",
|
||||||
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
|
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
|
||||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||||
|
"activityThought": "Pensamiento completado",
|
||||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||||
"cliActivityRunningOne": "Usando @{{name}}",
|
"cliActivityRunningOne": "Usando @{{name}}",
|
||||||
"cliActivityRanOne": "Usó @{{name}}",
|
"cliActivityRanOne": "Usó @{{name}}",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "Réponse copiée",
|
"copiedReply": "Réponse copiée",
|
||||||
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
|
||||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||||
|
"activityThought": "Réflexion terminée",
|
||||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||||
"cliActivityRunningOne": "Utilisation de @{{name}}",
|
"cliActivityRunningOne": "Utilisation de @{{name}}",
|
||||||
"cliActivityRanOne": "@{{name}} utilisé",
|
"cliActivityRanOne": "@{{name}} utilisé",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "Balasan disalin",
|
"copiedReply": "Balasan disalin",
|
||||||
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
|
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
|
||||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||||
|
"activityThought": "Selesai berpikir",
|
||||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||||
"cliActivityRunningOne": "Menggunakan @{{name}}",
|
"cliActivityRunningOne": "Menggunakan @{{name}}",
|
||||||
"cliActivityRanOne": "Menggunakan @{{name}} selesai",
|
"cliActivityRanOne": "Menggunakan @{{name}} selesai",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "返信をコピーしました",
|
"copiedReply": "返信をコピーしました",
|
||||||
"turnLatencyTitle": "応答時間(全行程)",
|
"turnLatencyTitle": "応答時間(全行程)",
|
||||||
"activityThinkingFor": "{{duration}}考えています",
|
"activityThinkingFor": "{{duration}}考えています",
|
||||||
|
"activityThought": "思考しました",
|
||||||
"activityThoughtFor": "{{duration}}考えました",
|
"activityThoughtFor": "{{duration}}考えました",
|
||||||
"cliActivityRunningOne": "@{{name}} を使用中",
|
"cliActivityRunningOne": "@{{name}} を使用中",
|
||||||
"cliActivityRanOne": "@{{name}} を使用しました",
|
"cliActivityRanOne": "@{{name}} を使用しました",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "답변이 복사됨",
|
"copiedReply": "답변이 복사됨",
|
||||||
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
||||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||||
|
"activityThought": "생각함",
|
||||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||||
"cliActivityRunningOne": "@{{name}} 사용 중",
|
"cliActivityRunningOne": "@{{name}} 사용 중",
|
||||||
"cliActivityRanOne": "@{{name}} 사용함",
|
"cliActivityRanOne": "@{{name}} 사용함",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "Đã sao chép trả lời",
|
"copiedReply": "Đã sao chép trả lời",
|
||||||
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
|
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
|
||||||
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
||||||
|
"activityThought": "Đã suy nghĩ",
|
||||||
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
||||||
"cliActivityRunningOne": "Đang dùng @{{name}}",
|
"cliActivityRunningOne": "Đang dùng @{{name}}",
|
||||||
"cliActivityRanOne": "Đã dùng @{{name}}",
|
"cliActivityRanOne": "Đã dùng @{{name}}",
|
||||||
|
|||||||
@ -619,6 +619,7 @@
|
|||||||
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||||
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||||
"activityThinkingFor": "思考中 {{duration}}",
|
"activityThinkingFor": "思考中 {{duration}}",
|
||||||
|
"activityThought": "已思考",
|
||||||
"activityThoughtFor": "思考了 {{duration}}",
|
"activityThoughtFor": "思考了 {{duration}}",
|
||||||
"cliActivityRunningOne": "正在使用 @{{name}}",
|
"cliActivityRunningOne": "正在使用 @{{name}}",
|
||||||
"cliActivityRanOne": "已使用 @{{name}}",
|
"cliActivityRanOne": "已使用 @{{name}}",
|
||||||
|
|||||||
@ -623,6 +623,7 @@
|
|||||||
"copiedReply": "已複製回覆",
|
"copiedReply": "已複製回覆",
|
||||||
"turnLatencyTitle": "本輪耗時(端到端)",
|
"turnLatencyTitle": "本輪耗時(端到端)",
|
||||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||||
|
"activityThought": "已思考",
|
||||||
"activityThoughtFor": "已思考 {{duration}}",
|
"activityThoughtFor": "已思考 {{duration}}",
|
||||||
"cliActivityRunningOne": "正在使用 @{{name}}",
|
"cliActivityRunningOne": "正在使用 @{{name}}",
|
||||||
"cliActivityRanOne": "已使用 @{{name}}",
|
"cliActivityRanOne": "已使用 @{{name}}",
|
||||||
|
|||||||
@ -340,6 +340,44 @@ describe("AgentActivityCluster", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses persisted turn latency for completed history instead of replay timestamps", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[{
|
||||||
|
id: "r-history",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
reasoning: "historical thought",
|
||||||
|
createdAt: 1,
|
||||||
|
}]}
|
||||||
|
isTurnStreaming={false}
|
||||||
|
hasBodyBelow
|
||||||
|
turnLatencyMs={12_400}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the duration when completed history has no reliable timing", () => {
|
||||||
|
render(
|
||||||
|
<AgentActivityCluster
|
||||||
|
messages={[{
|
||||||
|
id: "r-old-history",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
reasoning: "old historical thought",
|
||||||
|
createdAt: 1,
|
||||||
|
}]}
|
||||||
|
isTurnStreaming={false}
|
||||||
|
hasBodyBelow
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Thought")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders file edit totals and a compact expanded file list", async () => {
|
it("renders file edit totals and a compact expanded file list", async () => {
|
||||||
const restoreMotion = installReducedMotion();
|
const restoreMotion = installReducedMotion();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -226,6 +226,7 @@ describe("ThreadMessages", () => {
|
|||||||
content: "final answer",
|
content: "final answer",
|
||||||
reasoning: "summarize results",
|
reasoning: "summarize results",
|
||||||
reasoningStreaming: false,
|
reasoningStreaming: false,
|
||||||
|
latencyMs: 9_200,
|
||||||
createdAt: 3,
|
createdAt: 3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -239,6 +240,7 @@ describe("ThreadMessages", () => {
|
|||||||
"t1",
|
"t1",
|
||||||
"a1-reasoning",
|
"a1-reasoning",
|
||||||
]);
|
]);
|
||||||
|
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
|
||||||
expect(units[1]).toMatchObject({
|
expect(units[1]).toMatchObject({
|
||||||
type: "single",
|
type: "single",
|
||||||
message: {
|
message: {
|
||||||
@ -252,9 +254,43 @@ describe("ThreadMessages", () => {
|
|||||||
|
|
||||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||||
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /^thinking$/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Thought for 9s")).toBeInTheDocument();
|
||||||
expect(screen.getByText("final answer")).toBeInTheDocument();
|
expect(screen.getByText("final answer")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes assistant turn latency to the preceding completed activity cluster", () => {
|
||||||
|
const messages: UIMessage[] = [
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
reasoning: "search plan",
|
||||||
|
reasoningStreaming: false,
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
|
content: "web_search()",
|
||||||
|
traces: ["web_search()"],
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "final answer",
|
||||||
|
latencyMs: 14_800,
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Thought for 15s")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows copy only on the last assistant slice before the next user turn", () => {
|
it("shows copy only on the last assistant slice before the next user turn", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user