feat(web): hang Agent-authored A/B/C/D cards under rectification replies
Independent Staging Quality Gate / validate (push) Failing after 11m51s
Independent Staging Quality Gate / publish (push) Has been skipped

Conflict nodes stay server-owned; the Agent writes the question and option copy so users can tap instead of typing through an interrogation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 17:36:32 +08:00
co-authored by Cursor
parent 83eb676d0a
commit a03756bf18
24 changed files with 1057 additions and 221 deletions
@@ -33,6 +33,13 @@ import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
parseRectificationChoiceCard,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import {
@@ -41,6 +48,7 @@ import {
} from "./chat-message-actions";
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -113,23 +121,6 @@ function RectificationCandidateCards({
);
}
function RectificationOosPanel({
prompts,
}: Readonly<{ prompts: RectificationCandidateResult["oosBlindPrompts"] }>) {
if (prompts.length === 0) return null;
return (
<section className="rectification-refinement" aria-label="盘外对照">
<strong>盘外对照</strong>
<p>这些经历没有进入刚才的评分。若记得大概时间,可以补一条用来核对;不补也可以先看盘。</p>
<ul>
{prompts.map((item) => (
<li key={item.domain}>{item.user_meaning}</li>
))}
</ul>
</section>
);
}
type RectificationAgenticChatProps = Readonly<{
caseId: string;
sessionId: string;
@@ -256,6 +247,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -310,31 +302,43 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
top: container.scrollHeight,
behavior: busy || reduceMotion ? "auto" : "smooth",
});
}, [busy, candidateResult, error, messages, savedTime]);
}, [busy, candidateResult, choiceCard, error, messages, savedTime]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const loadCandidate = useCallback(async (): Promise<CandidateResult> => {
const loadCaseSnapshot = useCallback(async () => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return null;
if (!response.ok) return;
const payload = await response.json().catch(() => null);
return parseRectificationCandidateResult(payload?.latest_result);
const nextCandidate = parseRectificationCandidateResult(payload?.latest_result);
const nextChoice = parseRectificationChoiceCard(payload?.choice_card);
const acceptedTime = typeof payload?.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload?.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setChoiceCard(nextChoice);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
} else if (acceptedTime) {
setSavedTime(acceptedTime);
setSavedStatus("accepted");
}
} catch {
return null;
// Snapshot refresh is best-effort; the durable Case remains on the server.
}
}, [caseId, sessionId]);
useEffect(() => {
let active = true;
void loadCandidate().then((result) => {
if (active) setCandidateResult(result);
void loadCaseSnapshot().then(() => {
if (!active) return;
});
return () => { active = false; };
}, [loadCandidate]);
}, [loadCaseSnapshot]);
const send = useCallback(async (action: "opening" | "message", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
@@ -541,9 +545,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
await loadCandidate().then((result) => {
if (result) setCandidateResult(result);
});
await loadCaseSnapshot();
}
} catch (caught) {
const aborted = caught instanceof DOMException
@@ -584,7 +586,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (readonly || openingStarted.current || !shouldStartOpening) return;
@@ -642,12 +644,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setSavedStatus("accepted");
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
await loadCaseSnapshot();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingCandidateId(null);
}
}, [acceptingCandidateId, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
}, [acceptingCandidateId, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId]);
async function copyMessage(message: RenderMessage) {
try {
@@ -719,16 +722,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
));
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& !candidateResult.selectedTime
&& latestOfferMessage
&& turnOfferedSelection(latestOfferMessage)
&& !busy
&& regeneratingMessageKey === null,
);
const showChoiceCards = Boolean(
choiceCard
&& latestOfferMessage
&& !showSelectionCards
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const selectionCardMessageKey = showSelectionCards && latestOfferMessage
? latestOfferMessage.renderKey
: undefined;
const choiceCardMessageKey = showChoiceCards && latestOfferMessage
? latestOfferMessage.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void send("message", choiceCardUserMessage(choiceCard, key));
}
function submitStop() {
void send("message", choiceCard?.stop_message ?? CHOICE_STOP_MESSAGE);
}
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -813,6 +837,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onAccept={(candidateId) => void acceptCandidate(candidateId)}
/>
)}
{showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && (
<RectificationChoiceCard
card={choiceCard}
pending={busy}
disabled={readonly}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
</div>
);
})}
@@ -821,9 +854,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性候选,还不能确认唯一分钟)"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。
</p>
{savedStatus === "accepted" && candidateResult && (
<RectificationOosPanel prompts={candidateResult.oosBlindPrompts} />
)}
{savedStatus === "accepted" && onStartConsultation && (
<div className="rectification-consult-handoff">
<Button type="button" onClick={onStartConsultation}>
@@ -852,7 +882,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: "继续说你记得的人生经历,或回答刚才的问题…"}
: showChoiceCards
? "点上面的选项即可;想补一句细节再写"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type {
ChoiceKey,
RectificationChoiceCard as ChoiceCard,
} from "@/lib/rectification-agentic/v9/choice-card";
type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
pending: boolean;
disabled: boolean;
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [selectedKey, setSelectedKey] = useState<ChoiceKey | "stop" | "">("");
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
const primary = props.card.options.filter((option) => option.role === "primary");
const secondary = props.card.options.filter((option) => option.role === "secondary");
useEffect(() => {
setSelectedKey("");
}, [props.card.question_id]);
useEffect(() => {
if (!props.pending) firstChoiceRef.current?.focus();
}, [props.card.question_id, props.pending]);
function select(key: ChoiceKey) {
if (props.pending || props.disabled || selectedKey) return;
setSelectedKey(key);
props.onSelect(key);
}
function stop() {
if (props.pending || props.disabled || selectedKey) return;
setSelectedKey("stop");
props.onStop();
}
return (
<section
className="rectification-choice-card"
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
>
<fieldset className="birth-time-choice-question" disabled={props.pending || props.disabled || Boolean(selectedKey)}>
<legend>{props.card.prompt}</legend>
{props.card.why ? <p className="rectification-choice-why">{props.card.why}</p> : null}
<div className="birth-time-primary-choices">
{primary.map((option, index) => (
<button
key={option.key}
ref={index === 0 ? firstChoiceRef : undefined}
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === option.key ? "true" : "false"}
onClick={() => select(option.key)}
>
<strong>{option.key}.</strong> {option.label}
</button>
))}
</div>
<div className="birth-time-special-choices">
{secondary.map((option) => (
<button
key={option.key}
type="button"
className="birth-time-choice-option is-secondary"
data-selected={selectedKey === option.key ? "true" : "false"}
onClick={() => select(option.key)}
>
{option.key}. {option.label}
</button>
))}
<button
type="button"
className="birth-time-choice-option is-secondary"
data-selected={selectedKey === "stop" ? "true" : "false"}
onClick={stop}
>
{props.card.stop_label}
</button>
</div>
</fieldset>
</section>
);
}