fix(web): 会话进度行时长从本轮开始算到结论正文,不再显示 0 秒(FIX-9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
41a39f3cc8
commit
c5987a9db3
3 files changed
+74
-12
No files matched your search
@@ -54,6 +54,7 @@ import {
|
|||||||
activitySummary,
|
activitySummary,
|
||||||
authDenial,
|
authDenial,
|
||||||
buildConversation,
|
buildConversation,
|
||||||
|
type ConvBlock,
|
||||||
callCounts,
|
callCounts,
|
||||||
callCountText,
|
callCountText,
|
||||||
capabilityTitle,
|
capabilityTitle,
|
||||||
@@ -261,10 +262,16 @@ export function StepSummary({
|
|||||||
activity,
|
activity,
|
||||||
running,
|
running,
|
||||||
auditByCall,
|
auditByCall,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
}: {
|
}: {
|
||||||
activity: ActivityItem[];
|
activity: ActivityItem[];
|
||||||
running: boolean;
|
running: boolean;
|
||||||
auditByCall?: Map<string, InvocationAudit>;
|
auditByCall?: Map<string, InvocationAudit>;
|
||||||
|
/** 这一轮开始的时间(会话开始 / 用户消息);只给回合里的第一段 */
|
||||||
|
since?: string | null;
|
||||||
|
/** 结论正文开始的时间 */
|
||||||
|
until?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -273,7 +280,7 @@ export function StepSummary({
|
|||||||
const done = activitySummary(calls.filter((c) => c.status !== "running"));
|
const done = activitySummary(calls.filter((c) => c.status !== "running"));
|
||||||
const denied = calls.filter(isDenied).length;
|
const denied = calls.filter(isDenied).length;
|
||||||
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
|
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
|
||||||
const span = activitySpan(activity);
|
const span = activitySpan(activity, { since, until });
|
||||||
const counted = running ? "" : callCountText(callCounts(calls), "调用");
|
const counted = running ? "" : callCountText(callCounts(calls), "调用");
|
||||||
const thinking = activity.some((a) => a.kind === "thinking");
|
const thinking = activity.some((a) => a.kind === "thinking");
|
||||||
const head = current
|
const head = current
|
||||||
@@ -510,12 +517,12 @@ export function AgentConversation({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 px-1 py-2">
|
<div className="flex flex-col gap-5 px-1 py-2">
|
||||||
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
|
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
|
||||||
{blocks.map((b) =>
|
{blocks.map((b, bi) =>
|
||||||
b.kind === "user" ? (
|
b.kind === "user" ? (
|
||||||
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
|
<UserMessage key={b.key} text={b.text} note={b.pending ? "已送达,等待智能体读取" : undefined} animateIn />
|
||||||
) : (
|
) : (
|
||||||
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
|
<section key={b.key} aria-label="智能体回合" className="flex w-full min-w-0 flex-col items-start gap-2">
|
||||||
{b.parts.map((p) => {
|
{b.parts.map((p, pi) => {
|
||||||
const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key);
|
const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key);
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
case "segment": {
|
case "segment": {
|
||||||
@@ -525,7 +532,13 @@ export function AgentConversation({
|
|||||||
return (
|
return (
|
||||||
<div key={p.key} className="flex w-full min-w-0 flex-col gap-1.5">
|
<div key={p.key} className="flex w-full min-w-0 flex-col gap-1.5">
|
||||||
{p.activity.length ? (
|
{p.activity.length ? (
|
||||||
<StepSummary activity={p.activity} running={running || thinkingNow} auditByCall={auditByCall} />
|
<StepSummary
|
||||||
|
activity={p.activity}
|
||||||
|
running={running || thinkingNow}
|
||||||
|
auditByCall={auditByCall}
|
||||||
|
since={pi === 0 ? turnSince(blocks, bi, run) : null}
|
||||||
|
until={p.body[0]?.at ?? null}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{p.body.map((t) => {
|
{p.body.map((t) => {
|
||||||
const streaming = streamingText && t.key === lastKey;
|
const streaming = streamingText && t.key === lastKey;
|
||||||
@@ -586,6 +599,13 @@ export function AgentConversation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 回合开始的时间:前一条用户消息;第一轮是会话开始 */
|
||||||
|
function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null {
|
||||||
|
const prev = blocks[index - 1];
|
||||||
|
if (prev?.kind === "user") return prev.at;
|
||||||
|
return index === 0 ? run.startedAt : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 实际结果 ──
|
// ── 实际结果 ──
|
||||||
|
|
||||||
type ResultFact = { text: string; to?: string; tone?: "warning" };
|
type ResultFact = { text: string; to?: string; tone?: "warning" };
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import {
|
|||||||
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
|
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
|
||||||
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
|
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
|
||||||
import { arrangeHistory } from "./agent-session-list.tsx";
|
import { arrangeHistory } from "./agent-session-list.tsx";
|
||||||
import { type ActivityItem, activitySummary, buildConversation, deniedReason } from "./conversation-model.ts";
|
import {
|
||||||
|
type ActivityItem,
|
||||||
|
activitySpan,
|
||||||
|
activitySummary,
|
||||||
|
buildConversation,
|
||||||
|
deniedReason,
|
||||||
|
} from "./conversation-model.ts";
|
||||||
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
|
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
|
||||||
import {
|
import {
|
||||||
type AgentStreamState,
|
type AgentStreamState,
|
||||||
@@ -539,3 +545,29 @@ describe("实际结果行(FIX-9):只列系统核实的事实", () => {
|
|||||||
expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]);
|
expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("进度行时长(FIX-9):从这一轮开始算到结论正文", () => {
|
||||||
|
const quick: ActivityItem[] = [
|
||||||
|
{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list", startedAt: at(6), endedAt: at(6) }) },
|
||||||
|
];
|
||||||
|
it("只有一个瞬时工具调用时不再是 0 秒", () => {
|
||||||
|
expect(activitySpan(quick)).toBe(0);
|
||||||
|
expect(activitySpan(quick, { since: at(0), until: at(7) })).toBe(7000);
|
||||||
|
const t = text(render(createElement(StepSummary, { activity: quick, running: false, since: at(0), until: at(7) })));
|
||||||
|
expect(t).toContain("· 7 秒");
|
||||||
|
});
|
||||||
|
it("没有工具调用、只有思考时也有时长;没有步骤为 null", () => {
|
||||||
|
expect(activitySpan([{ kind: "thinking", key: "k", text: "想", at: at(2) }], { since: at(0) })).toBe(2000);
|
||||||
|
expect(activitySpan([])).toBeNull();
|
||||||
|
});
|
||||||
|
it("整段会话:第一轮从会话开始算", () => {
|
||||||
|
const events = [
|
||||||
|
ev(0, { type: "run_started", goal: "g", resumed: false }),
|
||||||
|
...tool(5, "t1", "site.list", '{"items":[]}'),
|
||||||
|
ev(7, { type: "text_delta", delta: "都正常。" }),
|
||||||
|
ev(8, { type: "run_completed", finalText: "", usage: {} }),
|
||||||
|
];
|
||||||
|
const t = text(render(createElement(AgentConversation, { run: RUN, live: liveOf(events) })));
|
||||||
|
expect(t).toMatch(/· 7 秒/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -291,13 +291,23 @@ export function buildConversation(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一段进度的起止时间(毫秒);没有工具调用为 null */
|
/**
|
||||||
export function activitySpan(activity: ActivityItem[]): number | null {
|
* 一段进度的时长(毫秒):从这一轮开始(since,如会话开始 / 用户消息的时间)或首个步骤,
|
||||||
const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : []));
|
* 到最后一个步骤结束或结论正文开始(until)。只算工具起止会漏掉模型思考的时间(整轮 7 秒显示成 0 秒)。
|
||||||
if (!calls.length) return null;
|
* 没有任何步骤为 null。
|
||||||
const start = Math.min(...calls.map((c) => Date.parse(c.startedAt)));
|
*/
|
||||||
const end = Math.max(...calls.map((c) => Date.parse(c.endedAt ?? c.startedAt)));
|
export function activitySpan(
|
||||||
return Number.isFinite(start) && Number.isFinite(end) ? end - start : null;
|
activity: ActivityItem[],
|
||||||
|
bounds: { since?: string | null; until?: string | null } = {},
|
||||||
|
): number | null {
|
||||||
|
if (!activity.length) return null;
|
||||||
|
const starts = activity.map((a) => Date.parse(a.kind === "tool" ? a.call.startedAt : a.at));
|
||||||
|
const ends = activity.map((a) => Date.parse(a.kind === "tool" ? (a.call.endedAt ?? a.call.startedAt) : a.at));
|
||||||
|
const since = bounds.since ? Date.parse(bounds.since) : Number.NaN;
|
||||||
|
const until = bounds.until ? Date.parse(bounds.until) : Number.NaN;
|
||||||
|
const start = Math.min(...starts.filter(Number.isFinite), ...(Number.isFinite(since) ? [since] : []));
|
||||||
|
const end = Math.max(...ends.filter(Number.isFinite), ...(Number.isFinite(until) ? [until] : []));
|
||||||
|
return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */
|
/** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */
|
||||||
|
|||||||
Reference in new issue
Block a user