diff --git a/CHANGELOG.md b/CHANGELOG.md index db730a6a..7dc4903f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 首页开场语改成一行问候 + +首页空对话的开场语从「一行称呼 + 一句追问」收成一行问候,例如「早上好,周宁」「还没睡呀,周宁」「欢迎回来」。每次回到首页重抽一次:按当前时段(早晨 / 中午 / 下午 / 傍晚 / 深夜)挑,周一、周五、周六、周日各多一条应景的,账号里已经有对话时还会出现「又见面了」这类回访问候;没填称呼时只出不带称呼的那几条。字号从最大 34px 降到最大 30px,副标题那一行去掉。追问改由输入框占位符承担,文案从「例如:未来半年是否适合换工作?」改成「想聊什么都可以」。Skill 版本不变。 + ## 2026-09-17 — 咨询不再整轮失败:每一轮先取回本轮星盘再回答 部署 `dc2f2a16` 之后,本命和申报时段咨询会整轮失败,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点。」现在会重新走排盘再回答。短追问(「?」「你在说什么鬼」)仍然每一轮先取回本轮星盘。Skill 版本不变。 diff --git a/docs/tasks/PROGRESS-starter-greeting-20260917.md b/docs/tasks/PROGRESS-starter-greeting-20260917.md new file mode 100644 index 00000000..beaf236d --- /dev/null +++ b/docs/tasks/PROGRESS-starter-greeting-20260917.md @@ -0,0 +1,78 @@ +# PROGRESS · 首页开场语改成一行问候(2026-09-17) + +- 基线 commit:`b51e7aff`(`origin/staging`,2026-09-17 取) +- 分支:`codex/starter-greeting-20260917`,worktree `.worktrees/starter-greeting-20260917` +- 模式:直接执行(产品拍板,非 Bug,不占 BUG 号,未写 `docs/BUG_HISTORY.md`) +- 未推送。 + +## 1. 做了什么 + +| # | 改动 | 文件 | +| --- | --- | --- | +| 1 | 开场语从「称呼行 + 追问句」五时段十五条,换成三个池子的单行问候 | `frontend/src/lib/starter-greeting.ts` | +| 2 | `createOnboardingFallbackGreeting` 改成 `欢迎,{名字}` | 同上 | +| 3 | `createStartGreetingParts` 返回 `{ text }`,两个导出都多一个 `hasSessions` 形参 | 同上 | +| 4 | `StarterGreeting` 删掉 `

`,只留 `

{starterGreeting.text}

