fix: bind onboarding greeting to profile

This commit is contained in:
Jesse_Chen
2026-07-19 21:50:52 +08:00
parent 28d58d4b6f
commit 5ee925cd1e
3 changed files with 58 additions and 52 deletions
+4 -46
View File
@@ -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<GreetingPeriod, Array<(name: string) => 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;
+48
View File
@@ -1,5 +1,35 @@
import { z } from "zod";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
const greetingVariants: Record<GreetingPeriod, readonly ((name: string) => 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,
+6 -6
View File
@@ -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<void>((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<void>((resolve) => setImmediate(resolve));
parent.abort(new DOMException("page unmounted", "AbortError"));