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 = { 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 { 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 { 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";