` | `frontend/src/components/starter-home.tsx` | +| 5 | `hasSessions` 由既有 `sessions` 列表派生(`sessions.some((session) => session.id !== activeSession?.id)`),未新增 `useState` / `useRef` | `frontend/src/app/(app)/page.tsx` | +| 6 | 删 `.starter-salutation` 规则;`.starter-greeting-block h1` 字号 `clamp(26px, 3.2vw, 34px)` → `clamp(24px, 2.8vw, 30px)`,`line-height` 1.3 → 1.25;`--font-display` / 500 / `-.02em` / `text-wrap: balance` 保持;`.starter-greeting-block` 未动 | `frontend/src/app/globals.css` | +| 7 | 普通输入框占位符末行 `例如:未来半年是否适合换工作?` → `想聊什么都可以`(其它分支未动) | `frontend/src/lib/rectification-session-composer-guard.ts` | +| 8 | DESIGN / VOICE / CHANGELOG / 任务索引 | `frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CHANGELOG.md`、`docs/tasks/README.md` | + +### 选择规则(实现口径) + +给定 `(name, now, variantSelection ∈ [0,1), hasSessions)` 确定性地得到一行: + +1. 时段池按 `now.getHours()`(早晨 5–11 / 中午 11–14 / 下午 14–18 / 傍晚 18–23 / 深夜其余),沿用改动前的划分; +2. 星期池按 `now.getDay()`,只有周一 / 周五 / 周六 / 周日各一条,其余星期不贡献条目; +3. 回访池只在 `hasSessions` 为真时拼进去; +4. 三池按「时段 → 星期 → 回访」顺序拼成一个数组;`name.trim()` 为空时先滤掉所有带 `{名字}` 的条目(时段池每段都留有不带称呼的条目,所以结果永不为空); +5. 下标 `clamp(floor(variantSelection * pool.length), 0, pool.length - 1)`,再把 `{名字}` 换成称呼。 + +句尾无句号;`今天过得怎么样?` 与 `最近还好吗,{名字}?` 保留问号。 + +## 2. 测试数字 + +| 项 | 基线 `b51e7aff` | 本轮 | 结论 | +| --- | --- | --- | --- | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | 0 错 | 通过 | +| `npm run lint` | 0 error / 118 warning | 0 error / 118 warning | 通过(warning 数量未变,未顺手修) | +| `npm test` 总数 | 3458 | 3465 | +7 | +| `npm test` pass | 3407 | 3414 | +7 | +| `npm test` fail | 36 | 36 | 失败清单逐条相同(见下) | +| `npm test` skipped | 15 | 15 | — | +| `next build` 的 `/` | `○ Static` | `○ Static` | 通过 | +| 首屏 gzip(`.next/static` 全部 `*.js` 逐个 gzip 后字节合计,68 个文件,同机同命令各测一次) | 1,450,864 B | 1,450,774 B | −90 B,−0.006%,在 ±2% 内 | + +失败清单逐条比对:基线 36 条与本轮 36 条排序后 `diff` 为空(`admin code functions…`、`rectification agent maps setup failures…`、`generating does not disable the textarea…`、若干 `database` / `ephemeris` / `live staging sync` 套件等)。全部是无 Docker / 无数据库 / 无外部引擎的既有环境缺口,与本轮无关。 + +`next build` 环境备忘:worktree 里把 `node_modules` 软链到 `.worktrees/accept-20260916/frontend/node_modules` 时,Turbopack 直接 panic(`Symlink [project]/frontend/node_modules is invalid, it points out of the filesystem root`)。按开工说明改成 `rm node_modules && npm ci` 后构建正常。基线数字的取法:`git diff > /tmp/work.patch` → `git checkout -- frontend/` → 构建取数 → `git apply /tmp/work.patch`(未用 `git stash`,AGENTS §3.5)。 + +## 3. 被改的既有断言(原值 / 新值 / 原因) + +| 文件 · 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `tests/onboarding-presentation.test.ts` · `terminal and fallback greetings follow the current profile name` | `createStartGreeting("周宁", SUNDAY(8), 0)`;`fallbackGreeting === "周宁,从你此刻最关心的问题开始吧。"` | `createStartGreeting("周宁", SUNDAY(8), 0.25)`(= `早上好,周宁`);`fallbackGreeting === "欢迎,周宁"` | 时段池第一条改成不带称呼的「早上好」,要取带称呼的条目得换下标;兜底问候按产品口径改成单行。两条「必须带当前称呼、不得带旧称呼」的断言原样保留,另加了逐字相等断言,强度不降 | +| 同上 · `the starter hero splits one time-aware greeting…` → `the starter greeting is one line drawn from daypart, weekday and returning pools` | 断言 `parts.salutation === "中午好,周宁。"`、`parts.question === "现在最想理清哪件事?"`、两半拼起来等于整行、每时段至少 12 条不同问句 | 断言 `parts.text` 按下标取到「中午好」/「中午好,周宁」,`createStartGreeting` 与 `parts.text` 相等,`variantSelection` 为 1 / 0.999999 不越界 | 「招呼语 + 问句」两半的形状被产品取消,问句池不再存在,原断言的被测对象没了。边界不越界这一条原样保留 | +| 同上 · `the starter hero stays at a salutation and a question` → `the starter hero is one heading and nothing else` | 断言 `

{starterGreeting.salutation}

` 与 `

{starterGreeting.question}

` 同时存在 | 断言只剩 `

{starterGreeting.text}

`,且 `starter-salutation`、`starterGreeting.salutation|question` 在源码与 `globals.css` 里都不得出现 | 副标题行连同它的样式一起下线;断言从「必须有两行」改成「只有一行且不得有第二行」,是收紧不是放松 | +| `tests/consultation-entrypoint.test.ts` · `the starter heading is drawn per visit…` | `

