055b7adca9
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>
300 lines
12 KiB
TypeScript
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;
|
|
}
|