feat: streamline conversational birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-22 17:39:11 +08:00
parent d40c16369d
commit 3069a7c8a0
30 changed files with 2059 additions and 590 deletions
@@ -1,4 +1,4 @@
export type BirthTimeAssessmentPhase = "saving_profile" | "assessing";
export type BirthTimeAssessmentPhase = "saving_profile" | "assessing" | "entering_home";
const progressCopy = {
saving_profile: {
@@ -9,6 +9,10 @@ const progressCopy = {
title: "正在生成生时评估",
detail: "正在读取已有资料并准备下一步,不需要重复操作。",
},
entering_home: {
title: "正在进入首页",
detail: "出生资料已保存,正在为你准备首页。",
},
} as const satisfies Record<BirthTimeAssessmentPhase, { readonly title: string; readonly detail: string }>;
export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) {
@@ -1,11 +1,11 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
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 { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import {
useConversationalRectification,
type ConversationalRectificationController,
@@ -20,46 +20,52 @@ const domainLabels = {
career: "事业与身份",
education: "学业与学习",
finance: "收入与资产",
health_pressure: "健康与重大压力",
relocation: "搬迁与居住地",
relationship: "重要关系",
family: "家庭变化",
other: "其他关键经历",
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
function orderedEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
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)));
return [...(turn.evidenceRequest?.domains ?? [])]
.sort((left, right) => Number(provided.has(left)) - Number(provided.has(right)))
.slice(0, 2);
}
function visibleTurnNarrative(turn: ConversationalRectificationTurn): string {
if (
turn.status !== "active"
|| turn.evidenceRecap.length === 0
|| /(?:已记录|已修订)/.test(turn.narrative)
) {
if (["paused", "abandoned", "completed"].includes(turn.status)) {
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("或")}领域已经发生的事件,并选择发生年月。`
: "下一步:请继续补充一件已经发生并带有年月的真实经历。";
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}范围暂未变化不代表提交失败,系统会结合后续经历继续比较相邻分钟。`,
nextStep,
].join("\n");
`候选范围现在是 ${candidateRange}范围暂未变化不代表提交失败,会结合后续经历继续比较相邻分钟。`,
nextQuestion,
].join("\n\n");
}
type SurfaceProps = Readonly<{
@@ -73,53 +79,11 @@ 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>
);
function candidateStatus(turn: ConversationalRectificationTurn): string {
if (turn.status === "completed" && turn.candidate.status === "confirmed") return "已确认";
if (turn.status === "completed") return "范围已保存,分钟未确认";
if (turn.candidate.status === "ready_for_confirmation") return "待确认,尚未验证";
return "待验证";
}
export function ConversationalRectificationSurface({
@@ -129,14 +93,11 @@ export function ConversationalRectificationSurface({
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="生时校正对话">
@@ -147,262 +108,166 @@ export function ConversationalRectificationSurface({
}
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 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 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("");
await controller.answer(undefined, controller.draft.trim());
composer.current?.focus();
} catch {
// The controller owns the visible request error and keeps the draft available for retry.
// The controller keeps the draft and owns the visible error.
}
};
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>
<section className="rectification-chat" aria-busy={controller.pending} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{turn.evidenceRecap.map((entry) => (
<ChatMessageRow
key={entry.id}
message={{
role: "user",
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
renderKey: entry.id,
state: "settled",
}}
/>
))}
{controller.pending && controller.draft.trim() && (
<ChatMessageRow
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
/>
)}
<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>
<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">
<summary>
{turn.candidate.representativeTime
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
: `校正进度 · ${candidateStatus(turn)}`}
</summary>
<p>
{turn.candidate.rangeStart && turn.candidate.rangeEnd
? `候选范围 ${turn.candidate.rangeStart}${turn.candidate.rangeEnd};已记录 ${turn.evidenceRecap.length} 条经历。`
: `已记录 ${turn.evidenceRecap.length} 条经历,尚未缩小候选范围。`}
</p>
<p></p>
{turn.evidenceRecap.length > 0 && (
<ul aria-label="已记录的真实经历">
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<span>{entry.dateLabel} · {entry.summary}{entry.isCorrection ? "(已修订)" : ""}</span>
{canAnswer && (
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
composer.current?.focus();
}}
>
</button>
)}
</li>
))}
</ul>
)}
</details>
</div>
</div>
</article>
{controller.pending && canAnswer && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
<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>}
{turn.status === "paused" && <ChatMessageRow message={{ role: "assistant", text: "校正已暂停,输入与现有证据都已保留。", renderKey: "rectification-paused", state: "settled" }} />}
{turn.status === "abandoned" && <ChatMessageRow message={{ role: "assistant", text: "本次校正已放弃,候选时间没有应用。", renderKey: "rectification-abandoned", state: "settled" }} />}
{turn.status === "completed" && <ChatMessageRow message={{
role: "assistant",
text: turn.candidate.status === "confirmed"
? "候选时间已经过你的明确确认。"
: "候选范围已保存,代表时间没有自动设为当前排盘时间。",
renderKey: "rectification-completed",
state: "settled",
}} />}
{controller.error && <p className="error-message" 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>
)}
{(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={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{turn.candidate.representativeTime}
</button>
)}
{canContinue && (
<button
disabled={controller.pending || 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={controller.pending} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
<Textarea
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={controller.pending}
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();
}
}}
/>
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
</form>
<div className="composer-footer">
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
</div>
</>}
</div>}
</section>
);
}
@@ -416,9 +281,7 @@ type ConversationalBirthTimeRectificationProps = Readonly<{
onContinueOriginalQuestion?: (question: string) => void;
}>;
export function ConversationalBirthTimeRectification(
props: ConversationalBirthTimeRectificationProps,
) {
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const pendingChange = useRef(props.onPendingChange);
useEffect(() => {
pendingChange.current = props.onPendingChange;