fix: recover slow personalized starters
This commit is contained in:
+30
-51
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user