feat(web): hang Agent-authored A/B/C/D cards under rectification replies
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user