Files
Jyotisha/frontend/src/components/conversational-birth-time-rectification.tsx
T
2026-07-21 22:53:56 +08:00

441 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Record<EvidenceDomain, string>>;
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<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
? "已明确确认"
: 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 (
<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>}
{!confirmed && recordedCount > 0 && turn.status !== "completed" && (
<div className="conversational-candidate-progress" role="status">
<strong> {recordedCount} </strong>
<span> 3 </span>
{nextDomains.length > 0 && (
<span>{nextDomains.join("或")}</span>
)}
</div>
)}
</section>
);
}
export function ConversationalRectificationSurface({
controller,
pendingConsultationQuestion,
continuationPending = false,
onContinueOriginalQuestion,
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(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 (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
<p className="conversational-empty-state" aria-live="polite" role="status"></p>
{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 = 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 (
<section
className="conversational-rectification"
aria-busy={controller.pending}
aria-label="生时校正对话"
>
<CandidateSummary turn={turn} />
<article className="conversational-narrative" aria-label="校正分析">
<div className="conversational-narrative-body">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
</div>
</article>
{requestedDomains.length > 0 && (
<fieldset className="conversational-domain-picker">
<legend className="conversational-domain-question"></legend>
<div>
{requestedDomains.map((domain) => (
<button
aria-label={`${domainLabels[domain]}${domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}`}
aria-pressed={controller.selectedDomain === domain}
data-evidence-domain={domain}
disabled={controller.pending || !canAnswer}
key={domain}
type="button"
onClick={() => {
controller.selectDomain(domain);
focusComposer();
}}
>
<span>{domainLabels[domain]}</span>
<small>{domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}</small>
</button>
))}
</div>
</fieldset>
)}
{controller.pending && canAnswer && (
<div
aria-atomic="true"
aria-label="正在核对经历"
aria-live="polite"
className="conversational-answer-pending"
role="status"
>
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
<div>
<strong></strong>
<span></span>
</div>
</div>
)}
{canAnswer && <form
className="conversational-composer"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{controller.correctionTarget && (
<div className="conversational-correction-target" role="status">
<p>
<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
</p>
<span>使</span>
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => controller.cancelEvidenceCorrection()}
>
</button>
</div>
)}
<label htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
<span></span>
</label>
<div className="conversational-event-date" role="group" aria-label="经历发生时间">
<label>
<select
aria-label="经历发生年份"
disabled={controller.pending || !canAnswer}
value={eventYear}
onChange={(event) => {
setEventYear(event.target.value);
if (!event.target.value) setEventMonth("");
}}
>
<option value=""></option>
{eventYears.map((year) => <option key={year} value={year}>{year} </option>)}
</select>
</label>
<label>
<select
aria-label="经历发生月份"
disabled={controller.pending || !canAnswer || !eventYear}
value={eventMonth}
onChange={(event) => setEventMonth(event.target.value)}
>
<option value=""></option>
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
<option key={month} value={month}>{month} </option>
))}
</select>
</label>
</div>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:其实是 2020 年 11 月离职"
: "请描述一件已经发生的具体事件"}
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();
void 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>
{entry.isCorrection && <span className="conversational-correction-badge"></span>}
</div>
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending || !canAnswer}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
focusComposer();
}}
>
</button>
</li>
))}
</ul>
</section>
)}
{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
aria-live="polite"
className="conversational-status"
>
{turn.status === "paused" && <p></p>}
{turn.status === "abandoned" && <p></p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <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
&& onContinueOriginalQuestion && (
<section className="conversational-original-question">
<p>{pendingQuestion}</p>
<button
className="button-primary"
disabled={controller.pending || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion)}
>
{continuationPending
? "正在继续回答原问题…"
: turn.candidate.status === "confirmed"
? "使用新确认时间继续回答原问题"
: "返回原对话并继续回答"}
</button>
</section>
)}
</section>
);
}
type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onTurn?: (turn: ConversationalRectificationTurn) => void;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (question: string) => void;
}>;
export function ConversationalBirthTimeRectification(
props: ConversationalBirthTimeRectificationProps,
) {
const pendingChange = useRef(props.onPendingChange);
useEffect(() => {
pendingChange.current = props.onPendingChange;
}, [props.onPendingChange]);
useEffect(() => () => pendingChange.current?.(false), []);
const controller = useConversationalRectification({
initialTurn: props.initialTurn,
onTurn: props.onTurn,
onPendingChange: props.onPendingChange,
});
return (
<ConversationalRectificationSurface
controller={controller}
pendingConsultationQuestion={props.pendingConsultationQuestion}
continuationPending={props.continuationPending}
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
/>
);
}