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
+54
View File
@@ -438,6 +438,51 @@ button:disabled { cursor: default; opacity: .45; }
.birth-time-success-note { padding: var(--space-4); border-left: 2px solid var(--color-success); background: var(--color-success-muted); color: var(--color-success); }
.birth-time-candidate-terminal { justify-items: start; }
.birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; }
.conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); overflow-wrap: anywhere; color: var(--color-ink); }
.conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; }
.conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; }
.conversational-rectification :where(button, textarea, summary):focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 3px; }
.conversational-narrative { padding: var(--space-5); border-left: 3px solid var(--color-action); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; background: var(--color-action-soft); }
.conversational-narrative .message-markdown { overflow-wrap: anywhere; }
.conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; }
.conversational-domain-picker legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
.conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); }
.conversational-domain-picker button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; }
.conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); }
.conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; }
.conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; }
.conversational-composer textarea { width: 100%; min-width: 0; min-height: 112px; resize: vertical; padding: var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); line-height: 1.55; }
.conversational-composer-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); }
.conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { min-width: 0; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.conversational-evidence-recap > header, .conversational-candidate > header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.conversational-evidence-recap h3, .conversational-candidate h3, .conversational-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.conversational-evidence-recap > header span, .conversational-candidate > header span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.conversational-candidate > header .is-unverified { color: var(--color-warning); font-weight: 600; }
.conversational-candidate > header .is-confirmed { color: var(--color-success); font-weight: 600; }
.conversational-evidence-recap ul { min-width: 0; display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.conversational-evidence-recap li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-soft); }
.conversational-evidence-recap li > div { min-width: 0; }
.conversational-evidence-recap time { color: var(--color-ink-tertiary); font-size: var(--type-overline); }
.conversational-evidence-recap p, .conversational-candidate p, .conversational-confirmation p, .conversational-original-question p, .conversational-abandon-confirmation p, .conversational-empty-state p, .conversational-status p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; }
.conversational-evidence-recap li button { min-width: 56px; padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.conversational-candidate dl, .conversational-technical-receipt dl { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); }
.conversational-candidate dl > div, .conversational-technical-receipt dl > div { min-width: 0; display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); }
.conversational-candidate dt, .conversational-technical-receipt dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); }
.conversational-candidate dd, .conversational-technical-receipt dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); overflow-wrap: anywhere; }
.conversational-candidate time { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.conversational-technical-receipt { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); }
.conversational-technical-receipt summary { min-height: 44px; display: flex; align-items: center; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; }
.conversational-technical-receipt code { font-family: var(--font-mono); font-size: var(--type-overline); }
.conversational-confirmation { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); }
.conversational-status { min-width: 0; min-height: 1px; }
.conversational-original-question { background: var(--color-success-muted); }
.conversational-session-actions { min-width: 0; display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
.conversational-abandon { min-height: 44px; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-danger); cursor: pointer; }
.conversational-abandon.is-confirm { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-on-dark); }
.conversational-abandon-confirmation { border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); }
.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.phrase-nowrap { white-space: nowrap; }
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; }
@@ -706,3 +751,12 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.auth-panel h1 { font-size: var(--type-display-sm); }
.admin-section { padding: var(--space-5); }
}
@media (max-width: 430px) {
.conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; }
.conversational-narrative, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-abandon-confirmation, .conversational-empty-state { padding: var(--space-3); }
.conversational-domain-picker > div, .conversational-candidate dl, .conversational-technical-receipt dl { grid-template-columns: minmax(0, 1fr); }
.conversational-composer-footer { align-items: stretch; flex-direction: column; }
.conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button, .conversational-session-actions > button, .conversational-abandon-confirmation button { width: 100%; }
.conversational-session-actions, .conversational-abandon-confirmation > div { flex-direction: column; }
}
@@ -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}
/>
);
}
@@ -0,0 +1,260 @@
"use client";
import { useState, useSyncExternalStore } from "react";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
createConversationalRectificationActionRegistry,
sendConversationalRectificationCommand,
type ConversationalRectificationActionIdentity,
} from "../lib/conversational-rectification/client.ts";
import type {
ConversationalRectificationCommand,
ConversationalRectificationTurn,
} from "../lib/conversational-rectification/contracts.ts";
type EvidenceDomain = NonNullable<
ConversationalRectificationTurn["evidenceRequest"]
>["domains"][number];
export type ConversationalRectificationControllerSnapshot = Readonly<{
turn: ConversationalRectificationTurn | null;
draft: string;
selectedDomain: EvidenceDomain | null;
pending: boolean;
error: string;
}>;
type MutationResult = Promise<ConversationalRectificationTurn | null>;
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
getSnapshot(): ConversationalRectificationControllerSnapshot;
subscribe(listener: () => void): () => void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
start(pendingConsultationQuestion?: string | null): MutationResult;
resume(): MutationResult;
answer(domain?: EvidenceDomain): MutationResult;
pause(): MutationResult;
abandon(): MutationResult;
confirm(time?: string): MutationResult;
}>;
type ControllerInput = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationTurn>;
createActionId?: () => string;
onTurn?: (turn: ConversationalRectificationTurn) => void;
}>;
type Mutation = Readonly<{
identity: ConversationalRectificationActionIdentity;
command(actionId: string): ConversationalRectificationCommand;
clearDraftOnSuccess?: boolean;
}>;
function displayError(error: unknown): string {
return error instanceof ConversationalRectificationRequestError
? error.message
: CONVERSATIONAL_RECTIFICATION_UNAVAILABLE;
}
function staleTurn(error: unknown): boolean {
return error instanceof ConversationalRectificationRequestError
&& error.status === 409
&& error.code === "stale_turn";
}
export function createConversationalRectificationController(
input: ControllerInput = {},
): ConversationalRectificationController {
const send = input.send ?? sendConversationalRectificationCommand;
const registry = createConversationalRectificationActionRegistry(input.createActionId);
const listeners = new Set<() => void>();
let snapshot: ConversationalRectificationControllerSnapshot = {
turn: input.initialTurn ?? null,
draft: "",
selectedDomain: null,
pending: false,
error: "",
};
let activeMutation: MutationResult | null = null;
const publish = (next: ConversationalRectificationControllerSnapshot) => {
snapshot = next;
for (const listener of listeners) listener();
};
const patch = (next: Partial<ConversationalRectificationControllerSnapshot>) => {
publish({ ...snapshot, ...next });
};
const acceptTurn = (
turn: ConversationalRectificationTurn,
clearDraft: boolean,
) => {
patch({
turn,
error: "",
...(clearDraft ? { draft: "", selectedDomain: null } : {}),
});
input.onTurn?.(turn);
return turn;
};
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation: "resume",
payload: {},
}, (actionId) => send({
type: "resume",
caseId: turn.caseId,
actionId,
turnVersion: turn.turnVersion,
}));
const run = (mutation: Mutation): MutationResult => {
if (activeMutation) return activeMutation;
patch({ pending: true, error: "" });
const turnAtStart = snapshot.turn;
const operation = registry.run(
mutation.identity,
(actionId) => send(mutation.command(actionId)),
).then((turn) => acceptTurn(turn, mutation.clearDraftOnSuccess === true))
.catch(async (error: unknown) => {
if (turnAtStart && staleTurn(error)) {
try {
const recovered = await recoverLatest(turnAtStart);
return acceptTurn(recovered, false);
} catch (recoveryError) {
patch({ error: displayError(recoveryError) });
throw recoveryError;
}
}
patch({ error: displayError(error) });
throw error;
})
.finally(() => {
activeMutation = null;
patch({ pending: false });
});
activeMutation = operation;
return operation;
};
const currentTurn = () => snapshot.turn;
const currentMutation = (
operation: Exclude<ConversationalRectificationCommand["type"], "start">,
payload: unknown,
command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand,
clearDraftOnSuccess = false,
): MutationResult => {
const turn = currentTurn();
if (!turn) return Promise.resolve(null);
return run({
identity: {
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation,
payload,
},
command: (actionId) => command(turn, actionId),
clearDraftOnSuccess,
});
};
const controller = {
get turn() { return snapshot.turn; },
get draft() { return snapshot.draft; },
get selectedDomain() { return snapshot.selectedDomain; },
get pending() { return snapshot.pending; },
get error() { return snapshot.error; },
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
setDraft(value: string) {
patch({ draft: value });
},
selectDomain(domain: EvidenceDomain | null) {
patch({ selectedDomain: domain });
},
start(pendingConsultationQuestion: string | null = null) {
return run({
identity: {
caseId: "new-case",
turnVersion: 0,
operation: "start",
payload: { pendingConsultationQuestion },
},
command: (actionId) => ({ type: "start", actionId, pendingConsultationQuestion }),
});
},
resume() {
return currentMutation("resume", {}, (turn, actionId) => ({
type: "resume",
caseId: turn.caseId,
actionId,
turnVersion: turn.turnVersion,
}));
},
answer(domain: EvidenceDomain | undefined = snapshot.selectedDomain ?? undefined) {
const turn = currentTurn();
const answer = snapshot.draft.trim();
if (!turn || !answer || !turn.actions.includes("answer")) return Promise.resolve(turn);
const payload = { answer, ...(domain ? { domain } : {}) };
return currentMutation("answer", payload, (current, actionId) => ({
type: "answer",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
answer,
...(domain ? { domain } : {}),
}), true);
},
pause() {
const turn = currentTurn();
if (!turn?.actions.includes("pause")) return activeMutation ?? Promise.resolve(turn);
return currentMutation("pause", {}, (current, actionId) => ({
type: "pause",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
}));
},
abandon() {
const turn = currentTurn();
if (!turn?.actions.includes("abandon")) return activeMutation ?? Promise.resolve(turn);
return currentMutation("abandon", {}, (current, actionId) => ({
type: "abandon",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
}));
},
confirm(time?: string) {
const turn = currentTurn();
const candidateTime = time ?? turn?.candidate.representativeTime ?? null;
if (!turn?.actions.includes("confirm") || turn.candidate.status !== "ready_for_confirmation"
|| !candidateTime) return activeMutation ?? Promise.resolve(turn);
return currentMutation("confirm", { time: candidateTime }, (current, actionId) => ({
type: "confirm",
caseId: current.caseId,
actionId,
turnVersion: current.turnVersion,
time: candidateTime,
}));
},
} satisfies ConversationalRectificationController;
return controller;
}
export function useConversationalRectification(
input: ControllerInput = {},
): ConversationalRectificationController {
const [controller] = useState(() => createConversationalRectificationController(input));
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
return { ...controller, ...snapshot };
}
@@ -0,0 +1,116 @@
import { z } from "zod";
import { postJson } from "../birth-time-client-transport.ts";
import {
conversationalRectificationCommandSchema,
conversationalRectificationTurnSchema,
type ConversationalRectificationCommand,
type ConversationalRectificationTurn,
} from "./contracts.ts";
export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。";
const publicErrorSchema = z.object({
code: z.string(),
message: z.string(),
}).passthrough();
export class ConversationalRectificationRequestError extends Error {
readonly name = "ConversationalRectificationRequestError";
readonly status: number;
readonly code: string | null;
constructor(status: number, code: string | null, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
function canonicalValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalValue);
if (value !== null && typeof value === "object") {
const record = value as Readonly<Record<string, unknown>>;
return Object.fromEntries(
Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => [key, canonicalValue(record[key])]),
);
}
return value;
}
export function canonicalConversationalRectificationPayload(value: unknown): string {
return JSON.stringify(canonicalValue(value));
}
export type ConversationalRectificationActionIdentity = Readonly<{
caseId: string;
turnVersion: number;
operation: ConversationalRectificationCommand["type"];
payload: unknown;
}>;
function actionIdentity(input: ConversationalRectificationActionIdentity): string {
return canonicalConversationalRectificationPayload({
caseId: input.caseId,
operation: input.operation,
payload: input.payload,
turnVersion: input.turnVersion,
});
}
export function createConversationalRectificationActionRegistry(
createId: () => string = () => globalThis.crypto.randomUUID(),
) {
const actionIds = new Map<string, string>();
return {
async run<T>(
input: ConversationalRectificationActionIdentity,
operation: (actionId: string) => Promise<T>,
): Promise<T> {
const identity = actionIdentity(input);
const actionId = actionIds.get(identity) ?? createId();
actionIds.set(identity, actionId);
const result = await operation(actionId);
if (actionIds.get(identity) === actionId) actionIds.delete(identity);
return result;
},
};
}
export type ConversationalRectificationActionRegistry = ReturnType<
typeof createConversationalRectificationActionRegistry
>;
export async function sendConversationalRectificationCommand(
command: ConversationalRectificationCommand,
): Promise<ConversationalRectificationTurn> {
const request = conversationalRectificationCommandSchema.parse(command);
try {
const { response, payload } = await postJson({
url: "/api/birth-time-conversation",
body: JSON.stringify(request),
retryLostResponse: true,
});
if (!response.ok) {
const parsed = publicErrorSchema.safeParse(payload);
const safeServerMessage = response.status < 500 && parsed.success
? parsed.data.message
: CONVERSATIONAL_RECTIFICATION_UNAVAILABLE;
throw new ConversationalRectificationRequestError(
response.status,
parsed.success ? parsed.data.code : null,
safeServerMessage,
);
}
return conversationalRectificationTurnSchema.parse(payload);
} catch (error) {
if (error instanceof ConversationalRectificationRequestError) throw error;
throw new ConversationalRectificationRequestError(
502,
null,
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
);
}
}
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
createConversationalRectificationActionRegistry,
sendConversationalRectificationCommand,
} from "../src/lib/conversational-rectification/client.ts";
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
const caseId = "00000000-0000-4000-8000-000000000801";
const firstActionId = "00000000-0000-4000-8000-000000000802";
const secondActionId = "00000000-0000-4000-8000-000000000803";
const turn: ConversationalRectificationTurn = {
caseId,
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion: 4,
narrative: "**05:18** 仍是待验证候选,请提供真实经历。",
candidate: {
status: "pending_validation",
representativeTime: "05:18",
rangeStart: "05:10",
rangeEnd: "05:26",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: {
domains: ["relationship", "career"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
};
test("action registry keeps one id for a failed canonical command and separates changed payloads", async () => {
const ids = [firstActionId, secondActionId];
const registry = createConversationalRectificationActionRegistry(
() => ids.shift() ?? assert.fail("unexpected action id allocation"),
);
const seen: string[] = [];
const first = {
caseId,
turnVersion: 4,
operation: "answer",
payload: { answer: "2021 年 7 月毕业", domain: "education" },
} as const;
await assert.rejects(registry.run(first, async (actionId) => {
seen.push(actionId);
throw new TypeError("response lost twice");
}));
await assert.rejects(registry.run({
...first,
payload: { domain: "education", answer: "2021 年 7 月毕业" },
}, async (actionId) => {
seen.push(actionId);
throw new TypeError("still offline");
}));
await assert.rejects(registry.run({
...first,
payload: { answer: "2022 年 3 月搬家", domain: "relocation" },
}, async (actionId) => {
seen.push(actionId);
throw new TypeError("offline");
}));
assert.deepEqual(seen, [firstActionId, firstActionId, secondActionId]);
});
test("client replays the exact action body after a lost response without adding a price", async (context) => {
const bodies: string[] = [];
let attempts = 0;
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
attempts += 1;
bodies.push(String(init?.body));
if (attempts === 1) throw new TypeError("response lost");
return new Response(JSON.stringify(turn), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const result = await sendConversationalRectificationCommand({
type: "answer",
caseId,
actionId: firstActionId,
turnVersion: 3,
domain: "career",
answer: "2021 年 7 月开始第一份工作",
});
assert.deepEqual(result, turn);
assert.equal(attempts, 2);
assert.equal(bodies[0], bodies[1]);
assert.equal(Object.hasOwn(JSON.parse(bodies[0]) as object, "price"), false);
});
test("502 and non-JSON failures expose one stable Chinese message", async (context) => {
context.mock.method(globalThis, "fetch", async () => new Response("upstream html", {
status: 502,
headers: { "content-type": "text/html" },
}));
await assert.rejects(
sendConversationalRectificationCommand({
type: "pause",
caseId,
actionId: firstActionId,
turnVersion: 4,
}),
(error: unknown) => error instanceof ConversationalRectificationRequestError
&& error.status === 502
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
);
});
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
ConversationalRectificationSurface,
} from "../src/components/conversational-birth-time-rectification.tsx";
import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
// The repository keeps JSX in preserve mode for Next.js; direct Node SSR needs the classic global.
Object.assign(globalThis, { React });
const turn: ConversationalRectificationTurn = {
caseId: "00000000-0000-4000-8000-000000000821",
journeyProtocol: "conversational-evidence-v3",
status: "confirming",
turnVersion: 6,
narrative: "## 当前判断\n\n**05:18** 只是待验证候选;D9 与 D10 仍需真实经历交叉验证。",
candidate: {
status: "ready_for_confirmation",
representativeTime: "05:18",
rangeStart: "05:16",
rangeEnd: "05:20",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: {
domains: ["relationship", "career", "relocation"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: [{
id: "00000000-0000-4000-8000-000000000822",
summary: "开始第一份长期工作",
dateLabel: "2021 年 7 月",
}],
actions: ["answer", "pause", "abandon", "confirm"],
pendingConsultationQuestion: "我适合什么时候换工作?",
};
function controller(overrides: Partial<ConversationalRectificationController> = {}): ConversationalRectificationController {
return {
turn,
draft: "",
selectedDomain: null,
pending: false,
error: "",
getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }),
subscribe: () => () => undefined,
setDraft: () => undefined,
selectDomain: () => undefined,
start: async () => turn,
resume: async () => turn,
answer: async () => turn,
pause: async () => turn,
abandon: async () => turn,
confirm: async () => turn,
...overrides,
};
}
test("rich narrative precedes 24 domain choices while free text remains available", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
));
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>05:18<\/strong>/);
assert.ok(markup.indexOf("当前判断") < markup.indexOf("重要关系"));
assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /Ctrl\/⌘ \+ Enter/);
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
test("evidence is correctable, technical receipts stay visible, and confirmation is explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
));
assert.match(markup, /已记录的真实经历/);
assert.match(markup, /2021 年 7 月/);
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.match(markup, /本轮技术回执/);
assert.match(markup, /rectification-technical-v1/);
assert.match(markup, /consult-d9/);
assert.match(markup, /待确认 · 未验证/);
assert.match(markup, /确认将 05:18 设为当前排盘时间/);
assert.match(markup, /不会自动采用/);
assert.match(markup, /暂停,稍后继续/);
assert.match(markup, /放弃本次校正/);
});
test("pending markup and responsive CSS expose accessibility contracts", () => {
const pendingController = controller({
pending: true,
draft: "保留中的文字",
getSnapshot: () => ({
turn,
draft: "保留中的文字",
selectedDomain: "career",
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: pendingController },
));
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
assert.match(markup, /aria-busy="true"/);
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
assert.match(markup, /aria-label="生时校正对话"/);
assert.match(markup, /role="alert"|aria-live="polite"/);
assert.match(css, /\.conversational-rectification[\s\S]*min-width:\s*0/);
assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/);
assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/);
assert.match(css, /\.conversational-rectification[^}]*:focus-visible/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(component, /确认放弃且不应用候选/);
});
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createConversationalRectificationController,
} from "../src/hooks/use-conversational-rectification.ts";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
} from "../src/lib/conversational-rectification/client.ts";
import type {
ConversationalRectificationCommand,
ConversationalRectificationTurn,
} from "../src/lib/conversational-rectification/contracts.ts";
const caseId = "00000000-0000-4000-8000-000000000811";
const actionIds = [
"00000000-0000-4000-8000-000000000812",
"00000000-0000-4000-8000-000000000813",
"00000000-0000-4000-8000-000000000814",
"00000000-0000-4000-8000-000000000815",
];
function activeTurn(turnVersion = 2): ConversationalRectificationTurn {
return {
caseId,
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion,
narrative: "候选仍需历史事件验证。",
candidate: {
status: "pending_validation",
representativeTime: "05:18",
rangeStart: "05:10",
rangeEnd: "05:26",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: {
domains: ["relationship", "career"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
};
}
function idFactory() {
const ids = [...actionIds];
return () => ids.shift() ?? assert.fail("unexpected action id allocation");
}
test("controller admits only one in-flight mutation and clears text only after success", async () => {
const commands: ConversationalRectificationCommand[] = [];
let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
const request = new Promise<ConversationalRectificationTurn>((resolve) => {
resolveRequest = resolve;
});
const controller = createConversationalRectificationController({
initialTurn: activeTurn(),
createActionId: idFactory(),
send: async (command) => {
commands.push(command);
return request;
},
});
controller.setDraft("2021 年 7 月开始第一份工作");
const answer = controller.answer("career");
const duplicate = controller.answer("career");
const pause = controller.pause();
assert.equal(controller.getSnapshot().pending, true);
assert.equal(controller.getSnapshot().draft, "2021 年 7 月开始第一份工作");
assert.equal(commands.length, 1);
resolveRequest?.(activeTurn(3));
await Promise.all([answer, duplicate, pause]);
assert.equal(commands.length, 1);
assert.equal(controller.getSnapshot().pending, false);
assert.equal(controller.getSnapshot().draft, "");
assert.equal(controller.getSnapshot().turn?.turnVersion, 3);
});
test("controller preserves the exact draft and stable action id across failures", async () => {
const commands: ConversationalRectificationCommand[] = [];
const controller = createConversationalRectificationController({
initialTurn: activeTurn(),
createActionId: idFactory(),
send: async (command) => {
commands.push(command);
throw new ConversationalRectificationRequestError(
502,
null,
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
);
},
});
controller.setDraft("2021 年 7 月毕业,具体日期不确定");
await assert.rejects(controller.answer("education"));
await assert.rejects(controller.answer("education"));
const snapshot = controller.getSnapshot();
assert.equal(snapshot.draft, "2021 年 7 月毕业,具体日期不确定");
assert.equal(snapshot.error, CONVERSATIONAL_RECTIFICATION_UNAVAILABLE);
assert.equal(commands.length, 2);
assert.equal(commands[0]?.actionId, commands[1]?.actionId);
});
test("a stale answer resumes the latest turn and returns recovered state without clearing text", async () => {
const commands: ConversationalRectificationCommand[] = [];
const recovered = activeTurn(5);
const controller = createConversationalRectificationController({
initialTurn: activeTurn(2),
createActionId: idFactory(),
send: async (command) => {
commands.push(command);
if (command.type === "answer") {
throw new ConversationalRectificationRequestError(409, "stale_turn", "请加载最新进度后再试。");
}
assert.equal(command.type, "resume");
return recovered;
},
});
controller.setDraft("2022 年 3 月搬到另一座城市");
const result = await controller.answer("relocation");
assert.deepEqual(result, recovered);
assert.deepEqual(commands.map((command) => command.type), ["answer", "resume"]);
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
assert.equal(controller.getSnapshot().draft, "2022 年 3 月搬到另一座城市");
assert.equal(controller.getSnapshot().error, "");
});
test("a changed payload receives a different action id after a failed send", async () => {
const commands: ConversationalRectificationCommand[] = [];
const controller = createConversationalRectificationController({
initialTurn: activeTurn(),
createActionId: idFactory(),
send: async (command) => {
commands.push(command);
throw new Error("offline");
},
});
controller.setDraft("2021 年 7 月毕业");
await assert.rejects(controller.answer("education"));
controller.setDraft("2022 年 3 月搬家");
await assert.rejects(controller.answer("relocation"));
assert.notEqual(commands[0]?.actionId, commands[1]?.actionId);
});