fix: bind onboarding greeting to profile
This commit is contained in:
@@ -46,6 +46,7 @@ import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
|
|||||||
import {
|
import {
|
||||||
OnboardingAuthenticationError,
|
OnboardingAuthenticationError,
|
||||||
type OnboardingContent,
|
type OnboardingContent,
|
||||||
|
createStartGreeting,
|
||||||
isCurrentOnboardingRequest,
|
isCurrentOnboardingRequest,
|
||||||
onboardingProfileFingerprint,
|
onboardingProfileFingerprint,
|
||||||
onboardingRequestIdentity,
|
onboardingRequestIdentity,
|
||||||
@@ -118,7 +119,6 @@ type StreamingReply = { sessionId: string; text: string };
|
|||||||
type BirthPlace = { label: string; lat: number; lon: number; tz: number };
|
type BirthPlace = { label: string; lat: number; lon: number; tz: number };
|
||||||
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
|
type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean };
|
||||||
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
||||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
|
||||||
type AccountDialog = "profile" | "redeem" | "logout";
|
type AccountDialog = "profile" | "redeem" | "logout";
|
||||||
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
|
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
|
||||||
type DailyStarlanguageApiResponse = {
|
type DailyStarlanguageApiResponse = {
|
||||||
@@ -188,48 +188,6 @@ const dailyStarlanguageCards: DailyStarlanguageCard[] = [
|
|||||||
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
|
{ 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 = {
|
const emptyProfile: Profile = {
|
||||||
name: "",
|
name: "",
|
||||||
date: "",
|
date: "",
|
||||||
@@ -1119,7 +1077,7 @@ export default function Home() {
|
|||||||
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
|
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
|
||||||
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
||||||
activeOnboardingRequestIdentity.current = requestIdentity;
|
activeOnboardingRequestIdentity.current = requestIdentity;
|
||||||
const presentation = { name: profile.name, startGreeting };
|
const presentationName = profile.name;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setOnboarding(null);
|
setOnboarding(null);
|
||||||
setOnboardingError("");
|
setOnboardingError("");
|
||||||
@@ -1134,7 +1092,7 @@ export default function Home() {
|
|||||||
|| !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
|| !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
||||||
setOnboarding({
|
setOnboarding({
|
||||||
...content,
|
...content,
|
||||||
greeting: presentation.startGreeting || createStartGreeting(presentation.name),
|
greeting: createStartGreeting(presentationName),
|
||||||
});
|
});
|
||||||
setOnboardingError("");
|
setOnboardingError("");
|
||||||
})
|
})
|
||||||
@@ -1153,7 +1111,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete, startGreeting]);
|
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
|
if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import { z } from "zod";
|
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({
|
const onboardingResponseSchema = z.object({
|
||||||
greeting: z.string().transform((value) => value.replace(/\s+/g, " ").trim().slice(0, 180)).pipe(z.string().min(8)),
|
greeting: z.string().transform((value) => value.replace(/\s+/g, " ").trim().slice(0, 180)).pipe(z.string().min(8)),
|
||||||
suggestions: z.tuple([
|
suggestions: z.tuple([
|
||||||
@@ -56,6 +86,24 @@ export type OnboardingContent = {
|
|||||||
readonly suggestions: readonly OnboardingSuggestion[];
|
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 {
|
export function onboardingProfileFingerprint(profile: OnboardingProfileFingerprintInput): string {
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
profile.name,
|
profile.name,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
OnboardingAuthenticationError,
|
OnboardingAuthenticationError,
|
||||||
OnboardingRequestError,
|
OnboardingRequestError,
|
||||||
|
createStartGreeting,
|
||||||
isCurrentOnboardingRequest,
|
isCurrentOnboardingRequest,
|
||||||
onboardingProfileFingerprint,
|
onboardingProfileFingerprint,
|
||||||
onboardingRequestIdentity,
|
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.
|
// When: a presentation-affecting profile field changes before that request completes.
|
||||||
const changedFingerprint = onboardingProfileFingerprint({ ...completeProfile, name: "周宁" });
|
const changedFingerprint = onboardingProfileFingerprint({ ...completeProfile, name: "周宁" });
|
||||||
const currentIdentity = onboardingRequestIdentity("account-1", changedFingerprint);
|
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.notEqual(currentIdentity, firstIdentity);
|
||||||
assert.equal(isCurrentOnboardingRequest(currentIdentity, firstIdentity), false);
|
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 () => {
|
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 originalFetch = globalThis.fetch;
|
||||||
const parent = new AbortController();
|
const parent = new AbortController();
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let markFetched: (() => void) | null = null;
|
|
||||||
const fetched = new Promise<void>((resolve) => { markFetched = resolve; });
|
|
||||||
globalThis.fetch = () => {
|
globalThis.fetch = () => {
|
||||||
requestCount += 1; markFetched?.();
|
requestCount += 1;
|
||||||
return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" }));
|
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, {
|
const request = requestOnboardingWithRecovery(parent.signal, () => undefined, {
|
||||||
requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3,
|
requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3,
|
||||||
});
|
});
|
||||||
await fetched;
|
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
parent.abort(new DOMException("page unmounted", "AbortError"));
|
parent.abort(new DOMException("page unmounted", "AbortError"));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user