From 6a34a843eca95204d1c985f74fc167a399051e90 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 21:21:58 +0800 Subject: [PATCH] fix: recover slow personalized starters --- frontend/src/app/page.tsx | 81 +++---- frontend/src/lib/onboarding-client.ts | 172 +++++++++++++++ frontend/tests/onboarding-client.test.ts | 258 +++++++++++++++++++++++ frontend/tests/starter-questions.test.ts | 19 ++ 4 files changed, 479 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/onboarding-client.ts create mode 100644 frontend/tests/onboarding-client.test.ts diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ac031394..2e19fc61 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -43,6 +43,11 @@ import { } from "@/lib/birth-time-guided-preview"; import { keepFocusWithin } from "@/lib/focus-trap"; import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; +import { + OnboardingAuthenticationError, + type OnboardingContent, + requestOnboardingWithRecovery, +} from "@/lib/onboarding-client"; import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; import { SessionModelPersistenceQueue, @@ -109,8 +114,6 @@ type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { label: string; lat: number; lon: number; tz: number }; type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean }; -type OnboardingSuggestion = { theme: Exclude; text: string }; -type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] }; type OnboardingStep = "name" | "birth" | "place" | "rectification"; type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; type AccountDialog = "profile" | "redeem" | "logout"; @@ -477,25 +480,6 @@ function readSuggestions(value: unknown) { .filter(Boolean))].slice(0, 3); } -function readOnboarding(value: unknown): OnboardingContent | null { - if (!value || typeof value !== "object") return null; - const payload = value as { greeting?: unknown; suggestions?: unknown }; - if (typeof payload.greeting !== "string" || !Array.isArray(payload.suggestions)) return null; - - const expectedThemes: OnboardingSuggestion["theme"][] = ["career", "marriage", "timing"]; - const suggestions = payload.suggestions.flatMap((item, index): OnboardingSuggestion[] => { - if (!item || typeof item !== "object") return []; - const suggestion = item as { theme?: unknown; text?: unknown }; - const theme = expectedThemes[index]; - if (!theme || suggestion.theme !== theme || typeof suggestion.text !== "string") return []; - const text = suggestion.text.replace(/\s+/g, " ").trim().slice(0, 80); - return text ? [{ theme, text }] : []; - }); - - const greeting = payload.greeting.replace(/\s+/g, " ").trim().slice(0, 180); - return greeting.length >= 8 && suggestions.length === 3 ? { greeting, suggestions } : null; -} - function readProfile(value: unknown): Profile { if (!value || typeof value !== "object") return emptyProfile; const profile = value as Partial & { @@ -776,6 +760,8 @@ export default function Home() { const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); const chartLibraryLoadedAccount = useRef(""); + const onboardingRequestIdentity = useRef(""); + const onboardingPresentation = useRef({ name: "", startGreeting: "" }); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); @@ -797,6 +783,7 @@ export default function Home() { const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; const accountId = account?.user.id; + onboardingPresentation.current = { name: profile.name, startGreeting }; useEffect(() => { activeSessionIdRef.current = activeSessionId; @@ -1126,44 +1113,36 @@ export default function Home() { }, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]); useEffect(() => { - if (!hydrated || !accountId || !profileComplete || onboarding || onboardingError) return; + if (!hydrated || !accountId || !profileComplete || uiPreview.current) return; + const requestIdentity = `${accountId}:profile-complete`; + if (onboardingRequestIdentity.current === requestIdentity) return; + onboardingRequestIdentity.current = requestIdentity; const controller = new AbortController(); - const onboardingTimeout = window.setTimeout(() => { - if (controller.signal.aborted) return; - controller.abort(); - setOnboardingError("个性化入门问题准备超时"); - }, 12000); - - async function loadOnboarding() { - try { - const response = await fetch("/api/onboarding", { - method: "POST", - cache: "no-store", - signal: controller.signal, + void requestOnboardingWithRecovery(controller.signal, () => { + if (!controller.signal.aborted) setOnboardingError("个性化入门问题准备超时"); + }) + .then((content) => { + if (controller.signal.aborted) return; + const presentation = onboardingPresentation.current; + setOnboarding({ + ...content, + greeting: presentation.startGreeting || createStartGreeting(presentation.name), }); - const payload = await response.json().catch(() => null); - if (response.status === 401) { + setOnboardingError(""); + }) + .catch((caught: unknown) => { + if (controller.signal.aborted) return; + if (caught instanceof OnboardingAuthenticationError) { window.location.assign("/login"); return; } - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法准备初始问题")); - const content = readOnboarding(payload); - if (!content) throw new Error("Agent 返回的初始问题格式不正确"); - setOnboarding({ ...content, greeting: startGreeting || createStartGreeting(profile.name) }); - setOnboardingError(""); - } catch (caught) { - if ((caught as Error).name !== "AbortError") { - setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题"); - } - } - } - - void loadOnboarding(); + setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题"); + }); return () => { - window.clearTimeout(onboardingTimeout); + if (onboardingRequestIdentity.current === requestIdentity) onboardingRequestIdentity.current = ""; controller.abort(); }; - }, [accountId, hydrated, onboarding, onboardingError, profile.name, profileComplete, startGreeting]); + }, [accountId, hydrated, profileComplete]); useEffect(() => { if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return; diff --git a/frontend/src/lib/onboarding-client.ts b/frontend/src/lib/onboarding-client.ts new file mode 100644 index 00000000..52ed9d8d --- /dev/null +++ b/frontend/src/lib/onboarding-client.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; + +const onboardingResponseSchema = z.object({ + greeting: z.string().transform((value) => value.replace(/\s+/g, " ").trim().slice(0, 180)).pipe(z.string().min(8)), + suggestions: z.tuple([ + z.object({ theme: z.literal("career"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }), + z.object({ theme: z.literal("marriage"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }), + z.object({ theme: z.literal("timing"), text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)) }), + ]), + source: z.enum(["agent", "cache", "fallback", "pending"]), +}); + +const defaultPolicy = { + requestTimeoutMs: 12_000, + retryDelayMs: 4_000, + maxAttempts: 3, +} as const; + +type OnboardingRecoveryPolicy = { + readonly requestTimeoutMs: number; + readonly retryDelayMs: number; + readonly maxAttempts: number; +}; + +type OnboardingAttemptResult = + | { readonly kind: "response"; readonly response: Response; readonly payload: unknown } + | { readonly kind: "timeout"; readonly error: unknown } + | { readonly kind: "network"; readonly error: unknown } + | { readonly kind: "invalid-response"; readonly error: unknown }; + +export type OnboardingSuggestion = { + readonly theme: "career" | "marriage" | "timing"; + readonly text: string; +}; + +export type OnboardingContent = { + readonly greeting: string; + readonly suggestions: readonly OnboardingSuggestion[]; +}; + +export class OnboardingAuthenticationError extends Error { + readonly name = "OnboardingAuthenticationError"; + readonly status = 401; + + constructor() { + super("登录后才能准备初始问题。"); + } +} + +export class OnboardingRequestError extends Error { + readonly name = "OnboardingRequestError"; + readonly reason: "http" | "invalid-response" | "timeout" | "network" | "pending"; + readonly status: number | null; + + constructor( + reason: "http" | "invalid-response" | "timeout" | "network" | "pending", + status: number | null = null, + options?: ErrorOptions, + ) { + super("暂时无法准备初始问题", options); + this.reason = reason; + this.status = status; + } +} + +function normalizeSuggestion(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 80); +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +function waitForRetry(delayMs: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, delayMs); + function handleAbort() { + clearTimeout(timer); + reject(abortReason(signal)); + } + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +async function fetchAttempt( + signal: AbortSignal, + timeoutMs: number, +): Promise { + signal.throwIfAborted(); + const controller = new AbortController(); + let timedOut = false; + let responseReceived = false; + const handleAbort = () => controller.abort(abortReason(signal)); + signal.addEventListener("abort", handleAbort, { once: true }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new DOMException("The operation timed out", "TimeoutError")); + }, timeoutMs); + + try { + const response = await fetch("/api/onboarding", { + method: "POST", + cache: "no-store", + signal: controller.signal, + }); + if (!response.ok) return { kind: "response", response, payload: null }; + responseReceived = true; + const payload: unknown = await response.json(); + return { kind: "response", response, payload }; + } catch (error) { + if (signal.aborted) throw abortReason(signal); + if (timedOut) return { kind: "timeout", error }; + if (responseReceived) return { kind: "invalid-response", error }; + return { kind: "network", error }; + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", handleAbort); + } +} + +export async function requestOnboardingWithRecovery( + signal: AbortSignal, + onSlow: () => void, + policy: OnboardingRecoveryPolicy = defaultPolicy, +): Promise { + let slowReported = false; + let lastError: OnboardingRequestError | null = null; + + for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) { + const result = await fetchAttempt(signal, policy.requestTimeoutMs); + switch (result.kind) { + case "timeout": + if (!slowReported) { + slowReported = true; + onSlow(); + } + lastError = new OnboardingRequestError("timeout", null, { cause: result.error }); + break; + case "network": + lastError = new OnboardingRequestError("network", null, { cause: result.error }); + break; + case "invalid-response": + throw new OnboardingRequestError("invalid-response", null, { cause: result.error }); + case "response": { + if (result.response.status === 401) throw new OnboardingAuthenticationError(); + if (!result.response.ok) { + lastError = new OnboardingRequestError("http", result.response.status); + break; + } + const parsed = onboardingResponseSchema.safeParse(result.payload); + if (!parsed.success) throw new OnboardingRequestError("invalid-response", null, { cause: parsed.error }); + if (parsed.data.source !== "pending") { + return { greeting: parsed.data.greeting, suggestions: parsed.data.suggestions }; + } + lastError = new OnboardingRequestError("pending"); + break; + } + default: { + const exhaustiveResult: never = result; + throw exhaustiveResult; + } + } + + if (attempt < policy.maxAttempts) await waitForRetry(policy.retryDelayMs, signal); + } + + throw lastError ?? new OnboardingRequestError("pending"); +} diff --git a/frontend/tests/onboarding-client.test.ts b/frontend/tests/onboarding-client.test.ts new file mode 100644 index 00000000..8d3ba503 --- /dev/null +++ b/frontend/tests/onboarding-client.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + OnboardingAuthenticationError, + OnboardingRequestError, + requestOnboardingWithRecovery, +} from "../src/lib/onboarding-client.ts"; + +const personalizedOnboarding = { + greeting: "林遥,欢迎回来。想先从哪个方向开始?", + suggestions: [ + { theme: "career", text: "我现在的事业选择应该优先考虑什么?" }, + { theme: "marriage", text: "我该怎样理解近期的关系模式?" }, + { theme: "timing", text: "未来一年哪些阶段适合主动推进?" }, + ], + source: "cache", +} as const; + +test("returns personalized cache content after a timeout and pending response", async () => { + // Given: the first request times out, the second is provisional, and the third is terminal. + const originalFetch = globalThis.fetch; + let requestCount = 0; + let slowCount = 0; + globalThis.fetch = (_input, init) => { + requestCount += 1; + if (requestCount === 1) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + } + if (requestCount === 2) { + return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); + } + return Promise.resolve(Response.json(personalizedOnboarding)); + }; + + try { + // When: bounded recovery runs with test-sized delays. + const content = await requestOnboardingWithRecovery( + new AbortController().signal, + () => { + slowCount += 1; + }, + { requestTimeoutMs: 5, retryDelayMs: 0, maxAttempts: 3 }, + ); + + // Then: slow fallback is shown once and terminal personalized content wins. + assert.deepEqual(content, { + greeting: personalizedOnboarding.greeting, + suggestions: personalizedOnboarding.suggestions, + }); + assert.equal(slowCount, 1); + assert.equal(requestCount, 3); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reports slow onboarding only once across repeated request timeouts", async () => { + // Given: every bounded request waits until its child timeout aborts it. + const originalFetch = globalThis.fetch; + let slowCount = 0; + globalThis.fetch = (_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + + try { + // When: all attempts time out. + await assert.rejects( + requestOnboardingWithRecovery( + new AbortController().signal, + () => { + slowCount += 1; + }, + { requestTimeoutMs: 2, retryDelayMs: 0, maxAttempts: 3 }, + ), + (error: unknown) => error instanceof OnboardingRequestError && error.reason === "timeout", + ); + + // Then: the UI fallback signal is emitted once for the whole sequence. + assert.equal(slowCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("throws an authentication error without retrying a 401 response", async () => { + // Given: the onboarding endpoint rejects the session. + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = () => { + requestCount += 1; + return Promise.resolve(Response.json({}, { status: 401 })); + }; + + try { + // When: recovery receives the authentication response. + await assert.rejects( + requestOnboardingWithRecovery( + new AbortController().signal, + () => undefined, + { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 }, + ), + OnboardingAuthenticationError, + ); + + // Then: authentication is terminal and no retry is attempted. + assert.equal(requestCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("throws a typed pending error after the bounded attempts are exhausted", async () => { + // Given: every response remains provisional. + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); + + try { + // When: the pending response consumes every attempt. + // Then: the caller receives a typed terminal failure. + await assert.rejects( + requestOnboardingWithRecovery( + new AbortController().signal, + () => undefined, + { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 }, + ), + (error: unknown) => error instanceof OnboardingRequestError && error.reason === "pending", + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("throws a typed HTTP error after non-authentication failures are exhausted", async () => { + // Given: the endpoint remains unavailable. + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = () => { + requestCount += 1; + return Promise.resolve(Response.json({}, { status: 503 })); + }; + + try { + // When: bounded recovery exhausts the failed responses. + await assert.rejects( + requestOnboardingWithRecovery( + new AbortController().signal, + () => undefined, + { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 }, + ), + (error: unknown) => error instanceof OnboardingRequestError + && error.reason === "http" + && error.status === 503, + ); + + // Then: no request exceeds the policy bound. + assert.equal(requestCount, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("rejects a malformed terminal response with a typed error", async () => { + // Given: a successful HTTP response violates the onboarding schema. + const originalFetch = globalThis.fetch; + globalThis.fetch = () => Promise.resolve(Response.json({ source: "cache", greeting: "short" })); + + try { + // When: the response crosses the client boundary. + // Then: invalid external data cannot enter the page as onboarding content. + await assert.rejects( + requestOnboardingWithRecovery( + new AbortController().signal, + () => undefined, + { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 }, + ), + (error: unknown) => error instanceof OnboardingRequestError && error.reason === "invalid-response", + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("parent cancellation aborts the child request without reporting slow state", async () => { + // Given: a request is in flight with a long child timeout. + const originalFetch = globalThis.fetch; + const parent = new AbortController(); + let markStarted: ((signal: AbortSignal) => void) | null = null; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let slowCount = 0; + globalThis.fetch = (_input, init) => new Promise((_resolve, reject) => { + assert.ok(init?.signal); + markStarted?.(init.signal); + init.signal.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + + try { + // When: the page lifecycle is cancelled before the request timeout. + const request = requestOnboardingWithRecovery( + parent.signal, + () => { + slowCount += 1; + }, + { requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 }, + ); + const childSignal = await started; + parent.abort(new DOMException("page unmounted", "AbortError")); + + // Then: cancellation reaches the child and produces no slow fallback signal. + await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); + assert.equal(childSignal?.aborted, true); + assert.equal(slowCount, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("parent cancellation interrupts response body parsing", async () => { + // Given: headers arrived, but the onboarding JSON body is still streaming. + const originalFetch = globalThis.fetch; + const parent = new AbortController(); + let markBodyRead: (() => void) | null = null; + const bodyRead = new Promise((resolve) => { + markBodyRead = resolve; + }); + globalThis.fetch = (_input, init) => { + assert.ok(init?.signal); + const childSignal = init.signal; + const body = new ReadableStream({ + start(controller) { + childSignal.addEventListener("abort", () => controller.error(childSignal.reason), { once: true }); + }, + pull() { + markBodyRead?.(); + }, + }); + return Promise.resolve(new Response(body, { headers: { "content-type": "application/json" } })); + }; + + try { + // When: the page unmounts while the body is being consumed. + const request = requestOnboardingWithRecovery( + parent.signal, + () => undefined, + { requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 }, + ); + await bodyRead; + parent.abort(new DOMException("page unmounted", "AbortError")); + + // Then: parsing is cancelled with the lifecycle reason, not converted into a schema error. + await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index d317cd34..f6439761 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -28,6 +28,25 @@ test("keeps starter questions visible while the user edits a draft", () => { assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/); }); +test("keeps onboarding recovery alive after safe defaults become visible", () => { + // Given: the homepage owns one recovery sequence per completed account profile. + const recoveryEffect = sourceBetween( + pageSource, + "const onboardingRequestIdentity =", + " useEffect(() => {\n if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;", + ); + + // When: the request identity, client call, and effect dependencies are inspected. + // Then: fallback state cannot cancel recovery and only authentication triggers login. + assert.match(pageSource, /requestOnboardingWithRecovery/); + assert.match(pageSource, /OnboardingAuthenticationError/); + assert.match(recoveryEffect, /onboardingRequestIdentity\.current === requestIdentity/); + assert.match(recoveryEffect, /requestOnboardingWithRecovery\(controller\.signal,/); + assert.match(recoveryEffect, /caught instanceof OnboardingAuthenticationError/); + assert.match(recoveryEffect, /\}, \[accountId, hydrated, profileComplete\]\);/); + assert.doesNotMatch(pageSource, /function readOnboarding\(/); +}); + test("keeps follow-up suggestions visible while the user edits a draft", () => { // Given: the follow-up suggestion block and its render guard. const suggestionGuard = sourceBetween(