fix: recover slow personalized starters

This commit is contained in:
Jesse_Chen
2026-07-19 21:21:58 +08:00
parent 82b2823612
commit 6a34a843ec
4 changed files with 479 additions and 51 deletions
+30 -51
View File
@@ -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<Theme, "general">; 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<Profile> & {
@@ -776,6 +760,8 @@ export default function Home() {
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const onboardingRequestIdentity = useRef("");
const onboardingPresentation = useRef({ name: "", startGreeting: "" });
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(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;
+172
View File
@@ -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<void> {
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<OnboardingAttemptResult> {
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<OnboardingContent> {
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");
}
+258
View File
@@ -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<Response>((_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<Response>((_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<AbortSignal>((resolve) => {
markStarted = resolve;
});
let slowCount = 0;
globalThis.fetch = (_input, init) => new Promise<Response>((_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<void>((resolve) => {
markBodyRead = resolve;
});
globalThis.fetch = (_input, init) => {
assert.ok(init?.signal);
const childSignal = init.signal;
const body = new ReadableStream<Uint8Array>({
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;
}
});
+19
View File
@@ -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(