diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 97ac95ad..22fb49d2 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -46,6 +46,7 @@ import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; import { OnboardingAuthenticationError, type OnboardingContent, + createStartGreeting, isCurrentOnboardingRequest, onboardingProfileFingerprint, onboardingRequestIdentity, @@ -118,7 +119,6 @@ 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 OnboardingStep = "name" | "birth" | "place" | "rectification"; -type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; type AccountDialog = "profile" | "redeem" | "logout"; type DailyStarlanguageCard = { trend: string; action: string; caution: string }; type DailyStarlanguageApiResponse = { @@ -188,48 +188,6 @@ const dailyStarlanguageCards: DailyStarlanguageCard[] = [ { trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" }, ]; -const greetingVariants: Record string>> = { - morning: [ - (name) => `早上好,${name}。今天最想先看什么?`, - (name) => `${name},早安。今天最该关注哪件事?`, - (name) => `早上好,${name}。想从哪个问题开始?`, - ], - noon: [ - (name) => `中午好,${name}。现在最想理清哪件事?`, - (name) => `${name},中午好。什么问题最需要方向?`, - (name) => `午间好,${name}。事业、关系或选择,想先聊哪个?`, - ], - afternoon: [ - (name) => `${name},下午好。现在最想推进哪件事?`, - (name) => `下午好,${name}。今天想先理清什么?`, - (name) => `${name},下午好。事业、关系或选择,想先聊哪个?`, - ], - evening: [ - (name) => `晚上好,${name}。今天最挂心的是哪件事?`, - (name) => `${name},晚上好。此刻最想聊哪件事?`, - (name) => `晚上好,${name}。把心里的问题告诉我吧。`, - ], - "late-night": [ - (name) => `夜深了,${name}。此刻最想问什么?`, - (name) => `${name},还没休息吗?想从哪件事说起?`, - (name) => `这么晚还醒着,${name}。直接说说最在意的问题吧。`, - ], -}; - -function greetingPeriod(hour: number): GreetingPeriod { - if (hour >= 5 && hour < 11) return "morning"; - if (hour >= 11 && hour < 14) return "noon"; - if (hour >= 14 && hour < 18) return "afternoon"; - if (hour >= 18 && hour < 23) return "evening"; - return "late-night"; -} - -function createStartGreeting(name: string, now = new Date()) { - const displayName = name.trim() || "你好"; - const variants = greetingVariants[greetingPeriod(now.getHours())]; - return variants[Math.floor(Math.random() * variants.length)](displayName); -} - const emptyProfile: Profile = { name: "", date: "", @@ -1119,7 +1077,7 @@ export default function Home() { const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint); if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; activeOnboardingRequestIdentity.current = requestIdentity; - const presentation = { name: profile.name, startGreeting }; + const presentationName = profile.name; const controller = new AbortController(); setOnboarding(null); setOnboardingError(""); @@ -1134,7 +1092,7 @@ export default function Home() { || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; setOnboarding({ ...content, - greeting: presentation.startGreeting || createStartGreeting(presentation.name), + greeting: createStartGreeting(presentationName), }); setOnboardingError(""); }) @@ -1153,7 +1111,7 @@ export default function Home() { } controller.abort(); }; - }, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete, startGreeting]); + }, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]); useEffect(() => { if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return; diff --git a/frontend/src/lib/onboarding-client.ts b/frontend/src/lib/onboarding-client.ts index 415afec2..67ba8b73 100644 --- a/frontend/src/lib/onboarding-client.ts +++ b/frontend/src/lib/onboarding-client.ts @@ -1,5 +1,35 @@ import { z } from "zod"; +type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; + +const greetingVariants: Record string)[]> = { + morning: [ + (name) => `早上好,${name}。今天最想先看什么?`, + (name) => `${name},早安。今天最该关注哪件事?`, + (name) => `早上好,${name}。想从哪个问题开始?`, + ], + noon: [ + (name) => `中午好,${name}。现在最想理清哪件事?`, + (name) => `${name},中午好。什么问题最需要方向?`, + (name) => `午间好,${name}。事业、关系或选择,想先聊哪个?`, + ], + afternoon: [ + (name) => `${name},下午好。现在最想推进哪件事?`, + (name) => `下午好,${name}。今天想先理清什么?`, + (name) => `${name},下午好。事业、关系或选择,想先聊哪个?`, + ], + evening: [ + (name) => `晚上好,${name}。今天最挂心的是哪件事?`, + (name) => `${name},晚上好。此刻最想聊哪件事?`, + (name) => `晚上好,${name}。把心里的问题告诉我吧。`, + ], + "late-night": [ + (name) => `夜深了,${name}。此刻最想问什么?`, + (name) => `${name},还没休息吗?想从哪件事说起?`, + (name) => `这么晚还醒着,${name}。直接说说最在意的问题吧。`, + ], +}; + 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([ @@ -56,6 +86,24 @@ export type OnboardingContent = { readonly suggestions: readonly OnboardingSuggestion[]; }; +export function createStartGreeting( + name: string, + now = new Date(), + variantSelection = Math.random(), +): string { + 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]; + return variants[Math.floor(variantSelection * variants.length)](displayName); +} + export function onboardingProfileFingerprint(profile: OnboardingProfileFingerprintInput): string { return JSON.stringify([ profile.name, diff --git a/frontend/tests/onboarding-client.test.ts b/frontend/tests/onboarding-client.test.ts index 7d215f65..ca6c0684 100644 --- a/frontend/tests/onboarding-client.test.ts +++ b/frontend/tests/onboarding-client.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { OnboardingAuthenticationError, OnboardingRequestError, + createStartGreeting, isCurrentOnboardingRequest, onboardingProfileFingerprint, onboardingRequestIdentity, @@ -32,11 +33,13 @@ test("rejects a stale completion after a complete profile changes", () => { // When: a presentation-affecting profile field changes before that request completes. const changedFingerprint = onboardingProfileFingerprint({ ...completeProfile, name: "周宁" }); const currentIdentity = onboardingRequestIdentity("account-1", changedFingerprint); + const committedGreeting = createStartGreeting("周宁", new Date("2026-07-19T08:00:00+08:00"), 0); - // Then: the new profile has a distinct request identity and the old completion is stale. + // Then: the new profile has a distinct identity and its greeting cannot retain the old name. assert.notEqual(currentIdentity, firstIdentity); assert.equal(isCurrentOnboardingRequest(currentIdentity, firstIdentity), false); - assert.equal(isCurrentOnboardingRequest(currentIdentity, currentIdentity), true); + assert.match(committedGreeting, /周宁/); + assert.doesNotMatch(committedGreeting, /林遥/); }); test("returns personalized cache content after a timeout and pending response", async () => { @@ -285,10 +288,8 @@ test("parent cancellation interrupts a pending-response retry delay", async () = const originalFetch = globalThis.fetch; const parent = new AbortController(); let requestCount = 0; - let markFetched: (() => void) | null = null; - const fetched = new Promise((resolve) => { markFetched = resolve; }); globalThis.fetch = () => { - requestCount += 1; markFetched?.(); + requestCount += 1; return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); }; @@ -297,7 +298,6 @@ test("parent cancellation interrupts a pending-response retry delay", async () = const request = requestOnboardingWithRecovery(parent.signal, () => undefined, { requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3, }); - await fetched; await new Promise((resolve) => setImmediate(resolve)); parent.abort(new DOMException("page unmounted", "AbortError"));