fix(rectification): reveal the surface once — hydrate the Case before the switch, never remount, static entry feedback
Opening a rectification Case used to switch sessions first and read the turns afterwards, so the reader saw a plain transcript, then an empty panel, then a remount when the turns arrived (and again after the first turn settled, because the panel key carried a ready/loading suffix). The surface hook now reads turns and snapshot in one Case request under the home reveal budget and only then makes the session active; the sidebar defers the switch the same way; a session selected at bootstrap (deep link, refresh) is hydrated during the prepare phase so the reveal shows the surface itself; the panel key is the session/Case binding only, later turns fill an empty transcript as a prop update, and unmounting aborts any stream or snapshot read. The homepage card and the sidebar row say 正在打开 statically while the Case opens — no spinner after the reveal. BUG-505 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
@@ -757,6 +757,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
|
||||
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
|
||||
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
|
||||
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
|
||||
.session-opening-note {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); }
|
||||
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; }
|
||||
.session-menu-trigger > svg { width: 18px; height: 18px; }
|
||||
@@ -995,6 +1003,9 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
|
||||
.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
|
||||
.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; }
|
||||
/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */
|
||||
.product-entrypoint-card[data-opening="true"],
|
||||
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
|
||||
@media (hover: hover) {
|
||||
|
||||
@@ -24,6 +24,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,
|
||||
@@ -295,6 +300,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 [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
|
||||
@@ -415,7 +422,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;
|
||||
@@ -482,7 +492,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,
|
||||
});
|
||||
|
||||
@@ -502,8 +513,13 @@ export default function Home() {
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [hydrated]);
|
||||
|
||||
// A rectification session selected at bootstrap (deep link, refresh, the
|
||||
// most recent session) is opened and hydrated during the prepare phase, so
|
||||
// the reveal shows the surface itself rather than a plain transcript that is
|
||||
// swapped out a moment later. After the reveal the same effect serves
|
||||
// history navigation.
|
||||
useEffect(() => {
|
||||
if (!hydrated
|
||||
if ((!hydrated && bootstrapPhase === "account")
|
||||
|| !account
|
||||
|| !modelCatalog
|
||||
|| activeSession?.sessionType !== "birth_time_rectification"
|
||||
@@ -516,6 +532,7 @@ export default function Home() {
|
||||
}, [
|
||||
account,
|
||||
activeSession,
|
||||
bootstrapPhase,
|
||||
creatingSession,
|
||||
hydrated,
|
||||
modelCatalog,
|
||||
@@ -647,6 +664,10 @@ export default function Home() {
|
||||
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
|
||||
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
|
||||
entrySummarySettled: rectificationEntrySummarySettled,
|
||||
rectificationApplicable: activeSession?.sessionType === "birth_time_rectification",
|
||||
rectificationSettled: activeSession?.id === rectificationSessionId
|
||||
|| rectificationError !== ""
|
||||
|| !profileComplete,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1718,6 +1739,7 @@ export default function Home() {
|
||||
sessions={sidebarSessions}
|
||||
charts={sidebarCharts}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
openingSessionId={rectificationOpeningSessionId}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
accountTriggerRef={accountTrigger}
|
||||
@@ -1928,12 +1950,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)}
|
||||
|
||||
@@ -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<{
|
||||
@@ -10,9 +11,18 @@ export type PersistedRectificationTurn = Readonly<{
|
||||
text: string | null;
|
||||
status: string;
|
||||
question?: unknown;
|
||||
offer_result_id?: string | null;
|
||||
receipt?: Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tool_activities?: readonly Readonly<{
|
||||
tool: string;
|
||||
status: string;
|
||||
methods?: readonly string[];
|
||||
started_at?: string | null;
|
||||
elapsed_ms?: number | null;
|
||||
detail?: Readonly<Record<string, unknown>> | null;
|
||||
}>[];
|
||||
tools: readonly string[];
|
||||
methods?: readonly string[];
|
||||
skill_name?: string;
|
||||
@@ -26,6 +36,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;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/rectification-board-model";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import type { RectificationCaseSnapshotPayload } from "@/lib/rectification-surface-state";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
@@ -196,6 +197,10 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedTurn[];
|
||||
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** The declared birth minute from the profile, for the board before any candidate exists. */
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
@@ -386,6 +391,32 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
});
|
||||
}
|
||||
|
||||
type CaseSnapshotState = Readonly<{
|
||||
candidate: CandidateResult;
|
||||
question: CurrentQuestionModel | null;
|
||||
questionSource: "focus" | "unavailable" | null;
|
||||
choice: ChoiceCardModel | null;
|
||||
caseStatus: RectificationCaseStatus | null;
|
||||
savedTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
}>;
|
||||
|
||||
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
|
||||
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),
|
||||
questionSource: questionSourceFromSnapshot(payload.question_source),
|
||||
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,
|
||||
@@ -393,6 +424,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
initialSnapshot,
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
@@ -411,14 +443,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
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 [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | 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 [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
@@ -432,6 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const openingStarted = useRef(false);
|
||||
const previousBoardResult = useRef<CandidateResult>(null);
|
||||
const runAbort = useRef<AbortController | null>(null);
|
||||
const snapshotAbort = useRef<AbortController | null>(null);
|
||||
const choiceActionIds = useRef(new Map<string, string>());
|
||||
const currentQuestionRef = useRef(currentQuestion);
|
||||
const offerSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -606,13 +639,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
question: CurrentQuestionModel | null;
|
||||
turns: readonly unknown[];
|
||||
} | null | undefined> => {
|
||||
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 undefined;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
applyCaseSnapshot(payload);
|
||||
return {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
@@ -621,25 +658,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
return undefined;
|
||||
} 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) return;
|
||||
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));
|
||||
}
|
||||
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
|
||||
@@ -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. */
|
||||
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
|
||||
|
||||
@@ -93,7 +93,11 @@ 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"
|
||||
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,28 +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; question?: 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"),
|
||||
question: turn?.question ?? null,
|
||||
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.
|
||||
}
|
||||
@@ -172,6 +166,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
rectificationOpenInFlight.current = true;
|
||||
setRectificationLoading(true);
|
||||
setRectificationError("");
|
||||
// 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;
|
||||
setRectificationOpeningSessionId(exactSessionId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/open", {
|
||||
@@ -225,6 +223,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
|
||||
void persistSession(merged).catch(() => {});
|
||||
|
||||
// One reveal: turns and snapshot are read before the surface mounts, so
|
||||
// the reader never sees an empty transcript or an empty question area
|
||||
// while they load. Past the deadline the surface opens with what arrived
|
||||
// and its own retry path fills in the rest.
|
||||
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
setRectificationPendingQuestion(pendingConsultationQuestion);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
@@ -235,13 +241,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setRectificationReadonly(
|
||||
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
||||
);
|
||||
setRectificationTurns([]);
|
||||
setRectificationTurns(hydration.turns);
|
||||
setRectificationSnapshot(hydration.snapshot);
|
||||
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 {
|
||||
@@ -250,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
} finally {
|
||||
sessionSelectionSource.current = "user";
|
||||
rectificationOpenInFlight.current = false;
|
||||
setRectificationOpeningSessionId(null);
|
||||
setRectificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,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.
|
||||
// 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("");
|
||||
@@ -327,7 +334,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);
|
||||
@@ -335,7 +342,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";
|
||||
|
||||
@@ -19,10 +19,14 @@ export type BootstrapPrepareState = Readonly<{
|
||||
dailyStarlanguageApplicable: boolean;
|
||||
dailyStarlanguageSettled: boolean;
|
||||
entrySummarySettled: boolean;
|
||||
/** The selected session is a rectification session: its Case must be open and hydrated before the reveal. */
|
||||
rectificationApplicable?: boolean;
|
||||
rectificationSettled?: boolean;
|
||||
}>;
|
||||
|
||||
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
|
||||
if (!state.entrySummarySettled) return false;
|
||||
if (state.rectificationApplicable && !state.rectificationSettled) return false;
|
||||
if (state.profileComplete && !state.onboardingSettled) return false;
|
||||
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Pure state for the rectification chat surface: what the entry, the
|
||||
* transcript and the composer status 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";
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
|
||||
|
||||
/**
|
||||
* 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 composer status's retry path fills the rest. It is
|
||||
* the home reveal budget: one constant, not two.
|
||||
*/
|
||||
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
|
||||
|
||||
/** 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 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;
|
||||
question_source?: 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);
|
||||
}
|
||||
|
||||
type RawTurn = {
|
||||
id?: unknown;
|
||||
role?: unknown;
|
||||
text?: unknown;
|
||||
status?: unknown;
|
||||
question?: unknown;
|
||||
offer_result_id?: unknown;
|
||||
receipt?: unknown;
|
||||
};
|
||||
|
||||
type RawToolActivity = {
|
||||
tool?: unknown;
|
||||
status?: unknown;
|
||||
methods?: unknown;
|
||||
started_at?: unknown;
|
||||
elapsed_ms?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function parseToolActivities(value: unknown): NonNullable<NonNullable<PersistedRectificationTurn["receipt"]>["tool_activities"]> | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.flatMap((item: RawToolActivity) => {
|
||||
if (!item || typeof item !== "object" || typeof item.tool !== "string" || typeof item.status !== "string") return [];
|
||||
return [{
|
||||
tool: item.tool,
|
||||
status: item.status,
|
||||
methods: Array.isArray(item.methods) ? item.methods.map(String) : undefined,
|
||||
started_at: typeof item.started_at === "string" ? item.started_at : null,
|
||||
elapsed_ms: typeof item.elapsed_ms === "number" ? item.elapsed_ms : null,
|
||||
detail: item.detail && typeof item.detail === "object" ? item.detail as Record<string, unknown> : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** 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: RawTurn) => {
|
||||
const receipt = turn?.receipt && typeof turn.receipt === "object"
|
||||
? turn.receipt as { status?: unknown; phases?: unknown; tools?: unknown; methods?: unknown; skill_name?: unknown; skill_version?: unknown; tool_activities?: unknown }
|
||||
: null;
|
||||
const toolActivities = parseToolActivities(receipt?.tool_activities);
|
||||
return {
|
||||
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"),
|
||||
question: turn?.question ?? null,
|
||||
offer_result_id: typeof turn?.offer_result_id === "string" ? turn.offer_result_id : null,
|
||||
receipt: receipt ? {
|
||||
status: String(receipt.status ?? ""),
|
||||
phases: stringList(receipt.phases),
|
||||
tools: stringList(receipt.tools),
|
||||
methods: stringList(receipt.methods),
|
||||
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
|
||||
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
|
||||
...(toolActivities ? { tool_activities: toolActivities } : {}),
|
||||
} : 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 RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
|
||||
|
||||
export type RectificationQuestionGapInput = Readonly<{
|
||||
/** The current question is rendered live inside an assistant message. */
|
||||
liveQuestionVisible: boolean;
|
||||
/** The snapshot names no current question at all. */
|
||||
questionMissing: boolean;
|
||||
/** The server said the question could not be loaded (`question_source: "unavailable"`). */
|
||||
questionLoadFailed: boolean;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
snapshotLoaded: boolean;
|
||||
resumableCase: boolean;
|
||||
retryAttempts: number;
|
||||
retryLimit?: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The gap between a settled turn and its next question. While retries remain
|
||||
* it is `preparing` (one live row, timed refetches); once they run out, or the
|
||||
* server itself reports the question unavailable, it is `unavailable` with a
|
||||
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* too: the same retries fill it. Questions themselves live inside assistant
|
||||
* messages, so a visible live question means there is no gap.
|
||||
*/
|
||||
export function rectificationQuestionGapState(input: RectificationQuestionGapInput): RectificationQuestionGapState {
|
||||
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
|
||||
if (input.readonly || input.busy || input.regenerating) return "idle";
|
||||
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
|
||||
if (!input.snapshotLoaded) return retryGate;
|
||||
if (!input.resumableCase) return "idle";
|
||||
if (input.liveQuestionVisible) return "idle";
|
||||
if (input.questionLoadFailed) return "unavailable";
|
||||
// Either the snapshot names no question, or it names one that no settled
|
||||
// message carries live yet: both are a gap the next read may close.
|
||||
return retryGate;
|
||||
}
|
||||
|
||||
/** Whether the gap should run another timed snapshot refetch. */
|
||||
export function rectificationQuestionRetryActive(state: RectificationQuestionGapState): 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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const component = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -82,9 +83,18 @@ 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"\}`\}/);
|
||||
// Was: key=...-${rectificationTurns.length > 0 ? "ready" : "loading"}. That suffix
|
||||
// remounted the whole surface when the first turns arrived (an empty panel, then
|
||||
// a flash), and again would have dropped a live stream; turns now hydrate before
|
||||
// the switch and later arrive as a prop update (BUG-505).
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
// Was: an inline `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)`
|
||||
// parser in the surface hook; the parser moved to rectification-surface-state.ts so
|
||||
// hydration and refresh share it (BUG-505).
|
||||
assert.match(page, /setRectificationTurns\(parsePersistedRectificationTurns\(payload\?\.turns\)\)/);
|
||||
assert.match(surfaceState, /methods: stringList\(receipt\.methods\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
|
||||
@@ -191,7 +201,9 @@ 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"\}`\}/);
|
||||
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
|
||||
// because nothing remounts: the key is the session/Case binding only (BUG-505).
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "../src/lib/home-bootstrap.ts";
|
||||
import {
|
||||
declaredBirthTime,
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationBoardEmptyCopy,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionGapState,
|
||||
rectificationQuestionRetryActive,
|
||||
type RectificationQuestionGapInput,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const gapBase: RectificationQuestionGapInput = {
|
||||
liveQuestionVisible: false,
|
||||
questionMissing: true,
|
||||
questionLoadFailed: 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-507).
|
||||
assert.equal(rectificationConversationState(base), "empty");
|
||||
});
|
||||
|
||||
test("question gap: a live question inside a message means no gap", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true, questionMissing: false }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true }), "idle");
|
||||
});
|
||||
|
||||
test("question gap: preparing while retries remain, then unavailable with a reload", () => {
|
||||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||||
// The server names a question that no settled message carries live yet.
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false }), "preparing");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: 5 }), "unavailable");
|
||||
// The server itself said the question could not be loaded: no point retrying on a timer.
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionLoadFailed: true }), "unavailable");
|
||||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("idle"), false);
|
||||
});
|
||||
|
||||
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false }), "preparing");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
});
|
||||
|
||||
test("question gap: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, busy: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, readonly: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, regenerating: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "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.deepEqual(rectificationBoardEmptyCopy(null), {
|
||||
clock: null,
|
||||
lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"],
|
||||
});
|
||||
assert.equal(declaredBirthTime({ reportedTime: "5:10", time: "" }), "5:10");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "14:30" }), "14:30");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
|
||||
assert.equal(declaredBirthTime({ reportedTime: "凌晨", time: "" }), null);
|
||||
});
|
||||
|
||||
test("persisted turns keep their question, offer and tool activities through the parser", () => {
|
||||
const turns = parsePersistedRectificationTurns([
|
||||
{
|
||||
id: "t1",
|
||||
role: "assistant",
|
||||
text: "先看事业。",
|
||||
status: "completed",
|
||||
question: { focus_id: "f1", kind: "choice" },
|
||||
offer_result_id: "r1",
|
||||
receipt: {
|
||||
status: "ready",
|
||||
phases: ["compute"],
|
||||
tools: ["rectification-compare-candidates"],
|
||||
methods: ["d9-navamsa", "d10-dashamsa"],
|
||||
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
|
||||
},
|
||||
},
|
||||
{ id: "t2", role: "user", text: "2014 年毕业。" },
|
||||
"junk",
|
||||
]);
|
||||
assert.equal(turns.length, 3);
|
||||
assert.equal(turns[0]?.offer_result_id, "r1");
|
||||
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
|
||||
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
|
||||
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
|
||||
assert.equal(turns[1]?.role, "user");
|
||||
assert.equal(turns[1]?.status, "completed");
|
||||
assert.equal(turns[1]?.receipt, null);
|
||||
assert.equal(turns[2]?.id, "");
|
||||
assert.deepEqual(parsePersistedRectificationTurns(null), []);
|
||||
});
|
||||
|
||||
test("hydration reads turns and snapshot from one Case read and is one constant with the home reveal budget", async () => {
|
||||
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, BOOTSTRAP_PREPARE_TIMEOUT_MS);
|
||||
const calls: string[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
calls.push(String(input));
|
||||
return new Response(JSON.stringify({
|
||||
turns: [{ id: "t1", role: "assistant", text: "hi", status: "completed" }],
|
||||
current_question: { kind: "choice", prompt: "哪一年?", focus_id: "f1", question_id: "q1" },
|
||||
case: { status: "collecting" },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
const hydration = await hydrateRectificationCase("case-1", "session-1", { fetchImpl, timeoutMs: 1_000 });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
|
||||
assert.equal(hydration.complete, true);
|
||||
assert.equal(hydration.turns.length, 1);
|
||||
assert.equal((hydration.snapshot?.case as { status?: unknown } | undefined)?.status, "collecting");
|
||||
});
|
||||
|
||||
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
|
||||
const failing = (async () => new Response("nope", { status: 500 })) as typeof fetch;
|
||||
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
|
||||
|
||||
let fire: (() => void) | undefined;
|
||||
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
|
||||
const late = hydrateRectificationCase("case-1", "session-1", {
|
||||
fetchImpl: never,
|
||||
timeoutMs: 4_000,
|
||||
setTimeoutImpl: (callback) => {
|
||||
fire = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutImpl: () => {},
|
||||
});
|
||||
assert.ok(fire);
|
||||
fire?.();
|
||||
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false });
|
||||
});
|
||||
Reference in New Issue
Block a user