feat: render conversational rectification
This commit is contained in:
@@ -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 2–4 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);
|
||||
});
|
||||
Reference in New Issue
Block a user