814c924e4a
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown. Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
step_state?: 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<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" | "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;
|
|
}
|