fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start
Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4dc0c8c7b3
commit
91b8b33aa6
@@ -2624,6 +2624,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
/* No candidate yet: the board only carries the declared time, so it takes less
|
||||
width until there is a house table to show (BUG-483). */
|
||||
.rectification-workspace.is-board-empty {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
|
||||
}
|
||||
.rectification-workspace.is-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
@@ -2926,6 +2931,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-question-slot__recover {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
}
|
||||
.rectification-message-wrap > .rectification-question-slot:empty { display: none; }
|
||||
/* A Case with nothing to show and no automatic opening: one line and one way to start (BUG-481). */
|
||||
.rectification-empty-state {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
justify-items: start;
|
||||
margin: var(--space-4) 0;
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-empty-state p { margin: 0; }
|
||||
/* The question slot's own live step reuses the timeline row; no summary above it. */
|
||||
.consultation-run-timeline__list.is-standalone { margin-top: 0; }
|
||||
/* Static "opening" notes: no spinner after the reveal (unified-loading ruling). */
|
||||
.session-opening-note {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.product-entrypoint-card[data-opening="true"],
|
||||
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
|
||||
.rectification-choice-card__pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rectification-choice-card__selected {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-start: var(--space-2);
|
||||
color: var(--color-action);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rectification-choice-card__selected svg { width: 14px; height: 14px; }
|
||||
.rectification-board__clock.is-declared { color: var(--color-ink-tertiary); }
|
||||
.rectification-board__empty { display: grid; gap: var(--space-1); }
|
||||
.rectification-board__empty p { margin: 0; }
|
||||
.rectification-snapshot {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
type RectificationEntrySummary,
|
||||
} from "@/lib/rectification-entry";
|
||||
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import {
|
||||
declaredBirthTime,
|
||||
RECTIFICATION_OPENING_LABEL,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import {
|
||||
toggleChatMessageFeedback,
|
||||
type ChatMessageFeedback,
|
||||
@@ -289,6 +294,8 @@ export default function Home() {
|
||||
const [rectificationReadonly, setRectificationReadonly] = useState(false);
|
||||
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
|
||||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||||
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
|
||||
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
|
||||
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
|
||||
@@ -406,7 +413,10 @@ export default function Home() {
|
||||
latestTerminal: null,
|
||||
},
|
||||
);
|
||||
const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction];
|
||||
const rectificationCardLabel = rectificationLoading
|
||||
? RECTIFICATION_OPENING_LABEL
|
||||
: rectificationEntryLabels[rectificationCardAction];
|
||||
const rectificationDeclaredTime = declaredBirthTime(profile);
|
||||
const rectificationErrorMessage = rectificationError === "profile_incomplete"
|
||||
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
|
||||
: rectificationError;
|
||||
@@ -474,7 +484,8 @@ export default function Home() {
|
||||
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
|
||||
setRectificationEntrySummary, setRectificationError, setRectificationLoading,
|
||||
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
|
||||
setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview,
|
||||
setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
|
||||
setRectificationTurns, setSessions, uiPreview,
|
||||
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
|
||||
});
|
||||
|
||||
@@ -1671,6 +1682,7 @@ export default function Home() {
|
||||
sessions={sidebarSessions}
|
||||
charts={sidebarCharts}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
openingSessionId={rectificationOpeningSessionId}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
accountTriggerRef={accountTrigger}
|
||||
@@ -1887,12 +1899,14 @@ export default function Home() {
|
||||
|
||||
{rectificationSurfaceOpen && rectificationCaseId && (
|
||||
<ConversationalBirthTimeRectification
|
||||
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`}
|
||||
key={`${rectificationSessionId}-${rectificationCaseId}`}
|
||||
caseId={rectificationCaseId}
|
||||
sessionId={rectificationSessionId ?? ""}
|
||||
readonly={rectificationReadonly}
|
||||
shouldStartOpening={rectificationShouldStartOpening}
|
||||
initialTurns={rectificationTurns}
|
||||
initialSnapshot={rectificationSnapshot}
|
||||
declaredTime={rectificationDeclaredTime}
|
||||
models={modelCatalog?.models ?? []}
|
||||
selectedModelId={activeSession?.modelId ?? ""}
|
||||
onSelectModel={(modelId) => void selectSessionModel(modelId)}
|
||||
|
||||
@@ -59,6 +59,8 @@ export type AppSidebarProps = {
|
||||
sessions: readonly SidebarSession[];
|
||||
charts: readonly SidebarChart[];
|
||||
activeSessionId: string | null;
|
||||
/** A rectification session whose Case is being opened and hydrated; its row says so, statically. */
|
||||
openingSessionId?: string | null;
|
||||
account: SidebarAccount;
|
||||
accountMenuOpen: boolean;
|
||||
accountTriggerRef: Ref<HTMLButtonElement>;
|
||||
@@ -82,6 +84,7 @@ export function AppSidebar({
|
||||
sessions,
|
||||
charts,
|
||||
activeSessionId,
|
||||
openingSessionId = null,
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef,
|
||||
@@ -141,6 +144,7 @@ export function AppSidebar({
|
||||
ref={index === 0 ? firstSessionRef : undefined}
|
||||
session={session}
|
||||
active={session.id === activeSessionId}
|
||||
opening={session.id === openingSessionId}
|
||||
disabled={sessionControls.disabled}
|
||||
menuOpen={sessionControls.menuSessionId === session.id}
|
||||
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
|
||||
|
||||
@@ -83,6 +83,20 @@ export function ConsultationRunTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One live step outside a reply's timeline: the same marker, spinner and
|
||||
* shimmer label as a timeline row, for a surface that is waiting on the agent
|
||||
* between turns (the rectification question slot). There is no second waiting
|
||||
* vocabulary; this is the timeline row itself.
|
||||
*/
|
||||
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
|
||||
return (
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
|
||||
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
const KindIcon = KIND_ICONS[row.kind];
|
||||
const expandable = Boolean(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import type { ChatMessage } from "../lib/chat-message-view.ts";
|
||||
import type { RectificationCaseSnapshotPayload } from "../lib/rectification-surface-state.ts";
|
||||
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
|
||||
|
||||
export type PersistedRectificationTurn = Readonly<{
|
||||
@@ -25,6 +26,10 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedRectificationTurn[];
|
||||
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
|
||||
@@ -48,10 +48,29 @@ import {
|
||||
isPublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
|
||||
import {
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||||
RECTIFICATION_EMPTY_COPY,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
|
||||
RECTIFICATION_QUESTION_PREPARING_LABEL,
|
||||
RECTIFICATION_QUESTION_RELOAD_LABEL,
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_STOPPED_NOTICE,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionSlotState,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
STOP_ACTION,
|
||||
isStructuredChoiceUserText,
|
||||
userVisibleChoiceLine,
|
||||
shouldContinueAfterStructuredChoice,
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
@@ -68,6 +87,7 @@ import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anc
|
||||
import { CharacterRemaining } from "./character-remaining";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
|
||||
import { JumpToLatestButton } from "./jump-to-latest-button";
|
||||
import {
|
||||
ChatMessageActions,
|
||||
@@ -172,6 +192,8 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedTurn[];
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
@@ -265,7 +287,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
}];
|
||||
}
|
||||
if (isIncompleteRunBanner(turn.text ?? "")) return [];
|
||||
if (isStructuredChoiceUserText(turn.text)) return [];
|
||||
// A structured choice the reader tapped is persisted as a user turn and is
|
||||
// shown as one: the reader can see what they answered after a refresh, the
|
||||
// same as the live echo (BUG-482).
|
||||
return [{
|
||||
role: "user",
|
||||
text: turn.text ?? "",
|
||||
@@ -275,6 +299,31 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
});
|
||||
}
|
||||
|
||||
type CaseSnapshotState = Readonly<{
|
||||
candidate: CandidateResult;
|
||||
question: CurrentQuestionModel | null;
|
||||
choice: ChoiceCardModel | null;
|
||||
caseStatus: RectificationCaseStatus | null;
|
||||
savedTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
}>;
|
||||
|
||||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||||
// from parsing agent text or hidden sentinels.
|
||||
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
|
||||
if (!payload) return null;
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
||||
return {
|
||||
candidate: parseRectificationCandidateResult(payload.latest_result),
|
||||
question: currentQuestionFromSnapshot(payload.current_question),
|
||||
choice: parseRectificationChoiceCard(payload.choice_card),
|
||||
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
|
||||
savedTime: confirmedTime ?? acceptedTime,
|
||||
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const {
|
||||
caseId,
|
||||
@@ -282,6 +331,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
initialSnapshot,
|
||||
declaredTime,
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
@@ -297,23 +348,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
headerSlot,
|
||||
} = props;
|
||||
|
||||
// Turns and snapshot were read before this surface mounted (one reveal,
|
||||
// BUG-479); both are initial state, and later arrivals are prop updates,
|
||||
// never a remount.
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [savedTime, setSavedTime] = useState<string | null>(null);
|
||||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
|
||||
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
|
||||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [openingRequested, setOpeningRequested] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
|
||||
const [choiceNonce, setChoiceNonce] = useState(0);
|
||||
const choiceContinuationPending = useRef(false);
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
const workspace = useRef<HTMLDivElement>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -359,11 +414,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
// A new turn starting also ends the question slot's retry cycle.
|
||||
if (value) setQuestionRetryAttempts(0);
|
||||
onPendingChange?.(value);
|
||||
}, [onPendingChange]);
|
||||
|
||||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||||
// from parsing agent text or hidden sentinels.
|
||||
const applyCaseSnapshot = useCallback((payload: {
|
||||
latest_result?: unknown;
|
||||
current_question?: unknown;
|
||||
@@ -373,62 +428,83 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
};
|
||||
turns?: unknown;
|
||||
} | null) => {
|
||||
if (!payload) return;
|
||||
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
|
||||
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
|
||||
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
|
||||
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
|
||||
? payload.case.status
|
||||
: null;
|
||||
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
const next = caseSnapshotState(payload);
|
||||
if (!next) return;
|
||||
const nextQuestion = next.question;
|
||||
setCandidateResult(next.candidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
// A question arriving ends the slot's retry cycle.
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setChoiceCard(next.choice);
|
||||
setCaseStatus(next.caseStatus);
|
||||
setCaseSnapshotLoaded(true);
|
||||
if (confirmedTime) {
|
||||
setSavedTime(confirmedTime);
|
||||
setSavedStatus("confirmed");
|
||||
} else if (acceptedTime) {
|
||||
setSavedTime(acceptedTime);
|
||||
setSavedStatus("accepted");
|
||||
if (next.savedStatus && next.savedTime) {
|
||||
setSavedTime(next.savedTime);
|
||||
setSavedStatus(next.savedStatus);
|
||||
}
|
||||
// A late hydration (the reveal deadline passed first) lands here: the
|
||||
// Case read carries the turns too, so an empty transcript fills from it.
|
||||
const turns = parsePersistedRectificationTurns(payload?.turns);
|
||||
if (turns.length > 0) {
|
||||
setMessages((current) => (current.length === 0 ? messagesFromTurns(turns) : current));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Snapshot reads after mount (turn completion, question-slot retries) are
|
||||
// abortable so none of them lands after the surface has gone.
|
||||
const snapshotAbort = useRef<AbortController | null>(null);
|
||||
const loadCaseSnapshot = useCallback(async () => {
|
||||
snapshotAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
snapshotAbort.current = controller;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
applyCaseSnapshot(await response.json().catch(() => null));
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
} finally {
|
||||
if (snapshotAbort.current === controller) snapshotAbort.current = null;
|
||||
}
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (!controller.signal.aborted) applyCaseSnapshot(payload);
|
||||
})
|
||||
.catch(() => {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
// Turns that arrive after mount (the parent refreshes the Case after each
|
||||
// completed turn) only ever fill an empty transcript; a live or finished
|
||||
// conversation is never overwritten or remounted. Adjusted during render
|
||||
// from the previous prop, the way React documents it, not in an effect.
|
||||
const [seededTurns, setSeededTurns] = useState(initialTurns);
|
||||
if (seededTurns !== initialTurns) {
|
||||
setSeededTurns(initialTurns);
|
||||
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
|
||||
}
|
||||
|
||||
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
|
||||
// Leaving the surface mid-turn ends the stream and any snapshot read
|
||||
// instead of letting them write into an unmounted component.
|
||||
useEffect(() => () => {
|
||||
runAbort.current?.abort();
|
||||
snapshotAbort.current?.abort();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Run one agent turn. A `continuation` reuses the live row an earlier step
|
||||
* (a tapped choice, an adopted candidate) already put at the end of the
|
||||
* transcript, so the reader sees one uninterrupted "working" row from the
|
||||
* tap to the next question instead of a gap and a re-appended row
|
||||
* (BUG-480). The caller of a continuation already holds `busy`.
|
||||
*/
|
||||
const send = useCallback(async (
|
||||
action: "opening" | "message" | "read_only",
|
||||
messageText: string,
|
||||
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
|
||||
) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
if ((action === "message" && !trimmed) || busy || readonly) return;
|
||||
if ((action === "message" && !trimmed) || readonly) return;
|
||||
if (!continuation && busy) return;
|
||||
setError("");
|
||||
setPending(true);
|
||||
|
||||
@@ -436,19 +512,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
const turnKey = keyCounter.current;
|
||||
const userRenderKey = `v9-user-${turnKey}`;
|
||||
const assistantRenderKey = `v9-assistant-${turnKey}`;
|
||||
const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`;
|
||||
const initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
|
||||
const initialActivity: AgentActivityView = {
|
||||
phase: "evidence-validation",
|
||||
label: initialLabel,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
startedAt: Date.now(),
|
||||
} },
|
||||
]);
|
||||
setMessages((current) => (continuation
|
||||
? current.map((message) => (message.renderKey === assistantRenderKey
|
||||
? { ...message, text: "", state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity }
|
||||
: message))
|
||||
: [
|
||||
...current,
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity },
|
||||
]));
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
@@ -456,11 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let activityReceiptState = createRectificationActivityReceiptState();
|
||||
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
let completedTurnId: string | undefined;
|
||||
let currentActivity: AgentActivityView | undefined = {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
let currentActivity: AgentActivityView | undefined = initialActivity;
|
||||
// Every stream event lands in `frames`; it commits at most once per animation
|
||||
// frame and releases text at a steady pace. The loop below never calls
|
||||
// setMessages for a live turn directly except on `attempt.reset`.
|
||||
@@ -507,7 +585,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 402) {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
// Say why before the page changes underneath the reader (task 1.5).
|
||||
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
|
||||
window.setTimeout(() => {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) setError("请先登录。");
|
||||
@@ -694,6 +776,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
// Stopped by the reader: what streamed stays in place and the notice
|
||||
// says so; nothing here is an outage (task 0.7).
|
||||
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
|
||||
@@ -763,6 +848,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
keyCounter.current += 1;
|
||||
const turnKey = keyCounter.current;
|
||||
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
||||
const echoRenderKey = `v9-choice-echo-${turnKey}`;
|
||||
// The tap is echoed as the reader's own line, right after the answered
|
||||
// card and before the working row, so the transcript reads as a
|
||||
// conversation and survives a refresh the same way (BUG-482).
|
||||
const echoText = optionId === "stop" ? answeredCard.stop_label : userVisibleChoiceLine(answeredCard, optionId);
|
||||
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
|
||||
setMessages((current) => [
|
||||
...current.map((message) => (
|
||||
offeringRenderKey && message.renderKey === offeringRenderKey
|
||||
@@ -772,6 +863,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
: message
|
||||
)),
|
||||
{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" },
|
||||
{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
@@ -780,7 +872,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: {
|
||||
phase: "evidence-validation",
|
||||
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
|
||||
label: recordingLabel,
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -808,7 +900,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
@@ -822,12 +914,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setError(payload?.message || payload?.error || `请求失败(${response.status})`);
|
||||
return;
|
||||
}
|
||||
const serverEcho = typeof payload?.userMessage === "string" && payload.userMessage.trim()
|
||||
? payload.userMessage.trim()
|
||||
: echoText;
|
||||
if (serverEcho !== echoText) {
|
||||
setMessages((current) => current.map((message) => (
|
||||
message.renderKey === echoRenderKey ? { ...message, text: serverEcho } : message
|
||||
)));
|
||||
}
|
||||
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
if (willContinue) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
choiceContinuationPending.current = true;
|
||||
// The same working row carries straight into the follow-up turn: no
|
||||
// frame without a live row, and no next card flashing in between
|
||||
// (BUG-480). `busy` stays true across the whole chain.
|
||||
onMessagesChange?.([{ role: "user", text: serverEcho }]);
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
@@ -841,12 +944,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
: message));
|
||||
onMessagesChange?.([
|
||||
{ role: "user", text: serverEcho },
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
@@ -868,16 +972,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onMessagesChange,
|
||||
onProfileIncomplete,
|
||||
readonly,
|
||||
send,
|
||||
sessionId,
|
||||
setPending,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!choiceContinuationPending.current || busy || readonly) return;
|
||||
choiceContinuationPending.current = false;
|
||||
void send("read_only", "");
|
||||
}, [busy, readonly, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTurns.length > 0) {
|
||||
if (shouldStartOpening) onOpeningConsumed?.();
|
||||
@@ -885,15 +984,43 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
if (readonly || openingStarted.current || !shouldStartOpening) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
onOpeningConsumed?.();
|
||||
void send("opening", "");
|
||||
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
|
||||
|
||||
// A Case with no turns and no automatic first turn (the server never starts
|
||||
// an opening for a resumed Case) needs a way to begin; the server suppresses
|
||||
// a duplicate opening, so this is safe to press twice (BUG-481).
|
||||
function startOpeningManually() {
|
||||
if (readonly || busy || openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
void send("opening", "");
|
||||
}
|
||||
|
||||
const acceptCandidate = useCallback(async (candidateId: string) => {
|
||||
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
|
||||
setError("");
|
||||
setAcceptingCandidateId(candidateId);
|
||||
setPending(true);
|
||||
// Adoption is a server round trip followed by an agent turn; the reader sees
|
||||
// one live row for the whole of it instead of a greyed button (BUG-480).
|
||||
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
|
||||
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
|
||||
keyCounter.current += 1;
|
||||
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: assistantRenderKey,
|
||||
state: "thinking",
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
|
||||
@@ -941,14 +1068,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onSaved?.(payload.saved_time, "accepted");
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
choiceContinuationPending.current = true;
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
|
||||
} catch (caught) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
||||
} finally {
|
||||
setAcceptingCandidateId(null);
|
||||
setPending(false);
|
||||
}
|
||||
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]);
|
||||
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
|
||||
|
||||
async function copyMessage(message: RenderMessage) {
|
||||
try {
|
||||
@@ -1048,24 +1176,53 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? currentQuestion.prompt
|
||||
: null;
|
||||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& ((currentQuestion.kind === "choice" && !choiceCard)
|
||||
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
|
||||
);
|
||||
// The slot has four visible states. A gap after a settled turn is
|
||||
// `preparing` (one live row, timed refetches) and then `unavailable` (a
|
||||
// reload button) — never bare copy telling the reader to wait (BUG-479).
|
||||
const questionSlot = rectificationQuestionSlotState({
|
||||
questionKind: currentQuestion?.kind ?? null,
|
||||
questionPrompt: currentQuestion?.prompt ?? null,
|
||||
hasChoiceCard: Boolean(choiceCard),
|
||||
choiceAnswered: Boolean(choiceCard && answeredQuestionIds.has(choiceCard.question_id)),
|
||||
busy,
|
||||
readonly,
|
||||
regenerating: regeneratingMessageKey !== null,
|
||||
snapshotLoaded: caseSnapshotLoaded,
|
||||
resumableCase,
|
||||
retryAttempts: questionRetryAttempts,
|
||||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
});
|
||||
const conversationState = rectificationConversationState({
|
||||
messageCount: messages.length,
|
||||
busy,
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
openingStarted: openingRequested,
|
||||
});
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
// Question-slot recovery: the turn already refetched once on completion;
|
||||
// while the slot is `preparing`, refetch on a timer up to the retry limit,
|
||||
// then hand the reader a reload button. Attempts reset once a question
|
||||
// arrives or another turn starts.
|
||||
const questionRetrying = questionSlot === "preparing";
|
||||
useVisibilityAwarePoll({
|
||||
enabled: questionRetrying,
|
||||
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
refreshOnVisible: false,
|
||||
onPoll: () => {
|
||||
setQuestionRetryAttempts((current) => current + 1);
|
||||
void refetchQuestion();
|
||||
},
|
||||
});
|
||||
async function refetchQuestion() {
|
||||
await loadCaseSnapshot();
|
||||
}
|
||||
function reloadQuestion() {
|
||||
setQuestionRetryAttempts(0);
|
||||
void refetchQuestion();
|
||||
}
|
||||
|
||||
function submitChoice(key: ChoiceKey) {
|
||||
if (!choiceCard) return;
|
||||
void submitStructuredChoice(CHOICE_ACTION, key);
|
||||
@@ -1079,6 +1236,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const boardPeek = compactBoard && !boardOpen ? (
|
||||
<RectificationBoardPeek
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
expanded={boardOpen}
|
||||
boardId={boardId}
|
||||
onOpen={toggleBoard}
|
||||
@@ -1088,7 +1246,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return (
|
||||
<div
|
||||
ref={workspace}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}${candidateResult ? "" : " is-board-empty"}`}
|
||||
>
|
||||
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
@@ -1104,6 +1262,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{conversationState === "empty" && (
|
||||
<div className="rectification-empty-state">
|
||||
<p>{RECTIFICATION_EMPTY_COPY}</p>
|
||||
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
@@ -1188,34 +1352,39 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<section className="rectification-question-slot" aria-label="当前问题">
|
||||
{showLiveChoiceCard && choiceCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||||
card={choiceCard}
|
||||
pending={busy}
|
||||
disabled={readonly}
|
||||
selectedKey=""
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{showMissingQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
</p>
|
||||
)}
|
||||
{showUnavailableQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前问题暂时无法显示,请等待服务端更新。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{/* The question slot is the last entry of the transcript, indented like
|
||||
every assistant entry, never a widget hanging below the cards. */}
|
||||
<div className="rectification-message-wrap rectification-message-entry">
|
||||
<section className="rectification-question-slot" aria-label="当前问题">
|
||||
{showLiveChoiceCard && choiceCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||||
card={choiceCard}
|
||||
pending={busy}
|
||||
disabled={readonly}
|
||||
selectedKey=""
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{questionSlot === "preparing" && (
|
||||
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
|
||||
)}
|
||||
{questionSlot === "unavailable" && (
|
||||
<div className="rectification-question-slot__recover" role="status">
|
||||
<p className="rectification-question-slot__status">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="secondary" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{savedTime && savedStatus === "confirmed" && (
|
||||
<p className="rectification-saved" role="status">
|
||||
已确认校正时间:{savedTime}
|
||||
@@ -1292,6 +1461,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
<RectificationBoard
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
savedStatus={savedStatus}
|
||||
diff={boardDiff}
|
||||
compact={compactBoard}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
|
||||
|
||||
@@ -39,6 +40,8 @@ export type SidebarSessionControls = {
|
||||
type SidebarSessionRowProps = {
|
||||
readonly session: SidebarSession;
|
||||
readonly active: boolean;
|
||||
/** Its Case is being opened: a static note, no spinner (BUG-479). */
|
||||
readonly opening?: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly menuOpen: boolean;
|
||||
readonly onMenuOpenChange: (open: boolean) => void;
|
||||
@@ -53,6 +56,7 @@ type SidebarSessionRowProps = {
|
||||
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
|
||||
session,
|
||||
active,
|
||||
opening = false,
|
||||
disabled,
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
@@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
type="button"
|
||||
isActive={active}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-busy={opening ? true : undefined}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="session-title">
|
||||
{session.pinned ? <Star aria-label="已收藏" /> : null}
|
||||
<span className="truncate">{session.title}</span>
|
||||
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
|
||||
@@ -96,7 +96,12 @@ export function StarterHome({
|
||||
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
|
||||
</div>
|
||||
</article>
|
||||
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
|
||||
<article
|
||||
className="birth-rectification-card product-entrypoint-card"
|
||||
aria-labelledby="birth-rectification-title"
|
||||
aria-busy={rectificationLoading ? true : undefined}
|
||||
data-opening={rectificationLoading ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
className="product-entrypoint-hitarea"
|
||||
type="button"
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
} from "@/lib/rectification-entry";
|
||||
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
import {
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
rectificationCaseHref,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
|
||||
export type RectificationSurfaceParams = {
|
||||
account: Account | null;
|
||||
@@ -58,6 +66,8 @@ export type RectificationSurfaceParams = {
|
||||
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
|
||||
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
uiPreview: MutableRefObject<boolean>;
|
||||
@@ -98,6 +108,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setRectificationReadonly,
|
||||
setRectificationSessionId,
|
||||
setRectificationShouldStartOpening,
|
||||
setRectificationSnapshot,
|
||||
setRectificationOpeningSessionId,
|
||||
setRectificationTurns,
|
||||
setSessions,
|
||||
uiPreview,
|
||||
@@ -121,27 +133,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
|
||||
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json().catch(() => null);
|
||||
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
|
||||
id: String(turn?.id ?? ""),
|
||||
role: turn?.role === "user" ? "user" as const : "assistant" as const,
|
||||
text: typeof turn?.text === "string" ? turn.text : null,
|
||||
status: String(turn?.status ?? "completed"),
|
||||
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
|
||||
status: String((turn.receipt as { status?: unknown }).status ?? ""),
|
||||
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
|
||||
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
|
||||
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
|
||||
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
|
||||
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
|
||||
} : null,
|
||||
})));
|
||||
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
||||
} catch {
|
||||
// History refresh is best-effort; the stream restores live turns.
|
||||
}
|
||||
@@ -168,8 +163,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the selection source now: `selectSession` resets it synchronously
|
||||
// right after handing off, and the URL write below happens after awaits.
|
||||
const selectionSource = sessionSelectionSource.current;
|
||||
rectificationOpenInFlight.current = true;
|
||||
setRectificationLoading(true);
|
||||
setRectificationOpeningSessionId(exactSessionId);
|
||||
setRectificationError("");
|
||||
|
||||
try {
|
||||
@@ -201,6 +200,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// One reveal: turns and snapshot are read before the surface mounts, so
|
||||
// the reader never sees an empty transcript or an empty question slot
|
||||
// while they load. Past the deadline the surface opens with what arrived
|
||||
// and its own retry path fills in the rest (BUG-479).
|
||||
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Merge the server-created session into the local list. The browser
|
||||
// never generates a Case id; it only mirrors the returned binding.
|
||||
const existing = sessions.find((session) => session.id === opened.sessionId);
|
||||
@@ -228,19 +235,20 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setRectificationTurns(hydration.turns);
|
||||
setRectificationSnapshot(hydration.snapshot);
|
||||
setRectificationSessionId(opened.sessionId);
|
||||
setRectificationCaseId(opened.caseId);
|
||||
setRectificationShouldStartOpening(opened.shouldStartOpening);
|
||||
setRectificationReadonly(
|
||||
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
||||
);
|
||||
setRectificationTurns([]);
|
||||
activeSessionIdRef.current = opened.sessionId;
|
||||
setActiveSessionId(opened.sessionId);
|
||||
if (!uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
if (!uiPreview.current && selectionSource === "user") {
|
||||
writeSessionUrl(opened.sessionId, "push");
|
||||
}
|
||||
void refreshRectificationCase(opened.caseId, opened.sessionId);
|
||||
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
|
||||
void refreshRectificationEntrySummary();
|
||||
return opened;
|
||||
} catch {
|
||||
@@ -249,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
} finally {
|
||||
sessionSelectionSource.current = "user";
|
||||
rectificationOpenInFlight.current = false;
|
||||
setRectificationOpeningSessionId(null);
|
||||
setRectificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +320,14 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
function selectSession(sessionId: string) {
|
||||
const nextSession = sessions.find((session) => session.id === sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
// A rectification session that is not the open one is not switched to
|
||||
// here: the surface hook opens the Case, hydrates turns and snapshot, and
|
||||
// only then makes it active and writes the URL, so the current view stays
|
||||
// put instead of flashing a plain transcript and then an empty panel
|
||||
// (BUG-479). It reads the selection source before we reset it below.
|
||||
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
|
||||
&& nextSession.id !== rectificationSessionId;
|
||||
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
|
||||
setDraft("");
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
@@ -333,7 +340,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
}
|
||||
if (nextSession?.sessionType === "birth_time_rectification") {
|
||||
setRectificationError("");
|
||||
if (nextSession.id !== rectificationSessionId) {
|
||||
if (deferredRectificationSwitch) {
|
||||
// The exact sessionId is passed to the server; the server resolves
|
||||
// the exact Case and never switches to another rectification record.
|
||||
void openRectificationSession(nextSession.id);
|
||||
@@ -341,7 +348,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
} else {
|
||||
void ensureSessionMessages(sessionId);
|
||||
}
|
||||
if (!uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
writeSessionUrl(sessionId, "push");
|
||||
}
|
||||
sessionSelectionSource.current = "user";
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Pure state for the rectification chat surface: what the transcript, the
|
||||
* question slot and the entry card show in each phase, and how a Case is
|
||||
* hydrated (turns + snapshot) before the surface is revealed.
|
||||
*
|
||||
* Nothing here touches React. The chat component and the surface hook call
|
||||
* these so the "is there anything to wait for, and what does the reader see
|
||||
* while waiting" decisions are testable without a DOM.
|
||||
*/
|
||||
|
||||
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
|
||||
|
||||
/**
|
||||
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
|
||||
* returns and before the surface is revealed. Past it the surface opens with
|
||||
* whatever arrived and the question slot's retry path fills the rest. Shared
|
||||
* with the home reveal budget: keep it equal to the unified-loading constant.
|
||||
*/
|
||||
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = 4_000;
|
||||
|
||||
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
|
||||
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
|
||||
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
|
||||
|
||||
/** Question-slot recovery: one immediate refetch after a turn, then this many timed retries. */
|
||||
export const RECTIFICATION_QUESTION_RETRY_LIMIT = 2;
|
||||
export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
|
||||
|
||||
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
|
||||
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
|
||||
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
|
||||
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
|
||||
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
|
||||
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
|
||||
|
||||
/** Live-row labels by the action that started the turn (task 1.4). */
|
||||
export const RECTIFICATION_OPENING_LIVE_LABEL = "正在读取你的出生资料,准备第一个问题…";
|
||||
export const RECTIFICATION_MESSAGE_LIVE_LABEL = "正在处理…";
|
||||
|
||||
export function rectificationInitialLiveLabel(
|
||||
action: "opening" | "message" | "read_only",
|
||||
continuationLabel?: string,
|
||||
): string {
|
||||
if (action === "opening") return RECTIFICATION_OPENING_LIVE_LABEL;
|
||||
if (action === "read_only" && continuationLabel) return continuationLabel;
|
||||
return RECTIFICATION_MESSAGE_LIVE_LABEL;
|
||||
}
|
||||
|
||||
export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
latest_result?: unknown;
|
||||
current_question?: unknown;
|
||||
choice_card?: unknown;
|
||||
case?: Readonly<{
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
}>;
|
||||
turns?: unknown;
|
||||
}>;
|
||||
|
||||
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** The Case API's `turns` rows, normalised to what the chat renders. */
|
||||
export function parsePersistedRectificationTurns(value: unknown): PersistedRectificationTurn[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown }) => ({
|
||||
id: String(turn?.id ?? ""),
|
||||
role: turn?.role === "user" ? "user" as const : "assistant" as const,
|
||||
text: typeof turn?.text === "string" ? turn.text : null,
|
||||
status: String(turn?.status ?? "completed"),
|
||||
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
|
||||
status: String((turn.receipt as { status?: unknown }).status ?? ""),
|
||||
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
|
||||
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
|
||||
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
|
||||
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
|
||||
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
|
||||
} : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export type RectificationCaseHydration = Readonly<{
|
||||
turns: PersistedRectificationTurn[];
|
||||
snapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** False when the request failed or the deadline passed first. */
|
||||
complete: boolean;
|
||||
}>;
|
||||
|
||||
export function rectificationCaseHref(caseId: string, sessionId: string): string {
|
||||
return `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns and snapshot come from one Case read, so hydration is a single request
|
||||
* raced against the reveal deadline. A late or failed read still resolves, with
|
||||
* empty turns and no snapshot, so the caller can reveal and let the surface's
|
||||
* own retry path fill in the rest.
|
||||
*/
|
||||
export async function hydrateRectificationCase(
|
||||
caseId: string,
|
||||
sessionId: string,
|
||||
options: Readonly<{
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
setTimeoutImpl?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearTimeoutImpl?: (handle: unknown) => void;
|
||||
}> = {},
|
||||
): Promise<RectificationCaseHydration> {
|
||||
const timeoutMs = options.timeoutMs ?? RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
|
||||
|
||||
let deadline: unknown;
|
||||
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
|
||||
deadline = schedule(() => resolve(incomplete), timeoutMs);
|
||||
});
|
||||
const read = (async (): Promise<RectificationCaseHydration> => {
|
||||
try {
|
||||
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return incomplete;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
|
||||
return {
|
||||
turns: parsePersistedRectificationTurns(payload.turns),
|
||||
snapshot: payload,
|
||||
complete: true,
|
||||
};
|
||||
} catch {
|
||||
return incomplete;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await Promise.race([read, timeout]);
|
||||
} finally {
|
||||
cancel(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
export type RectificationConversationState =
|
||||
| "readonly"
|
||||
| "busy"
|
||||
| "opening"
|
||||
| "empty"
|
||||
| "conversation";
|
||||
|
||||
/**
|
||||
* What the transcript area is doing when there are no rendered turns. `opening`
|
||||
* means the first turn is about to be requested (server said so); `empty` means
|
||||
* the Case has nothing to show and no automatic first turn — the reader needs a
|
||||
* way to start.
|
||||
*/
|
||||
export function rectificationConversationState(input: Readonly<{
|
||||
messageCount: number;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
openingStarted: boolean;
|
||||
}>): RectificationConversationState {
|
||||
if (input.readonly) return "readonly";
|
||||
if (input.busy) return "busy";
|
||||
if (input.messageCount > 0) return "conversation";
|
||||
if (input.shouldStartOpening || input.openingStarted) return "opening";
|
||||
return "empty";
|
||||
}
|
||||
|
||||
export type RectificationQuestionSlotState =
|
||||
| "choice-live"
|
||||
| "spoken-prompt"
|
||||
| "preparing"
|
||||
| "unavailable"
|
||||
| "idle";
|
||||
|
||||
export type RectificationQuestionSlotInput = Readonly<{
|
||||
questionKind: "choice" | "collect_spoken" | null;
|
||||
questionPrompt: string | null;
|
||||
hasChoiceCard: boolean;
|
||||
choiceAnswered: boolean;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
snapshotLoaded: boolean;
|
||||
resumableCase: boolean;
|
||||
retryAttempts: number;
|
||||
retryLimit?: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The four visible states of the question slot. A gap between a settled turn
|
||||
* and its next question is `preparing` while retries remain, and
|
||||
* `unavailable` — with a manual reload — once they run out. There is no
|
||||
* "waiting for the snapshot" state: the snapshot arrives with the reveal.
|
||||
*/
|
||||
export function rectificationQuestionSlotState(input: RectificationQuestionSlotInput): RectificationQuestionSlotState {
|
||||
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
|
||||
if (input.readonly || input.busy || input.regenerating) return "idle";
|
||||
if (input.questionKind === "choice" && input.hasChoiceCard && !input.choiceAnswered) return "choice-live";
|
||||
if (input.questionKind === "collect_spoken" && input.questionPrompt) return "spoken-prompt";
|
||||
if (!input.snapshotLoaded || !input.resumableCase) return "idle";
|
||||
// An answered card that is still the current question means the next one
|
||||
// has not arrived yet: that is a gap too.
|
||||
const gap = input.questionKind === null
|
||||
|| (input.questionKind === "choice" && (!input.hasChoiceCard || input.choiceAnswered))
|
||||
|| (input.questionKind === "collect_spoken" && !input.questionPrompt);
|
||||
if (!gap) return "idle";
|
||||
return input.retryAttempts < limit ? "preparing" : "unavailable";
|
||||
}
|
||||
|
||||
/** Whether the question slot should run another timed snapshot refetch. */
|
||||
export function rectificationQuestionRetryActive(state: RectificationQuestionSlotState): boolean {
|
||||
return state === "preparing";
|
||||
}
|
||||
|
||||
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
|
||||
export function rectificationAdoptingLabel(time: string): string {
|
||||
return `正在采用 ${time}…`;
|
||||
}
|
||||
|
||||
/** Board copy when no candidate result exists yet (task 1.3). */
|
||||
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
|
||||
clock: string | null;
|
||||
lines: readonly string[];
|
||||
}> {
|
||||
if (!declaredTime) {
|
||||
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
|
||||
}
|
||||
return {
|
||||
clock: declaredTime,
|
||||
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
};
|
||||
}
|
||||
|
||||
/** The declared birth minute from the profile, or null when only a period was given. */
|
||||
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
|
||||
const candidate = (profile.reportedTime || profile.time || "").trim();
|
||||
return /^\d{1,2}:\d{2}$/.test(candidate) ? candidate : null;
|
||||
}
|
||||
@@ -82,9 +82,20 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
|
||||
assert.doesNotMatch(chat, /spoken-answer/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
// Former lock read this parser out of the page surface (`use-rectification-surface.ts`). The
|
||||
// turn parser moved to `rectification-surface-state.ts` so the hook's hydration and the chat's
|
||||
// late-snapshot fill share one (BUG-479); the shape it produces is unchanged.
|
||||
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
||||
assert.match(surfaceState, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
assert.match(page, /parsePersistedRectificationTurns\(payload\?\.turns\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
|
||||
@@ -191,7 +202,13 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
|
||||
assert.match(chat, /initialTurns/);
|
||||
assert.match(chat, /const openingStarted = useRef\(false\)/);
|
||||
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
@@ -218,10 +235,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
|
||||
assert.match(chat, /resultId: candidateResult\.resultId/);
|
||||
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
|
||||
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
|
||||
// Former locks: `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`, the
|
||||
// `if (!choiceContinuationPending.current || busy || readonly) return; … void send("read_only", "")`
|
||||
// effect, and a doesNotMatch on `await loadCaseSnapshot(); … await send("read_only", "")`. That
|
||||
// effect hop dropped the live row for a frame and let the next card flash in (BUG-480); the
|
||||
// follow-up turn now continues in the same async chain on the same row, still through the
|
||||
// durable case endpoint first.
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
||||
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
|
||||
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
||||
assert.doesNotMatch(chat, /action: "accept_candidate"/);
|
||||
const acceptRoute = readFileSync(
|
||||
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
|
||||
@@ -501,7 +524,9 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
|
||||
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
|
||||
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
|
||||
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
|
||||
// Former lock: the workspace class expression ended at ` is-board-open`. The board now also
|
||||
// carries `is-board-empty` before any candidate exists, so the empty board takes less width (BUG-483).
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}\$\{candidateResult \? "" : " is-board-empty"\}`\}/);
|
||||
});
|
||||
|
||||
test("rectification composer can stop a live agent run", () => {
|
||||
@@ -554,7 +579,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.match(choiceCardComponent, /selectedKey/);
|
||||
assert.match(choiceCardComponent, /is-answered/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
|
||||
assert.match(chat, /isStructuredChoiceUserText/);
|
||||
// Former lock: `isStructuredChoiceUserText` in the chat — it filtered the reader's tapped choice out
|
||||
// of the persisted transcript, so after a refresh nobody could see what they had answered (BUG-482).
|
||||
// The tap is now echoed as the reader's line, live and persisted alike.
|
||||
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
||||
assert.match(chat, /userVisibleChoiceLine\(answeredCard, optionId\)/);
|
||||
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
|
||||
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
|
||||
assert.match(route, /answer_choice/);
|
||||
|
||||
@@ -1120,7 +1120,10 @@ test("the public agent route treats structured choice as a non-model command", (
|
||||
assert.match(chat, /isPersistedFocusId\(focusId\)/);
|
||||
assert.match(chat, /focusId,/);
|
||||
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
|
||||
assert.match(chat, /send\("read_only", ""\)/);
|
||||
// Former lock: `send("read_only", "")` with no third argument. The follow-up turn now reuses the
|
||||
// live row the tap put in place (`{ reuseAssistantRenderKey, label }`) so nothing disappears
|
||||
// between the tap and the next question (BUG-480).
|
||||
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\)/);
|
||||
assert.match(chat, /willContinue/);
|
||||
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
|
||||
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
|
||||
|
||||
@@ -131,11 +131,15 @@ test("collect_spoken current_question renders the prompt in the question slot",
|
||||
|
||||
test("missing current_question is explicit only for resumable cases", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
|
||||
// Former locks: `const showMissingQuestion = Boolean(caseSnapshotLoaded … resumableCase … currentQuestion === null`
|
||||
// and the copy 「当前没有可回答的问题,正在等待服务端更新」. That copy told the reader to wait with no
|
||||
// action and no end (BUG-479); the gap is now a pure slot state — `preparing` with timed refetches,
|
||||
// then `unavailable` with a reload — still only for resumable cases with a loaded snapshot.
|
||||
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.doesNotMatch(chat, /等待服务端更新/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
|
||||
assert.doesNotMatch(chat, /questionSlot[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
test("choice_card still renders through the existing choice-card branch", () => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||||
const hook = read("../src/hooks/use-rectification-surface.ts");
|
||||
const sessions = read("../src/hooks/use-session-management.ts");
|
||||
const starter = read("../src/components/starter-home.tsx");
|
||||
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
|
||||
const board = read("../src/components/rectification-board.tsx");
|
||||
const choiceCard = read("../src/components/rectification-choice-card.tsx");
|
||||
const wrapper = read("../src/components/conversational-birth-time-rectification.tsx");
|
||||
const timeline = read("../src/components/consultation-run-timeline.tsx");
|
||||
const styles = read("../src/app/globals.css");
|
||||
|
||||
test("the rectification surface is revealed once: Case hydration precedes the switch and the key never remounts", () => {
|
||||
// BUG-479: open → hydrate (turns + snapshot, one Case read, deadline) → switch, in that order.
|
||||
assert.match(hook, /const hydration = await hydrateRectificationCase\(opened\.caseId, opened\.sessionId, \{\s*timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,/);
|
||||
const openBody = hook.slice(hook.indexOf("const hydration = await hydrateRectificationCase"), hook.indexOf("void refreshRectificationEntrySummary();"));
|
||||
assert.match(openBody, /setRectificationTurns\(hydration\.turns\);[\s\S]*setRectificationSnapshot\(hydration\.snapshot\);[\s\S]*setActiveSessionId\(opened\.sessionId\);/);
|
||||
assert.match(openBody, /if \(!hydration\.complete\) setComposerNotice\(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE\);/);
|
||||
assert.doesNotMatch(hook, /void refreshRectificationCase\(opened\.caseId/);
|
||||
// The selection source is read before any await; selectSession resets it synchronously.
|
||||
assert.match(hook, /const selectionSource = sessionSelectionSource\.current;[\s\S]*await fetch\("\/api\/rectification\/cases\/open"/);
|
||||
assert.match(hook, /selectionSource === "user"\) \{\s*writeSessionUrl\(opened\.sessionId, "push"\)/);
|
||||
// selectSession does not switch to a rectification session before its Case is hydrated.
|
||||
assert.match(sessions, /const deferredRectificationSwitch = nextSession\?\.sessionType === "birth_time_rectification"\s*&& nextSession\.id !== rectificationSessionId;/);
|
||||
assert.match(sessions, /if \(!deferredRectificationSwitch\) setActiveSessionId\(sessionId\);/);
|
||||
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
|
||||
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
|
||||
assert.match(chat, /useState\(initialSnapshot !== null\)/);
|
||||
// Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect.
|
||||
assert.match(chat, /const \[seededTurns, setSeededTurns\] = useState\(initialTurns\);\s*if \(seededTurns !== initialTurns\) \{\s*setSeededTurns\(initialTurns\);\s*if \(messages\.length === 0 && initialTurns\.length > 0\) setMessages\(messagesFromTurns\(initialTurns\)\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(initialTurns\.length === 0\) return;/);
|
||||
// No self-fetch on mount; unmount ends the stream and any snapshot read.
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*const controller = new AbortController\(\);\s*void fetch\(/);
|
||||
assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/);
|
||||
});
|
||||
|
||||
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
|
||||
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
|
||||
assert.match(starter, /aria-busy=\{rectificationLoading \? true : undefined\}/);
|
||||
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);
|
||||
assert.doesNotMatch(starter, /InlineSpinner/);
|
||||
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
|
||||
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
|
||||
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
|
||||
assert.match(styles, /\.product-entrypoint-card\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
|
||||
assert.match(styles, /\.session-opening-note \{/);
|
||||
// The only spinner inside the chat is a timeline live row (generation), never a loading state.
|
||||
const spinnerUses = chat.match(/InlineSpinner/g) ?? [];
|
||||
assert.equal(spinnerUses.length, 0);
|
||||
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
|
||||
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
|
||||
});
|
||||
|
||||
test("the question slot has four states, sits in the transcript flow, and never tells the reader to wait for the server", () => {
|
||||
assert.doesNotMatch(chat, /等待服务端更新/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion/);
|
||||
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{/);
|
||||
assert.match(chat, /questionSlot === "preparing" && \(/);
|
||||
assert.match(chat, /questionSlot === "unavailable" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionRetrying,\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
|
||||
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
|
||||
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
|
||||
// In flow: wrapped like every other entry, after the message loop, before the saved-time line.
|
||||
const slotIndex = chat.indexOf('<div className="rectification-message-wrap rectification-message-entry">\n <section className="rectification-question-slot"');
|
||||
assert.ok(slotIndex > chat.indexOf("{messages.map((message) => {"));
|
||||
assert.ok(slotIndex < chat.indexOf("{savedTime &&"));
|
||||
});
|
||||
|
||||
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
|
||||
// BUG-480: no effect hop, no removed row, busy held across the chain.
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.match(chat, /const send = useCallback\(async \(\s*action: "opening" \| "message" \| "read_only",\s*messageText: string,\s*continuation\?: Readonly<\{ reuseAssistantRenderKey: string; label: string \}>,/);
|
||||
assert.match(chat, /if \(!continuation && busy\) return;/);
|
||||
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\);/);
|
||||
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
||||
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
||||
assert.doesNotMatch(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)\);\s*choiceContinuationPending/);
|
||||
});
|
||||
|
||||
test("an empty Case with no automatic opening offers a way to start", () => {
|
||||
// BUG-481
|
||||
assert.match(chat, /conversationState === "empty" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_EMPTY_COPY/);
|
||||
assert.match(chat, /onClick=\{startOpeningManually\}>\{RECTIFICATION_EMPTY_ACTION_LABEL\}/);
|
||||
assert.match(chat, /function startOpeningManually\(\) \{\s*if \(readonly \|\| busy \|\| openingStarted\.current\) return;\s*openingStarted\.current = true;/);
|
||||
assert.match(styles, /\.rectification-empty-state \{/);
|
||||
});
|
||||
|
||||
test("stopping keeps what streamed and says so; the live-row label follows the action", () => {
|
||||
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
|
||||
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
|
||||
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/);
|
||||
});
|
||||
|
||||
test("the tap is echoed as the reader's line and the card confirms the selection", () => {
|
||||
// BUG-482
|
||||
assert.match(chat, /\{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" \}/);
|
||||
assert.match(chat, /typeof payload\?\.userMessage === "string"/);
|
||||
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
||||
assert.match(choiceCard, /<Check aria-hidden="true" \/>/);
|
||||
assert.match(choiceCard, /已选择/);
|
||||
assert.match(choiceCard, /rectification-choice-card__pending/);
|
||||
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
|
||||
assert.match(styles, /\.rectification-choice-card__selected \{/);
|
||||
assert.match(styles, /\.rectification-choice-card__pending \{/);
|
||||
});
|
||||
|
||||
test("the board's first state shows the declared minute and takes less width", () => {
|
||||
// BUG-483
|
||||
assert.match(board, /declaredTime: string \| null;/);
|
||||
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime\)/);
|
||||
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime\)/);
|
||||
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
|
||||
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
|
||||
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
|
||||
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
|
||||
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
declaredBirthTime,
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationBoardEmptyCopy,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionRetryActive,
|
||||
rectificationQuestionSlotState,
|
||||
type RectificationQuestionSlotInput,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const slotBase: RectificationQuestionSlotInput = {
|
||||
questionKind: null,
|
||||
questionPrompt: null,
|
||||
hasChoiceCard: false,
|
||||
choiceAnswered: false,
|
||||
busy: false,
|
||||
readonly: false,
|
||||
regenerating: false,
|
||||
snapshotLoaded: true,
|
||||
resumableCase: true,
|
||||
retryAttempts: 0,
|
||||
};
|
||||
|
||||
test("conversation state: readonly and busy win, then opening, then empty", () => {
|
||||
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
|
||||
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
|
||||
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
|
||||
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
|
||||
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
|
||||
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
|
||||
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-481).
|
||||
assert.equal(rectificationConversationState(base), "empty");
|
||||
});
|
||||
|
||||
test("question slot: a live choice card and a spoken prompt render as themselves", () => {
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true }), "choice-live");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true, choiceAnswered: true }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: "哪年上的大学?" }), "spoken-prompt");
|
||||
});
|
||||
|
||||
test("question slot: a gap is preparing while retries remain, then unavailable with a reload", () => {
|
||||
assert.equal(rectificationQuestionSlotState(slotBase), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: false }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: null }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: 5 }), "unavailable");
|
||||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("choice-live"), false);
|
||||
});
|
||||
|
||||
test("question slot: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, busy: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, regenerating: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, resumableCase: false }), "idle");
|
||||
// No snapshot at all (hydration failed and no retry has answered yet): nothing to say about a question.
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, snapshotLoaded: false }), "idle");
|
||||
// Readonly hides even a live card.
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true, questionKind: "choice", hasChoiceCard: true }), "idle");
|
||||
});
|
||||
|
||||
test("live-row labels follow the action that started the turn", () => {
|
||||
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
|
||||
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
|
||||
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
|
||||
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
|
||||
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
|
||||
});
|
||||
|
||||
test("board copy before any candidate shows the declared minute, never invented data", () => {
|
||||
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
|
||||
clock: "05:10",
|
||||
lines: ["填报出生时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
});
|
||||
assert.equal(rectificationBoardEmptyCopy(null).clock, null);
|
||||
assert.equal(rectificationBoardEmptyCopy(null).lines.length, 1);
|
||||
assert.equal(declaredBirthTime({ reportedTime: "05:10", time: "05:10" }), "05:10");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "7:05" }), "7:05");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
|
||||
assert.equal(declaredBirthTime({ time: "凌晨" }), null);
|
||||
});
|
||||
|
||||
test("persisted turns parse with receipts and tolerate junk", () => {
|
||||
const turns = parsePersistedRectificationTurns([
|
||||
{ id: "t1", role: "user", text: "2014年毕业", status: "completed" },
|
||||
{ id: "t2", role: "assistant", text: "记下了", status: "completed", receipt: { status: "ok", phases: ["a"], tools: ["x"], methods: ["d9-navamsa"], skill_name: "s", skill_version: "1" } },
|
||||
{ id: 3, role: "other", text: 5 },
|
||||
]);
|
||||
assert.equal(turns.length, 3);
|
||||
assert.equal(turns[0]?.role, "user");
|
||||
assert.equal(turns[1]?.receipt?.methods?.[0], "d9-navamsa");
|
||||
assert.equal(turns[2]?.role, "assistant");
|
||||
assert.equal(turns[2]?.text, null);
|
||||
assert.deepEqual(parsePersistedRectificationTurns(undefined), []);
|
||||
});
|
||||
|
||||
function fakeResponse(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
test("hydration reads turns and snapshot from one Case read", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(String(input));
|
||||
return fakeResponse({
|
||||
turns: [{ id: "t1", role: "assistant", text: "你好", status: "completed" }],
|
||||
current_question: { kind: "choice", prompt: "哪一个?" },
|
||||
case: { status: "collecting", accepted_time: null, confirmed_time: null },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.turns.length, 1);
|
||||
assert.equal((result.snapshot?.current_question as { kind?: string })?.kind, "choice");
|
||||
});
|
||||
|
||||
test("hydration resolves incomplete, not rejected, on a failed read or a passed deadline", async () => {
|
||||
const failed = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async () => fakeResponse({ error: "nope" }, false),
|
||||
});
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
|
||||
|
||||
const thrown = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async () => { throw new Error("network"); },
|
||||
});
|
||||
assert.equal(thrown.complete, false);
|
||||
|
||||
let fire: (() => void) | undefined;
|
||||
const late = hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
fetchImpl: () => new Promise(() => {}),
|
||||
setTimeoutImpl: (callback, delayMs) => {
|
||||
assert.equal(delayMs, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS);
|
||||
fire = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutImpl: () => {},
|
||||
});
|
||||
assert.ok(fire);
|
||||
fire?.();
|
||||
const timedOut = await late;
|
||||
assert.equal(timedOut.complete, false);
|
||||
assert.deepEqual(timedOut.turns, []);
|
||||
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, 4_000);
|
||||
});
|
||||
Reference in New Issue
Block a user