Xuehao Xu c24fd1e66f
fix(frontend): keep clarification text outside execution steps (#5508)
* fix(frontend): keep clarification text outside execution steps

* refactor(frontend): share clarification run boundary detection
2026-09-18 09:35:32 +08:00

1115 lines
32 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
ChevronUp,
CoinsIcon,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
MessageCircleQuestionMarkIcon,
MessageSquareTextIcon,
MonitorIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtSearchResult,
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { Button } from "@/components/ui/button";
import {
buildWriteFileArtifactURL,
resolveArtifactURL,
} from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { formatTokenCount } from "@/core/messages/usage";
import type { TokenDebugStep } from "@/core/messages/usage-model";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
extractTextFromMessage,
} from "@/core/messages/utils";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { useMaybeBrowserView } from "../browser-view";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content";
import { ToolCallDetails } from "./tool-call-details";
interface MessageGroupProps {
className?: string;
messages: Message[];
isLoading?: boolean;
deferBrowserPreviews?: boolean;
tokenDebugSteps?: TokenDebugStep[];
showTokenDebugSummaries?: boolean;
threadId?: string;
}
function MessageGroupComponent({
className,
messages,
isLoading = false,
deferBrowserPreviews = false,
tokenDebugSteps = [],
showTokenDebugSummaries = false,
threadId,
}: MessageGroupProps) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const allSteps = useMemo(() => convertToSteps(messages), [messages]);
// Keep the original messages and tool associations intact. Only the display
// of clarification context moves outside the execution disclosure (#5503).
const clarificationTextSteps = useMemo(
() =>
allSteps.filter(
(step): step is CoTAssistantTextStep =>
step.type === "assistantText" && step.isClarificationContext === true,
),
[allSteps],
);
const steps = useMemo(
() =>
allSteps.filter(
(step) => step.type !== "assistantText" || !step.isClarificationContext,
),
[allSteps],
);
const stepIndexByStep = useMemo(
() => new Map(steps.map((step, index) => [step, index] as const)),
[steps],
);
const debugStepByMessageId = useMemo(
() =>
new Map(
tokenDebugSteps.map(
(step) => [step.messageId || step.id, step] as const,
),
),
[tokenDebugSteps],
);
const toolCallCountByMessageId = useMemo(() => {
const counts = new Map<string, number>();
for (const step of steps) {
if (step.type !== "toolCall" || !step.messageId) {
continue;
}
counts.set(step.messageId, (counts.get(step.messageId) ?? 0) + 1);
}
return counts;
}, [steps]);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1];
}, [steps]);
const aboveLastToolCallSteps = useMemo(() => {
if (lastToolCallStep) {
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps.slice(0, index);
}
return [];
}, [lastToolCallStep, stepIndexByStep, steps]);
const afterLastToolCallAssistantTextSteps = useMemo(() => {
if (!lastToolCallStep) {
return [];
}
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps
.slice(index + 1)
.filter((step) => step.type === "assistantText");
}, [lastToolCallStep, stepIndexByStep, steps]);
const collapsibleAboveLastToolCallSteps = useMemo(
() =>
aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"),
[aboveLastToolCallSteps],
);
const lastReasoningStep = useMemo(() => {
if (lastToolCallStep) {
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps.slice(index + 1).find((step) => step.type === "reasoning");
} else {
const filteredSteps = steps.filter((step) => step.type === "reasoning");
return filteredSteps[filteredSteps.length - 1];
}
}, [lastToolCallStep, stepIndexByStep, steps]);
// Assistant text emitted after the trailing reasoning is the answer that
// reasoning produced, so it renders below the reasoning disclosure. The
// settled assistant bubble always paints reasoning above content, and the
// streaming processing group has to agree or the two swap places the moment
// the turn ends (#4576). Text emitted before that reasoning keeps its
// earlier position.
const belowLastReasoningAssistantTextSteps = useMemo(() => {
if (!lastReasoningStep) {
return [];
}
const index = stepIndexByStep.get(lastReasoningStep) ?? -1;
return steps
.slice(index + 1)
.filter((step) => step.type === "assistantText");
}, [lastReasoningStep, stepIndexByStep, steps]);
const belowLastReasoningSteps = useMemo(
() => new Set<CoTStep>(belowLastReasoningAssistantTextSteps),
[belowLastReasoningAssistantTextSteps],
);
const firstEligibleDebugSummaryStepIndexByMessageId = useMemo(() => {
const firstIndices = new Map<string, number>();
if (!showTokenDebugSummaries) {
return firstIndices;
}
for (const [index, step] of steps.entries()) {
const messageId = step.messageId;
if (!messageId || firstIndices.has(messageId)) {
continue;
}
const debugStep = debugStepByMessageId.get(messageId);
if (!debugStep) {
continue;
}
const toolCallCount = toolCallCountByMessageId.get(messageId) ?? 0;
if (!debugStep.sharedAttribution && toolCallCount > 0) {
continue;
}
if (
!debugStep.sharedAttribution &&
toolCallCount === 0 &&
debugStep.label === t.common.thinking &&
debugStep.secondaryLabels.length === 0
) {
continue;
}
firstIndices.set(messageId, index);
}
return firstIndices;
}, [
debugStepByMessageId,
showTokenDebugSummaries,
steps,
t.common.thinking,
toolCallCountByMessageId,
]);
const renderDebugSummary = (
messageId: string | undefined,
stepIndex: number,
) => {
if (!showTokenDebugSummaries || !messageId) {
return null;
}
const debugStep = debugStepByMessageId.get(messageId);
if (!debugStep) {
return null;
}
if (
firstEligibleDebugSummaryStepIndexByMessageId.get(messageId) !== stepIndex
) {
return null;
}
return (
<ChainOfThoughtStep
key={`token-debug-${messageId}`}
icon={CoinsIcon}
label={
<DebugStepLabel
label={debugStep.label}
token={formatDebugToken(debugStep, t)}
/>
}
description={
debugStep.sharedAttribution
? t.tokenUsage.sharedAttribution
: undefined
}
>
{debugStep.secondaryLabels.length > 0 && (
<ChainOfThoughtSearchResults>
{debugStep.secondaryLabels.map((label, index) => (
<ChainOfThoughtSearchResult
key={`${debugStep.id}-${index}-${label}`}
>
{label}
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
};
const renderToolCall = (
step: CoTToolCallStep,
options?: { isLast?: boolean },
) => {
const debugStep =
showTokenDebugSummaries && step.messageId
? debugStepByMessageId.get(step.messageId)
: undefined;
return (
<ToolCall
key={step.id}
{...step}
threadId={threadId}
isLast={options?.isLast}
isLoading={isLoading}
deferBrowserPreview={deferBrowserPreviews}
showDetails={showTokenDebugSummaries}
tokenDebugStep={
debugStep && !debugStep.sharedAttribution ? debugStep : undefined
}
/>
);
};
const renderAssistantText = (step: CoTAssistantTextStep) => (
<ChainOfThoughtStep
key={step.id}
icon={MessageSquareTextIcon}
label={<MarkdownContent content={step.content} isLoading={isLoading} />}
></ChainOfThoughtStep>
);
const renderStep = (step: CoTStep) => {
const stepIndex = stepIndexByStep.get(step) ?? -1;
if (step.type === "assistantText") {
return [
renderDebugSummary(step.messageId, stepIndex),
renderAssistantText(step),
];
}
if (step.type === "reasoning") {
return [
renderDebugSummary(step.messageId, stepIndex),
<ChainOfThoughtStep
key={step.id}
label={
<MarkdownContent
content={step.reasoning ?? ""}
isLoading={isLoading}
/>
}
></ChainOfThoughtStep>,
];
}
return [
renderDebugSummary(step.messageId, stepIndex),
renderToolCall(step),
];
};
const lastReasoningDebugStep =
showTokenDebugSummaries && lastReasoningStep?.messageId
? debugStepByMessageId.get(lastReasoningStep.messageId)
: undefined;
const processingPanel = (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
open={true}
>
{collapsibleAboveLastToolCallSteps.length > 0 && (
<Button
key="above"
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowAbove(!showAbove)}
>
<ChainOfThoughtStep
label={
<span className="opacity-60">
{showAbove
? t.toolCalls.lessSteps
: t.toolCalls.moreSteps(
collapsibleAboveLastToolCallSteps.length,
)}
</span>
}
icon={
<ChevronUp
className={cn(
"size-4 opacity-60 transition-transform duration-200",
showAbove ? "rotate-180" : "",
)}
/>
}
></ChainOfThoughtStep>
</Button>
)}
{(lastToolCallStep ??
steps.some(
(step) =>
step.type === "assistantText" && !belowLastReasoningSteps.has(step),
)) && (
<ChainOfThoughtContent className="px-4 pb-2">
{(lastToolCallStep
? showAbove
? aboveLastToolCallSteps
: aboveLastToolCallSteps.filter(
(step) => step.type === "assistantText",
)
: steps.filter(
(step) =>
step.type === "assistantText" &&
!belowLastReasoningSteps.has(step),
)
).flatMap(renderStep)}
{lastToolCallStep && (
<>
{renderDebugSummary(
lastToolCallStep.messageId,
stepIndexByStep.get(lastToolCallStep) ?? -1,
)}
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
{renderToolCall(lastToolCallStep, { isLast: true })}
</FlipDisplay>
{afterLastToolCallAssistantTextSteps
.filter((step) => !belowLastReasoningSteps.has(step))
.flatMap(renderStep)}
</>
)}
</ChainOfThoughtContent>
)}
{lastReasoningStep && (
<>
{renderDebugSummary(
lastReasoningStep.messageId,
stepIndexByStep.get(lastReasoningStep) ?? -1,
)}
<Button
key={lastReasoningStep.id}
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowLastThinking(!showLastThinking)}
>
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
className="font-normal"
label={
<DebugStepLabel
label={t.common.thinking}
token={shouldInlineThinkingToken({
debugStep: lastReasoningDebugStep,
toolCallCount: lastReasoningStep.messageId
? (toolCallCountByMessageId.get(
lastReasoningStep.messageId,
) ?? 0)
: 0,
enabled: showTokenDebugSummaries,
thinkingLabel: t.common.thinking,
t,
})}
/>
}
icon={LightbulbIcon}
></ChainOfThoughtStep>
<div>
<ChevronUp
className={cn(
"text-muted-foreground size-4",
showLastThinking ? "" : "rotate-180",
)}
/>
</div>
</div>
</Button>
{showLastThinking && (
<ChainOfThoughtContent className="px-4 pb-2">
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MarkdownContent
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
/>
}
></ChainOfThoughtStep>
</ChainOfThoughtContent>
)}
{belowLastReasoningAssistantTextSteps.length > 0 && (
<ChainOfThoughtContent className="px-4 pb-2">
{belowLastReasoningAssistantTextSteps.flatMap(renderStep)}
</ChainOfThoughtContent>
)}
</>
)}
</ChainOfThought>
);
return (
<>
{processingPanel}
{clarificationTextSteps.map((step) => (
<div key={step.id} className="w-full">
<MarkdownContent content={step.content} isLoading={isLoading} />
</div>
))}
</>
);
}
export const MessageGroup = memo(
MessageGroupComponent,
areMessageGroupPropsEqual,
);
MessageGroup.displayName = "MessageGroup";
function areMessageGroupPropsEqual(
previous: MessageGroupProps,
next: MessageGroupProps,
): boolean {
if (next.isLoading) {
return false;
}
return (
previous.className === next.className &&
Boolean(previous.isLoading) === Boolean(next.isLoading) &&
Boolean(previous.deferBrowserPreviews) ===
Boolean(next.deferBrowserPreviews) &&
Boolean(previous.showTokenDebugSummaries) ===
Boolean(next.showTokenDebugSummaries) &&
previous.threadId === next.threadId &&
sameReferences(previous.messages, next.messages) &&
sameReferences(previous.tokenDebugSteps, next.tokenDebugSteps)
);
}
function sameReferences<T>(
previous: readonly T[] | undefined,
next: readonly T[] | undefined,
): boolean {
if (previous === next) {
return true;
}
const previousItems = previous ?? [];
const nextItems = next ?? [];
return (
previousItems.length === nextItems.length &&
previousItems.every((item, index) => item === nextItems[index])
);
}
function formatDebugToken(
debugStep: TokenDebugStep,
t: ReturnType<typeof useI18n>["t"],
) {
return debugStep.usage
? `${formatTokenCount(debugStep.usage.totalTokens)} ${t.tokenUsage.label}`
: t.tokenUsage.unavailableShort;
}
function shouldInlineThinkingToken({
debugStep,
toolCallCount,
enabled,
thinkingLabel,
t,
}: {
debugStep?: TokenDebugStep;
toolCallCount: number;
enabled: boolean;
thinkingLabel: string;
t: ReturnType<typeof useI18n>["t"];
}) {
if (
!enabled ||
!debugStep ||
debugStep.sharedAttribution ||
toolCallCount > 0 ||
debugStep.label !== thinkingLabel
) {
return null;
}
return formatDebugToken(debugStep, t);
}
function DebugStepLabel({
label,
token,
}: {
label: React.ReactNode;
token?: string | null;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">{label}</div>
{token ? (
<div className="text-muted-foreground shrink-0 font-mono text-[11px]">
{token}
</div>
) : null}
</div>
);
}
function browserToolLabel(
name: string,
args: Record<string, unknown>,
t: ReturnType<typeof useI18n>["t"],
): string {
switch (name) {
case "browser_navigate":
return typeof args.url === "string"
? t.toolCalls.browserNavigate(args.url)
: t.toolCalls.browserNavigateGeneric;
case "browser_click":
return t.toolCalls.browserClick;
case "browser_type":
return t.toolCalls.browserType;
case "browser_snapshot":
return t.toolCalls.browserSnapshot;
case "browser_get_text":
return t.toolCalls.browserGetText;
case "browser_back":
return t.toolCalls.browserBack;
case "browser_screenshot":
return t.toolCalls.browserScreenshot;
case "browser_close":
return t.toolCalls.browserClose;
default:
return t.toolCalls.useTool(name);
}
}
// Shared routing for result conversion and specialized rendering.
function getToolCallKind(name: string) {
if (name.startsWith("browser_")) return "browser";
switch (name) {
case "web_search":
case "image_search":
case "web_fetch":
case "ls":
case "read_file":
case "write_file":
case "str_replace":
case "bash":
case "ask_clarification":
case "write_todos":
return name;
default:
return "generic";
}
}
function ToolCall({
id,
messageId,
name,
args,
result,
isLast = false,
isLoading = false,
deferBrowserPreview = false,
tokenDebugStep,
showDetails = false,
resultMessage,
browserView,
threadId,
}: {
id?: string;
messageId?: string;
name: string;
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
deferBrowserPreview?: boolean;
tokenDebugStep?: TokenDebugStep;
showDetails?: boolean;
resultMessage?: Extract<Message, { type: "tool" }>;
browserView?: BrowserViewMeta;
threadId?: string;
}) {
const { t } = useI18n();
const kind = getToolCallKind(name);
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
const browserViewPanel = useMaybeBrowserView();
const tokenLabel = tokenDebugStep
? formatDebugToken(tokenDebugStep, t)
: null;
const resolveLabel = (fallback: React.ReactNode) =>
tokenDebugStep ? (
<DebugStepLabel label={tokenDebugStep.label} token={tokenLabel} />
) : (
fallback
);
const writeFilePath =
(kind === "write_file" || kind === "str_replace") &&
typeof args.path === "string"
? args.path
: undefined;
const writeFileArtifactUrl = writeFilePath
? buildWriteFileArtifactURL({
filepath: writeFilePath,
messageId,
toolCallId: id,
})
: null;
const autoOpenArtifactUrl =
isLoading &&
isLast &&
autoOpen &&
autoSelect &&
writeFileArtifactUrl &&
!result
? writeFileArtifactUrl
: null;
useEffect(() => {
if (!autoOpenArtifactUrl || selectedArtifact === autoOpenArtifactUrl) {
return;
}
const timeout = window.setTimeout(() => {
select(autoOpenArtifactUrl, true);
setOpen(true);
}, 100);
return () => window.clearTimeout(timeout);
}, [autoOpenArtifactUrl, select, selectedArtifact, setOpen]);
if (kind === "browser") {
const shot = browserView?.screenshot;
const previewUrl =
shot && threadId ? resolveArtifactURL(shot, threadId) : undefined;
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(browserToolLabel(name, args, t))}
icon={MonitorIcon}
>
{previewUrl && !deferBrowserPreview && (
<button
type="button"
className="border-border mt-1 block w-full max-w-md cursor-pointer overflow-hidden rounded-lg border"
onClick={() => {
if (!shot) {
return;
}
if (browserViewPanel) {
browserViewPanel.pushFrame({
screenshot: shot,
url: browserView?.url,
title: browserView?.title,
});
browserViewPanel.openPanel();
} else {
select(shot);
setOpen(true);
}
}}
>
<img
className="w-full object-contain"
src={previewUrl}
alt={browserView?.title ?? "browser view"}
loading="lazy"
decoding="async"
/>
{browserView?.url && (
<div className="text-muted-foreground bg-muted/40 truncate px-2 py-1 text-left text-[11px]">
{browserView.url}
</div>
)}
</button>
)}
</ChainOfThoughtStep>
);
} else if (kind === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") {
label = t.toolCalls.searchOnWebFor(args.query);
}
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(label)}
icon={SearchIcon}
>
{Array.isArray(result) && (
<ChainOfThoughtSearchResults>
{result.map((item) => (
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noopener noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (kind === "image_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedImages;
if (typeof args.query === "string") {
label = t.toolCalls.searchForRelatedImagesFor(args.query);
}
const results = (
result as {
results: {
source_url: string;
thumbnail_url: string;
image_url: string;
title: string;
}[];
}
)?.results;
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(label)}
icon={SearchIcon}
>
{Array.isArray(results) && (
<ChainOfThoughtSearchResults>
{Array.isArray(results) &&
results.map((item) => (
<Tooltip key={item.image_url} content={item.title}>
<a
className="size-24 overflow-hidden rounded-lg object-cover"
href={item.source_url}
target="_blank"
rel="noopener noreferrer"
>
<div className="bg-accent size-24">
<img
className="size-full object-cover"
src={item.thumbnail_url}
alt={item.title}
width={100}
height={100}
/>
</div>
</a>
</Tooltip>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (kind === "web_fetch") {
const url = (args as { url: string })?.url;
let title = url;
if (typeof result === "string") {
const potentialTitle = extractTitleFromMarkdown(result);
if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") {
title = potentialTitle;
}
}
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(t.toolCalls.viewWebPage)}
icon={GlobeIcon}
>
<ChainOfThoughtSearchResult>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer"
>
{title}
</a>
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else if (kind === "ls") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.listFolder;
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(description)}
icon={FolderOpenIcon}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (kind === "read_file") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.readFile;
}
const { path } = args as { path: string; content: string };
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(description)}
icon={BookOpenTextIcon}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (kind === "write_file" || kind === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
return (
<ChainOfThoughtStep
key={id}
className={writeFileArtifactUrl ? "cursor-pointer" : undefined}
label={resolveLabel(description)}
icon={NotebookPenIcon}
onClick={() => {
if (!writeFileArtifactUrl) {
return;
}
select(writeFileArtifactUrl);
setOpen(true);
}}
>
{writeFilePath && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{writeFilePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (kind === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(t.toolCalls.executeCommand)}
icon={SquareTerminalIcon}
/>
);
}
const command: string | undefined = (args as { command: string })?.command;
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(description)}
icon={SquareTerminalIcon}
>
{command && (
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
/>
)}
</ChainOfThoughtStep>
);
} else if (kind === "ask_clarification") {
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(t.toolCalls.needYourHelp)}
icon={MessageCircleQuestionMarkIcon}
></ChainOfThoughtStep>
);
} else if (kind === "write_todos") {
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(t.toolCalls.writeTodos)}
icon={ListTodoIcon}
></ChainOfThoughtStep>
);
} else {
const description: string | undefined = (args as { description: string })
?.description;
return (
<ChainOfThoughtStep
key={id}
label={resolveLabel(description ?? t.toolCalls.useTool(name))}
icon={WrenchIcon}
>
{showDetails && (
<ToolCallDetails
name={name}
callId={id}
args={args}
resultMessage={resultMessage}
/>
)}
</ChainOfThoughtStep>
);
}
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
reasoning: string | null;
}
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
resultMessage?: Extract<Message, { type: "tool" }>;
browserView?: BrowserViewMeta;
}
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
isClarificationContext?: boolean;
content: string;
}
type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep;
interface BrowserViewMeta {
screenshot: string;
url?: string;
title?: string;
}
function indexToolCallData(messages: Message[]) {
const toolCallResults = new Map<string, string>();
const browserViews = new Map<string, BrowserViewMeta>();
const resultMessages = new Map<string, Extract<Message, { type: "tool" }>>();
for (const message of messages) {
if (message.type !== "tool" || !message.tool_call_id) {
continue;
}
const toolCallId = message.tool_call_id;
if (!resultMessages.has(toolCallId))
resultMessages.set(toolCallId, message);
if (!toolCallResults.has(toolCallId)) {
const result = extractTextFromMessage(message);
if (result) {
toolCallResults.set(toolCallId, result);
resultMessages.set(toolCallId, message);
}
}
if (!browserViews.has(toolCallId)) {
const browserView = (
message.additional_kwargs as
| { browser_view?: BrowserViewMeta }
| undefined
)?.browser_view;
if (browserView && typeof browserView.screenshot === "string") {
browserViews.set(toolCallId, browserView);
}
}
}
return { browserViews, toolCallResults, resultMessages };
}
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
const { browserViews, toolCallResults, resultMessages } =
indexToolCallData(messages);
for (const [messageIndex, message] of messages.entries()) {
if (message.type === "ai") {
// Reasoning precedes the answer text it produced, so it is pushed first:
// step order is what the group renders in, and a message carrying both
// would otherwise paint its answer above its own thinking (#4576).
const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) {
const step: CoTReasoningStep = {
id: message.id,
messageId: message.id,
type: "reasoning",
reasoning,
};
steps.push(step);
}
const content = extractContentFromMessage(message);
if (content) {
steps.push({
id: `${message.id ?? `ai-${messageIndex}`}-content`,
messageId: message.id,
type: "assistantText",
content,
isClarificationContext: message.tool_calls?.some(
(toolCall) => toolCall.name === "ask_clarification",
),
});
}
for (const tool_call of message.tool_calls ?? []) {
if (tool_call.name === "task") {
continue;
}
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = toolCallResults.get(toolCallId);
step.resultMessage = resultMessages.get(toolCallId);
// Generic details preserve received text; specialized tools retain their parsing.
if (toolCallResult && getToolCallKind(tool_call.name) !== "generic") {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
step.result = toolCallResult;
}
}
step.browserView = browserViews.get(toolCallId);
}
steps.push(step);
}
}
}
return steps;
}