fix(rectification): persist the next interview after a choice tap
Closing a discriminator used to leave GET without a card after refresh. Write the next dated question in the same request, skip childhood career and move probes, and do not continue a read-only turn when that question is already persisted. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,14 +50,13 @@ import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
STOP_ACTION,
|
||||
isStructuredChoiceUserText,
|
||||
shouldContinueAfterStructuredChoice,
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import {
|
||||
CHOICE_STOP_MESSAGE,
|
||||
choiceCardUserMessage,
|
||||
isPersistedFocusId,
|
||||
parseRectificationChoiceCard,
|
||||
type ChoiceKey,
|
||||
@@ -164,12 +163,18 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
headerSlot: HTMLElement | null;
|
||||
}>;
|
||||
|
||||
type SettledChoiceAttachment = Readonly<{
|
||||
card: ChoiceCardModel;
|
||||
selectedKey: ChoiceKey | "stop";
|
||||
}>;
|
||||
|
||||
type RenderMessage = ChatMessageView & {
|
||||
renderKey: string;
|
||||
completedReceipt?: CompletedActivityReceiptView;
|
||||
failed?: boolean;
|
||||
turnId?: string;
|
||||
activityTrace?: readonly AgentActivityTraceItem[];
|
||||
choiceAttachment?: SettledChoiceAttachment;
|
||||
};
|
||||
|
||||
function turnOfferedSelection(message: RenderMessage): boolean {
|
||||
@@ -241,6 +246,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
}];
|
||||
}
|
||||
if (isIncompleteRunBanner(turn.text ?? "")) return [];
|
||||
if (isStructuredChoiceUserText(turn.text)) return [];
|
||||
return [{
|
||||
role: "user",
|
||||
text: turn.text ?? "",
|
||||
@@ -517,6 +523,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
type?: unknown;
|
||||
status?: unknown;
|
||||
text?: unknown;
|
||||
replace?: unknown;
|
||||
message?: unknown;
|
||||
tool?: unknown;
|
||||
methods?: unknown;
|
||||
@@ -531,7 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
if (typeof event.type !== "string") continue;
|
||||
if (event.type === "answer.delta" && typeof event.text === "string") {
|
||||
raw += event.text;
|
||||
raw = event.replace === true ? event.text : raw + event.text;
|
||||
activityTrace = freezeLiveThink(activityTrace);
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
@@ -747,18 +754,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
const questionId = choiceCard.question_id;
|
||||
const actionId = actionIdForChoice(focusId, optionId);
|
||||
const userText = optionId === "stop"
|
||||
? (choiceCard.stop_message || CHOICE_STOP_MESSAGE)
|
||||
: choiceCardUserMessage(choiceCard, optionId);
|
||||
const answeredCard = choiceCard;
|
||||
const offeringRenderKey = [...messages].reverse().find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text)
|
||||
&& !message.choiceAttachment
|
||||
))?.renderKey ?? null;
|
||||
setError("");
|
||||
setPending(true);
|
||||
keyCounter.current += 1;
|
||||
const turnKey = keyCounter.current;
|
||||
const userRenderKey = `v9-choice-user-${turnKey}`;
|
||||
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: "user", text: userText, renderKey: userRenderKey, state: "settled" },
|
||||
...current.map((message) => (
|
||||
offeringRenderKey && message.renderKey === offeringRenderKey
|
||||
? {
|
||||
...message,
|
||||
choiceAttachment: { card: answeredCard, selectedKey: optionId },
|
||||
}
|
||||
: message
|
||||
)),
|
||||
{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
@@ -772,6 +787,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
},
|
||||
},
|
||||
]);
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
@@ -793,9 +809,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
setMessages((current) => current.filter((message) => (
|
||||
message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey
|
||||
)));
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
return [message];
|
||||
}));
|
||||
setChoiceNonce((current) => current + 1);
|
||||
if (payload?.code === "profile_incomplete") {
|
||||
onProfileIncomplete?.();
|
||||
@@ -804,30 +824,36 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setError(payload?.message || payload?.error || `请求失败(${response.status})`);
|
||||
return;
|
||||
}
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
text: narration,
|
||||
state: "settled" as const,
|
||||
activity: undefined,
|
||||
}
|
||||
: message));
|
||||
onMessagesChange?.([
|
||||
{ role: "user", text: userText },
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
if (shouldContinueAfterStructuredChoice(payload?.nextAction)) {
|
||||
if (willContinue) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
choiceContinuationPending.current = true;
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
text: narration,
|
||||
state: "settled" as const,
|
||||
activity: undefined,
|
||||
}
|
||||
: message));
|
||||
onMessagesChange?.([
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
setMessages((current) => current.filter((message) => (
|
||||
message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey
|
||||
)));
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
return [message];
|
||||
}));
|
||||
setChoiceNonce((current) => current + 1);
|
||||
setError("选择题处理失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -839,6 +865,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
caseId,
|
||||
choiceCard,
|
||||
loadCaseSnapshot,
|
||||
messages,
|
||||
onCompleted,
|
||||
onMessagesChange,
|
||||
onProfileIncomplete,
|
||||
@@ -994,32 +1021,45 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& Boolean(message.text)
|
||||
));
|
||||
const offeredSelectionOnce = messages.some(turnOfferedSelection);
|
||||
const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant));
|
||||
const showChoiceCards = Boolean(
|
||||
const answeredQuestionIds = new Set(
|
||||
messages.flatMap((message) => message.choiceAttachment
|
||||
? [message.choiceAttachment.card.question_id]
|
||||
: []),
|
||||
);
|
||||
const liveChoiceHost = [...messages]
|
||||
.reverse()
|
||||
.find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text)
|
||||
&& !message.choiceAttachment
|
||||
));
|
||||
const showLiveChoiceCard = Boolean(
|
||||
choiceCard
|
||||
&& latestSettledAssistant
|
||||
&& !offeredThisTurn
|
||||
&& liveChoiceHost
|
||||
&& !answeredQuestionIds.has(choiceCard.question_id)
|
||||
&& !turnOfferedSelection(liveChoiceHost)
|
||||
&& !busy
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
choiceCardsOpen.current = showChoiceCards;
|
||||
choiceCardsOpen.current = showLiveChoiceCard;
|
||||
updateFollowState();
|
||||
}, [showChoiceCards, updateFollowState]);
|
||||
}, [showLiveChoiceCard, updateFollowState]);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult?.selectionAllowed
|
||||
&& offeredSelectionOnce
|
||||
&& latestSettledAssistant
|
||||
&& !showChoiceCards
|
||||
&& !showLiveChoiceCard
|
||||
&& !busy
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const selectionCardMessageKey = showSelectionCards && latestSettledAssistant
|
||||
? latestSettledAssistant.renderKey
|
||||
: undefined;
|
||||
const choiceCardMessageKey = showChoiceCards && latestSettledAssistant
|
||||
? latestSettledAssistant.renderKey
|
||||
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
|
||||
? liveChoiceHost.renderKey
|
||||
: undefined;
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
@@ -1084,6 +1124,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}),
|
||||
}
|
||||
: message;
|
||||
const settledChoice = message.choiceAttachment;
|
||||
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
|
||||
? choiceCard
|
||||
: null;
|
||||
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
|
||||
const vargaSentence = !message.failed
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
: null;
|
||||
@@ -1122,12 +1167,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
/>
|
||||
)}
|
||||
{showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && (
|
||||
{visibleChoiceCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||||
card={choiceCard}
|
||||
pending={busy}
|
||||
disabled={readonly}
|
||||
key={settledChoice
|
||||
? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}`
|
||||
: `${visibleChoiceCard.question_id}:${choiceNonce}`}
|
||||
card={visibleChoiceCard}
|
||||
pending={Boolean(liveChoice) && busy}
|
||||
disabled={readonly || Boolean(settledChoice)}
|
||||
selectedKey={settledChoice?.selectedKey ?? ""}
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
@@ -1177,7 +1225,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
disabled={!canSend}
|
||||
placeholder={readonly
|
||||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||||
: showChoiceCards
|
||||
: showLiveChoiceCard
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
|
||||
@@ -10,42 +10,48 @@ type RectificationChoiceCardProps = Readonly<{
|
||||
card: ChoiceCard;
|
||||
pending: boolean;
|
||||
disabled: boolean;
|
||||
selectedKey?: ChoiceKey | "stop" | "";
|
||||
onSelect: (key: ChoiceKey) => void;
|
||||
onStop: () => void;
|
||||
}>;
|
||||
|
||||
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
const [selectedKey, setSelectedKey] = useState<ChoiceKey | "stop" | "">("");
|
||||
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
|
||||
const selectedKey = props.selectedKey || localSelected;
|
||||
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");
|
||||
const answered = Boolean(selectedKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.pending) firstChoiceRef.current?.focus();
|
||||
}, [props.card.question_id, props.pending]);
|
||||
if (props.pending || answered) return;
|
||||
firstChoiceRef.current?.focus();
|
||||
}, [answered, props.card.question_id, props.pending]);
|
||||
|
||||
function select(key: ChoiceKey) {
|
||||
if (props.pending || props.disabled || selectedKey) return;
|
||||
setSelectedKey(key);
|
||||
setLocalSelected(key);
|
||||
props.onSelect(key);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (props.pending || props.disabled || selectedKey) return;
|
||||
setSelectedKey("stop");
|
||||
setLocalSelected("stop");
|
||||
props.onStop();
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rectification-choice-card"
|
||||
className={`rectification-choice-card${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
|
||||
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
|
||||
>
|
||||
<fieldset className="birth-time-choice-question" disabled={props.pending || props.disabled || Boolean(selectedKey)}>
|
||||
<fieldset
|
||||
className="birth-time-choice-question"
|
||||
disabled={props.pending || props.disabled || answered}
|
||||
data-answered={answered ? "true" : "false"}
|
||||
>
|
||||
<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) => (
|
||||
{props.card.options.map((option, index) => (
|
||||
<button
|
||||
key={option.key}
|
||||
ref={index === 0 ? firstChoiceRef : undefined}
|
||||
@@ -57,22 +63,9 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
<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"
|
||||
className="birth-time-choice-option is-primary"
|
||||
data-selected={selectedKey === "stop" ? "true" : "false"}
|
||||
onClick={stop}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user