refactor: make birth time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-27 22:31:11 +08:00
parent b569cf7825
commit a550b98da7
25 changed files with 756 additions and 1788 deletions
@@ -1,337 +1,21 @@
"use client";
import { ArrowUp, Check, Copy, RotateCcw, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ChatMessageRow } from "./chat-message-row.tsx";
import { AppLoadingIndicator } from "./app-loading-indicator.tsx";
import { ModelSelector } from "./model-selector.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import type { PublicLanguageModel } from "../lib/public-models.ts";
import {
RectificationV4Panel,
type RectificationV4Continuation,
} from "./rectification-v4-panel.tsx";
import {
type ConversationalRectificationMessage,
type ConversationalRectificationStoredMessage,
type ConversationalRectificationController,
} from "../hooks/use-conversational-rectification.ts";
import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts";
import type { PublicLanguageModel } from "../lib/public-models.ts";
type SurfaceProps = Readonly<{
controller: ConversationalRectificationController;
openingAssistantText?: string;
export type ConversationalBirthTimeRectificationProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onContinueOriginalQuestion?: (question: string) => void;
}>;
const ANSWER_UNDO_WINDOW_MS = 2_500;
function safely(request: Promise<unknown>) {
void request.catch(() => undefined);
}
export function ConversationalRectificationSurface({
controller,
openingAssistantText = "",
models,
selectedModelId,
onSelectModel,
pendingConsultationQuestion,
continuationPending = false,
onContinueOriginalQuestion,
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submission, setSubmission] = useState<Readonly<{
text: string;
phase: "undo" | "generating";
turnVersion: number;
}> | null>(null);
const turn = controller.turn;
const messageCount = controller.messages?.length ?? 0;
const latestMessageText = controller.messages?.[messageCount - 1]?.text ?? turn?.narrative ?? "";
const latestAssistantKey = [...(controller.messages ?? [])]
.reverse()
.find((message) => message.role === "assistant")?.renderKey
?? `assistant-${turn?.turnVersion ?? 0}`;
useEffect(() => () => {
if (undoTimer.current) clearTimeout(undoTimer.current);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
behavior: controller.pending || submission !== null || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [controller.error, controller.pending, latestMessageText, messageCount, submission, turn?.turnVersion]);
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
if (!turn) {
return (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
{openingAssistantText
? <ChatMessageRow message={{
role: "assistant",
text: openingAssistantText,
renderKey: "rectification-opening-assistant",
state: "streaming",
}} />
: <div className="conversational-loading" aria-live="polite" role="status">
<AppLoadingIndicator title="正在建立校正记录…" detail="正在加载校正进度,准备第一条问题。" />
</div>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</section>
);
}
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
const canConfirm = turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& Boolean(turn.candidate.representativeTime);
const canContinue = turn.actions.includes("continue_original_question")
&& Boolean(pendingQuestion)
&& Boolean(onContinueOriginalQuestion);
const busy = controller.pending || submission !== null;
const submit = () => {
const text = controller.draft.trim();
if (!canAnswer || !text || busy) return;
controller.setDraft("");
setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion });
undoTimer.current = setTimeout(async () => {
undoTimer.current = null;
setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion });
try {
await controller.answer(undefined, text);
} catch {
controller.setDraft(text);
} finally {
setSubmission(null);
composer.current?.focus();
}
}, ANSWER_UNDO_WINDOW_MS);
};
const copyMessage = async (message: ConversationalRectificationMessage) => {
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.
}
};
const regenerateMessage = async (messageKey: string) => {
setRegeneratingMessageKey(messageKey);
try {
await controller.regenerate();
} finally {
setRegeneratingMessageKey((current) => current === messageKey ? null : current);
}
};
const undoSubmission = () => {
if (submission?.phase !== "undo") return;
if (undoTimer.current) clearTimeout(undoTimer.current);
undoTimer.current = null;
controller.setDraft(submission.text);
setSubmission(null);
requestAnimationFrame(() => composer.current?.focus());
};
return (
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{(controller.messages ?? [{
role: "assistant" as const,
text: turn.narrative,
renderKey: `assistant-${turn.turnVersion}`,
}]).map((message) => (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow
message={regeneratingMessageKey === message.renderKey
? { role: "assistant", text: "", renderKey: message.renderKey, state: "thinking" }
: {
role: message.role,
text: message.text,
renderKey: message.renderKey,
state: "settled",
}}
/>
{message.role === "assistant" && regeneratingMessageKey !== message.renderKey && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: current[message.renderKey] === "up" ? undefined : "up",
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: current[message.renderKey] === "down" ? undefined : "down",
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重跑回答"
disabled={busy || message.renderKey !== latestAssistantKey || !canAnswer}
title={message.renderKey === latestAssistantKey ? "重跑" : "只能重跑最新回答"}
type="button"
onClick={() => safely(regenerateMessage(message.renderKey))}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
))}
{submission && turn.turnVersion === submission.turnVersion && (
<ChatMessageRow
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
/>
)}
{controller.pending && canAnswer && regeneratingMessageKey === null && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
{(canAnswer || canConfirm || canContinue) && <div className="composer-wrap rectification-composer-wrap">
{(canConfirm || canContinue) && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canConfirm && (
<button
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
disabled={busy}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{turn.candidate.representativeTime}
</button>
)}
{canContinue && (
<button
disabled={busy || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
>
{continuationPending ? "正在继续回答…" : "返回原问题"}
</button>
)}
</div>
)}
{canAnswer && <>
{controller.correctionTarget && (
<div className="rectification-correction-target" role="status">
<p>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
<button disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
<Textarea
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={busy}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:更正为 2020 年 11 月离职"
: "像聊天一样回答即可,例如:2018 年 6 月去了上海工作"}
rows={2}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
{submission?.phase === "undo" ? (
<Button aria-label="撤回发送,本次不计入校正" title="撤回发送" size="icon" type="button" onClick={undoSubmission}>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={busy ? "正在核对" : "发送"} disabled={busy || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy}
onSelect={onSelectModel}
/>
</div>
</>}
</div>}
</section>
);
}
type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
initialMessages?: readonly ConversationalRectificationStoredMessage[];
openingAssistantText?: string;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onTurn?: (
turn: ConversationalRectificationTurn,
messages: readonly ConversationalRectificationMessage[],
) => void;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
return (
<RectificationV4Panel
pendingConsultationQuestion={props.pendingConsultationQuestion}
continuationPending={props.continuationPending}
onPendingChange={props.onPendingChange}
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
/>
);
return <RectificationV4Panel {...props} />;
}
+211 -163
View File
@@ -1,47 +1,16 @@
"use client";
import { ArrowUp, Check, Pause, Play, Square } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
import type { CandidateCluster, LifeEventRevision } from "@/lib/rectification-v4/contracts";
import { AppLoadingIndicator } from "./app-loading-indicator";
import type { ChatMessageView } from "@/lib/chat-message-view";
import type { PublicLanguageModel } from "@/lib/public-models";
import type { RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
const phaseCopy = {
extracting_evidence: "正在整理经历",
scoring_candidates: "正在比较候选时间",
checking_robustness: "正在做稳定性复核",
planning_question: "正在准备下一步问题",
collecting_evidence: "正在准备下一步问题",
complete: "正在整理结果",
} as const;
function minutes(time: string) {
const [hour = 0, minute = 0] = time.split(":").map(Number);
return hour * 60 + minute;
}
function inCluster(time: string, cluster: CandidateCluster) {
const value = minutes(time);
const start = minutes(cluster.startTime);
const end = minutes(cluster.endTime);
return end >= start ? value >= start && value <= end : value >= start || value <= end;
}
function latestEvents(events: readonly LifeEventRevision[]) {
const latest = new Map<string, LifeEventRevision>();
for (const event of events) {
const current = latest.get(event.eventId);
if (!current || current.revision < event.revision) latest.set(event.eventId, event);
}
return [...latest.values()].sort((left, right) => left.dateRange.start.localeCompare(right.dateRange.start));
}
function eventText(event: LifeEventRevision) {
return `${event.dateRange.label} · ${event.summary}`;
}
export type RectificationV4Continuation = Readonly<{
protocol: "rectification-evidence-v4";
question: string;
@@ -50,155 +19,234 @@ export type RectificationV4Continuation = Readonly<{
acceptedRange: Readonly<{ start: string; end: string }>;
}>;
export function RectificationV4Panel(props: Readonly<{
type RectificationV4PanelProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>) {
}>;
export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
pendingConsultationQuestion?: string | null,
): readonly ChatMessageView[] {
if (!data) {
return [{
role: "assistant",
text: "",
renderKey: "rectification-loading",
state: "thinking",
}];
}
const messages: ChatMessageView[] = [];
if (pendingConsultationQuestion?.trim()) {
messages.push({
role: "assistant",
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingConsultationQuestion.trim()}`,
renderKey: "rectification-pending-consultation",
state: "settled",
});
}
for (const turn of data.turns) {
messages.push({
role: "assistant",
text: turn.question,
renderKey: `rectification-question-${turn.id}`,
state: "settled",
});
if (turn.answer) {
messages.push({
role: "user",
text: turn.answer,
renderKey: `rectification-answer-${turn.id}`,
state: "settled",
});
}
}
const caseValue = data.case;
const primary = caseValue.latestSnapshot?.clusters[0];
if (caseValue.acceptedRange) {
messages.push({
role: "assistant",
text: `候选范围已保存为 ${caseValue.acceptedRange.start}${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`,
renderKey: `rectification-accepted-${caseValue.version}`,
state: "settled",
});
} else if (caseValue.status === "range_ready" && primary) {
messages.push({
role: "assistant",
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
renderKey: `rectification-range-${caseValue.version}`,
state: "settled",
});
}
if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange) {
messages.push({
role: "assistant",
text: caseValue.currentQuestion.prompt,
renderKey: `rectification-current-${caseValue.currentQuestion.id}`,
state: "settled",
});
}
if (processing) {
messages.push({
role: "assistant",
text: "",
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
state: "thinking",
});
} else if (caseValue.status === "paused") {
messages.push({
role: "assistant",
text: "进度已经保存。准备好后,我们可以从这里继续。",
renderKey: `rectification-paused-${caseValue.version}`,
state: "settled",
});
} else if (caseValue.status === "abandoned") {
messages.push({
role: "assistant",
text: "这次校正已经结束,原出生时间没有被改写。",
renderKey: `rectification-abandoned-${caseValue.version}`,
state: "settled",
});
}
return messages;
}
export function RectificationV4Panel(props: RectificationV4PanelProps) {
const controller = useRectificationV4({
pendingConsultationQuestion: props.pendingConsultationQuestion,
onPendingChange: props.onPendingChange,
});
const [draft, setDraft] = useState("");
const composer = useRef<HTMLTextAreaElement>(null);
const data = controller.data;
const caseValue = data?.case;
const snapshot = caseValue?.latestSnapshot;
const primary = snapshot?.clusters[0];
const allEvents = useMemo(() => latestEvents(data?.events ?? []), [data?.events]);
const eventById = useMemo(() => new Map(allEvents.map((event) => [event.eventId, event])), [allEvents]);
const evidence = useMemo(() => {
if (!snapshot || !primary) return { supporting: [] as LifeEventRevision[], conflicting: [] as LifeEventRevision[] };
const candidates = snapshot.candidates.filter((candidate) => inCluster(candidate.time, primary));
const supporting = new Set(candidates.flatMap((candidate) => candidate.supportingEventIds));
const conflicting = new Set(candidates.flatMap((candidate) => candidate.conflictingEventIds));
return {
supporting: [...supporting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
conflicting: [...conflicting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
};
}, [eventById, primary, snapshot]);
if (controller.loading) {
return <section className="rectification-v4-panel"><AppLoadingIndicator title="正在打开生时校正" detail="正在恢复已经保存的进度" /></section>;
}
if (!caseValue) {
return <section className="rectification-v4-panel" role="alert"><p>{controller.error || "暂时无法打开生时校正。"}</p></section>;
}
const processing = caseValue.status === "processing" || ["pending", "processing"].includes(controller.job?.status ?? "");
const phase = controller.job?.phase ?? caseValue.phase;
const canAnswer = Boolean(caseValue.currentQuestion) && !processing && ["awaiting_answer", "range_ready"].includes(caseValue.status);
const accepted = caseValue.acceptedRange;
const conversationEnd = useRef<HTMLDivElement>(null);
const caseValue = controller.data?.case;
const processing = Boolean(caseValue && (
caseValue.status === "processing"
|| ["pending", "processing"].includes(controller.job?.status ?? "")
));
const messages = rectificationV4ChatMessages(
controller.data,
processing,
props.pendingConsultationQuestion,
);
const canAnswer = Boolean(caseValue?.currentQuestion)
&& !processing
&& !controller.pending
&& ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? "");
const canAcceptRange = caseValue?.status === "range_ready"
&& Boolean(caseValue.latestSnapshot?.canAcceptRange)
&& !caseValue.acceptedRange;
const handoff = controller.handoff;
const canContinue = Boolean(accepted && handoff?.status === "pending" && props.onContinueOriginalQuestion);
const canContinue = Boolean(
caseValue?.acceptedRange
&& handoff?.status === "pending"
&& props.onContinueOriginalQuestion,
);
const showControls = Boolean(caseValue && caseValue.status !== "abandoned" && (
canAnswer || canAcceptRange || canContinue || caseValue.status === "paused"
));
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
behavior: processing || controller.pending || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [controller.error, controller.pending, messages.length, processing]);
async function submit(event: React.FormEvent) {
event.preventDefault();
const answer = draft.trim();
if (!answer || !canAnswer) return;
const result = await controller.answer(answer);
const result = await controller.answer(answer, props.selectedModelId || null);
if (result) setDraft("");
}
function continueOriginalQuestion() {
if (!caseValue?.acceptedRange || !handoff) return;
props.onContinueOriginalQuestion?.({
protocol: "rectification-evidence-v4",
question: handoff.question,
caseId: caseValue.id,
caseVersion: caseValue.version,
acceptedRange: caseValue.acceptedRange,
});
}
return (
<section className="rectification-v4-panel" aria-busy={processing || controller.pending}>
<header className="rectification-v4-header">
<div>
<p className="rectification-v4-eyebrow"> · </p>
<h2></h2>
<p> {caseValue.calculationSpec.candidateRange.start}{caseValue.calculationSpec.candidateRange.end} </p>
</div>
{caseValue.status === "paused" ? (
<Button type="button" variant="outline" disabled={controller.pending} onClick={() => void controller.resume()}><Play aria-hidden="true" /></Button>
) : caseValue.status !== "abandoned" && !accepted ? (
<Button type="button" variant="outline" disabled={processing || controller.pending} onClick={() => void controller.pause()}><Pause aria-hidden="true" /></Button>
) : null}
</header>
<section className="rectification-chat" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
{props.pendingConsultationQuestion && (
<aside className="rectification-v4-context">
<b></b>
<p>{props.pendingConsultationQuestion}</p>
</aside>
)}
{showControls && (
<div className="composer-wrap">
{(canAcceptRange || canContinue || caseValue?.status === "paused") && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canAcceptRange && (
<button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}>
</button>
)}
{canContinue && (
<button type="button" disabled={props.continuationPending} onClick={continueOriginalQuestion}>
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
</button>
)}
{caseValue?.status === "paused" && (
<button type="button" disabled={controller.pending} onClick={() => void controller.resume()}>
</button>
)}
</div>
)}
{processing && (
<div className="rectification-v4-processing" role="status">
<AppLoadingIndicator title={phaseCopy[phase]} detail="回答已经保存,计算在后台继续" />
<p></p>
{canAnswer && (
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label="继续描述你的经历"
value={draft}
disabled={controller.pending}
placeholder="继续说你记得的人生经历…"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label="发送" disabled={!draft.trim() || controller.pending} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
</form>
)}
<div className="composer-footer">
<ModelSelector
models={props.models}
selectedModelId={props.selectedModelId}
disabled={controller.pending || processing}
onSelect={props.onSelectModel}
/>
</div>
</div>
)}
{snapshot && primary && !processing && (
<article className="rectification-v4-result">
<p className="rectification-v4-eyebrow"></p>
<div className="rectification-v4-ranges">
<div><span></span><strong>{primary.startTime}{primary.endTime}</strong></div>
{snapshot.clusters[1] && <div><span></span><strong>{snapshot.clusters[1].startTime}{snapshot.clusters[1].endTime}</strong></div>}
</div>
<div className="rectification-v4-evidence-grid">
<div>
<h3></h3>
{evidence.supporting.length > 0 ? <ul>{evidence.supporting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p></p>}
</div>
<div>
<h3></h3>
{evidence.conflicting.length > 0 ? <ul>{evidence.conflicting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p></p>}
</div>
</div>
<p className="rectification-v4-uncertainty"></p>
<div className="rectification-v4-actions">
{caseValue.currentQuestion && !accepted && <Button type="button" variant="outline" onClick={() => composer.current?.focus()}></Button>}
{snapshot.canAcceptRange && !accepted && <Button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}><Check aria-hidden="true" /></Button>}
{accepted && <span className="rectification-v4-saved"><Check aria-hidden="true" /> {accepted.start}{accepted.end}</span>}
</div>
</article>
)}
{caseValue.status === "paused" && <p className="rectification-v4-notice"></p>}
{caseValue.status === "abandoned" && <p className="rectification-v4-notice"></p>}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
{canAnswer && (
<form className="rectification-v4-composer" onSubmit={submit}>
<label htmlFor="rectification-v4-answer">{caseValue.currentQuestion?.prompt}</label>
<Textarea
id="rectification-v4-answer"
ref={composer}
value={draft}
disabled={controller.pending}
placeholder="例如:2015 年高中毕业后复读一年,2016 年再次毕业。"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label="提交这段经历" disabled={!draft.trim() || controller.pending} size="icon" type="submit"><ArrowUp aria-hidden="true" /></Button>
</form>
)}
<footer className="rectification-v4-footer">
{canContinue && accepted && handoff && (
<Button type="button" disabled={props.continuationPending} onClick={() => props.onContinueOriginalQuestion?.({
protocol: "rectification-evidence-v4",
question: handoff.question,
caseId: caseValue.id,
caseVersion: caseValue.version,
acceptedRange: accepted,
})}>
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
</Button>
)}
{accepted && handoff?.status === "in_progress" && <span className="rectification-v4-saved"></span>}
{accepted && handoff?.status === "consumed" && <span className="rectification-v4-saved"><Check aria-hidden="true" /></span>}
{!accepted && caseValue.status !== "abandoned" && (
<button type="button" disabled={processing || controller.pending} onClick={() => void controller.abandon()}><Square aria-hidden="true" /></button>
)}
</footer>
</section>
);
}