"use client"; import { useEffect, useRef, useState, } from "react"; import { ChatMessageContent } from "./chat-message-content.tsx"; import { useConversationalRectification, type ConversationalRectificationController, } from "../hooks/use-conversational-rectification.ts"; import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts"; type EvidenceDomain = NonNullable< ConversationalRectificationTurn["evidenceRequest"] >["domains"][number]; const domainLabels = { career: "事业与身份", education: "学业与学习", finance: "收入与资产", relocation: "搬迁与居住地", relationship: "重要关系", family: "家庭变化", other: "其他关键经历", } as const satisfies Readonly>; function orderedEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] { const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : [])); return [...(turn.evidenceRequest?.domains ?? [])].sort((left, right) => Number(provided.has(left)) - Number(provided.has(right))); } function visibleTurnNarrative(turn: ConversationalRectificationTurn): string { if ( turn.status !== "active" || turn.evidenceRecap.length === 0 || /(?:已记录|已修订):/.test(turn.narrative) ) { return turn.narrative; } const latestEvidence = turn.evidenceRecap.at(-1)!; const candidate = turn.candidate; const candidateRange = candidate.rangeStart && candidate.rangeEnd ? `${candidate.rangeStart}–${candidate.rangeEnd}` : candidate.representativeTime ?? "当前候选范围"; const nextDomains = orderedEvidenceDomains(turn) .slice(0, 2) .map((domain) => domainLabels[domain]); const nextStep = candidate.status === "ready_for_confirmation" ? "当前证据已形成候选总结;请先核对上方候选时间,再决定是否确认或继续补充经历。" : nextDomains.length > 0 ? `下一步:请优先补充一件${nextDomains.join("或")}领域已经发生的事件,并选择发生年月。` : "下一步:请继续补充一件已经发生并带有年月的真实经历。"; return [ `${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`, `候选范围当前为 ${candidateRange};范围暂未变化不代表提交失败,系统会结合后续经历继续比较相邻分钟。`, nextStep, ].join("\n"); } type SurfaceProps = Readonly<{ controller: ConversationalRectificationController; pendingConsultationQuestion?: string | null; continuationPending?: boolean; onContinueOriginalQuestion?: (question: string) => void; }>; function safely(request: Promise) { void request.catch(() => undefined); } function CandidateSummary({ turn }: { readonly turn: ConversationalRectificationTurn }) { const candidate = turn.candidate; const confirmed = candidate.status === "confirmed" && turn.status === "completed"; const status = confirmed ? "已明确确认" : turn.status === "completed" ? "范围已保存 · 分钟未确认" : candidate.status === "ready_for_confirmation" ? "待确认 · 未验证" : "待验证 · 未确认"; const recordedCount = turn.evidenceRecap.length; const nextDomains = orderedEvidenceDomains(turn) .slice(0, 2) .map((domain) => domainLabels[domain]); return (

候选时间

{status}
{candidate.representativeTime ? (
代表时间
候选范围
{candidate.rangeStart && candidate.rangeEnd ? `${candidate.rangeStart}—${candidate.rangeEnd}` : "尚未缩小"}
) :

尚未形成可供确认的具体时间。

} {!confirmed &&

候选仍待真实经历验证,未经你的明确确认不会成为当前排盘时间。

} {!confirmed && recordedCount > 0 && turn.status !== "completed" && (
已记录 {recordedCount} 条经历 系统至少需要 3 条时间明确、可评分的经历后才会可靠缩小范围;范围不变不代表提交失败。 {nextDomains.length > 0 && ( 下一步优先补充:{nextDomains.join("或")}领域已经发生的事件。 )}
)}
); } export function ConversationalRectificationSurface({ controller, pendingConsultationQuestion, continuationPending = false, onContinueOriginalQuestion, }: SurfaceProps) { const composer = useRef(null); const [eventYear, setEventYear] = useState(""); const [eventMonth, setEventMonth] = useState(""); const currentYear = new Date().getFullYear(); const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index); const turn = controller.turn; const pendingQuestion = turn?.status === "completed" ? turn.pendingConsultationQuestion : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; if (!turn) { return (

正在建立校正记录…

{controller.error &&

{controller.error}

}
); } const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; const requestedDomains = orderedEvidenceDomains(turn); const providedDomains = new Set( turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []), ); const priorityDomain = requestedDomains.find((domain) => !providedDomains.has(domain)) ?? requestedDomains[0] ?? null; const submit = async () => { if (!canAnswer || !controller.draft.trim() || controller.pending) return; const dateLabel = eventYear ? `${eventYear} 年${eventMonth ? ` ${Number(eventMonth)} 月` : ""}` : "时间不确定"; const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`; try { await controller.answer(undefined, answer); setEventYear(""); setEventMonth(""); } catch { // The controller owns the visible request error and keeps the draft available for retry. } }; const focusComposer = () => composer.current?.focus(); return (
{requestedDomains.length > 0 && (
这轮想先补充哪个领域?
{requestedDomains.map((domain) => ( ))}
)} {controller.pending && canAnswer && (
正在核对这段经历 正在比较相邻候选分钟;完成后会继续提问或更新候选范围。
)} {canAnswer &&
{ event.preventDefault(); void submit(); }} > {controller.correctionTarget && (

正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}

一次只更正一条事件。提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。
)}