"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(() => 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(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [choiceNonce, setChoiceNonce] = useState(0); const messageActionsRef = useRef({ submitChoice() {}, submitStop() {}, copyMessage() {}, onFeedback() {}, }); const conversation = useRef(null); const workspace = useRef(null); const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); const runAbort = useRef(null); const choiceActionIds = useRef(new Map()); const [selectionOfferLock, setSelectionOfferLock] = useState(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 ? ( ) : null; return (
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
{/* Its own grid row, above and outside the scroll container, so scrollHeight is untouched and the scroll anchor needs no changes. */}
{resultNotice &&

{resultNotice}

} {resultNotice && canRecompare && !historyReadonly && ( )} {pendingConsultationQuestion?.trim() && (

先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”

)} {conversationState === "empty" && (

{RECTIFICATION_EMPTY_COPY}

)} {messages.map((message) => { // D3: the same message with the persisted question placed on it. const entryMessage = placedByKey?.get(message.renderKey) ?? message; return (
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && ( <> void acceptCandidate(candidateId)} adoptLocked={liveInterviewBlockingAdopt} /> {verifiedIdleCopy && (

{verifiedIdleCopy}

)} )} {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( )}
); })} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime}

)} {error &&

{error}

} {historyReadonly && (

该校正已结束,只能查看历史。

)}
{conversationAnchor.latestBelowFold && ( )} { 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={( )} onStop={stopRun} />
); }