refactor: make birth time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-27 22:33:12 +08:00
parent b569cf7825
commit a550b98da7
25 changed files with 753 additions and 1785 deletions
+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>
);
}