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
714 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|