feat: make birth-time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-23 13:12:09 +08:00
parent 200c39d22b
commit f9010c8797
34 changed files with 1720 additions and 177 deletions
@@ -1,9 +1,8 @@
"use client";
import { ArrowUp } from "lucide-react";
import { useEffect, useRef } from "react";
import { ChatMessageContent } from "./chat-message-content.tsx";
import { AgentAvatar, ChatMessageRow } from "./chat-message-row.tsx";
import { ArrowUp, Square } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ChatMessageRow } from "./chat-message-row.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import {
@@ -12,62 +11,6 @@ import {
} 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: "收入与资产",
health_pressure: "健康与重大压力",
relocation: "搬迁与居住地",
relationship: "重要关系",
family: "家庭变化",
other: "其他关键经历",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
function nextEvidenceDomains(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)))
.slice(0, 2);
}
function visibleTurnNarrative(turn: ConversationalRectificationTurn): string {
if (["paused", "abandoned", "completed"].includes(turn.status)) {
return turn.narrative;
}
const suggestedDomains = nextEvidenceDomains(turn).map((domain) => domainLabels[domain]);
if (turn.evidenceRecap.length === 0) {
const examples = suggestedDomains.length > 0
? suggestedDomains.join("或")
: "工作、搬迁、关系或学业";
return `为了帮助校正出生时间,请先说一件${examples}方面已经发生的重要经历。最好带上年月,直接像聊天一样描述即可。`;
}
const latestEvidence = turn.evidenceRecap.at(-1)!;
if (turn.candidate.status === "ready_for_confirmation") {
return [
`${latestEvidence.isCorrection ? "已修订" : "已记录"}${latestEvidence.dateLabel} · ${latestEvidence.summary}`,
"目前已经形成一个待确认候选。你可以展开下方详情核对,也可以继续补充一件带年月的真实经历。",
].join("\n\n");
}
const candidateRange = turn.candidate.rangeStart && turn.candidate.rangeEnd
? `${turn.candidate.rangeStart}${turn.candidate.rangeEnd}`
: turn.candidate.representativeTime ?? "当前候选范围";
const nextQuestion = suggestedDomains.length > 0
? `接下来请说一件${suggestedDomains.join("或")}方面已经发生的事,尽量带上年月。`
: "接下来请再说一件已经发生的真实经历,尽量带上年月。";
return [
`${latestEvidence.isCorrection ? "已修订" : "已记录"}${latestEvidence.dateLabel} · ${latestEvidence.summary}`,
`候选范围现在是 ${candidateRange}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`,
nextQuestion,
].join("\n\n");
}
type SurfaceProps = Readonly<{
controller: ConversationalRectificationController;
pendingConsultationQuestion?: string | null;
@@ -75,6 +18,8 @@ type SurfaceProps = Readonly<{
onContinueOriginalQuestion?: (question: string) => void;
}>;
const ANSWER_UNDO_WINDOW_MS = 2_500;
function safely(request: Promise<unknown>) {
void request.catch(() => undefined);
}
@@ -93,7 +38,16 @@ export function ConversationalRectificationSurface({
onContinueOriginalQuestion,
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(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;
useEffect(() => () => {
if (undoTimer.current) clearTimeout(undoTimer.current);
}, []);
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
@@ -114,42 +68,59 @@ export function ConversationalRectificationSurface({
const canContinue = turn.actions.includes("continue_original_question")
&& Boolean(pendingQuestion)
&& Boolean(onContinueOriginalQuestion);
const submit = async () => {
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
try {
await controller.answer(undefined, controller.draft.trim());
composer.current?.focus();
} catch {
// The controller keeps the draft and owns the visible error.
}
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 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={controller.pending} aria-label="生时校正对话">
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{turn.evidenceRecap.map((entry) => (
<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) => (
<ChatMessageRow
key={entry.id}
key={message.renderKey}
message={{
role: "user",
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
renderKey: entry.id,
role: message.role,
text: message.text,
renderKey: message.renderKey,
state: "settled",
}}
/>
))}
{controller.pending && controller.draft.trim() && (
{submission && turn.turnVersion === submission.turnVersion && (
<ChatMessageRow
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
/>
)}
<article className="message message-assistant" aria-label="Jyotisha">
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
<details className="rectification-message-details">
<details className="rectification-message-details rectification-progress-details">
<summary>
{turn.candidate.representativeTime
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
@@ -169,7 +140,7 @@ export function ConversationalRectificationSurface({
{canAnswer && (
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending}
disabled={busy}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
@@ -183,10 +154,7 @@ export function ConversationalRectificationSurface({
))}
</ul>
)}
</details>
</div>
</div>
</article>
</details>
{controller.pending && canAnswer && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
@@ -209,7 +177,7 @@ export function ConversationalRectificationSurface({
{canConfirm && (
<button
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
disabled={controller.pending}
disabled={busy}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
@@ -218,7 +186,7 @@ export function ConversationalRectificationSurface({
)}
{canContinue && (
<button
disabled={controller.pending || continuationPending}
disabled={busy || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
>
@@ -231,12 +199,12 @@ export function ConversationalRectificationSurface({
{controller.correctionTarget && (
<div className="rectification-correction-target" role="status">
<p>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
<button disabled={controller.pending} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
<button disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
@@ -244,7 +212,7 @@ export function ConversationalRectificationSurface({
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={controller.pending}
disabled={busy}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:更正为 2020 年 11 月离职"
@@ -259,12 +227,20 @@ export function ConversationalRectificationSurface({
}
}}
/>
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
{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">
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
<p>{submission?.phase === "undo"
? "已发送,2.5 秒内可撤回修改,本次不会计入校正。"
: busy ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
</div>
</>}
</div>}