{starterGreeting.question}

` | `

{starterGreeting.text}

` | 同上;「每次进首页重抽一次」(`useState(() => Math.random())` + 离开首页重抽)两条断言原样保留 | +| `tests/consultation-entrypoint.test.ts` · `the starter greeting keeps the display stack and display weight` | `font-size: clamp(26px,` | `font-size: clamp(24px, 2.8vw, 30px);`,并新增 `line-height: 1.25;` | 单行之后不需要 34px 上限那一档。原断言只匹配前缀,新断言匹配完整 clamp 并多钉一条 `line-height`,强度提高;`--font-display` / `font-weight: 500` / 不得是 `--font-body` 三条原样保留 | +| `tests/chat-composer-queue.test.ts` · `a queued card renders above the live textarea` | fixture `placeholder: "例如:未来半年是否适合换工作?"` | `placeholder: "想聊什么都可以"` | 只是渲染 `ChatComposer` 时传的样例 prop,不是被断言的对象;跟着产品文案更新以免留下过期字面量。断言本身未动 | + +## 4. 新增测试(`tests/onboarding-presentation.test.ts`,3 条 → 10 条) + +| 测试 | 覆盖 | +| --- | --- | +| `late night has its own three lines and none of them asks a follow-up` | 深夜三条逐字;23 点与 4 点同属深夜 | +| `a blank name leaves only the lines that carry no name` | 名字为空(含全空白)只出不带称呼的条目;周一条目与回访池整条不可达;结果非空、不残留 `{名字}`、不以逗号收尾 | +| `the weekday pool only opens on its own day` | 周五能选到 `周五快乐`,周三选不到且池子恰好 3 条;周一 / 周日各自的条目可达 | +| `the returning pool needs at least one session behind the account` | `hasSessions=false` 时四条回访句一条都选不到(池子 3 条);`hasSessions=true` 时四条全部可达(池子 7 条) | +| `the same inputs always produce the same line` | 同一 `(name, now, variantSelection, hasSessions)` 连调两次结果相同 | +| `no greeting line uses a metaphor or trails off into a sentence` | 7 天 × 24 小时 × 2 种称呼 × 2 种 `hasSessions` × 100 个 selection:任何条目不含 `星` `月` `陪`、不以 `。` 结尾、长度 ≤ 12 | +| `the greeting knows whether the account already has sessions` | `hasSessions` 必须由既有 `sessions` 列表派生,`page.tsx` 不得另开状态 | + +`frontend/tests/home-shell-growth-contract.test.ts` 未改动且仍绿:`page.tsx` 只多了一行 `const starterHasPriorSessions = …`,`useState` / `useRef` 计数不变。 + +## 5. 环境缺口 + +- 无登录态、无浏览器走查:真实首页上「一行问候居中、不再一大一小、占位符文案」的观感未在浏览器里确认,仅有 `next build` + 源码/样式合同。 +- 本轮未跑 `npm run test:db`(未动数据库结构)、未跑 Python 测试(无 Python 改动)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 34f0b97f..b2b5426b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -137,6 +137,7 @@ | `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — | | `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` | | `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 | +| — | `PROGRESS-starter-greeting-20260917.md` | 首页开场语改成 claude.ai 式单行问候:`starter-greeting.ts` 的「称呼 + 追问句」五时段十五条收成三个池子(时段 / 星期 / 回访,按 `variantSelection` 确定性取一条),副标题行与 `.starter-salutation` 下线,h1 降到 `clamp(24px, 2.8vw, 30px)`;追问移到输入框占位符「想聊什么都可以」。产品直接拍板,非 Bug,不占 BUG 号 | 待验收 | `codex/starter-greeting-20260917` | ### 个人报告 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 1bc156c5..37d653a6 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -471,10 +471,16 @@ The empty state is the composer. It used to be a bordered hero card at sitting on top of the input, so the primary action was the last thing on the page. Three parts now, in reading order: -- **Greeting** (`.starter-greeting-block`): a tertiary-ink salutation line and - one question at `clamp(26px, 3.2vw, 34px)`, weight 500, centred. It lives in - the conversation area with `align-content: end`, so it sits directly above the - composer rather than floating in the middle of the row. +- **Greeting** (`.starter-greeting-block`): one line, no subtitle — a single h1 + at `clamp(24px, 2.8vw, 30px)`, weight 500, `line-height: 1.25`, centred. The + salutation line above the question is gone; so is the question, which is the + composer placeholder's job now («想聊什么都可以»). The line is drawn per visit + from three pools — daypart (morning / noon / afternoon / evening / late-night), + weekday (Mon, Fri, Sat, Sun only), and returning (accounts that already have a + session) — concatenated in that order and indexed by one random seed, with the + `{名字}` entries dropped when no name is set. It lives in the conversation area + with `align-content: end`, so it sits directly above the composer rather than + floating in the middle of the row. - **Composer**: unchanged, and *in the same DOM position it occupies during a live session*. Nothing remounts when the first message lands, so an in-flight draft, focus, or queued card survives the transition. Its width is diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 707c9d4f..b5e53a10 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -8,6 +8,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 对用户称「你」。口语化,但不轻佻。不加「亲爱的」,不每轮感谢信任,不用 emoji。 +首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。 + ## 五条原则 1. **先回应人,再展开方法。** 用户问婚姻,先用人话答方向,再进入度数和分盘。 @@ -68,6 +70,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | | (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | | 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 | +| 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 44b994d9..ccfb685a 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -721,7 +721,8 @@ export default function Home() { ); const onboardingFormActive = !profileComplete && onboardingStep !== "name"; const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : ""; - const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection); + const starterHasPriorSessions = sessions.some((session) => session.id !== activeSession?.id); + const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection, starterHasPriorSessions); const conversationAnchor = useConversationScrollAnchor( conversation, !rectificationSurfaceOpen && !starterHomeVisible, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 821303f2..e8ed332d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2089,29 +2089,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class padding: var(--space-8) 0 var(--space-5); } -/* The greeting: one line of salutation, one question. It used to be an h1 at +/* The greeting: one line, two to five words. It used to be an h1 at clamp(34px, 5vw, 52px) inside a bordered hero card, with two 132px product - cards under it — together about 800px sitting on top of the composer. */ + cards under it — together about 800px sitting on top of the composer; then a + salutation line above a question. The question moved into the composer + placeholder, so there is no second line to size against. */ .starter-greeting-block { width: 100%; min-width: 0; text-align: center; } -.starter-salutation { - margin: 0 0 var(--space-2); - color: var(--color-ink-tertiary); - font-size: var(--type-body-sm); -} - .starter-greeting-block h1 { margin: 0; color: var(--color-ink); font-family: var(--font-display); - font-size: clamp(26px, 3.2vw, 34px); + font-size: clamp(24px, 2.8vw, 30px); font-weight: 500; letter-spacing: -.02em; - line-height: 1.3; + line-height: 1.25; text-wrap: balance; } diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index 9f4bc970..234f3818 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -19,14 +19,15 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry"; */ export type StarterGreetingProps = { - readonly starterGreeting: { readonly salutation: string; readonly question: string }; + readonly starterGreeting: { readonly text: string }; }; +/* One line. The salutation/question pair it replaces asked the user's question + for them; the composer placeholder asks now. */ export function StarterGreeting({ starterGreeting }: StarterGreetingProps) { return (
-

{starterGreeting.salutation}

-

{starterGreeting.question}

+

{starterGreeting.text}

); } diff --git a/frontend/src/lib/rectification-session-composer-guard.ts b/frontend/src/lib/rectification-session-composer-guard.ts index 72e5dbc0..d1cb5025 100644 --- a/frontend/src/lib/rectification-session-composer-guard.ts +++ b/frontend/src/lib/rectification-session-composer-guard.ts @@ -69,7 +69,7 @@ export function ordinaryComposerPlaceholder(input: { return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"; } if (input.creditsEmpty) return "余额不足,可在账户与点数中补充"; - return "例如:未来半年是否适合换工作?"; + return "想聊什么都可以"; } export function rectificationOrdinaryComposerReopen(input: { diff --git a/frontend/src/lib/starter-greeting.ts b/frontend/src/lib/starter-greeting.ts index 0109b56e..a584d797 100644 --- a/frontend/src/lib/starter-greeting.ts +++ b/frontend/src/lib/starter-greeting.ts @@ -1,72 +1,86 @@ type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; -type GreetingVariant = { - readonly salutation: (name: string) => string; - readonly question: string; +/** + * One line, two to five words, no follow-up question. + * + * The homepage opening used to be a salutation plus a question ("这么晚还醒着, + * {名字}。直接说说最在意的问题吧。") — an office voice that answered for the user + * before they had said anything. Asking is the composer placeholder's job now; + * the greeting only greets. No metaphors (星 / 月 / 陪), no emoji, no period. + */ +const NAME_TOKEN = "{名字}"; + +const periodGreetings: Record = { + morning: ["早上好", "早上好,{名字}", "早呀,{名字}"], + noon: ["中午好", "中午好,{名字}"], + afternoon: ["下午好", "下午好,{名字}"], + evening: ["晚上好", "晚上好,{名字}", "今天过得怎么样?"], + "late-night": ["夜猫子,晚上好", "还没睡呀,{名字}", "晚安前再聊一会"], }; -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: "直接说说最在意的问题吧。" }, - ], +/** Keyed by `Date#getDay()`. Days with nothing worth saying stay out of the pool. */ +const weekdayGreetings: Readonly> = { + 0: ["周日,慢一点"], + 1: ["周一了,{名字}"], + 5: ["周五快乐"], + 6: ["周末愉快,{名字}"], }; +/** Only reachable once the account has at least one session behind it. */ +const returningGreetings: readonly string[] = [ + "又见面了,{名字}", + "欢迎回来", + "嗨,{名字}", + "最近还好吗,{名字}?", +]; + +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"; +} + +/** + * Every line this moment can produce, in pool order: daypart, weekday, returning. + * A blank name drops the lines that would print an empty one; the daypart pool + * always keeps a nameless line, so the result is never empty. + */ +function starterGreetingPool(name: string, now: Date, hasSessions: boolean): readonly string[] { + const templates = [ + ...periodGreetings[greetingPeriod(now.getHours())], + ...(weekdayGreetings[now.getDay()] ?? []), + ...(hasSessions ? returningGreetings : []), + ]; + return name ? templates : templates.filter((template) => !template.includes(NAME_TOKEN)); +} + export type StartGreetingParts = { - readonly salutation: string; - readonly question: string; + readonly text: string; }; export function createStartGreetingParts( name: string, now = new Date(), variantSelection = Math.random(), + hasSessions = false, ): 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 }; + const displayName = name.trim(); + const pool = starterGreetingPool(displayName, now, hasSessions); + const index = Math.min(Math.max(Math.floor(variantSelection * pool.length), 0), pool.length - 1); + return { text: pool[index]!.replaceAll(NAME_TOKEN, displayName) }; } export function createStartGreeting( name: string, now = new Date(), variantSelection = Math.random(), + hasSessions = false, ): string { - const parts = createStartGreetingParts(name, now, variantSelection); - return `${parts.salutation}${parts.question}`; + return createStartGreetingParts(name, now, variantSelection, hasSessions).text; } export function createOnboardingFallbackGreeting(name: string): string { - return `${name.trim()},从你此刻最关心的问题开始吧。`; + return `欢迎,${name.trim()}`; } diff --git a/frontend/tests/chat-composer-queue.test.ts b/frontend/tests/chat-composer-queue.test.ts index a8de7861..afb1265d 100644 --- a/frontend/tests/chat-composer-queue.test.ts +++ b/frontend/tests/chat-composer-queue.test.ts @@ -77,7 +77,7 @@ test("a queued card renders above the live textarea", () => { inputRef: createRef(), value: "还想再问一句", inputLabel: "输入你的问题", - placeholder: "例如:未来半年是否适合换工作?", + placeholder: "想聊什么都可以", maxLength: 500, inputDisabled: false, submitLabel: "发送", diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 9aa359a4..b90fa583 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -386,7 +386,9 @@ test("the starter heading is drawn per visit and the entry pills carry no fine p const source = homeSurface; // Given: the heading reads from a variant seeded once per mount and redrawn when the home is left. - assert.match(source, /

\{starterGreeting\.question\}<\/h1>/); + // 原值 `{starterGreeting.question}` / 新值 `{starterGreeting.text}` / 原因:开场语改成单行问候, + // 「招呼语 + 问句」两半的形状取消,heading 直接就是整行;「每次进首页重抽」这条意图没变。 + assert.match(source, /

