"use client"; import { ArrowUp, Square } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { parseAgentReply } from "@/lib/agent-reply"; import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; import { RECTIFICATION_TOOL_PROGRESS_LABELS, rectificationCompletedTrail, rectificationToolActivityPhase, } from "@/lib/rectification-activity-labels"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, type CompletedActivityReceiptView, } from "@/lib/rectification-activity-receipt"; import { 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 { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence"; import { isPublicRectificationMethod, isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import { CHOICE_STOP_MESSAGE, choiceCardUserMessage, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel, } from "@/lib/rectification-agentic/v9/choice-card"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; import { ChatMessageActions, toggleChatMessageFeedback, } from "./chat-message-actions"; import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; type PersistedTurn = Readonly<{ id: string; role: "user" | "assistant"; text: string | null; status: string; receipt?: Readonly<{ status: string; phases: readonly string[]; tool_activities?: readonly Readonly<{ tool: string; status: string; methods?: readonly string[]; }>[]; tools: readonly string[]; methods?: readonly string[]; skill_name?: string; skill_version?: string; }> | null; }>; type CandidateResult = RectificationCandidateResult | null; 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[]; 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; onStartConsultation?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; headerSlot: HTMLElement | null; }>; type RenderMessage = ChatMessageView & { renderKey: string; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; }; function turnOfferedSelection(message: RenderMessage): boolean { return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates")); } 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); return [{ role: "assistant", text: failed ? "" : turn.text ?? "", renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt: completedReceiptFromPersisted(turn.receipt), failed, turnId: turn.id, }]; } return [{ role: "user", text: turn.text ?? "", renderKey: key, state: "settled", }]; }); } export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, sessionId, readonly, shouldStartOpening, initialTurns, models, selectedModelId, onSelectModel, onMessagesChange, onCompleted, onPendingChange, onProfileIncomplete, onSaved, onStartConsultation, 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(null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); const [candidateResult, setCandidateResult] = useState(null); const [choiceCard, setChoiceCard] = useState(null); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); 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 [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); const boardId = useId(); const boardTitleId = useId(); 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); onPendingChange?.(value); }, [onPendingChange]); useEffect(() => { const container = conversation.current; if (!container) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: container.scrollHeight, behavior: busy || reduceMotion ? "auto" : "smooth", }); }, [busy, candidateResult, choiceCard, error, messages, savedTime]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: { latest_result?: unknown; choice_card?: unknown; case?: { accepted_time?: unknown; confirmed_time?: unknown }; } | null) => { if (!payload) return; const nextCandidate = parseRectificationCandidateResult(payload.latest_result); const nextChoice = parseRectificationChoiceCard(payload.choice_card); const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; setCandidateResult(nextCandidate); setChoiceCard(nextChoice); if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); } else if (acceptedTime) { setSavedTime(acceptedTime); setSavedStatus("accepted"); } }, []); const loadCaseSnapshot = useCallback(async () => { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store" }, ); if (!response.ok) return; applyCaseSnapshot(await response.json().catch(() => null)); } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. } }, [applyCaseSnapshot, caseId, sessionId]); useEffect(() => { const controller = new AbortController(); void fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store", signal: controller.signal }, ) .then((response) => (response.ok ? response.json() : null)) .then((payload) => { if (!controller.signal.aborted) applyCaseSnapshot(payload); }) .catch(() => { // Snapshot refresh is best-effort; the durable Case remains on the server. }); return () => controller.abort(); }, [applyCaseSnapshot, caseId, sessionId]); const send = useCallback(async (action: "opening" | "message", messageText: string) => { const trimmed = action === "message" ? messageText.trim() : ""; if ((action === "message" && !trimmed) || busy || readonly) return; setError(""); setPending(true); keyCounter.current += 1; const requestId = globalThis.crypto.randomUUID(); const turnKey = keyCounter.current; const userRenderKey = `v9-user-${turnKey}`; const assistantRenderKey = `v9-assistant-${turnKey}`; setMessages((current) => [ ...current, ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: { phase: "evidence-validation", label: "正在处理…", startedAt: Date.now(), } }, ]); setDraft(""); let raw = ""; let thinkingRaw = ""; let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; 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 } : {}), }), }); 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) { window.location.assign(membershipHref("rectification")); 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; 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; message?: unknown; tool?: unknown; methods?: unknown; turnId?: 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.text; const parsed = parseAgentReply(raw); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming", activity: nextActivityView(message.activity, { phase: "answer-composition", label: "正在组织回答…", }), } : message)); } else if (event.type === "thinking.delta" && typeof event.text === "string") { thinkingRaw += event.text; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, thinkingText: thinkingRaw, state: raw ? "streaming" : "thinking", } : message)); } else if (event.type === "attempt.reset") { raw = ""; thinkingRaw = ""; activityReceiptState = createRectificationActivityReceiptState(); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); completedTurnId = undefined; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: "", thinkingText: undefined, state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined, activity: nextActivityView(undefined, { phase: "evidence-validation", label: "正在处理…", }), } : message)); } else if (event.type === "run.failed") { streamFailed = true; } 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") { setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, activity: nextActivityView(message.activity, { phase: rectificationToolActivityPhase(tool), label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool], completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps), }), } : message)); continue; } if (event.status !== "completed" && event.status !== "failed") continue; activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, { tool, status: event.status, methods: event.status === "completed" && Array.isArray(event.methods) ? event.methods.filter(isPublicRectificationMethod) : [], }); completedReceipt = receiptFromRectificationActivityState(activityReceiptState); } } } const parsed = parseAgentReply(raw); 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, state: "settled", completedReceipt, failed: false, turnId: completedTurnId, activity: undefined, }]; } if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) { return [{ ...message, text: parsed.text, state: "settled", completedReceipt, failed: true, activity: undefined, }]; } return []; })); if (!succeeded && parsed.text) { setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。"); } else if (!succeeded && completedReceipt.failedTool) { setError(completedReceipt.failedTool === "rectification-compare-candidates" ? "候选比较未完成,当前进度已保留。" : "本轮处理未完成,当前进度已保留。请稍后再试。"); } if (succeeded) { onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), { role: "assistant", text: parsed.text }, ]); onCompleted?.(); await loadCaseSnapshot(); } } catch (caught) { const aborted = caught instanceof DOMException ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; if (aborted) { const parsed = parseAgentReply(raw); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (parsed.text) { return [{ ...message, text: parsed.text, state: "settled", completedReceipt, failed: false, turnId: completedTurnId, }]; } return []; })); 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 { if (runAbort.current === abortController) runAbort.current = null; setPending(false); } }, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); useEffect(() => { if (readonly || openingStarted.current || !shouldStartOpening) return; openingStarted.current = true; void send("opening", ""); }, [readonly, send, shouldStartOpening]); const acceptCandidate = useCallback(async (candidateId: string) => { if (!candidateResult || acceptingCandidateId || readonly) return; setError(""); setAcceptingCandidateId(candidateId); 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(); } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { setAcceptingCandidateId(null); } }, [acceptingCandidateId, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId]); async function copyMessage(message: RenderMessage) { try { await navigator.clipboard.writeText(message.text); 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 || "暂时无法重新生成回答"); } setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: payload.assistantMessage, 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 offeredSelectionOnce = messages.some(turnOfferedSelection); const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant)); const showChoiceCards = Boolean( choiceCard && latestSettledAssistant && !offeredThisTurn && !busy && !readonly && regeneratingMessageKey === null, ); const showSelectionCards = Boolean( candidateResult?.selectionAllowed && offeredSelectionOnce && latestSettledAssistant && !showChoiceCards && !busy && regeneratingMessageKey === null, ); const selectionCardMessageKey = showSelectionCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; const choiceCardMessageKey = showChoiceCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; const canSend = !busy && !readonly && !regeneratingMessageKey; function submitChoice(key: ChoiceKey) { if (!choiceCard) return; void send("message", choiceCardUserMessage(choiceCard, key)); } function submitStop() { void send("message", choiceCard?.stop_message ?? CHOICE_STOP_MESSAGE); } const boardPeek = compactBoard && !boardOpen ? ( ) : null; return (
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
{pendingConsultationQuestion?.trim() && (

先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”

)} {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; const displayedMessage = regenerating ? { ...message, text: "", thinkingText: undefined, state: "thinking" as const, activity: nextActivityView(undefined, { phase: "answer-composition", label: "正在组织回答…", }), } : message; const vargaSentence = message.state === "settled" && !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; return (
{message.state === "settled" && message.failed && (

{message.text ? "回答未完成,已保留现有内容;本次不会扣点。" : "本轮处理未完成,已保留服务端记录的执行进度。"}

)} {(!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)} /> )} {showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && ( )}
); })} {savedTime && (

{savedStatus === "confirmed" ? "已确认校正时间" : "当前排盘时间(代表性时间,本会话不确认唯一分钟)"}:{savedTime}。后续排盘将使用该时间;你仍可继续补充事件或改选其他候选。

{savedStatus === "accepted" && onStartConsultation && (
)}
)} {error &&

{error}

} {readonly && (

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

)}