Files
Jyotisha/frontend/src/lib/onboarding-client.ts
T
Jesse_Chen 8ed6118b9a fix(web): persist uncertain birth time as a start/end clock window
Users pick a clock range instead of a coarse period plus notes, so
rectification and window consult scan that range instead of a leftover afternoon bucket.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 20:48:01 +08:00

300 lines
10 KiB
TypeScript

import { z } from "zod";
import { consultationDomainSchema } from "./consultation-domain-registry";
import { onboardingSuggestionThemes } from "./onboarding-payload";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type GreetingVariant = {
readonly salutation: (name: string) => string;
readonly question: string;
};
const greetingVariants: Record<GreetingPeriod, readonly GreetingVariant[]> = {
morning: [
{ salutation: (name) => `早上好,${name}`, question: "今天最想先看什么?" },
{ salutation: (name) => `${name},早安。`, question: "今天最该关注哪件事?" },
{ salutation: (name) => `早上好,${name}`, question: "想从哪个问题开始?" },
],
noon: [
{ salutation: (name) => `中午好,${name}`, question: "现在最想理清哪件事?" },
{ salutation: (name) => `${name},中午好。`, question: "什么问题最需要方向?" },
{ salutation: (name) => `午间好,${name}`, question: "事业、关系或选择,想先聊哪个?" },
],
afternoon: [
{ salutation: (name) => `${name},下午好。`, question: "现在最想推进哪件事?" },
{ salutation: (name) => `下午好,${name}`, question: "今天想先理清什么?" },
{ salutation: (name) => `${name},下午好。`, question: "事业、关系或选择,想先聊哪个?" },
],
evening: [
{ salutation: (name) => `晚上好,${name}`, question: "今天最挂心的是哪件事?" },
{ salutation: (name) => `${name},晚上好。`, question: "此刻最想聊哪件事?" },
{ salutation: (name) => `晚上好,${name}`, question: "把心里的问题告诉我吧。" },
],
"late-night": [
{ salutation: (name) => `夜深了,${name}`, question: "此刻最想问什么?" },
{ salutation: (name) => `${name},还没休息吗?`, question: "想从哪件事说起?" },
{ salutation: (name) => `这么晚还醒着,${name}`, question: "直接说说最在意的问题吧。" },
],
};
const onboardingResponseSchema = z.object({
suggestions: z.array(z.object({
theme: consultationDomainSchema,
text: z.string().transform(normalizeSuggestion).pipe(z.string().min(1)),
})).refine(
(items) => items.length === onboardingSuggestionThemes.length
&& items.every((item, index) => item.theme === onboardingSuggestionThemes[index]),
"suggestions_must_cover_every_domain_in_order",
),
source: z.enum(["agent", "cache", "fallback", "pending"]),
});
const defaultPolicy = {
// The server now writes one question per domain, so a single attempt has to
// outlast its 45s generation budget instead of the three-question era's 18s.
requestTimeoutMs: 50_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 };
type OnboardingProfileFingerprintInput = {
readonly name: string;
readonly date: string;
readonly time: string;
readonly reportedTime: string;
readonly birthTimeSource: string;
readonly birthTimePeriod: string;
readonly declaredWindowStart: string;
readonly declaredWindowEnd: string;
readonly birthTimeClue: string;
readonly uncertaintyBeforeMinutes: number | null;
readonly uncertaintyAfterMinutes: number | null;
readonly birthTimeStatus: string;
readonly rectificationCaseId: string;
readonly countryCode: string;
readonly provinceCode: string;
readonly cityCode: string;
readonly districtCode: string;
};
export type OnboardingSuggestion = {
readonly theme: ConsultationTheme;
readonly text: string;
};
export type OnboardingContent = {
readonly suggestions: readonly OnboardingSuggestion[];
};
export type StartGreetingParts = {
readonly salutation: string;
readonly question: string;
};
export function createStartGreetingParts(
name: string,
now = new Date(),
variantSelection = Math.random(),
): StartGreetingParts {
const displayName = name.trim() || "你好";
const hour = now.getHours();
const period: GreetingPeriod = hour >= 5 && hour < 11
? "morning"
: hour >= 11 && hour < 14
? "noon"
: hour >= 14 && hour < 18
? "afternoon"
: hour >= 18 && hour < 23 ? "evening" : "late-night";
const variants = greetingVariants[period];
const index = Math.min(Math.max(Math.floor(variantSelection * variants.length), 0), variants.length - 1);
const variant = variants[index]!;
return { salutation: variant.salutation(displayName), question: variant.question };
}
export function createStartGreeting(
name: string,
now = new Date(),
variantSelection = Math.random(),
): string {
const parts = createStartGreetingParts(name, now, variantSelection);
return `${parts.salutation}${parts.question}`;
}
export function createOnboardingFallbackGreeting(name: string): string {
return `${name.trim()},从你此刻最关心的问题开始吧。`;
}
export function onboardingProfileFingerprint(profile: OnboardingProfileFingerprintInput): string {
return JSON.stringify([
profile.name,
profile.date,
profile.time,
profile.reportedTime,
profile.birthTimeSource,
profile.birthTimePeriod,
profile.declaredWindowStart,
profile.declaredWindowEnd,
profile.birthTimeClue,
profile.uncertaintyBeforeMinutes,
profile.uncertaintyAfterMinutes,
profile.birthTimeStatus,
profile.rectificationCaseId,
profile.countryCode,
profile.provinceCode,
profile.cityCode,
profile.districtCode,
]);
}
export function onboardingRequestIdentity(accountId: string, profileFingerprint: string): string {
return JSON.stringify([accountId, profileFingerprint]);
}
export function isCurrentOnboardingRequest(currentIdentity: string, completedIdentity: string): boolean {
return currentIdentity.length > 0 && currentIdentity === completedIdentity;
}
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 { 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");
}
import type { ConsultationTheme } from "./consultation-workflow-request";