\{starterGreeting\.text\}<\/h1>/); assert.match(source, /useState\(\(\) => Math\.random\(\)\)/); assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/); @@ -426,7 +428,10 @@ test("the starter greeting keeps the display stack and display weight", () => { assert.match(heading, /font-weight:\s*500\s*;/); assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/); // 问候不该再是 52px 的落地页大标题,它现在只是输入框上面的一行。 - assert.match(heading, /font-size:\s*clamp\(26px,/); + // 原值 `clamp(26px,` / 新值 `clamp(24px,` / 原因:开场语从「招呼语 + 问句」两行收成单行问候, + // 不再需要 34px 上限那一档;上限同步降到 30px,断言仍然钉住「不是落地页大标题」这条意图。 + assert.match(heading, /font-size:\s*clamp\(24px, 2\.8vw, 30px\);/); + assert.match(heading, /line-height:\s*1\.25;/); }); test("the starter home hides technical chart parameters", () => { diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts index 9ec89248..e0140073 100644 --- a/frontend/tests/onboarding-presentation.test.ts +++ b/frontend/tests/onboarding-presentation.test.ts @@ -8,59 +8,171 @@ import { } from "../src/lib/starter-greeting.ts"; import { homeSurface } from "./home-surface.ts"; +// 2026-07-19 是周日,20 周一,22 周三(星期池为空),24 周五,25 周六。 +const SUNDAY = (hour: number) => new Date(2026, 6, 19, hour); +const MONDAY = (hour: number) => new Date(2026, 6, 20, hour); +const WEDNESDAY = (hour: number) => new Date(2026, 6, 22, hour); +const FRIDAY = (hour: number) => new Date(2026, 6, 24, hour); + test("terminal and fallback greetings follow the current profile name", () => { // 原值: 同时锁 onboardingRequestIdentity / isCurrentOnboardingRequest,避免建议问题串号。 // 新值: 只保留问候语随当前称呼变化。 // 原因: 建议问题请求已删除,身份去重不再有消费点。 - const terminalGreeting = createStartGreeting("周宁", new Date(2026, 6, 19, 8), 0); + // + // 原值 `createStartGreeting("周宁", …, 0)` + `"周宁,从你此刻最关心的问题开始吧。"` + // / 新值 `createStartGreeting("周宁", …, 0.25)` + `"欢迎,周宁"` + // / 原因:时段池现在第一条是不带称呼的「早上好」,要取带称呼的条目得换下标; + // 兜底问候按产品口径改成单行「欢迎,{名字}」,不再追问。断言强度没变: + // 两条都仍然必须带当前称呼、不得带旧称呼。 + const terminalGreeting = createStartGreeting("周宁", SUNDAY(8), 0.25); const fallbackGreeting = createOnboardingFallbackGreeting("周宁"); for (const greeting of [terminalGreeting, fallbackGreeting]) { assert.match(greeting, /周宁/); assert.doesNotMatch(greeting, /林遥/); } - assert.equal(fallbackGreeting, "周宁,从你此刻最关心的问题开始吧。"); + assert.equal(terminalGreeting, "早上好,周宁"); + assert.equal(fallbackGreeting, "欢迎,周宁"); }); -test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => { - // Given: the same daypart variant, asked for as parts and as one line. - const noon = new Date(2026, 6, 19, 12); - const parts = createStartGreetingParts("周宁", noon, 0); - const whole = createStartGreeting("周宁", noon, 0); +test("the starter greeting is one line drawn from daypart, weekday and returning pools", () => { + // 原值:`parts.salutation` = "中午好,周宁。"、`parts.question` = "现在最想理清哪件事?", + // 并断言两半拼起来等于整行、每个时段至少 12 条不同问句。 + // 新值:`parts.text` 就是整行问候;断言时段池按下标取到「中午好」/「中午好,周宁」。 + // 原因:产品把开场语改成 claude.ai 式的单行问候,追问句移到输入框占位符, + // 「招呼语 + 问句」两半这个形状本身被取消,问句池不再存在。 + const noon = WEDNESDAY(12); + assert.equal(createStartGreetingParts("周宁", noon, 0).text, "中午好"); + assert.equal(createStartGreetingParts("周宁", noon, 0.5).text, "中午好,周宁"); + assert.equal(createStartGreeting("周宁", noon, 0.5), createStartGreetingParts("周宁", noon, 0.5).text); - // Then: the salutation carries the name and the question carries no name, and they recompose exactly. - assert.equal(parts.salutation, "中午好,周宁。"); - assert.equal(parts.question, "现在最想理清哪件事?"); - assert.equal(`${parts.salutation}${parts.question}`, whole); - assert.doesNotMatch(parts.question, /周宁/); - - // And: the extremes stay inside the variant list rather than falling off the end. - assert.equal(createStartGreetingParts("周宁", noon, 1).question, "事业、关系或选择,想先聊哪个?"); - assert.equal(createStartGreetingParts("周宁", noon, 0.999999).question, "事业、关系或选择,想先聊哪个?"); - - // And: every daypart is reachable, so the heading is not one fixed line in disguise. - const questions = new Set([0, 8, 12, 16, 20, 23].flatMap((hour) => - [0, 1 / 3, 2 / 3].map((selection) => - createStartGreetingParts("周宁", new Date(2026, 6, 19, hour), selection).question))); - assert.ok(questions.size >= 12); + // 边界不越界:1 与 0.999999 都落在池内最后一条。 + assert.equal(createStartGreetingParts("周宁", noon, 1).text, "中午好,周宁"); + assert.equal(createStartGreetingParts("周宁", noon, 0.999999).text, "中午好,周宁"); }); -test("the starter hero stays at a salutation and a question", () => { +test("late night has its own three lines and none of them asks a follow-up", () => { + const lateNight = WEDNESDAY(2); + assert.equal(createStartGreetingParts("周宁", lateNight, 0).text, "夜猫子,晚上好"); + assert.equal(createStartGreetingParts("周宁", lateNight, 1 / 3).text, "还没睡呀,周宁"); + assert.equal(createStartGreetingParts("周宁", lateNight, 0.7).text, "晚安前再聊一会"); + // 23 点与 4 点同属深夜。 + assert.equal(createStartGreetingParts("周宁", WEDNESDAY(23), 1 / 3).text, "还没睡呀,周宁"); + assert.equal(createStartGreetingParts("周宁", WEDNESDAY(4), 1 / 3).text, "还没睡呀,周宁"); +}); + +test("a blank name leaves only the lines that carry no name", () => { + const lateNight = WEDNESDAY(2); + assert.equal(createStartGreetingParts("", lateNight, 0).text, "夜猫子,晚上好"); + assert.equal(createStartGreetingParts(" ", lateNight, 0.9).text, "晚安前再聊一会"); + + // 周一的星期条目带称呼,名字为空时整条不可达;回访池同理。 + for (let step = 0; step < 100; step += 1) { + for (const hasSessions of [false, true]) { + const text = createStartGreetingParts("", MONDAY(9), step / 100, hasSessions).text; + assert.doesNotMatch(text, /周一了/); + assert.doesNotMatch(text, /又见面了|嗨,|最近还好吗/); + assert.ok(text.length > 0); + assert.doesNotMatch(text, /\{名字\}/); + assert.doesNotMatch(text, /,$|^,/); + } + } +}); + +test("the weekday pool only opens on its own day", () => { + const friday = FRIDAY(9); + const fridayLines = new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", friday, step / 100).text)); + assert.ok(fridayLines.has("周五快乐")); + + const wednesdayLines = new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", WEDNESDAY(9), step / 100).text)); + assert.ok(!wednesdayLines.has("周五快乐")); + assert.equal(wednesdayLines.size, 3); + + assert.ok(new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", MONDAY(9), step / 100).text)).has("周一了,周宁")); + assert.ok(new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", SUNDAY(9), step / 100).text)).has("周日,慢一点")); +}); + +test("the returning pool needs at least one session behind the account", () => { + const evening = WEDNESDAY(20); + const returning = ["又见面了,周宁", "欢迎回来", "嗨,周宁", "最近还好吗,周宁?"]; + + const firstVisit = new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", evening, step / 100, false).text)); + for (const line of returning) assert.ok(!firstVisit.has(line), `first visit must not reach ${line}`); + assert.equal(firstVisit.size, 3); + + const returnVisit = new Set(Array.from({ length: 100 }, (_, step) => + createStartGreetingParts("周宁", evening, step / 100, true).text)); + for (const line of returning) assert.ok(returnVisit.has(line), `return visit must reach ${line}`); + assert.equal(returnVisit.size, 7); +}); + +test("the same inputs always produce the same line", () => { + for (const hour of [2, 8, 12, 16, 20]) { + for (const hasSessions of [false, true]) { + for (const step of [0, 17, 42, 99]) { + const args = [hour, step / 100, hasSessions] as const; + const first = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]); + const second = createStartGreeting("周宁", WEDNESDAY(args[0]), args[1], args[2]); + assert.equal(first, second); + } + } + } +}); + +test("no greeting line uses a metaphor or trails off into a sentence", () => { + for (let day = 19; day <= 25; day += 1) { + for (let hour = 0; hour < 24; hour += 1) { + for (const name of ["周宁", ""]) { + for (const hasSessions of [false, true]) { + for (let step = 0; step < 100; step += 1) { + const text = createStartGreetingParts( + name, + new Date(2026, 6, day, hour), + step / 100, + hasSessions, + ).text; + assert.doesNotMatch(text, /[星月陪]/, `metaphor in ${text}`); + assert.doesNotMatch(text, /。$/, `sentence period in ${text}`); + assert.ok(text.length > 0); + assert.ok(text.length <= 12, `${text} is longer than one short line`); + } + } + } + } + } +}); + +test("the starter hero is one heading and nothing else", () => { const source = homeSurface; - // Given: the hero heading is the question half of the time-aware greeting. // 原值 `className="starter-greeting"` / 新值 `className="starter-salutation"` // / 原因:`.starter-greeting` 这个类名此前既指招呼语那一行、又被读成整块问候区, // 拆成 `.starter-greeting-block`(容器)与 `.starter-salutation`(招呼语)后不再歧义。 - // 「两行:招呼语 + 问句」这个意图本身没变。 - assert.match(source, /

\{starterGreeting\.salutation\}<\/p>/); - assert.match(source, /

\{starterGreeting\.question\}<\/h1>/); + // + // 原值:断言 `

