"use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { parseAgentReply } from "@/lib/agent-reply"; import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer"; import { completeActivityTrace, completeActivityTraceStep, emptyActivityTrace, freezeLiveThink, startActivityTraceStep, type AgentActivityTraceItem, } from "@/lib/agent-activity-trace"; import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS, RECTIFICATION_TOOL_DONE_LABELS, RECTIFICATION_TOOL_PROGRESS_LABELS, activityTraceFromReceipt, rectificationCompletedTrail, rectificationLiveProgressLabel, rectificationToolActivityPhase, } from "@/lib/rectification-activity-labels"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, type CompletedActivityReceiptView, } from "@/lib/rectification-activity-receipt"; import { canShowRectificationReadonlyRange, canShowRectificationSelectionCards, isRecommendedRectificationCandidate, natalRecastMeaning, parseRectificationCandidateResult, workingRectificationHouseTable, type RectificationCandidateResult, } from "@/lib/rectification-candidate-result"; import { diffRectificationBoard, RECTIFICATION_BOARD_SPLIT_MIN_PX, } from "@/lib/rectification-board-model"; import { membershipHref } from "@/lib/membership"; import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter"; import { rectificationAdoptingLabel, RECTIFICATION_EMPTY_ACTION_LABEL, RECTIFICATION_EMPTY_COPY, RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS, RECTIFICATION_QUESTION_PREPARING_LABEL, RECTIFICATION_QUESTION_RELOAD_LABEL, RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, RECTIFICATION_QUESTION_RETRY_LIMIT, RECTIFICATION_QUESTION_UNAVAILABLE_COPY, RECTIFICATION_STOPPED_NOTICE, rectificationConversationState, rectificationInitialLiveLabel, rectificationQuestionGapState, type RectificationCaseSnapshotPayload, } from "@/lib/rectification-surface-state"; import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence"; import { isPublicRectificationActivity, isPublicRectificationMethod, isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; import { CHOICE_ACTION, STOP_ACTION, isStructuredChoiceUserText, shouldContinueAfterStructuredChoice, stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } 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 { CharacterRemaining } from "./character-remaining"; import { ChatComposer } from "./chat-composer"; import { ChatMessageRow } from "./chat-message-row"; import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; import { JumpToLatestButton } from "./jump-to-latest-button"; import { ChatMessageActions, toggleChatMessageFeedback, } from "./chat-message-actions"; import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question"; import { Button } from "@/components/ui/button"; type PersistedTurn = Readonly<{ id: string; role: "user" | "assistant"; text: string | null; status: string; question?: unknown; offer_result_id?: string | null; receipt?: Readonly<{ status: string; phases: readonly string[]; tool_activities?: readonly Readonly<{ tool: string; status: string; methods?: readonly string[]; started_at?: string | null; elapsed_ms?: number | null; detail?: Readonly> | null; }>[]; tools: readonly string[]; methods?: readonly string[]; skill_name?: string; skill_version?: string; }> | null; }>; /** Same ceiling as the main chat composer, so both surfaces count down the same way. */ const RECTIFICATION_COMPOSER_MAX_LENGTH = 500; type CandidateResult = RectificationCandidateResult | null; type CurrentQuestionModel = Readonly<{ kind: "choice" | "collect_spoken"; prompt: string | null; focus_id: string | null; question_id: string | null; }>; function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown }; if (question.kind !== "choice" && question.kind !== "collect_spoken" && question.kind !== "reverse_verify") { return null; } return { kind: question.kind === "collect_spoken" ? "collect_spoken" : "choice", prompt: typeof question.prompt === "string" && question.prompt.trim() ? question.prompt.trim() : null, focus_id: typeof question.focus_id === "string" ? question.focus_id : null, question_id: typeof question.question_id === "string" ? question.question_id : null, }; } function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null { if (value === "unavailable") return "unavailable"; if (value === "focus") return "focus"; return null; } function RectificationReadonlyRange({ range, }: Readonly<{ range: readonly [string, string] }>) { return (

目前范围 {range[0]}–{range[1]},还在收窄

); } function RectificationCandidateCards({ result, acceptingCandidateId, readonly, onAccept, }: Readonly<{ result: RectificationCandidateResult; acceptingCandidateId: string | null; readonly: boolean; onAccept: (candidateId: string) => void; }>) { return (
当前可能的出生时间 相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。
{result.candidates.map((candidate) => { const selected = result.selectedTime === candidate.time; const recommended = isRecommendedRectificationCandidate(result, candidate); return ( ); })}
); } 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; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; onMessagesChange?: (messages: ChatMessage[]) => void; onCompleted?: () => void; onPendingChange?: (pending: boolean) => void; onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; headerSlot: HTMLElement | null; }>; type RenderMessage = ChatMessageView & { renderKey: string; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; activityTrace?: readonly AgentActivityTraceItem[]; question?: TurnQuestion; candidateOffer?: Readonly<{ resultId: string }>; }; function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { const byId = new Map(); for (const item of turns) { if (!item || typeof item !== "object") continue; const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown }; if (typeof turn.id !== "string") continue; byId.set(turn.id, { question: parseTurnQuestion(turn.question), offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null, }); } return current.map((message) => { if (!message.turnId || !byId.has(message.turnId)) return message; const next = byId.get(message.turnId); const question = next?.question ?? undefined; return { ...message, question: question ?? undefined, candidateOffer: next?.offerResultId ? { resultId: next.offerResultId } : message.candidateOffer, }; }); } function markQuestionAnswered( current: RenderMessage[], focusId: string, selected: ChoiceKey | "stop" | "typed", ): RenderMessage[] { return current.map((message) => { const question = message.question; if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message; return { ...message, question: { ...question, status: "resolved", answer_option: selected === "typed" ? null : selected, }, }; }); } function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] { return Array.isArray(payload?.turns) ? payload.turns : []; } function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : [])); const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => ( message.role === "assistant" && message.turnId && !known.has(message.turnId) )); return extras.length ? [...current, ...extras] : current; } function choiceCardFromQuestion( question: TurnQuestion, live: ChoiceCardModel | null, ): ChoiceCardModel | null { if (live && live.focus_id === question.focus_id && live.options.length === 4) { return { ...live, prompt: question.prompt }; } if (!question.options || question.options.length !== 4) return null; return { question_id: question.question_id, method_id: "", prompt: question.prompt, why: "", varga: null, choice_mode: CHOICE_MODE, options: question.options.map((option) => ({ key: option.key, label: option.label, answer_class: "unsure", role: "primary", })), stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL, stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE, scoring: question.kind !== "reverse_verify", probe_id: question.probe_id, case_revision: null, focus_id: question.focus_id, }; } function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { if (!receipt) return { steps: [], methods: [] }; if (Array.isArray(receipt.tool_activities)) { let state = createRectificationActivityReceiptState(); for (const activity of receipt.tool_activities) { if (!isPublicRectificationTool(activity.tool) || (activity.status !== "completed" && activity.status !== "failed")) continue; state = reduceRectificationActivityReceipt(state, { tool: activity.tool, status: activity.status, methods: activity.status === "completed" && Array.isArray(activity.methods) ? activity.methods.filter(isPublicRectificationMethod) : [], }); } return receiptFromRectificationActivityState(state); } return { steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))], methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))], }; } export function toggleRectificationFeedback( current: "up" | "down" | undefined, requested: "up" | "down", ): "up" | "down" | undefined { return toggleChatMessageFeedback(current, requested); } function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean { return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool); } function persistedTurnFailed(turn: PersistedTurn): boolean { return turn.status === "failed" || turn.status === "retryable" || turn.receipt?.status === "failed" || turn.receipt?.status === "degraded" || turn.receipt?.status === "blocked"; } function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] { return initialTurns.flatMap((turn, index): RenderMessage[] => { const key = `persisted-${turn.id}-${index}`; if (turn.role === "assistant") { const failed = persistedTurnFailed(turn); const raw = failed ? "" : turn.text ?? ""; if (failed && !raw) return []; if (isIncompleteRunBanner(raw)) return []; const completedReceipt = completedReceiptFromPersisted(turn.receipt); return [{ role: "assistant", text: raw, renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt, activityTrace: activityTraceFromReceipt(completedReceipt), failed, turnId: turn.id, question: parseTurnQuestion(turn.question) ?? undefined, candidateOffer: typeof turn.offer_result_id === "string" ? { resultId: turn.offer_result_id } : undefined, }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; if (isStructuredChoiceUserText(turn.text)) return []; return [{ role: "user", text: turn.text ?? "", renderKey: key, state: "settled", }]; }); } type CaseSnapshotState = Readonly<{ candidate: CandidateResult; question: CurrentQuestionModel | null; questionSource: "focus" | "unavailable" | null; choice: ChoiceCardModel | null; caseStatus: RectificationCaseStatus | null; savedTime: string | null; savedStatus: "accepted" | "confirmed" | null; }>; /** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null { if (!payload) return null; const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; return { candidate: parseRectificationCandidateResult(payload.latest_result), question: currentQuestionFromSnapshot(payload.current_question), questionSource: questionSourceFromSnapshot(payload.question_source), choice: parseRectificationChoiceCard(payload.choice_card), caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, savedTime: confirmedTime ?? acceptedTime, savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, }; } export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, sessionId, readonly, shouldStartOpening, initialTurns, initialSnapshot, declaredTime, models, selectedModelId, onSelectModel, onMessagesChange, onCompleted, onPendingChange, onProfileIncomplete, onSaved, onOpeningConsumed, pendingConsultationQuestion, onRestart, headerSlot, } = props; const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); const [openingRequested, setOpeningRequested] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); const [choiceNonce, setChoiceNonce] = useState(0); const conversation = useRef(null); const workspace = useRef(null); const composer = useRef(null); const keyCounter = useRef(0); const openingStarted = useRef(false); const previousBoardResult = useRef(null); const runAbort = useRef(null); const snapshotAbort = useRef(null); const choiceActionIds = useRef(new Map()); const currentQuestionRef = useRef(currentQuestion); const offerSectionRef = useRef(null); const offerFlashTimer = useRef | null>(null); const [offerFlash, setOfferFlash] = useState(false); const runStartedAtRef = useRef(0); const stepStartedAtRef = useRef(0); const liveToolRef = useRef(null); const liveBaseLabelRef = useRef("正在处理…"); const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); const boardId = useId(); const boardTitleId = useId(); const composerRemainingId = useId(); // The same anchor-and-follow the consultation surface uses: streamed tokens and // new cards land the viewport on the bottom only while the reader is there. const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId); useLayoutEffect(() => { currentQuestionRef.current = currentQuestion; }, [currentQuestion]); useEffect(() => () => { if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current); }, []); const scrollToAdoptOffer = useCallback(() => { offerSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }); setOfferFlash(true); if (offerFlashTimer.current) clearTimeout(offerFlashTimer.current); offerFlashTimer.current = setTimeout(() => setOfferFlash(false), 600); }, []); useLayoutEffect(() => { const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`); const update = () => { const nextCompact = query.matches; setCompactBoard(nextCompact); if (!nextCompact) setBoardOpen(false); }; update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); useEffect(() => { setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult)); previousBoardResult.current = candidateResult; }, [candidateResult]); const closeBoard = useCallback(() => setBoardOpen(false), []); const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []); 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); onPendingChange?.(value); }, [onPendingChange]); const beginLiveRun = useCallback((baseLabel: string) => { const now = Date.now(); runStartedAtRef.current = now; stepStartedAtRef.current = now; liveToolRef.current = null; liveBaseLabelRef.current = baseLabel; }, []); const rememberLiveActivity = useCallback((baseLabel: string, tool: string | null = liveToolRef.current) => { if (liveBaseLabelRef.current !== baseLabel) { liveBaseLabelRef.current = baseLabel; stepStartedAtRef.current = Date.now(); } liveToolRef.current = tool; return rectificationLiveProgressLabel({ tool, baseLabel, stepStartedAt: stepStartedAtRef.current, runStartedAt: runStartedAtRef.current, }); }, []); useEffect(() => { if (!busy) return; const id = window.setInterval(() => { const label = rectificationLiveProgressLabel({ tool: liveToolRef.current, baseLabel: liveBaseLabelRef.current, stepStartedAt: stepStartedAtRef.current, runStartedAt: runStartedAtRef.current, }); setMessages((current) => current.map((message) => { if (message.state !== "thinking" && message.state !== "streaming") return message; if (!message.activity || message.activity.label === label) return message; return { ...message, activity: { ...message.activity, label } }; })); }, 4000); return () => window.clearInterval(id); }, [busy]); 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) => { if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current; if (adopted && current.some((message) => message.candidateOffer)) { return current.map((message) => ( message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message )); } if (!canOffer || adopted) return current; const owner = [...current].reverse().find((message) => ( message.role === "assistant" && message.state === "settled" && Boolean(message.text) && !message.failed )); if (!owner) return current; return current.map((message) => ( message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message )); }); }); }, [candidateResult]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: { latest_result?: unknown; current_question?: unknown; choice_card?: unknown; turns?: unknown; question_source?: unknown; case?: { status?: unknown; accepted_time?: unknown; confirmed_time?: unknown; }; } | null) => { if (!payload) return; const nextCandidate = parseRectificationCandidateResult(payload.latest_result); const nextQuestion = currentQuestionFromSnapshot(payload.current_question); const nextChoice = parseRectificationChoiceCard(payload.choice_card); const nextCaseStatus = isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null; 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); setCurrentQuestion(nextQuestion); if (nextQuestion !== null) setQuestionRetryAttempts(0); setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); setCaseStatus(nextCaseStatus); setCaseSnapshotLoaded(true); if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); } else if (acceptedTime) { setSavedTime(acceptedTime); setSavedStatus("accepted"); } }, []); const loadCaseSnapshot = useCallback(async (): Promise<{ question: CurrentQuestionModel | null; turns: readonly unknown[]; } | null | undefined> => { snapshotAbort.current?.abort(); const controller = new AbortController(); snapshotAbort.current = controller; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store", signal: controller.signal }, ); if (!response.ok) return undefined; const payload = await response.json().catch(() => null); if (controller.signal.aborted) return undefined; applyCaseSnapshot(payload); return { question: currentQuestionFromSnapshot(payload?.current_question), turns: snapshotTurns(payload), }; } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. return undefined; } finally { if (snapshotAbort.current === controller) snapshotAbort.current = null; } }, [applyCaseSnapshot, caseId, sessionId]); // 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. A `continuation` reuses the live row an earlier step * (a tapped choice, an adopted candidate) already put at the end of the * transcript, so the reader sees one uninterrupted working row from the tap * to the next question instead of a gap and a re-appended row. 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 }>, ) => { const trimmed = action === "message" ? messageText.trim() : ""; if ((action === "message" && !trimmed) || readonly) return; if (!continuation && busy) return; setError(""); setPending(true); const initialLabel = rectificationInitialLiveLabel(action, continuation?.label); beginLiveRun(initialLabel); keyCounter.current += 1; const requestId = globalThis.crypto.randomUUID(); const turnKey = keyCounter.current; const userRenderKey = `v9-user-${turnKey}`; const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`; const pendingFocusId = currentQuestionRef.current?.focus_id; const liveRow: RenderMessage = { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() }, }; setMessages((current) => (continuation ? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message)) : [ ...(action === "message" && pendingFocusId ? markQuestionAnswered(current, pendingFocusId, "typed") : current), ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), liveRow, ])); setDraft(""); let raw = ""; let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; let currentActivity: AgentActivityView | undefined = { phase: "evidence-validation", label: initialLabel, startedAt: Date.now(), }; // Every stream event lands in `frames`; it commits at most once per animation // frame and releases text at a steady pace. The loop below never calls // setMessages for a live turn directly except on `attempt.reset`. const frames = createStreamFrameBuffer({ initialMeta: null, flush: (frame) => { const text = frame.answer; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text, activityTrace, completedReceipt, state: text.trim() ? "streaming" : "thinking", activity: currentActivity, } : message)); }, }); const abortController = new AbortController(); runAbort.current = abortController; try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, signal: abortController.signal, body: JSON.stringify({ caseId, sessionId, requestId, action, modelId: selectedModelId, ...(action === "message" ? { message: trimmed } : {}), origin: action === "read_only" ? "choice_click" : "typed", clientActionId: requestId, }), }); if (!response.ok) { const payload = await response.json().catch(() => null); const message = payload?.message || payload?.error || `请求失败(${response.status})`; setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); return; } if (response.status === 402) { // Say why before the page leaves, instead of vanishing mid-chat. setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); window.setTimeout(() => { window.location.assign(membershipHref("rectification")); }, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS); return; } if (response.status === 401) setError("请先登录。"); else setError(message); return; } if (!response.body) { setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setError("服务暂时不可用,请稍后再试。"); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let completed = false; let streamFailed = false; let runFailedMessage = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; let event: { type?: unknown; status?: unknown; text?: unknown; replace?: unknown; message?: unknown; tool?: unknown; methods?: unknown; turnId?: unknown; code?: unknown; activity?: unknown; }; try { event = JSON.parse(line) as typeof event; } catch { continue; } if (typeof event.type !== "string") continue; if (event.type === "answer.delta" && typeof event.text === "string") { raw = event.replace === true ? event.text : raw + event.text; activityTrace = freezeLiveThink(activityTrace); currentActivity = nextActivityView(currentActivity, { phase: "answer-composition", label: rememberLiveActivity("正在组织回答…"), }); frames.setAnswer(raw); } else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) { const activity = event.activity; currentActivity = nextActivityView(currentActivity, { phase: "evidence-validation", label: rememberLiveActivity(RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]), }); frames.touch(); } else if (event.type === "attempt.reset") { raw = ""; activityTrace = emptyActivityTrace(); activityReceiptState = createRectificationActivityReceiptState(); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); completedTurnId = undefined; currentActivity = nextActivityView(undefined, { phase: "evidence-validation", label: rememberLiveActivity("正在处理…", null), }); frames.reset(); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: "", thinkingText: undefined, activityTrace, state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined, activity: currentActivity, } : message)); } else if (event.type === "run.failed") { streamFailed = true; runFailedMessage = typeof event.message === "string" && event.message.trim() ? event.message : userFacingRunFailure(typeof event.code === "string" ? event.code : null); } else if (event.type === "error") { streamFailed = true; setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。"); } else if (event.type === "run.completed") { completed = true; if (typeof event.turnId === "string") completedTurnId = event.turnId; } else if (event.type === "tool.activity") { const tool = isPublicRectificationTool(event.tool) ? event.tool : null; if (!tool) continue; if (event.status === "started") { activityTrace = startActivityTraceStep( activityTrace, tool, RECTIFICATION_TOOL_PROGRESS_LABELS[tool], ); currentActivity = nextActivityView(currentActivity, { phase: rectificationToolActivityPhase(tool), label: rememberLiveActivity(RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool), completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), }); frames.touch(); continue; } if (event.status !== "completed" && event.status !== "failed") continue; activityTrace = completeActivityTraceStep( activityTrace, tool, RECTIFICATION_TOOL_DONE_LABELS[tool], ); activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, { tool, status: event.status, methods: event.status === "completed" && Array.isArray(event.methods) ? event.methods.filter(isPublicRectificationMethod) : [], }); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); currentActivity = nextActivityView(currentActivity, { phase: rectificationToolActivityPhase(tool), label: rememberLiveActivity(RECTIFICATION_TOOL_DONE_LABELS[tool], tool), completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), }); frames.touch(); } } } frames.settle(); const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (succeeded) { return [{ ...message, text: parsed.text, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: false, turnId: completedTurnId, activity: undefined, }]; } if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) { return [{ ...message, text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: true, activity: undefined, }]; } return []; })); if (!succeeded && raw.trim()) { setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。"); } else if (!succeeded && runFailedMessage) { setError((current) => current || runFailedMessage); } else if (!succeeded && completedReceipt.failedTool) { setError((current) => current || userFacingRunFailure("run_failed")); } if (succeeded) { const snapshot = await loadCaseSnapshot(); if (snapshot?.turns.length) { setMessages((current) => mergeTurnQuestions(current, snapshot.turns)); } onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), { role: "assistant", text: parsed.text }, ]); onCompleted?.(); } } catch (caught) { frames.settle(); const aborted = caught instanceof DOMException ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; if (aborted) { setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (raw.trim() || hasActivityReceipt(completedReceipt)) { return [{ ...message, text: raw, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, failed: true, turnId: completedTurnId, }]; } return []; })); // The reader pressed stop: what streamed stays, and the notice says so // rather than blaming the service. if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE); return; } setError("生时校正暂时不可用,请稍后再试。"); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; return hasActivityReceipt(completedReceipt) ? [{ ...message, text: "", state: "settled", completedReceipt, failed: true, }] : []; })); } finally { frames.dispose(); if (runAbort.current === abortController) runAbort.current = null; setPending(false); } }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); 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: typeof CHOICE_ACTION | typeof STOP_ACTION, optionId: ChoiceKey | "stop", override?: Readonly<{ focusId: string; questionId: string | null; probeId?: string | null; caseRevision?: number | null; }>, ) => { const focusId = override?.focusId ?? choiceCard?.focus_id; if (!focusId || busy || readonly) return; if (!override && !choiceCard) return; if (!isPersistedFocusId(focusId)) { setError("当前选择题已失效,请等待下一问。"); return; } const questionId = override?.questionId ?? choiceCard?.question_id; const probeId = override?.probeId ?? choiceCard?.probe_id; const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0; const actionId = actionIdForChoice(focusId, optionId); setError(""); keyCounter.current += 1; const turnKey = keyCounter.current; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; beginLiveRun(recordingLabel); setMessages((current) => [ ...markQuestionAnswered(current, focusId, optionId), { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: recordingLabel, startedAt: Date.now(), }, }, ]); setPending(true); try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ caseId, sessionId, requestId: actionId, action, actionId, focusId, questionId, probeId: probeId ?? null, optionId: optionId === "stop" ? undefined : optionId, expectedRevision, origin: "choice_click", clientActionId: actionId, }), }); const payload = await response.json().catch(() => null); if (!response.ok) { setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setChoiceNonce((current) => current + 1); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); return; } setError(payload?.message || payload?.error || `请求失败(${response.status})`); return; } const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); onCompleted?.(); const snapshot = await loadCaseSnapshot(); const turns = snapshot?.turns ?? []; if (willContinue) { // The follow-up turn continues on the row already in place: no removed // row, no effect hop, and `busy` never drops in between (so the next // card cannot flash before the turn hides it). setMessages((current) => mergeTurnQuestions(current, turns)); await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); } else { const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() : "已记录你的选择。"; setMessages((current) => { const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); const withHistory = appendUnseenAssistantTurns( mergeTurnQuestions(withoutPlaceholder, turns), turns, ); if (withHistory.length > withoutPlaceholder.length) return withHistory; return [ ...withHistory, { role: "assistant" as const, text: narration, renderKey: assistantRenderKey, state: "settled" as const, activity: undefined, }, ]; }); onMessagesChange?.([ { role: "assistant", text: narration }, ]); } } catch { setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setChoiceNonce((current) => current + 1); setError("选择题处理失败,请稍后重试。"); } finally { setPending(false); } }, [ beginLiveRun, caseId, choiceCard, 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) => { if (!candidateResult || acceptingCandidateId || busy || readonly) return; setError(""); setAcceptingCandidateId(candidateId); setPending(true); // Adoption is a server round trip followed by an agent turn; the reader sees // one live row for the whole of it instead of a greyed button. const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; const adoptingLabel = rectificationAdoptingLabel(candidateTime); keyCounter.current += 1; const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; beginLiveRun(adoptingLabel); setMessages((current) => [ ...current, { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, }, ]); try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, resultId: candidateResult.resultId, candidateId, requestId: globalThis.crypto.randomUUID(), }), }, ); const payload = await response.json().catch(() => null); if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") { throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间"); } setCandidateResult((current) => { if (!current) return current; const selectedTime = typeof payload.saved_time === "string" ? payload.saved_time.slice(0, 5) : current.selectedTime; const next = { ...current, selectedTime, selectionKind: "user_accepted" as const, }; const houseTable = workingRectificationHouseTable(next); const recastMeaning = natalRecastMeaning(houseTable); return { ...next, houseTable: houseTable ?? current.houseTable, natalRecast: houseTable && recastMeaning ? { time: houseTable.time, lagna: houseTable.lagna, user_meaning: recastMeaning, unique_minute_claim: false as const, confirmation_allowed: false as const, } : current.natalRecast, }; }); setSavedTime(payload.saved_time); setSavedStatus("accepted"); onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); await loadCaseSnapshot(); await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); } catch (caught) { setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingCandidateId(null); setPending(false); } }, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); async function copyMessage(message: RenderMessage) { try { await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question)); setCopiedMessageKey(message.renderKey); window.setTimeout(() => setCopiedMessageKey((current) => ( current === message.renderKey ? null : current )), 1_500); } catch { // Clipboard permission failures must not interrupt the conversation. } } async function regenerateMessage(message: RenderMessage) { if (!message.turnId || regeneratingMessageKey || busy || readonly) return; setError(""); setRegeneratingMessageKey(message.renderKey); const previousText = message.text; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/turns/${encodeURIComponent(message.turnId)}/regenerate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, requestId: globalThis.crypto.randomUUID(), }), }, ); const payload = await response.json().catch(() => null); if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") { throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答"); } const nextText = payload.assistantMessage; setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: nextText, state: "settled" } : item)); } catch (caught) { setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: previousText, state: "settled" } : item)); setError(caught instanceof Error ? caught.message : "暂时无法重新生成回答"); } finally { setRegeneratingMessageKey((current) => current === message.renderKey ? null : current); } } async function submit(event: React.FormEvent) { event.preventDefault(); await send("message", draft); } const latestRegeneratableKey = [...messages] .reverse() .find((message) => ( message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.turnId) && Boolean(message.text) ))?.renderKey; const latestSettledAssistant = [...messages] .reverse() .find((message) => ( message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.text) )); const latestLiveQuestion = [...messages].reverse().find((message) => ( message.role === "assistant" && message.question && !questionIsAnswered(message.question) ))?.question ?? null; const showLiveChoiceCard = Boolean( latestLiveQuestion && latestLiveQuestion.options?.length === 4 && currentQuestion?.focus_id === latestLiveQuestion.focus_id && (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify") && !busy && !readonly && regeneratingMessageKey === null, ); const canOfferCards = canShowRectificationSelectionCards(candidateResult); const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey; const selectionCardMessageKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); const showSelectionCards = Boolean( candidateResult && selectionCardMessageKey && (canOfferCards || Boolean(candidateResult.selectedTime)), ); const showReadonlyRange = Boolean( canShowRectificationReadonlyRange(candidateResult) && latestSettledAssistant && !showSelectionCards, ); const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null; const showCollectStop = Boolean( latestLiveQuestion && latestLiveQuestion.kind === "collect_spoken" && currentQuestion?.focus_id === latestLiveQuestion.focus_id && !questionIsAnswered(latestLiveQuestion) && !busy && !readonly && regeneratingMessageKey === null, ); const adoptedRangeLabel = candidateResult?.credibleRange ? `${candidateResult.credibleRange[0]}–${candidateResult.credibleRange[1]}` : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); const liveQuestionOnMessages = messages.some((message) => ( message.role === "assistant" && message.state === "settled" && message.question && currentQuestion && message.question.focus_id === currentQuestion.focus_id && !questionIsAnswered(message.question) )); // The gap between a settled turn and its next question has two visible // states: `preparing` (one live timeline row, timed refetches) and // `unavailable` (copy and a reload button) — never bare copy telling the // reader to wait for the server. const questionGap = rectificationQuestionGapState({ liveQuestionVisible: liveQuestionOnMessages, questionMissing: currentQuestion === null, questionLoadFailed: questionSource === "unavailable", offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime, busy, readonly, regenerating: regeneratingMessageKey !== null, snapshotLoaded: caseSnapshotLoaded, resumableCase, retryAttempts: questionRetryAttempts, retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, }); const conversationState = rectificationConversationState({ messageCount: messages.length, busy, readonly, shouldStartOpening, openingStarted: openingRequested, }); const canSend = !busy && !readonly && !regeneratingMessageKey; // 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 reload 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 loadCaseSnapshot(); } function reloadQuestion() { setQuestionRetryAttempts(0); void refetchQuestion(); } // 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) return; void submitStructuredChoice(STOP_ACTION, "stop"); } function submitCollectStop() { if (!latestLiveQuestion || latestLiveQuestion.kind !== "collect_spoken") return; void submitStructuredChoice(STOP_ACTION, "stop", { focusId: latestLiveQuestion.focus_id, questionId: latestLiveQuestion.question_id, probeId: latestLiveQuestion.probe_id, caseRevision: choiceCard?.case_revision ?? 0, }); } const boardPeek = compactBoard && !boardOpen ? ( ) : null; return (
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
{pendingConsultationQuestion?.trim() && (

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

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

{RECTIFICATION_EMPTY_COPY}

)} {messages.map((message) => { const showActions = message.role === "assistant" && message.state === "settled" && !message.failed && Boolean(message.text); const regenerating = regeneratingMessageKey === message.renderKey; const canRegenerate = message.renderKey === latestRegeneratableKey && !busy && !readonly && regeneratingMessageKey === null; // Both surfaces render the same step timeline: the tool trace and receipt // are projected onto ConsultationRunTimeline rows. A regenerating reply shows // the queued row until real events fill it in, never a staged label. const displayedMessage: RenderMessage = regenerating ? { ...message, text: "", thinkingText: undefined, state: "thinking" as const, activity: undefined, activityTrace: emptyActivityTrace(), completedReceipt: undefined, timeline: [], } : { ...message, timeline: rectificationTimelineRows({ trace: message.activityTrace, receipt: message.completedReceipt, activity: message.activity, settled: message.state === "settled", }), }; const vargaSentence = !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; const question = regenerating ? undefined : message.question; const liveQuestion = Boolean( question && currentQuestion?.focus_id === question.focus_id && !questionIsAnswered(question) && !busy && !readonly && regeneratingMessageKey === null && ( question.kind === "collect_spoken" || ( choiceCard && choiceCard.focus_id === question.focus_id && (question.kind === "choice" || question.kind === "reverse_verify") ) ), ); const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null; const afterAnswer = question && displayedMessage.state === "settled" ? (

{question.prompt}

{embeddedCard && ( )} {question.status === "skipped" && (

{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}

)}
) : undefined; return (
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && ( )} {showActions && !regenerating && ( setFeedback((current) => ({ ...current, [message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested), }))} onCopy={() => void copyMessage(message)} onRegenerate={() => void regenerateMessage(message)} /> )} {showSelectionCards && message.renderKey === selectionCardMessageKey && candidateResult && ( void acceptCandidate(candidateId)} /> )} {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( )}
); })} {questionGap === "preparing" && (
)} {questionGap === "unavailable" && (

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

)} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime}

)} {error &&

{error}

} {readonly && (

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

)}
{!conversationAnchor.anchored && ( )} {savedTime && savedStatus === "accepted" && (
已采用 {savedTime} {adoptedRangeLabel ? ` · 范围 ${adoptedRangeLabel}` : ""} {showSelectionCards && ( )} 之后新建对话即按此时间排盘。
)} setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} onStop={stopRun} /> {showCollectStop && ( )}
); }