/** * 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 function isAbortError(caught: unknown): boolean { return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError"; } 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; next_user_action?: Readonly<{ id?: 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["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 : 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 { 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)); const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false }; let deadline: unknown; const timeout = new Promise((resolve) => { deadline = schedule(() => resolve(incomplete), timeoutMs); }); const read = (async (): Promise => { 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" | "verified_idle"; 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; /** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */ offerAwaitingReader?: boolean; /** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */ nextUserActionId?: string | null; 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 || input.offerAwaitingReader) return "idle"; if (input.nextUserActionId === "start_consultation") return "verified_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; }