` 与 `

{starterGreeting.question}

` 同时存在 + // / 新值:断言只剩 `

{starterGreeting.text}

`,且 `starter-salutation` 在源码与样式里都消失 + // / 原因:开场语改成单行问候,副标题那一行连同它的样式一起下线;断言从「两行」改成「一行且不得有第二行」,强度不降反升。 + assert.match(source, /

\{starterGreeting\.text\}<\/h1>/); + assert.doesNotMatch(source, /starter-salutation/); + assert.doesNotMatch(source, /starterGreeting\.(salutation|question)/); - // Then: no third line repeats the welcome the two lines above already carry. + // Then: no third line repeats the welcome the heading already carries. assert.doesNotMatch(source, /starter-hero-note/); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); assert.doesNotMatch(styles, /starter-hero-note/); + assert.doesNotMatch(styles, /starter-salutation/); // And: the separate static heading pool is gone for good. assert.doesNotMatch(source, /starterPrompt|createStarterPrompt|greetingForHour/); assert.doesNotMatch(source, /greeting: createStartGreeting\(/); }); + +test("the greeting knows whether the account already has sessions", () => { + // hasSessions 必须来自已有的会话列表变量,不得新开一份状态。 + assert.match(homeSurface, /const starterHasPriorSessions = sessions\.some\(/); + assert.match(homeSurface, /createStartGreetingParts\(profile\.name, new Date\(\), starterGreetingSelection, starterHasPriorSessions\)/); +});