feat(web): show consult runs as a Lucide timeline with sliced compose
Keep provider thinking on a separate channel so process talk is not billed as the spoken reply (BUG-359). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { AgentActivityStatus } from "@/components/agent-activity-status";
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
|
||||
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
@@ -61,9 +62,11 @@ export function ChatMessageRow({
|
||||
const activityLabel = message.activity?.label
|
||||
?? (message.state === "thinking" ? "正在处理…" : undefined);
|
||||
const hasAnswer = Boolean(message.text.trim());
|
||||
const consultTimeline = message.timeline;
|
||||
const thinkingSections = message.thinkingSections ?? [];
|
||||
const showReport = thinkingSections.length > 0;
|
||||
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
|
||||
const showReport = consultTimeline === undefined && thinkingSections.length > 0;
|
||||
const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
|
||||
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
|
||||
const showSpokenAnswer = !showReport && Boolean(message.text);
|
||||
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
|
||||
|
||||
@@ -100,11 +103,11 @@ export function ChatMessageRow({
|
||||
completedTrail={message.activity?.completedTrail}
|
||||
thinkingText={message.thinkingText}
|
||||
hasAnswer={hasAnswer}
|
||||
showLive={showActivity}
|
||||
showLive={showLiveActivity}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
const spokenAnswer = showSpokenAnswer
|
||||
const spokenAnswer = showSpokenAnswer || (consultTimeline !== undefined && hasAnswer)
|
||||
? (
|
||||
<ChatMessageContent
|
||||
text={message.text}
|
||||
@@ -124,33 +127,48 @@ export function ChatMessageRow({
|
||||
<div className="message-content">
|
||||
<div className="message-bubble">
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
{showReport && (
|
||||
<ConsultationThinkingReport
|
||||
sections={thinkingSections}
|
||||
answer={message.text}
|
||||
live={showActivity && !hasAnswer}
|
||||
liveLabel={activityLabel}
|
||||
liveState={activityState}
|
||||
startedAt={message.activity?.startedAt}
|
||||
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
|
||||
vargaSentence={vargaSentence}
|
||||
consultTimeline !== undefined ? (
|
||||
<div className="consultation-thinking-report">
|
||||
<ConsultationRunTimeline
|
||||
rows={consultTimeline}
|
||||
live={showActivity && message.state !== "settled"}
|
||||
/>
|
||||
)}
|
||||
{stackedThinkingAndAnswer ? (
|
||||
<div className="consultation-thinking-report">
|
||||
{thinkingPanel}
|
||||
{hasAnswer ? (
|
||||
<section className="consultation-report-analysis" aria-label="回复">
|
||||
{spokenAnswer}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{thinkingPanel}
|
||||
{spokenAnswer}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showReport && (
|
||||
<ConsultationThinkingReport
|
||||
sections={thinkingSections}
|
||||
thinkingText={message.thinkingText}
|
||||
answer={message.text}
|
||||
live={showActivity && !hasAnswer}
|
||||
liveLabel={activityLabel}
|
||||
liveState={activityState}
|
||||
startedAt={message.activity?.startedAt}
|
||||
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
)}
|
||||
{stackedThinkingAndAnswer ? (
|
||||
<div className="consultation-thinking-report">
|
||||
{thinkingPanel}
|
||||
<section className="consultation-report-analysis" aria-label="回复">
|
||||
{spokenAnswer}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{thinkingPanel}
|
||||
{spokenAnswer}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : <p>{message.text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
|
||||
|
||||
import type {
|
||||
ConsultationTimelineKind,
|
||||
ConsultationTimelineRow,
|
||||
} from "@/lib/consultation-run-timeline";
|
||||
|
||||
const KIND_ICONS: Record<ConsultationTimelineKind, LucideIcon> = {
|
||||
method: BookOpen,
|
||||
calculate: Layers,
|
||||
think: ListTodo,
|
||||
write: PenLine,
|
||||
};
|
||||
|
||||
export function ConsultationRunTimeline({
|
||||
rows,
|
||||
live = false,
|
||||
}: Readonly<{
|
||||
rows: readonly ConsultationTimelineRow[];
|
||||
live?: boolean;
|
||||
}>) {
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<details
|
||||
key={live ? "live" : "settled"}
|
||||
className="consultation-run-timeline"
|
||||
open={live ? true : undefined}
|
||||
>
|
||||
<summary className="consultation-run-timeline__summary">
|
||||
<span className="consultation-run-timeline__summary-label">
|
||||
{live ? "正在分析" : `已完成 ${rows.length} 步`}
|
||||
</span>
|
||||
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
|
||||
{rows.map((row) => (
|
||||
<TimelineRow key={row.id} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
const KindIcon = KIND_ICONS[row.kind];
|
||||
const expandable = Boolean(
|
||||
(row.queries && row.queries.length > 0)
|
||||
|| (row.sources && row.sources.length > 0)
|
||||
|| row.thinkingText?.trim(),
|
||||
);
|
||||
const marker = (
|
||||
<span
|
||||
className={`agent-thinking-marker${row.status === "live" ? " is-live-marker" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{row.status === "live"
|
||||
? <LoaderCircle className="consultation-run-timeline__spinner" />
|
||||
: <Check />}
|
||||
</span>
|
||||
);
|
||||
const label = (
|
||||
<span className="consultation-run-timeline__label">
|
||||
<KindIcon className="consultation-run-timeline__kind-icon" aria-hidden="true" />
|
||||
<span>{row.label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!expandable) {
|
||||
return (
|
||||
<li
|
||||
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
|
||||
data-kind={row.kind}
|
||||
data-status={row.status}
|
||||
>
|
||||
{marker}
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
|
||||
data-kind={row.kind}
|
||||
data-status={row.status}
|
||||
>
|
||||
<details className="consultation-run-timeline__details">
|
||||
<summary>
|
||||
{marker}
|
||||
{label}
|
||||
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="consultation-run-timeline__body">
|
||||
{row.queries && row.queries.length > 0 ? (
|
||||
<ul className="consultation-run-timeline__queries">
|
||||
{row.queries.map((query) => (
|
||||
<li key={query}>{query}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{row.sources && row.sources.length > 0 ? (
|
||||
<div className="consultation-run-timeline__sources">
|
||||
{row.sources.map((source) => (
|
||||
<span className="consultation-run-timeline__source" key={source}>{source}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{row.thinkingText?.trim() ? (
|
||||
<p className="consultation-run-timeline__thinking">{row.thinkingText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
export function ConsultationThinkingReport({
|
||||
sections,
|
||||
answer,
|
||||
thinkingText,
|
||||
live = false,
|
||||
liveLabel,
|
||||
liveState,
|
||||
@@ -20,6 +21,7 @@ export function ConsultationThinkingReport({
|
||||
}: Readonly<{
|
||||
sections: readonly PublicThinkingSection[];
|
||||
answer: string;
|
||||
thinkingText?: string;
|
||||
live?: boolean;
|
||||
liveLabel?: string;
|
||||
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
|
||||
@@ -39,6 +41,7 @@ export function ConsultationThinkingReport({
|
||||
<div className="consultation-thinking-report">
|
||||
<ThinkingStepTree
|
||||
caption="思考"
|
||||
reasoning={thinkingText}
|
||||
groups={progressed.map((section, index) => ({
|
||||
id: section.id,
|
||||
intent: section.title,
|
||||
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
isPublicRectificationMethod,
|
||||
isPublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import {
|
||||
finalizeRectificationSpokenAndThinking,
|
||||
settleRectificationSpokenAndThinking,
|
||||
} from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import {
|
||||
CHOICE_STOP_MESSAGE,
|
||||
choiceCardUserMessage,
|
||||
@@ -207,7 +210,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
const thinkingText = split.thinking.trim() || undefined;
|
||||
return [{
|
||||
role: "assistant",
|
||||
text: split.spoken || raw,
|
||||
text: split.spoken,
|
||||
...(thinkingText ? { thinkingText } : {}),
|
||||
renderKey: key,
|
||||
state: turn.status === "completed" || failed ? "settled" : "thinking",
|
||||
@@ -461,12 +464,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (typeof event.type !== "string") continue;
|
||||
if (event.type === "answer.delta" && typeof event.text === "string") {
|
||||
raw += event.text;
|
||||
const parsed = parseAgentReply(raw);
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
const parsed = parseAgentReply(settled.spoken);
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
text: parsed.text,
|
||||
state: "streaming",
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
state: parsed.text ? "streaming" : "thinking",
|
||||
activity: nextActivityView(message.activity, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
@@ -475,11 +480,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: message));
|
||||
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
|
||||
thinkingRaw += event.text;
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
const parsed = parseAgentReply(settled.spoken);
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
thinkingText: thinkingRaw,
|
||||
state: raw ? "streaming" : "thinking",
|
||||
text: parsed.text,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
state: parsed.text ? "streaming" : "thinking",
|
||||
}
|
||||
: message));
|
||||
} else if (event.type === "attempt.reset") {
|
||||
@@ -540,7 +548,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseAgentReply(raw);
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
const parsed = parseAgentReply(settled.spoken);
|
||||
const succeeded = completed && !streamFailed && Boolean(parsed.text);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
@@ -548,6 +557,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: false,
|
||||
@@ -555,10 +565,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activity: undefined,
|
||||
}];
|
||||
}
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text || settled.thinking.trim()) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: true,
|
||||
@@ -587,13 +598,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
const parsed = parseAgentReply(raw);
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
const parsed = parseAgentReply(settled.spoken);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (parsed.text) {
|
||||
if (parsed.text || settled.thinking.trim()) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Check } from "lucide-react";
|
||||
import type { OrbState } from "thinking-orbs";
|
||||
@@ -117,6 +117,7 @@ function StepList({
|
||||
export function ThinkingStepTree({
|
||||
caption,
|
||||
intent,
|
||||
reasoning,
|
||||
steps = [],
|
||||
groups,
|
||||
hiddenCount,
|
||||
@@ -129,6 +130,7 @@ export function ThinkingStepTree({
|
||||
}: Readonly<{
|
||||
caption?: string;
|
||||
intent?: string;
|
||||
reasoning?: string;
|
||||
steps?: readonly PublicThinkingStep[];
|
||||
groups?: readonly ThinkingStepGroup[];
|
||||
hiddenCount?: number;
|
||||
@@ -140,6 +142,7 @@ export function ThinkingStepTree({
|
||||
defaultOpen?: boolean;
|
||||
}>) {
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const treeId = useId();
|
||||
const groupItems = groups ?? [{
|
||||
id: "default",
|
||||
intent,
|
||||
@@ -147,21 +150,43 @@ export function ThinkingStepTree({
|
||||
live,
|
||||
}];
|
||||
const showAll = revealAll || hiddenCount === 0;
|
||||
const stagedCount = groupItems.filter((group) => Boolean(group.intent)).length;
|
||||
const body = (
|
||||
<>
|
||||
{groupItems.map((group) => (
|
||||
<div className="consultation-step-tree__group" key={group.id}>
|
||||
{group.intent ? <p className="consultation-step-tree__intent">{group.intent}</p> : null}
|
||||
<StepList
|
||||
steps={group.steps}
|
||||
revealAll={showAll}
|
||||
live={Boolean(group.live)}
|
||||
liveLabel={group.live ? liveLabel : undefined}
|
||||
liveState={liveState}
|
||||
startedAt={group.live ? startedAt : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{groupItems.map((group, index) => {
|
||||
const stageId = `${treeId}-${group.id}-stage`;
|
||||
return (
|
||||
<section
|
||||
className="consultation-step-tree__group"
|
||||
key={group.id}
|
||||
aria-labelledby={group.intent ? stageId : undefined}
|
||||
>
|
||||
{group.intent ? (
|
||||
<h3 className="consultation-step-tree__stage" id={stageId}>
|
||||
{stagedCount > 1 ? (
|
||||
<span className="consultation-step-tree__stage-index" aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="consultation-step-tree__stage-mark" aria-hidden="true" />
|
||||
)}
|
||||
<span className="consultation-step-tree__stage-title">
|
||||
{stagedCount > 1 ? <span className="sr-only">阶段 {index + 1}:</span> : null}
|
||||
{group.intent}
|
||||
</span>
|
||||
</h3>
|
||||
) : null}
|
||||
<StepList
|
||||
steps={group.steps}
|
||||
revealAll={showAll}
|
||||
live={Boolean(group.live)}
|
||||
liveLabel={group.live ? liveLabel : undefined}
|
||||
liveState={liveState}
|
||||
startedAt={group.live ? startedAt : undefined}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -177,7 +202,12 @@ export function ThinkingStepTree({
|
||||
}}
|
||||
>
|
||||
<summary>{caption}</summary>
|
||||
<div className="message-thinking-body">{body}</div>
|
||||
<div className="message-thinking-body">
|
||||
{reasoning?.trim() ? (
|
||||
<div className="consultation-step-tree__reasoning">{reasoning}</div>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user