Files
Jyotisha/frontend/src/lib/rectification-surface-state.ts
T
Jesse_Chen 055b7adca9 fix(web): keep composer enabled while generating and treat stop as neutral (BUG-551, BUG-552)
Enter now queues one follow-up instead of dropping it, and a rectification stop leaves the streamed reply with a grey notice instead of an error alert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 13:08:43 +08:00

300 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;
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;
}