fix: harden conversational rectification UI

This commit is contained in:
Jesse_Chen
2026-07-21 04:23:59 +08:00
parent 4d8c5268d3
commit 741a395a77
7 changed files with 881 additions and 45 deletions
+3 -1
View File
@@ -481,7 +481,9 @@ button:disabled { cursor: default; opacity: .45; }
.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-scrim { position: fixed; inset: 0; z-index: 80; display: grid; align-items: center; justify-items: center; padding: var(--space-4); overflow-y: auto; background: var(--color-scrim); }
.conversational-abandon-confirmation { width: min(480px, 100%); border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border)); background: var(--color-danger-muted); box-shadow: var(--shadow-elevated); }
.conversational-abandon-confirmation h3 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
.conversational-abandon-confirmation > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.phrase-nowrap { white-space: nowrap; }
@@ -1,6 +1,11 @@
"use client";
import { useRef, useState } from "react";
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { ChatMessageContent } from "./chat-message-content.tsx";
import {
useConversationalRectification,
@@ -85,10 +90,41 @@ export function ConversationalRectificationSurface({
pendingConsultationQuestion,
onContinueOriginalQuestion,
}: SurfaceProps) {
const [abandonArmed, setAbandonArmed] = useState(false);
const [abandonArmedFor, setAbandonArmedFor] = useState<string | null>(null);
const [localAnnouncement, setLocalAnnouncement] = useState<Readonly<{
identity: string;
message: string;
}> | null>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const abandonTrigger = useRef<HTMLButtonElement>(null);
const abandonCancel = useRef<HTMLButtonElement>(null);
const abandonConfirm = useRef<HTMLButtonElement>(null);
const restoreAbandonFocus = useRef(false);
const turn = controller.turn;
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
const abandonIdentity = turn
? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
: null;
const canAbandon = Boolean(
turn?.actions.includes("abandon")
&& turn.status !== "abandoned"
&& turn.status !== "completed",
);
const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity;
const statusAnnouncement = localAnnouncement?.identity === abandonIdentity
? localAnnouncement.message
: "";
useEffect(() => {
if (abandonArmed) {
abandonCancel.current?.focus();
return;
}
if (restoreAbandonFocus.current) {
restoreAbandonFocus.current = false;
abandonTrigger.current?.focus();
}
}, [abandonArmed]);
if (!turn) {
return (
@@ -115,6 +151,34 @@ export function ConversationalRectificationSurface({
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
};
const focusComposer = () => composer.current?.focus();
const continueLocally = () => {
if (abandonIdentity) {
setLocalAnnouncement({
identity: abandonIdentity,
message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。",
});
}
focusComposer();
};
const closeAbandonDialog = () => {
restoreAbandonFocus.current = true;
setAbandonArmedFor(null);
};
const handleAbandonDialogKey = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "Escape") {
event.preventDefault();
closeAbandonDialog();
return;
}
if (event.key !== "Tab") return;
if (event.shiftKey && document.activeElement === abandonCancel.current) {
event.preventDefault();
abandonConfirm.current?.focus();
} else if (!event.shiftKey && document.activeElement === abandonConfirm.current) {
event.preventDefault();
abandonCancel.current?.focus();
}
};
return (
<section
@@ -241,6 +305,7 @@ export function ConversationalRectificationSurface({
{turn.status === "abandoned" && <p>本次校正已放弃,候选时间没有应用。</p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <p>候选时间已经过你的明确确认。</p>}
{statusAnnouncement && <p>{statusAnnouncement}</p>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</div>
@@ -264,7 +329,7 @@ export function ConversationalRectificationSurface({
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.resume())}
onClick={continueLocally}
>
继续校正
</button>
@@ -279,12 +344,16 @@ export function ConversationalRectificationSurface({
</button>
) : null}
{turn.actions.includes("abandon") && !abandonArmed && (
{canAbandon && !abandonArmed && (
<button
className="conversational-abandon"
disabled={controller.pending}
ref={abandonTrigger}
type="button"
onClick={() => setAbandonArmed(true)}
onClick={() => {
setLocalAnnouncement(null);
setAbandonArmedFor(abandonIdentity);
}}
>
放弃本次校正
</button>
@@ -292,27 +361,41 @@ export function ConversationalRectificationSurface({
</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>
<div className="conversational-abandon-scrim">
<section
aria-describedby="conversational-abandon-description"
aria-labelledby="conversational-abandon-title"
aria-modal="true"
className="conversational-abandon-confirmation"
onKeyDown={handleAbandonDialogKey}
role="alertdialog"
>
<h3 id="conversational-abandon-title">确认放弃本次校正?</h3>
<p id="conversational-abandon-description">
放弃后会保留审计记录,但不会应用任何候选时间。
</p>
<div>
<button
className="button-secondary"
disabled={controller.pending}
ref={abandonCancel}
type="button"
onClick={closeAbandonDialog}
>
返回校正
</button>
<button
className="conversational-abandon is-confirm"
disabled={controller.pending}
ref={abandonConfirm}
type="button"
onClick={() => safely(controller.abandon())}
>
确认放弃且不应用候选
</button>
</div>
</section>
</div>
)}
</section>
);
@@ -1,6 +1,6 @@
"use client";
import { useState, useSyncExternalStore } from "react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
ConversationalRectificationRequestError,
@@ -30,6 +30,7 @@ type MutationResult = Promise<ConversationalRectificationTurn | null>;
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
getSnapshot(): ConversationalRectificationControllerSnapshot;
subscribe(listener: () => void): () => void;
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
start(pendingConsultationQuestion?: string | null): MutationResult;
@@ -53,6 +54,21 @@ type Mutation = Readonly<{
clearDraftOnSuccess?: boolean;
}>;
function createLatestControllerInput(initial: ControllerInput) {
let current = initial;
return {
update(next: ControllerInput) {
current = next;
},
send(command: ConversationalRectificationCommand) {
return (current.send ?? sendConversationalRectificationCommand)(command);
},
onTurn(turn: ConversationalRectificationTurn) {
current.onTurn?.(turn);
},
};
}
function displayError(error: unknown): string {
return error instanceof ConversationalRectificationRequestError
? error.message
@@ -79,6 +95,7 @@ export function createConversationalRectificationController(
error: "",
};
let activeMutation: MutationResult | null = null;
let caseContext = 0;
const publish = (next: ConversationalRectificationControllerSnapshot) => {
snapshot = next;
@@ -90,13 +107,27 @@ export function createConversationalRectificationController(
const acceptTurn = (
turn: ConversationalRectificationTurn,
clearDraft: boolean,
expectedCaseContext: number,
) => {
const current = snapshot.turn;
if (caseContext !== expectedCaseContext) return turn;
if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn;
const selectedDomain = clearDraft
? null
: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null;
patch({
turn,
error: "",
...(clearDraft ? { draft: "", selectedDomain: null } : {}),
selectedDomain,
...(clearDraft ? { draft: "" } : {}),
});
input.onTurn?.(turn);
try {
input.onTurn?.(turn);
} catch {
// A consumer callback is observational. It must never turn a durable success into a failure.
}
return turn;
};
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
@@ -114,15 +145,20 @@ export function createConversationalRectificationController(
if (activeMutation) return activeMutation;
patch({ pending: true, error: "" });
const turnAtStart = snapshot.turn;
const caseContextAtStart = caseContext;
const operation = registry.run(
mutation.identity,
(actionId) => send(mutation.command(actionId)),
).then((turn) => acceptTurn(turn, mutation.clearDraftOnSuccess === true))
).then((turn) => acceptTurn(
turn,
mutation.clearDraftOnSuccess === true,
caseContextAtStart,
))
.catch(async (error: unknown) => {
if (turnAtStart && staleTurn(error)) {
try {
const recovered = await recoverLatest(turnAtStart);
return acceptTurn(recovered, false);
return acceptTurn(recovered, false, caseContextAtStart);
} catch (recoveryError) {
patch({ error: displayError(recoveryError) });
throw recoveryError;
@@ -170,6 +206,29 @@ export function createConversationalRectificationController(
listeners.add(listener);
return () => listeners.delete(listener);
},
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) {
const current = snapshot.turn;
if (turn === null) {
if (current === null) return;
caseContext += 1;
patch({ turn: null, draft: "", selectedDomain: null, error: "" });
return;
}
if (current === null || current.caseId !== turn.caseId) {
caseContext += 1;
patch({ turn, draft: "", selectedDomain: null, error: "" });
return;
}
if (turn.turnVersion <= current.turnVersion) return;
patch({
turn,
error: "",
selectedDomain: snapshot.selectedDomain
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null,
});
},
setDraft(value: string) {
patch({ draft: value });
},
@@ -250,11 +309,23 @@ export function createConversationalRectificationController(
export function useConversationalRectification(
input: ControllerInput = {},
): ConversationalRectificationController {
const [controller] = useState(() => createConversationalRectificationController(input));
const [latestInput] = useState(() => createLatestControllerInput(input));
const [controller] = useState(() => createConversationalRectificationController({
initialTurn: input.initialTurn,
createActionId: input.createActionId,
send: latestInput.send,
onTurn: latestInput.onTurn,
}));
const snapshot = useSyncExternalStore(
controller.subscribe,
controller.getSnapshot,
controller.getSnapshot,
);
useLayoutEffect(() => {
latestInput.update(input);
}, [input, latestInput]);
useEffect(() => {
controller.synchronizeInitialTurn(input.initialTurn ?? null);
}, [controller, input.initialTurn]);
return { ...controller, ...snapshot };
}
@@ -83,16 +83,46 @@ export type ConversationalRectificationActionRegistry = ReturnType<
typeof createConversationalRectificationActionRegistry
>;
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
function isRetryableTransportError(error: unknown): boolean {
return !isAbortError(error) && (
error instanceof TypeError
|| error instanceof SyntaxError
|| (error instanceof DOMException && error.name === "SyntaxError")
);
}
async function postCommandWithOneReplay(body: string) {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const result = await postJson({
url: "/api/birth-time-conversation",
body,
retryLostResponse: false,
});
// postJson deliberately projects an unparseable non-ok body to null. Treating all null
// error payloads as replayable also covers proxies that mislabel HTML as application/json.
const nonJsonFailure = !result.response.ok && result.payload === null;
if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue;
return result;
} catch (error) {
if (attempt === 0 && isRetryableTransportError(error)) continue;
throw error;
}
}
throw new TypeError("unreachable conversational rectification replay state");
}
export async function sendConversationalRectificationCommand(
command: ConversationalRectificationCommand,
): Promise<ConversationalRectificationTurn> {
const request = conversationalRectificationCommandSchema.parse(command);
const body = JSON.stringify(request);
try {
const { response, payload } = await postJson({
url: "/api/birth-time-conversation",
body: JSON.stringify(request),
retryLostResponse: true,
});
const { response, payload } = await postCommandWithOneReplay(body);
if (!response.ok) {
const parsed = publicErrorSchema.safeParse(payload);
const safeServerMessage = response.status < 500 && parsed.success