8ed6118b9a
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>
300 lines
10 KiB
TypeScript
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";
|