fix(web): stop consultation thinking from pinching the answer

Disable provider thinking so Flash CoT cannot fill max_tokens, raise the
spoken budget to 16384, emit a server-owned step tree, and continue once
when the body ends on length.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-22 21:19:00 +08:00
parent d2a5f0f5bf
commit 0ca7da997f
26 changed files with 1013 additions and 88 deletions
+17 -2
View File
@@ -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 { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
@@ -60,7 +61,9 @@ export function ChatMessageRow({
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const showThinkingPanel = showActivity || Boolean(message.thinkingText?.trim());
const thinkingSections = message.thinkingSections ?? [];
const showReport = thinkingSections.length > 0;
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
useEntryEffect(() => {
const row = messageRow.current;
@@ -97,6 +100,18 @@ export function ChatMessageRow({
<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}
/>
)}
{showThinkingPanel && (
<AgentActivityStatus
state={activityState}
@@ -108,7 +123,7 @@ export function ChatMessageRow({
showLive={showActivity}
/>
)}
{message.text && (
{!showReport && message.text && (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
@@ -0,0 +1,86 @@
"use client";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ThinkingStepTree } from "@/components/thinking-step-tree";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
REPORT_HEADING,
splitAnswerByHeadings,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
function analysisForSection(
section: PublicThinkingSection,
preamble: string,
slices: Record<string, string>,
isFirst: boolean,
): string {
const own = slices[section.heading] ?? "";
const wrap = section.id === "close" ? (slices[REPORT_HEADING.wrap] ?? "") : "";
const lead = isFirst || section.id === "foundation" || section.id === "answer" || section.id === "window"
? preamble
: "";
return [lead, own, wrap].filter((part) => part.trim()).join("\n\n");
}
export function ConsultationThinkingReport({
sections,
answer,
live = false,
liveLabel,
liveState,
startedAt,
auditRows,
vargaSentence,
}: Readonly<{
sections: readonly PublicThinkingSection[];
answer: string;
live?: boolean;
liveLabel?: string;
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
startedAt?: number;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
}>) {
const progressed = applyThinkingSectionProgress(sections, answer);
const headings = [
...progressed.map((section) => section.heading),
REPORT_HEADING.wrap,
];
const { preamble, slices } = splitAnswerByHeadings(answer, headings);
return (
<div className="consultation-thinking-report">
{progressed.map((section, index) => {
const active = section.steps.some((step) => step.status === "active");
const analysis = analysisForSection(section, preamble, slices, index === 0);
const last = index === progressed.length - 1;
return (
<section className="consultation-report-block" key={section.id}>
<ThinkingStepTree
caption="思考"
intent={section.title}
steps={section.steps}
live={live && active}
liveLabel={live && active ? liveLabel : undefined}
liveState={liveState}
startedAt={live && active ? startedAt : undefined}
defaultOpen={active || (live && index === 0 && !answer.trim())}
/>
{analysis.trim() ? (
<div className="consultation-report-analysis">
<h3 className="consultation-report-analysis__label"></h3>
<ChatMessageContent
text={analysis}
auditRows={last ? auditRows : undefined}
vargaSentence={last ? vargaSentence : null}
/>
</div>
) : null}
</section>
);
})}
</div>
);
}
@@ -0,0 +1,132 @@
"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 { activityElapsedLabel } from "@/lib/chat-message-view";
import {
visibleThinkingSteps,
type PublicThinkingStep,
type ThinkingStepStatus,
} from "@/lib/consultation-thinking-plan";
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);
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 StepMarker({
status,
liveState,
}: Readonly<{
status: ThinkingStepStatus | "live";
liveState?: OrbState;
}>) {
if (status === "live" || status === "active") {
return (
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={liveState ?? "working"} size={20} />
</span>
);
}
if (status === "done") {
return (
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
);
}
return <span className="agent-thinking-marker is-pending" aria-hidden="true" />;
}
export function ThinkingStepTree({
caption,
intent,
steps,
hiddenCount,
live = false,
liveLabel,
liveState = "working",
startedAt,
defaultOpen = false,
}: Readonly<{
caption?: string;
intent?: string;
steps: readonly PublicThinkingStep[];
hiddenCount?: number;
live?: boolean;
liveLabel?: string;
liveState?: OrbState;
startedAt?: number;
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const visible = hiddenCount === undefined ? visibleThinkingSteps(steps) : { visible: steps, hiddenCount };
const body = (
<>
{intent ? <p className="consultation-step-tree__intent">{intent}</p> : null}
<ol className="agent-thinking-timeline">
{visible.visible.map((step) => (
<li
className={`agent-thinking-step is-${step.status}`}
key={step.id}
>
<StepMarker status={step.status} liveState={liveState} />
<span>{step.label}</span>
</li>
))}
{visible.hiddenCount > 0 ? (
<li className="agent-thinking-step is-more">
<span className="agent-thinking-marker is-pending" aria-hidden="true" />
<span> {visible.hiddenCount} </span>
</li>
) : null}
{live && liveLabel ? (
<li className="agent-thinking-step is-live">
<StepMarker status="live" liveState={liveState} />
<span className="agent-activity-status__live" role="status">
<span key={liveLabel} className="agent-activity-status__text">{liveLabel}</span>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</span>
</li>
) : null}
</ol>
</>
);
if (!caption) return <div className="consultation-step-tree">{body}</div>;
const open = userOpen ?? defaultOpen;
return (
<details
className="message-thinking consultation-step-tree"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary>{caption}</summary>
<div className="message-thinking-body">{body}</div>
</details>
);
}