feat: render conversational rectification

This commit is contained in:
Jesse_Chen
2026-07-21 03:46:11 +08:00
parent 9f9c249de8
commit 4d8c5268d3
7 changed files with 1188 additions and 0 deletions
@@ -0,0 +1,342 @@
"use client";
import { 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: "学业与学习",
relocation: "搬迁与居住地",
relationship: "重要关系",
family: "家庭变化",
other: "其他关键经历",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
type SurfaceProps = Readonly<{
controller: ConversationalRectificationController;
pendingConsultationQuestion?: string | null;
onContinueOriginalQuestion?: (question: string) => void;
}>;
function safely(request: Promise<unknown>) {
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
? "已明确确认"
: candidate.status === "ready_for_confirmation"
? "待确认 · 未验证"
: "待验证 · 未确认";
return (
<section className="conversational-candidate" aria-labelledby="conversational-candidate-title">
<header>
<h3 id="conversational-candidate-title"></h3>
<span className={confirmed ? "is-confirmed" : "is-unverified"}>{status}</span>
</header>
{candidate.representativeTime ? (
<dl>
<div>
<dt></dt>
<dd><time dateTime={candidate.representativeTime}>{candidate.representativeTime}</time></dd>
</div>
<div>
<dt></dt>
<dd>{candidate.rangeStart && candidate.rangeEnd
? `${candidate.rangeStart}${candidate.rangeEnd}`
: "尚未缩小"}</dd>
</div>
</dl>
) : <p></p>}
{!confirmed && <p></p>}
</section>
);
}
function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectificationTurn }) {
const receipt = turn.technicalReceipt;
return (
<details className="conversational-technical-receipt">
<summary></summary>
<dl>
<div><dt></dt><dd><code>{receipt.calculationVersion}</code></dd></div>
<div><dt></dt><dd>{receipt.stableLayers.join("、") || "无"}</dd></div>
<div><dt></dt><dd>{receipt.sensitiveLayers.join("、") || "无"}</dd></div>
<div><dt></dt><dd>{receipt.candidateDifferenceRefs.join("、") || "无"}</dd></div>
</dl>
</details>
);
}
export function ConversationalRectificationSurface({
controller,
pendingConsultationQuestion,
onContinueOriginalQuestion,
}: SurfaceProps) {
const [abandonArmed, setAbandonArmed] = useState(false);
const composer = useRef<HTMLTextAreaElement>(null);
const turn = controller.turn;
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
if (!turn) {
return (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
<div className="conversational-empty-state" aria-live="polite">
<p></p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.start(pendingQuestion))}
>
{controller.pending ? "正在建立校正记录…" : "开始生时校正"}
</button>
</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 requestedDomains = turn.evidenceRequest?.domains ?? [];
const submit = () => {
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
};
const focusComposer = () => composer.current?.focus();
return (
<section
className="conversational-rectification"
aria-busy={controller.pending}
aria-label="生时校正对话"
>
<article className="conversational-narrative" aria-label="校正分析">
<ChatMessageContent text={turn.narrative} />
</article>
{requestedDomains.length > 0 && (
<fieldset className="conversational-domain-picker">
<legend></legend>
<div>
{requestedDomains.map((domain) => (
<button
aria-pressed={controller.selectedDomain === domain}
data-evidence-domain={domain}
disabled={controller.pending || !canAnswer}
key={domain}
type="button"
onClick={() => {
controller.selectDomain(domain);
focusComposer();
}}
>
{domainLabels[domain]}
</button>
))}
</div>
</fieldset>
)}
<form
className="conversational-composer"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<label htmlFor="conversational-rectification-answer">
<span></span>
</label>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
ref={composer}
rows={4}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
submit();
}
}}
/>
<div className="conversational-composer-footer">
<small>Ctrl/ + Enter </small>
<button
className="button-primary"
disabled={controller.pending || !canAnswer || !controller.draft.trim()}
type="submit"
>
{controller.pending ? "正在核对…" : "发送这段经历"}
</button>
</div>
</form>
{turn.evidenceRecap.length > 0 && (
<section className="conversational-evidence-recap" aria-labelledby="conversational-evidence-title">
<header>
<h3 id="conversational-evidence-title"></h3>
<span>{turn.evidenceRecap.length} </span>
</header>
<ul>
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<div><time>{entry.dateLabel}</time><p>{entry.summary}</p></div>
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending || !canAnswer}
type="button"
onClick={() => {
controller.setDraft(`更正「${entry.summary}」(${entry.dateLabel}):`);
focusComposer();
}}
>
</button>
</li>
))}
</ul>
</section>
)}
<CandidateSummary turn={turn} />
<TechnicalReceipt turn={turn} />
{turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& turn.candidate.representativeTime && (
<section className="conversational-confirmation" aria-labelledby="conversational-confirmation-title">
<h3 id="conversational-confirmation-title"></h3>
<p></p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{controller.pending
? "确认中…"
: `确认将 ${turn.candidate.representativeTime} 设为当前排盘时间`}
</button>
</section>
)}
<div className="conversational-status" aria-live="polite">
{turn.status === "paused" && <p></p>}
{turn.status === "abandoned" && <p></p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <p></p>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</div>
{turn.actions.includes("continue_original_question") && pendingQuestion && (
<section className="conversational-original-question">
<p>{pendingQuestion}</p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion)}
>
</button>
</section>
)}
<footer className="conversational-session-actions">
{turn.status === "paused" ? (
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.resume())}
>
</button>
) : turn.actions.includes("pause") ? (
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.pause())}
>
</button>
) : null}
{turn.actions.includes("abandon") && !abandonArmed && (
<button
className="conversational-abandon"
disabled={controller.pending}
type="button"
onClick={() => setAbandonArmed(true)}
>
</button>
)}
</footer>
{abandonArmed && (
<section className="conversational-abandon-confirmation" role="alert">
<p></p>
<div>
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => setAbandonArmed(false)}
>
</button>
<button
className="conversational-abandon is-confirm"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.abandon())}
>
</button>
</div>
</section>
)}
</section>
);
}
type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
pendingConsultationQuestion?: string | null;
onTurn?: (turn: ConversationalRectificationTurn) => void;
onContinueOriginalQuestion?: (question: string) => void;
}>;
export function ConversationalBirthTimeRectification(
props: ConversationalBirthTimeRectificationProps,
) {
const controller = useConversationalRectification({
initialTurn: props.initialTurn,
onTurn: props.onTurn,
});
return (
<ConversationalRectificationSurface
controller={controller}
pendingConsultationQuestion={props.pendingConsultationQuestion}
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
/>
);
}