fix(web): split rectification process talk from the spoken reply

Keep provider thinking off, classify CoT as 思考, and stop remounted sessions from firing a second opening.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 10:14:35 +08:00
parent f699c35f56
commit f03a2706b4
13 changed files with 376 additions and 32 deletions
@@ -59,7 +59,7 @@ function MessageThinkingTrace({
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
+37 -17
View File
@@ -64,6 +64,8 @@ export function ChatMessageRow({
const thinkingSections = message.thinkingSections ?? [];
const showReport = thinkingSections.length > 0;
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
useEntryEffect(() => {
const row = messageRow.current;
@@ -89,6 +91,29 @@ export function ChatMessageRow({
return () => motion.revert();
}, [message.role]);
const thinkingPanel = showThinkingPanel
? (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
/>
)
: null;
const spokenAnswer = showSpokenAnswer
? (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
)
: null;
return (
<article
ref={messageRow}
@@ -112,23 +137,18 @@ export function ChatMessageRow({
vargaSentence={vargaSentence}
/>
)}
{showThinkingPanel && (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
/>
)}
{!showReport && message.text && (
<ChatMessageContent
text={message.text}
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>}
@@ -34,6 +34,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onStartConsultation?: () => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
@@ -33,6 +33,7 @@ import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
@@ -136,6 +137,7 @@ type RectificationAgenticChatProps = Readonly<{
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onStartConsultation?: () => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
@@ -200,9 +202,13 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
const failed = persistedTurnFailed(turn);
const raw = failed ? "" : turn.text ?? "";
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const thinkingText = split.thinking.trim() || undefined;
return [{
role: "assistant",
text: failed ? "" : turn.text ?? "",
text: split.spoken || raw,
...(thinkingText ? { thinkingText } : {}),
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
@@ -235,6 +241,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onProfileIncomplete,
onSaved,
onStartConsultation,
onOpeningConsumed,
pendingConsultationQuestion,
onRestart,
headerSlot,
@@ -617,10 +624,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
return;
}
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
onOpeningConsumed?.();
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || readonly) return;