Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

714 lines
25 KiB
TypeScript

"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_COMPOSER_DEFAULT_PLACEHOLDER,
RECTIFICATION_DELIVERED_PLACEHOLDER,
recompareHistoricalResult,
contrastProbesFromReceipt,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import { RectificationTimeline } from "@/components/rectification-timeline";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { useRectificationCaseSnapshot } from "@/hooks/use-rectification-case-snapshot";
import { useRectificationBoardLayout } from "@/hooks/use-rectification-board-layout";
import { useRectificationLiveActivity } from "@/hooks/use-rectification-live-activity";
import { ChatComposer } from "./chat-composer";
import { JumpToLatestButton } from "./jump-to-latest-button";
import { toggleChatMessageFeedback } from "./chat-message-actions";
import {
RectificationMessageEntry,
type RectificationMessageActions,
type RenderMessage,
} from "./rectification-message-entry";
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import {
RectificationQuestionGapNotices,
RectificationReadonlyRange,
} from "./rectification-question-gap-notices";
import {
applyLiveCandidateOffer,
type SelectionCardLock,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages";
import { runRectificationChatTurn } from "@/lib/rectification-chat-turn-run";
import {
runRectificationStructuredChoice,
type RectificationStructuredChoiceAction,
type RectificationStructuredChoiceOverride,
} from "@/lib/rectification-chat-choice-run";
import { runRectificationCandidateAccept } from "@/lib/rectification-chat-accept-run";
import { copyRectificationMessage } from "@/lib/rectification-chat-message-actions";
import {
refetchRectificationQuestion,
repairRectificationQuestion,
} from "@/lib/rectification-chat-question-repair";
import { deriveRectificationChatView } from "@/lib/rectification-chat-view";
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
type RectificationAgenticChatProps = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, for the board before any candidate exists. */
declaredTime: string | null;
/** Profile birth date; the year-entry picker spans this year through the current year. */
birthDate: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
}>;
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return toggleChatMessageFeedback(current, requested);
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
readonly: historyReadonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
declaredTime,
birthDate,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
onPendingChange,
onProfileIncomplete,
onOpenBilling,
onSaved,
onOpeningConsumed,
pendingConsultationQuestion,
onRestart,
headerSlot,
} = props;
// Case snapshot sync (candidate, current question, choice card, saved time,
// timeline window / stage, question recovery counters) lives in its own hook.
const {
resultNotice,
canRecompare,
savedTime,
setSavedTime,
savedStatus,
setSavedStatus,
candidateResult,
setCandidateResult,
choiceCard,
currentQuestion,
questionSource,
caseStatus,
searchWindow,
caseStage,
nextUserActionId,
setNextUserActionId,
interviewStopReason,
interviewSessionOutcome,
birthTimeSource,
caseSnapshotLoaded,
questionRetryAttempts,
setQuestionRetryAttempts,
questionRepairAttempts,
setQuestionRepairAttempts,
questionRepairing,
setQuestionRepairing,
snapshotFailure,
snapshotAbort,
currentQuestionRef,
applyCaseSnapshot,
loadCaseSnapshot,
} = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot });
const readonly = historyReadonly || Boolean(resultNotice);
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const queued = useQueuedMessage();
const lastRunOutcome = useRef<"succeeded" | "stopped" | "failed" | "readonly" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const messageActionsRef = useRef<RectificationMessageActions>({
submitChoice() {},
submitStop() {},
copyMessage() {},
onFeedback() {},
});
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const runAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const [selectionOfferLock, setSelectionOfferLock] = useState<SelectionCardLock | null>(null);
const composerRemainingId = useId();
// Same pin-the-turn-head hook as the consultation surface (BUG-930).
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
const {
compactBoard,
boardOpen,
boardDiff,
boardId,
boardTitleId,
closeBoard,
toggleBoard,
} = useRectificationBoardLayout(candidateResult);
const { beginLiveRun, rememberLiveActivity } = useRectificationLiveActivity({ busy, setMessages });
const stopRun = useCallback(() => {
runAbort.current?.abort();
}, []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
if (value) setQuestionRepairAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
useEffect(() => {
if (!candidateResult?.resultId) return;
const canOffer = canShowRectificationSelectionCards(candidateResult);
const adopted = Boolean(candidateResult.selectedTime);
if (!canOffer && !adopted) return;
const resultId = candidateResult.resultId;
queueMicrotask(() => {
setMessages((current) => applyLiveCandidateOffer(current, {
resultId,
canOffer,
adopted,
}));
});
}, [candidateResult]);
// Turns that arrive after mount (the parent refreshes the Case after each
// completed turn) only ever fill an empty transcript; a live or finished
// conversation is never overwritten or remounted. Adjusted during render
// from the previous prop, the way React documents it, not in an effect.
const [seededTurns, setSeededTurns] = useState(initialTurns);
if (seededTurns !== initialTurns) {
setSeededTurns(initialTurns);
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
}
// Leaving the surface mid-turn ends the stream and any snapshot read
// instead of letting them write into an unmounted component.
useEffect(() => () => {
runAbort.current?.abort();
snapshotAbort.current?.abort();
}, []);
/**
* Run one agent turn (runRectificationChatTurn). A `continuation` reuses the
* live row an earlier step already put at the end of the transcript; the
* caller of a continuation already holds `busy`.
*/
const send = useCallback(async (
action: "opening" | "message" | "read_only",
messageText: string,
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
) => runRectificationChatTurn({
caseId,
sessionId,
selectedModelId,
busy,
readonly,
conversationReadonly,
keyCounter,
currentQuestionRef,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setPending,
beginLiveRun,
rememberLiveActivity,
loadCaseSnapshot,
onCompleted,
onMessagesChange,
onOpenBilling,
onProfileIncomplete,
}, action, messageText, continuation), [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;
const outcome = lastRunOutcome.current;
lastRunOutcome.current = null;
if (!outcome || !queued.text) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(outcome) === "send" && !readonly) {
void send("message", next);
return;
}
setDraft((current) => appendQueuedText(current, next));
}, [busy, readonly, send]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId);
const existing = choiceActionIds.current.get(key);
if (existing) return existing;
const next = globalThis.crypto.randomUUID();
choiceActionIds.current.set(key, next);
return next;
}, []);
const submitStructuredChoice = useCallback(async (
action: RectificationStructuredChoiceAction,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: RectificationStructuredChoiceOverride,
) => runRectificationStructuredChoice({
caseId,
sessionId,
busy,
readonly,
choiceCard,
currentQuestion,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
actionIdForChoice,
setError,
setMessages,
setChoiceNonce,
setNextUserActionId,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onMessagesChange,
onProfileIncomplete,
}, action, optionId, override), [
beginLiveRun,
caseId,
choiceCard,
currentQuestion,
loadCaseSnapshot,
onCompleted,
onMessagesChange,
onProfileIncomplete,
readonly,
send,
sessionId,
setPending,
]);
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
return;
}
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
onOpeningConsumed?.();
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved,
}, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
await copyRectificationMessage({ setCopiedMessageKey }, message);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
if (readonly) return;
if (busy) {
if (queued.enqueue(draft)) setDraft("");
return;
}
const text = draft;
setDraft("");
await send("message", text);
}
const {
latestSettledAssistant,
showLiveChoiceCard,
timelineView,
nextOfferLock,
selectionCardMessageKey,
liveInterviewBlockingAdopt,
showSelectionCards,
showReadonlyRange,
collectSpokenPrompt,
liveQuestionOnMessages,
questionGap,
placedByKey,
showStandaloneQuestion,
verifiedIdleCopy,
conversationState,
} = deriveRectificationChatView({
messages,
busy,
readonly,
shouldStartOpening,
openingRequested,
acceptingCandidateId,
selectionOfferLock,
candidateResult,
currentQuestion,
questionSource,
choiceCard,
caseStatus,
caseSnapshotLoaded,
searchWindow,
caseStage,
nextUserActionId,
interviewStopReason,
interviewSessionOutcome,
questionRetryAttempts,
savedTime,
});
// Same render-time store update as seededTurns: the lock must be current
// before this paint (BUG-620). A ref write here trips react-hooks/refs.
if (nextOfferLock !== selectionOfferLock) {
setSelectionOfferLock(nextOfferLock);
}
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a repair button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
refreshOnVisible: false,
onPoll: () => {
setQuestionRetryAttempts((current) => current + 1);
void refetchQuestion();
},
});
async function refetchQuestion() {
await refetchRectificationQuestion({ loadCaseSnapshot, setMessages });
}
async function repairQuestion() {
await repairRectificationQuestion({
caseId,
sessionId,
questionRepairing,
questionRepairAttempts,
setQuestionRepairing,
setQuestionRepairAttempts,
applyCaseSnapshot,
loadCaseSnapshot,
setMessages,
});
}
// A Case with no turns and no automatic first turn (the server never starts
// an opening for a resumed Case) needs a way to begin; the server suppresses
// a duplicate opening, so this is safe to press twice.
function startOpeningManually() {
if (readonly || busy || openingStarted.current) return;
openingStarted.current = true;
setOpeningRequested(true);
void send("opening", "");
}
function submitChoice(key: ChoiceKey) {
if (!choiceCard) return;
void submitStructuredChoice(CHOICE_ACTION, key);
}
function submitStop() {
if (choiceCard) {
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
return;
}
if (currentQuestion?.kind === "collect_spoken" && currentQuestion.focus_id) {
void submitStructuredChoice(STOP_ACTION, "stop", {
focusId: currentQuestion.focus_id,
questionId: currentQuestion.question_id,
});
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
}
useEffect(() => {
messageActionsRef.current = {
submitChoice,
submitStop,
copyMessage,
onFeedback(renderKey, requested) {
setFeedback((current) => ({
...current,
[renderKey]: toggleRectificationFeedback(current[renderKey], requested),
}));
},
};
});
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
birthTimeSource={birthTimeSource}
expanded={boardOpen}
boardId={boardId}
onOpen={toggleBoard}
/>
) : null;
return (
<div
ref={workspace}
className={`rectification-workspace${candidateResult ? "" : " is-board-empty"}${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
{/* Its own grid row, above and outside the scroll container, so
scrollHeight is untouched and the scroll anchor needs no changes. */}
<RectificationTimeline view={timelineView} />
<section
ref={conversation}
className="conversation is-rectification"
aria-label="生时校正对话"
aria-busy={busy}
>
<div className="message-list">
{resultNotice && <p role="status" className="rectification-pending-note">{resultNotice}</p>}
{resultNotice && canRecompare && !historyReadonly && (
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
)}
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
</p>
)}
{conversationState === "empty" && (
<div className="rectification-empty-state">
<p>{RECTIFICATION_EMPTY_COPY}</p>
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
</div>
)}
{messages.map((message) => {
// D3: the same message with the persisted question placed on it.
const entryMessage = placedByKey?.get(message.renderKey) ?? message;
return (
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
>
<RectificationMessageEntry
message={entryMessage}
busy={busy}
readonly={readonly}
currentQuestionFocusId={currentQuestion?.focus_id ?? null}
interactive={!busy && !readonly}
liveChoiceCard={choiceCard}
choiceNonce={choiceNonce}
savedTime={savedTime}
copied={copiedMessageKey === message.renderKey}
feedback={feedback[message.renderKey]}
actionsRef={messageActionsRef}
/>
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<>
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
adoptLocked={liveInterviewBlockingAdopt}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
{verifiedIdleCopy}
</p>
)}
</>
)}
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
sessionOutcome={candidateResult.sessionOutcome}
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
/>
)}
</div>
);
})}
<RectificationQuestionGapNotices
view={{ showStandaloneQuestion, questionGap, showSelectionCards, verifiedIdleCopy }}
currentQuestion={currentQuestion}
choiceCard={choiceCard}
choiceNonce={choiceNonce}
busy={busy}
readonly={readonly}
snapshotFailure={snapshotFailure}
questionRepairAttempts={questionRepairAttempts}
questionRepairing={questionRepairing}
submitChoice={submitChoice}
submitStop={submitStop}
refetchQuestion={refetchQuestion}
repairQuestion={repairQuestion}
/>
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
已确认校正时间:{savedTime}
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{historyReadonly && (
<div className="rectification-terminal-actions">
<p className="rectification-terminal-note">该校正已结束,只能查看历史。</p>
<Button type="button" onClick={onRestart}>再次校正</Button>
</div>
)}
</div>
</section>
<div className="composer-wrap">
{conversationAnchor.latestBelowFold && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<ChatComposer
inputRef={composer}
value={draft}
remainingId={composerRemainingId}
inputLabel={resultNotice ? "当前结果仅供查看" : readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
placeholder={resultNotice
? "当前结果仅供查看,暂不能选择或采用。"
: readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
: !snapshotFailure && questionGap === "collect_waiting"
? RECTIFICATION_COLLECT_WAITING_PLACEHOLDER
: questionGap === "delivered"
? RECTIFICATION_DELIVERED_PLACEHOLDER
: RECTIFICATION_COMPOSER_DEFAULT_PLACEHOLDER}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={readonly}
submitLabel="发送"
submitBlocked={readonly || Boolean(queued.text)}
stopVisible={busy}
stopLabel="停止回答"
stopTitle="停止当前推理,已生成内容会保留"
queued={queued.text ? { text: queued.text, onRecall: () => {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(recalled, draft));
composer.current?.focus({ preventScroll: true });
} } : undefined}
onSubmit={submit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
toolbar={(
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
)}
onStop={stopRun}
/>
</div>
</div>
<RectificationBoard
result={candidateResult}
declaredTime={declaredTime}
birthTimeSource={birthTimeSource}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}