From 8144fca27d3c572e15498e68cc3fa98da82b8cf4 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 15 Sep 2026 12:27:16 +0800 Subject: [PATCH] fix(chat): fold natal reports and drop homepage topic cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homepage no longer waits on /api/onboarding for six starter questions. Natal answers show the spoken layer first; the Level 2 skeleton sits in a collapsed 完整分析 block. Wide tables scroll sideways on a phone. Form inputs are 16px so iOS does not zoom on focus. --- CHANGELOG.md | 4 + .../PROGRESS-chat-reading-load-20260915.md | 78 ++++ docs/tasks/README.md | 4 +- docs/testing/chat-reading-load-20260915.md | 52 +++ frontend/DESIGN.md | 16 +- frontend/docs/VOICE.md | 11 + frontend/src/app/api/consult/route.ts | 4 +- frontend/src/app/api/onboarding/route.ts | 95 ----- frontend/src/app/globals.css | 209 ++++------ frontend/src/app/page.tsx | 70 +--- .../src/components/chat-answer-detail.tsx | 25 ++ .../src/components/chat-message-content.tsx | 29 +- frontend/src/components/starter-home.tsx | 59 +-- frontend/src/hooks/use-profile-onboarding.ts | 2 +- frontend/src/lib/chat-answer-split.ts | 60 +++ frontend/src/lib/consultation-entrypoint.ts | 1 + .../src/lib/consultation-thinking-plan.ts | 4 +- frontend/src/lib/home-bootstrap.ts | 11 +- frontend/src/lib/onboarding-cache-policy.ts | 98 ----- frontend/src/lib/onboarding-client.ts | 299 --------------- frontend/src/lib/onboarding-payload.ts | 51 --- frontend/src/lib/onboarding-post.ts | 279 -------------- frontend/src/lib/starter-greeting.ts | 72 ++++ frontend/src/mastra/index.ts | 31 +- frontend/src/mastra/product-voice.ts | 2 +- frontend/src/mastra/skill-binding.ts | 2 +- .../api-service-unavailable-20260904.test.ts | 6 +- frontend/tests/chat-answer-detail.test.ts | 70 ++++ frontend/tests/chat-answer-split.test.ts | 203 ++++++++++ frontend/tests/chat-markdown-split.test.ts | 6 +- frontend/tests/chat-reading-load-home.test.ts | 72 ++++ .../tests/chat-reading-load-mobile.test.ts | 72 ++++ .../consultation-birth-time-mode.test.ts | 9 +- .../consultation-domain-registry.test.ts | 63 ++- .../tests/consultation-entrypoint.test.ts | 17 +- .../tests/consultation-voice-contract.test.ts | 15 +- frontend/tests/home-bootstrap-reveal.test.ts | 14 +- frontend/tests/model-catalog.test.ts | 10 +- .../tests/onboarding-cache-policy.test.ts | 213 ---------- frontend/tests/onboarding-client.test.ts | 314 --------------- .../tests/onboarding-presentation.test.ts | 30 +- frontend/tests/onboarding-route-fake.ts | 100 ----- frontend/tests/onboarding-route.test.ts | 363 ------------------ frontend/tests/skill-binding.test.ts | 9 +- frontend/tests/starter-questions.test.ts | 102 +---- .../starter-theme-layout-contract.test.ts | 13 - 46 files changed, 966 insertions(+), 2303 deletions(-) create mode 100644 docs/tasks/PROGRESS-chat-reading-load-20260915.md create mode 100644 docs/testing/chat-reading-load-20260915.md delete mode 100644 frontend/src/app/api/onboarding/route.ts create mode 100644 frontend/src/components/chat-answer-detail.tsx create mode 100644 frontend/src/lib/chat-answer-split.ts delete mode 100644 frontend/src/lib/onboarding-cache-policy.ts delete mode 100644 frontend/src/lib/onboarding-client.ts delete mode 100644 frontend/src/lib/onboarding-payload.ts delete mode 100644 frontend/src/lib/onboarding-post.ts create mode 100644 frontend/src/lib/starter-greeting.ts create mode 100644 frontend/tests/chat-answer-detail.test.ts create mode 100644 frontend/tests/chat-answer-split.test.ts create mode 100644 frontend/tests/chat-reading-load-home.test.ts create mode 100644 frontend/tests/chat-reading-load-mobile.test.ts delete mode 100644 frontend/tests/onboarding-cache-policy.test.ts delete mode 100644 frontend/tests/onboarding-client.test.ts delete mode 100644 frontend/tests/onboarding-route-fake.ts delete mode 100644 frontend/tests/onboarding-route.test.ts delete mode 100644 frontend/tests/starter-theme-layout-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 626f180f..541f0f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 首页不再给主题卡;回答默认只展开口语层 + +首页「从一个主题开始」整段下线,不再为六张卡调大模型、也不再等它揭幕。聊天里本命类回答默认只看到口语层(一句结论、2–3 条短要点、一句下一步);从「统一参数与原始结构」起的骨架折在「完整分析」里,内容一字不删,复制和导出仍是全文。手机上「完整分析」里的四列表可以横着滑,三列技法表仍是卡片。登录和资料输入框改成 16px,避免 iPhone 一点就整页放大。Skill 版本不变。 + ## 2026-09-15 — 并列邀请按候选数说话,不再把 4 个候选叫成「这两分钟」 生时校正里,固定采集线问完后的补充邀请会按还剩几个候选来写:剩 2 个说「这两分钟」,多于 2 个说「这几个候选」,数不清楚时不说这半句。不再出现同一段话里「4 个候选」和「这两分钟」打架。Skill 版本不变。 diff --git a/docs/tasks/PROGRESS-chat-reading-load-20260915.md b/docs/tasks/PROGRESS-chat-reading-load-20260915.md new file mode 100644 index 00000000..4a5bb8b5 --- /dev/null +++ b/docs/tasks/PROGRESS-chat-reading-load-20260915.md @@ -0,0 +1,78 @@ +# PROGRESS · 首页主题卡下线 + 聊天回答两层化(2026-09-15) + +- 执行分支:`codex/chat-reading-load-20260915` +- 工作树:`.worktrees/chat-reading-load-20260915` +- 基线:任务书写 `ebd5ba68`;开工时工作树从 `41473261` 拉起;中途并入文档 `899f955d`(任务 2.6 / 任务 5) + +## 做了什么 + +任务 1:删除首页「从一个主题开始」及 `/api/onboarding` 建议问题链路。问候语、今日星语卡、生时校正卡、出生资料录入链路保留。`guided_topic` 枚举保留并加注释。问候语函数搬到 `frontend/src/lib/starter-greeting.ts`。 + +任务 2:本命回答按第一个 H2 切成口语层 / 完整分析。默认收起。流式时折叠块不渲染 children,副标「正在写…」。复制仍走 `message.text` 全文。 + +任务 2.6:窄屏下三列表仍卡片化;四列及以上在 `.conversation` 里 `width: auto; min-width: max-content` 以触发横滑,并用 `--color-canvas` 渐隐提示。个人报告表格不在范围内。 + +任务 3:口语层定形为一句结论 → 2–3 条短要点 → 一句下一步,≤400 字。`product-voice` / `consultation-thinking-plan` / `skill-binding` / `consult/route` 口径对齐。`VOICE.md` 新增「口语层的形状」。 + +任务 5:全局 `input` / `select` 无条件改为 `--type-body-md`(16px)。`.otp-input` 20px、`.composer textarea` 16px 未动。 + +任务 4:见下表。真机清单 `docs/testing/chat-reading-load-20260915.md`。 + +## BUG-630 + +主题卡发出点已删。`guided_topic` 枚举与 `pinsConsultationDomains` 仍可读回历史会话。走查清单不再可执行。状态板改为「入口已下线,机制保留供历史会话」。 + +## 测试数字 + +| | tests(`test(` 声明) | TAP 全量 | 说明 | +| --- | ---: | --- | --- | +| 基线 `origin/staging` | 3186 | 本机全量 TAP 无法当作门禁数字 | Windows 上 skill 包 `EPERM` symlink、Docker/DB、`G:\\G:\\` 路径是环境缺口,全量 fail 不是本轮回归 | +| 改后工作树 | 3200 | 定向合同 fail=0 | 声明数 +14 | + +定向套件(本轮合同,fail=0):`chat-answer-split` / `chat-answer-detail` / `chat-reading-load-home` / `chat-reading-load-mobile` / `starter-questions` / `home-bootstrap-reveal` / `onboarding-presentation` / `consultation-domain-registry` / `consultation-entrypoint` / `consultation-voice-contract` / `consultation-technique-audit` / `consultation-thinking-plan` / `chat-markdown-split` / `model-catalog`。 + +`tsc --noEmit` 0 错。`npm run lint` 0 error(存量 warning 未动)。 + +`next build`:编译通过;收集 `/api/daily-starlanguage` 等路由时因 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 而失败。与本轮改动无关。`/` 是否仍 Static、首屏 gzip 留给 Gitea 门禁与 Linux 构建;本机记为环境缺口。 + +## 删 / 增断言 + +删除的文件(整文件): + +- `frontend/tests/onboarding-route.test.ts`(15) +- `frontend/tests/onboarding-client.test.ts`(10) +- `frontend/tests/onboarding-cache-policy.test.ts`(8) +- `frontend/tests/onboarding-route-fake.ts` +- `frontend/tests/starter-theme-layout-contract.test.ts`(1) + +`starter-questions.test.ts`:删除 `keeps starter questions visible while the user edits a draft`。领域注册表两条搬到 `consultation-domain-registry.test.ts`。今日星语卡测试改名,去掉 `guided_topic` 点击断言。 + +新增: + +- `chat-answer-split.test.ts` 26 +- `chat-answer-detail.test.ts` 10 +- `chat-reading-load-home.test.ts` 6 +- `chat-reading-load-mobile.test.ts` 7 + +既有断言三栏(摘): + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `home-bootstrap-reveal` prepare 守卫 | 3(含 onboarding) | 2 | 建议问题请求已删 | +| `bootstrapPrepareSettled` | 含 `onboardingSettled` | 只等星语 / 入口摘要 / 校正会话 | 同上 | +| `model-catalog` | 断言 `/api/onboarding` + `getOnboardingAgent` | 不再覆盖该路由 | 接口已删 | +| `consultation-voice-contract` | 切 `onboardingInstructions` | 断言该常量不存在 | onboarding agent 已删 | +| `chat-markdown-split` | `renderMarkdown(spoken)` | `renderProse(spoken, renderMarkdown)` | 口语层与报告层共用解析 | +| 全局 input 字号 | `--type-body-sm` | `--type-body-md` | 任务 5,iOS 聚焦放大 | + +## 偏离 + +- `grep guided_topic frontend/src` 仍会命中 `personal-report-generation.ts` 的 `guided_topics`(复数,报告模块,不是入口枚举)。发出点已无。 +- 任务 5 选无条件 16px,不是 `(pointer: coarse)`。 +- 任务 2.6 横滑提示用 `--color-canvas` 渐隐,深色主题同 token。未让步。 +- 本机 `next build` / 全量 `npm test` 的失败是 Windows 环境缺口,不是产品回归。 +- 浏览器与 iPhone 真机项见 `docs/testing/chat-reading-load-20260915.md`,不得写成通过。键盘遮挡见 `docs/testing/ios-keyboard-composer-20260915.md`,本轮不修。 + +## 未做 + +`TASK-mobile-touch-and-breakpoints-20260915.md`(BUG-695~697)按任务书必须排在本轮合入 staging 之后,本提交不含。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 86c1b9d7..e27f93a2 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -116,8 +116,8 @@ | `TASK-agent-voice-20260901.md` | — | Agent 输出语气 | 已合入 | `ed9497e7`(PR #48) | | `TASK-chat-streaming-ux-20260901.md` | `PROGRESS-chat-streaming-ux-20260901.md` | 流式体验与双会话面统一 | 已验收 | `4dc0c8c7`(BUG-473~478) | | `TASK-unified-loading-20260902.md` | `PROGRESS-unified-loading-20260902.md` | 首页一次等待一次揭幕 | 已验收 | `482796fc`(BUG-479) | -| — | — | 初始化后点首页「家庭」报运行合同未完成:本命第一步未强制计算工具、主题卡无入口钉死 | 待验收 | `codex/consultation-family-contract-incomplete-20260909`(BUG-630);走查 `docs/testing/consultation-family-contract-incomplete-20260909.md` | -| `TASK-chat-reading-load-20260915.md` | — | 首页「从一个主题开始」整段下线(连同 `/api/onboarding` 的 LLM 取数与揭幕闸门);聊天回答改两层可见性:口语层默认展开、第一个 H2 起的骨架进默认收起的「完整分析」折叠块;口语层定形为「结论+2–3 要点+下一步」≤400 字。内容一字不删,只改默认可见性。⚠️ 会让 BUG-630 的走查路径失效(`guided_topic` 入口下线,枚举保留) | 待领取 | `codex/chat-reading-load-20260915` | +| — | — | 初始化后点首页「家庭」报运行合同未完成:本命第一步未强制计算工具、主题卡无入口钉死 | 入口已下线,机制保留供历史会话 | `codex/consultation-family-contract-incomplete-20260909`(BUG-630);主题卡发出点已删,`guided_topic` 枚举仍可读回;走查 `docs/testing/consultation-family-contract-incomplete-20260909.md` 不再可执行 | +| `TASK-chat-reading-load-20260915.md` | `PROGRESS-chat-reading-load-20260915.md` | 首页主题卡下线 + 回答两层可见性 + 折叠层宽表可横滑 + 输入框 16px。内容一字不删。⚠️ BUG-630 走查路径失效 | 待验收 | `codex/chat-reading-load-20260915` | | `TASK-mobile-touch-and-breakpoints-20260915.md` | — | 消息操作按钮触屏命中区 27×34、相邻仅隔 1px(BUG-695);CSS 平板上限 900px 与 `sidebarViewportForWidth` 的 1024 不一致,901–1023 是混合态(BUG-696);报告域 720/760/860 三个断点互不对齐,761–860 目录已塌、正文还是桌面(BUG-697)。含断点白名单契约测试。**串行在 chat-reading-load 之后** | 待领取 | `codex/mobile-touch-and-breakpoints-20260915` | ### 个人报告 diff --git a/docs/testing/chat-reading-load-20260915.md b/docs/testing/chat-reading-load-20260915.md new file mode 100644 index 00000000..fa9816ba --- /dev/null +++ b/docs/testing/chat-reading-load-20260915.md @@ -0,0 +1,52 @@ +# 真机清单 · 聊天阅读负担(2026-09-15) + +代码级合同在 `frontend/tests/chat-answer-*.test.ts`、`chat-reading-load-home.test.ts`、`chat-reading-load-mobile.test.ts`。下面这些必须用真机或浏览器开发者工具做,自动化替代不了。 + +地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。 + +## A. 首页主题卡已下线 + +1. 登录后进入空首页。 +2. **看:** 只有问候语、今日星语卡、生时校正卡、底部输入框。没有「从一个主题开始」。 +3. 打开 Network,刷新。**看:** 没有 `POST /api/onboarding`。 +4. 用一个没有可用出生分钟的账号再走一遍 A.2。 + +## B. 回答两层可见性 + +1. 问一句本命类问题(例如「我事业怎么样」),等回答写完。 +2. **看:** 默认只看到口语层(结论 + 短要点 + 下一步)。下面有一个收起的「完整分析」,副标带章节名。没有转圈、没有骨架、没有「正在加载」。 +3. 流式过程中。**看:** 「完整分析」保持收起,副标是「正在写…」,不展开。 +4. 点开「完整分析」。**看:** 章节顺序是统一参数 → 领域 → 技法审计表 → 现代生活,和复制出来的原文一致。 +5. 点复制。**看:** 剪贴板是全文(含骨架),不是只有口语层。 +6. 刷新页面再打开这条历史。**看:** 「完整分析」仍是收起的。 +7. 发一句闲聊(无标题)。**看:** 没有折叠块,渲染与改动前一致。 + +## C. 375px 下的宽表(真机或开发者工具) + +视口宽度 375px。展开一条本命回答的「完整分析」。 + +1. **三列技法审计表:** 仍是卡片式(技法名 + 状态在第一行,说明在第二行),不是横滑。 +2. **四列 Yoga 表 / 统一参数宽表:** 可以横向滑动,单元格不被压成竖排单字,也不把页面撑出横向滚动条。 +3. 右缘有渐隐,提示右边还有内容。 +4. 页面本身(`.conversation` 外)**没有**横向滚动条。 + +模拟器可以看横滑和页面是否溢出。渐隐在深色主题下也看一眼。 + +## D. 输入框 16px / iOS 聚焦放大(必须真机) + +模拟器不一定复现 iOS Safari 的聚焦放大。设备:iPhone Safari。 + +1. `/login` 点邮箱输入框。**看:** 页面有没有突然放大。 +2. 验证码框(`.otp-input`)聚焦。**看:** 仍是大号等宽数字,页面不放大。 +3. 新账号走姓名 → 出生日期 → 出生时间 → 出生地搜索。每一个聚焦时看页面是否放大。 +4. 设置弹窗里的字段再点一遍。 +5. 375px 与桌面宽度下,上述表单有没有换行崩坏或溢出。 +6. 聊天输入框(已是 16px)聚焦时确认行为没变坏。 + +iOS 放大测不了就在本页结论里写「未测」,不要写成通过。 + +键盘是否挡住输入框见 `docs/testing/ios-keyboard-composer-20260915.md`,不在本单验收范围。 + +## 结论 + +> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 54223bdc..c899594d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -370,7 +370,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a ### Product entrypoint card - **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”. -- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, topic cards as “事业 · …”, and rectification as “生时校正 · 8月21日”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. +- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, typed natal questions as “事业 · …”, and rectification as “生时校正 · 8月21日”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. The homepage no longer shows a “从一个主题开始” grid; leftover `guided_topic` values in stored sessions still pin domains when replayed. - **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback. - **Responsive:** cards stack below 900px without introducing a large nested button. Stacked cards hug their copy instead of stretching to a min-height; the footer stacks supporting copy above a leading-aligned one-line action. - **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target. @@ -480,7 +480,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小 校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出卡。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。 -首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 +首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 ### 报告生成等待态 @@ -558,3 +558,15 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **换升时刻默认折叠。** 时间轴已常驻显示范围与候选点,右栏「换升时刻」整段默认收进 `
`,summary 为「换升时刻 · N 个」;逐分钟 LayerChips 不变。「显示层」子折叠并入同一折叠。确认门折叠、两段本命上升折叠保留原样。 +## 13. 回答的两层可见性 + +本命类聊天回答默认只展开口语层。从第一个 ATX H2(合同上是 `## 统一参数与原始结构`,切点按任意 H2,以便模型漂移时仍能折)起的全部章节装进 `
`,默认收起。骨架仍完整生成、入库、可复制、可导出;折叠只改默认可见性。 + +- **口语层:** 第一个 H2 之前的全部内容,直接画在 `.message-markdown`。无 H2 的闲聊和 onboarding 打字机消息整篇留在这一层,不出现折叠块。 +- **折叠层:** `` 主标「完整分析」;副标用 `` 写章节名,取 `headings` 前三个(`统一参数与原始结构` 写成「统一参数」,`技法审计表` 写成「技法审计」),多于三个补「等 N 节」。不要只写「展开」。 +- **技法审计:** 正文里已经有审计表时不另折一份;只有事件行、正文没有表时,`TechniqueAuditDisclosure` 跟在折叠块内部。没有 H2 时仍留在回答底部原位。 +- **流式:** 折叠块保持收起,不渲染 children,副标写「正在写…」。不得出现 spinner / 骨架 / 「正在加载」。流式结束后再解析报告 markdown。口语层在流式期间仍走 `StreamingMarkdown`。 +- **个人报告页**走 `personal-report-document-view.tsx`,不经过本组件。 +- **窄屏表格:** 三列表(技法审计)继续卡片化。四列及以上(Yoga、统一参数)在 `.conversation` 里横向滚动,右缘用 canvas 渐隐提示还可滑;`.conversation` 自身 `overflow-x: hidden`,页面不得出现横向滚动条。个人报告表格不走这套规则。 +- **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。 + diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 82a1532c..489de88a 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -93,3 +93,14 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 不得对用户写「概率 / 置信度 / 确定 / 相对支持度」。专业数字只留在看盘板。 - 采用旁白里「这段区间里哪些主题稳定、哪些会随分钟变」只能来自服务端 `theme_sensitivity`,模型不得自编;声明时段咨询必须写明「这只是粗看」。 - 技法审计表仍在本命回答文末。 + +## 口语层的形状 + +本命类回答默认只展开口语层。从第一个 H2 起的骨架仍完整写出、入库、可复制,但折在「完整分析」里。折叠是可见性,不是省略。 + +口语层没有标题,仍是 3–6 句,总量不超过 400 字。形状固定为:一句结论 → 2–3 条短要点 → 一句下一步。要点必须是完整句子,每条不超过 30 字,不得写成名词短语枚举。 + +| 坏 | 好 | 为什么 | +|---|---|---| +| 你的事业宫在第十宫,D10 上升与本命一致。当前大运土星、副运木星,从度数上看今明两年有岗位变动的窗口。下面进入统一参数与原始结构。 | 你更适合把已经积累的专业做成长期事业,而不是频繁换赛道。现有工作里还有没做完的深耕。今明两年更像内部推进,而不是另起一摊。下一步把你最想推进的那件事说具体。 | 先回人问的方向,再给短要点和下一步;度数留在折叠里。 | +| 事业、财富、变动窗口、土星大运 | 你更适合把已经积累的专业做成长期事业,而不是频繁换赛道。 | 要点是完整句子,不是公文枚举。 | diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 6bae5f70..258ce8df 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -1353,7 +1353,7 @@ export async function POST(request: Request) { content: consultationUserTurnContent({ currentTime: currentTimeContext(requestTime), name, - instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", + instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", summaryText: historyWindow.summaryText, question: resolvedQuestion.modelQuestion, }), @@ -1377,7 +1377,7 @@ export async function POST(request: Request) { content: consultationUserTurnContent({ currentTime: currentTimeContext(requestTime), name, - instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", + instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", summaryText: historyWindow.summaryText, question: resolvedQuestion.modelQuestion, }), diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts deleted file mode 100644 index 5e706b0f..00000000 --- a/frontend/src/app/api/onboarding/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - createOnboardingPost, - type OnboardingProfileRepository, -} from "@/lib/onboarding-post"; -import { consultationDomainDefinition } from "@/lib/consultation-domain-registry"; -import { onboardingSuggestionThemes } from "@/lib/onboarding-payload"; -import { createAdminSupabaseClient } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; -import { getOnboardingAgent } from "@/mastra"; -import { loadLanguageModelCatalog } from "@/lib/model-catalog"; - -export const runtime = "nodejs"; -// Ten questions in one call take materially longer than the previous three. -export const maxDuration = 60; - -function createProfileRepository( - admin: ReturnType, -): OnboardingProfileRepository { - return { - async loadProfile(userId) { - return admin - .from("profiles") - .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,onboarding_payload,onboarding_version,onboarding_generated_at") - .eq("id", userId) - .maybeSingle(); - }, - async claimProfile(command) { - let claim = admin - .from("profiles") - .update({ - onboarding_version: command.pendingVersion, - onboarding_generated_at: command.claimedAt, - }) - .eq("id", command.userId); - claim = command.expectedVersion === null - ? claim.is("onboarding_version", null) - : claim.eq("onboarding_version", command.expectedVersion); - claim = command.expectedGeneratedAt === null - ? claim.is("onboarding_generated_at", null) - : claim.eq("onboarding_generated_at", command.expectedGeneratedAt); - return claim.select("id").maybeSingle(); - }, - async completeProfile(command) { - return admin - .from("profiles") - .update({ - onboarding_payload: command.payload, - onboarding_version: command.readyVersion, - onboarding_generated_at: command.generatedAt, - }) - .eq("id", command.userId) - .eq("onboarding_version", command.expectedPendingVersion) - .select("id") - .maybeSingle(); - }, - }; -} - -export const POST = createOnboardingPost({ - openSession: async () => { - const supabase = await createServerSupabaseClient(); - const admin = createAdminSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - return { - userId: user?.id ?? null, - authError: Boolean(error), - repository: createProfileRepository(admin), - }; - }, - generateText: async (name, signal) => { - const preferredModelId = process.env.ONBOARDING_MODEL_ID?.trim() || "deepseek-v4-flash"; - const catalog = await loadLanguageModelCatalog(); - const model = catalog.models.find((entry) => entry.id === preferredModelId) - ?? catalog.models.find((entry) => entry.id === catalog.defaultModelId) - ?? null; - if (!model) return null; - const result = await getOnboardingAgent(model).generate([ - { - role: "user", - content: [ - name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。", - `需要的主题,按此顺序各写一个问题:${onboardingSuggestionThemes.join("、")}`, - ...onboardingSuggestionThemes.map((theme) => { - const domain = consultationDomainDefinition(theme); - return `- ${theme}(${domain.label}):${domain.claimBoundary}`; - }), - "只返回 suggestions 数组,不要欢迎语。", - ].join("\n"), - }, - ], { abortSignal: signal }); - return result.text; - }, - now: () => new Date(), - warn: (message, detail) => console.warn(message, detail), -}); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 89cb3cf0..de01103c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -563,8 +563,7 @@ button:disabled { cursor: default; opacity: .45; } .chat-header > div { min-width: 0; flex: 1; } .chat-header strong { max-width: 100%; } .onboarding-card { padding: 16px; } - .starter-list > button { min-height: 64px; } - .starter-content span { font-size: 13px; } + .message-assistant .message-content { max-width: 100%; } .message-user p { font-size: 14px; } .composer { min-height: 56px; } @@ -1002,13 +1001,6 @@ button:disabled { cursor: default; opacity: .45; } .phrase-nowrap { white-space: nowrap; } .starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; } -.starter-list > button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 136px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); } -.starter-list > button:first-of-type { border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); } -.starter-list > button:first-of-type .starter-content span { color: var(--color-ink); white-space: normal; } -.starter-list > button:first-of-type .starter-arrow { color: var(--color-action); } -.starter-content { min-width: 0; display: grid; gap: var(--space-2); } -.starter-content b { color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } -.starter-content span { overflow: hidden; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.4; text-overflow: clip; text-wrap: pretty; white-space: normal; } .starter-arrow { width: 17px; height: 17px; color: var(--color-ink-secondary); } .product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: var(--space-3); } .product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); } @@ -1040,7 +1032,6 @@ button:disabled { cursor: default; opacity: .45; } .product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; } .product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; } .rectification-entry-error { grid-column: 1 / -1; margin: 0; } -.starter-note { margin: var(--space-3) 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: var(--type-caption); } .message-list { --assistant-content-inset: calc(32px + var(--space-3)); margin: 0 auto; @@ -1418,6 +1409,50 @@ button:disabled { cursor: default; opacity: .45; } font-size: inherit; font-variant-numeric: tabular-nums; } +.answer-detail { + margin-top: var(--space-5); + padding-top: var(--space-3); + border-top: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); +} +.answer-detail > summary { + width: fit-content; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 32px; + margin-inline: calc(var(--space-1) * -1); + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + color: var(--color-ink-tertiary); + cursor: pointer; + list-style: none; + font-size: var(--type-caption); + font-weight: 500; +} +.answer-detail > summary::-webkit-details-marker { display: none; } +.answer-detail > summary::before { + width: 6px; + height: 6px; + border-inline-end: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + content: ""; + transform: rotate(-45deg); + transition: transform 120ms ease-out; +} +.answer-detail[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); } +.answer-detail > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); } +.answer-detail > summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); + outline-offset: 1px; +} +.answer-detail > summary small { + color: var(--color-ink-tertiary); + font-size: inherit; + font-variant-numeric: tabular-nums; +} +.answer-detail-panel { + margin-top: var(--space-3); +} .technique-audit-panel { max-width: 100%; margin-top: var(--space-2); @@ -1518,6 +1553,23 @@ button:disabled { cursor: default; opacity: .45; } .technique-audit-panel tbody tr:last-child { border-bottom: 0; } + + /* Four-or-more-column tables (Yoga, raw structure) scroll sideways. + Three-column audit tables keep the card stack above. Scoped to chat + so personal-report tables stay on their own rules. */ + .conversation .markdown-table:has(table:not(:has(thead th:nth-child(3):last-child))) { + background: + linear-gradient(to right, var(--color-canvas) 30%, transparent) left center / 1.5rem 100% local no-repeat, + linear-gradient(to left, var(--color-canvas) 30%, transparent) right center / 1.5rem 100% local no-repeat, + linear-gradient(to right, color-mix(in srgb, var(--color-ink) 16%, transparent), transparent) left center / 0.75rem 100% scroll no-repeat, + linear-gradient(to left, color-mix(in srgb, var(--color-ink) 16%, transparent), transparent) right center / 0.75rem 100% scroll no-repeat; + overscroll-behavior-x: contain; + } + + .conversation .markdown-table table:not(:has(thead th:nth-child(3):last-child)) { + width: auto; + min-width: max-content; + } } .error-message { margin: var(--space-3) 0 0; padding: var(--space-3) var(--space-4); border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: var(--type-caption); line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; } .message-stopped-notice { margin: var(--space-2) 0 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; } @@ -1746,7 +1798,7 @@ button.chart-library-item { cursor: pointer; text-align: left; } .synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; } .synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); } -input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: var(--type-body-sm); } +input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: var(--type-body-md); } input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); } .button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 var(--space-4); border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: var(--type-body-sm); font-weight: 500; } .button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); } @@ -1883,7 +1935,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .birth-time-evidence-actions > button, .birth-time-guided-actions > button, .birth-time-candidate-terminal > button, .birth-time-confirmation-panel > button { width: 100%; } .birth-time-draft-fields { grid-template-columns: 1fr; } .welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); } - .starter-content span { font-size: var(--type-title-sm); } .account-modal h2 { font-size: var(--type-title-lg); } .account-redeem-form > div { grid-template-columns: 1fr; } .paywall-footer { align-items: stretch; flex-direction: column; } @@ -1929,8 +1980,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .starter-hero, -.product-entrypoints, -.starter-themes { +.product-entrypoints { width: 100%; min-width: 0; } @@ -2133,98 +2183,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: currentColor; } -.starter-themes { - display: grid; - gap: var(--space-4); -} - -.starter-section-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--space-5); -} - -.starter-section-heading h2 { - margin: 0; - color: var(--color-ink); - font-family: var(--font-display); - font-size: var(--type-title-lg); - font-weight: 400; - letter-spacing: -.025em; -} - -.starter-section-heading p { - margin: 0; - color: var(--color-ink-tertiary); - font-size: var(--type-caption); -} - -.starter-theme-accordion { - min-width: 0; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-3); -} - -.starter-theme-card { - position: relative; - min-width: 0; - min-height: 156px; - display: grid; - grid-template-columns: minmax(0, 1fr) 20px; - align-items: end; - gap: var(--space-3); - padding: var(--space-5); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-canvas-soft); - cursor: pointer; - text-align: left; - transition: border-color 180ms ease-out, background-color 180ms ease-out, box-shadow 180ms ease-out, transform 180ms var(--ease-out); -} - -.starter-theme-card:not(:disabled):active { - background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas)); -} - -.starter-theme-card:focus-visible { - border-color: color-mix(in srgb, var(--color-action) 38%, var(--color-border)); - box-shadow: var(--shadow-soft); -} - -@media (hover: hover) { - .starter-theme-card:not(:disabled):hover .starter-arrow { - color: var(--color-action); - transform: translate(2px, -2px); - } -} - -.starter-content { - min-width: 0; - display: grid; - gap: var(--space-3); -} - -.starter-content b { - color: var(--color-action); - font-size: var(--type-body-sm); - font-weight: 600; - letter-spacing: .04em; -} - -.starter-content span { - max-width: none; - overflow: visible; - color: var(--color-ink); - font-family: var(--font-display); - font-size: var(--type-title-md); - line-height: 1.38; - text-overflow: clip; - text-wrap: pretty; - white-space: normal; -} - .starter-arrow { width: 17px; height: 17px; @@ -2232,14 +2190,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class transition: color 180ms ease-out, transform 180ms var(--ease-out); } - -.starter-note { - margin: calc(var(--space-2) * -1) 0 0; - color: var(--color-ink-secondary); - line-height: 1.5; - font-size: var(--type-caption); -} - .composer-wrap-starter .composer, .composer-wrap-starter .composer-footer { width: min(1040px, 100%); @@ -2277,14 +2227,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin-left: 0; justify-content: flex-start; } - - .starter-theme-accordion { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .starter-theme-card { - min-height: 142px; - } } @media (max-width: 767px) { @@ -2342,34 +2284,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-body-sm); font-weight: 500; } - - .starter-section-heading { - display: grid; - gap: var(--space-1); - } - - .starter-theme-accordion { - grid-template-columns: 1fr; - gap: var(--space-3); - } - - .starter-theme-card { - min-height: 112px; - } - - .starter-content span { - max-width: 440px; - } } @media (max-width: 480px) { .starter-hero { padding: var(--space-6) var(--space-5); } - - .starter-content span { - font-size: var(--type-title-sm); - } } /* Session conversation */ @@ -4587,4 +4507,5 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class -webkit-text-fill-color: currentColor; } .technique-audit > summary::before { transition: none; } + .answer-detail > summary::before { transition: none; } } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3a21b831..9a577d5a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -85,7 +85,7 @@ import { isGuidedBirthTimePreview, previewRectificationJourney, } from "@/lib/birth-time-guided-preview"; -import { generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; + import { keepFocusWithin } from "@/lib/focus-trap"; import { BALANCE_SYNC_KEY, @@ -132,15 +132,9 @@ import { } from "@/lib/chat-session-url"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { - OnboardingAuthenticationError, - type OnboardingContent, createStartGreeting, createStartGreetingParts, - isCurrentOnboardingRequest, - onboardingProfileFingerprint, - onboardingRequestIdentity, - requestOnboardingWithRecovery, -} from "@/lib/onboarding-client"; +} from "@/lib/starter-greeting"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey, @@ -161,7 +155,6 @@ import { pendingConsultationStorageKey, presetOnboardingMessage, previewModelCatalog, - themes, timestamp, type Account, type AccountDialog, @@ -314,8 +307,6 @@ export default function Home() { const [creatingSession, setCreatingSession] = useState(false); const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); - const [onboarding, setOnboarding] = useState(null); - const [onboardingError, setOnboardingError] = useState(""); const [onboardingStep, setOnboardingStep] = useState("name"); const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false); const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false); @@ -357,7 +348,6 @@ export default function Home() { onRegenerate() {}, onFollowUp() {}, }); - const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationOpenInFlight = useRef(false); @@ -429,8 +419,6 @@ export default function Home() { const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; - const onboardingFingerprint = onboardingProfileFingerprint(profile); - const { updateSession, persistSession, @@ -657,11 +645,8 @@ export default function Home() { const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); const natalMinuteAvailable = birthTimeRoute.kind === "consult" && (birthTimeRoute.mode === "verified_chart" || birthTimeRoute.mode === "unverified_birth_time"); - const personalConsultationAvailable = birthTimeRoute.kind === "consult" - && birthTimeRoute.mode !== "general_no_birth_time"; const dailyStarlanguageFingerprint = dailyStarlanguageProfileKey(profile); - const starterThemes = personalConsultationAvailable ? themes : generalGuidedJyotishTopics; const dailyStarlanguageQuestion = !natalMinuteAvailable ? GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION : dailyStarlanguage.kind === "unavailable" @@ -671,10 +656,7 @@ export default function Home() { ? dailyStarlanguage.card.trend : "今天的星语还没写出来。"; const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : ""; - const onboardingPending = profileComplete && !onboarding && !onboardingError; const bootstrapPrepareReady = bootstrapPrepareSettled({ - profileComplete, - onboardingSettled: onboarding !== null || onboardingError !== "", dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable, dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending", entrySummarySettled: rectificationEntrySummarySettled, @@ -726,12 +708,8 @@ export default function Home() { const shouldStreamOnboarding = !profileComplete; const presetMessageFinished = !shouldStreamOnboarding || presetMessageLength >= currentOnboardingMessage.length; const onboardingCardReady = presetMessageFinished || birthTimeAssessmentPhase !== null; - const starterSuggestions = starterThemes.map((theme) => personalConsultationAvailable - ? onboarding?.suggestions.find((item) => item.theme === theme.id) ?? { theme: theme.id, text: theme.prompt } - : { theme: theme.id, text: theme.prompt }); const starterHomeVisible = profileComplete && presetMessageFinished - && !onboardingPending && !rectificationSurfaceOpen && Boolean(activeSession?.messagesHydrated) && !activeSession.messages.length; @@ -931,9 +909,6 @@ export default function Home() { setActiveSessionId(previewSession.id); const previewGreeting = previewProfile.name.trim() ? createStartGreeting(previewProfile.name) : ""; setStartGreeting(previewGreeting); - setOnboarding(previewMode === "onboarding" - ? null - : { suggestions: themes.map(({ id, prompt }) => ({ theme: id, text: prompt })) }); setHydrated(true); return; } @@ -1261,43 +1236,6 @@ export default function Home() { return () => window.clearInterval(timer); }, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]); - useEffect(() => { - if (bootstrapPhase === "account" || !accountId || !profileComplete || uiPreview.current) return; - const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint); - if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; - activeOnboardingRequestIdentity.current = requestIdentity; - const controller = new AbortController(); - setOnboarding(null); - setOnboardingError(""); - void requestOnboardingWithRecovery(controller.signal, () => { - if (!controller.signal.aborted - && isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) { - setOnboardingError("个性化入门问题准备超时"); - } - }) - .then((content) => { - if (controller.signal.aborted - || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; - setOnboarding(content); - setOnboardingError(""); - }) - .catch((caught: unknown) => { - if (controller.signal.aborted - || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; - if (caught instanceof OnboardingAuthenticationError) { - window.location.assign("/login"); - return; - } - setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题"); - }); - return () => { - if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) { - activeOnboardingRequestIdentity.current = ""; - } - controller.abort(); - }; - }, [accountId, bootstrapPhase, onboardingFingerprint, profile.name, profileComplete]); - useEffect(() => { if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return; const today = calendarDateInTimeZone(new Date(), profile.timezoneId); @@ -1882,10 +1820,6 @@ export default function Home() { rectificationError={rectificationErrorSessionId ? "" : rectificationError} rectificationSurfaceOpen={rectificationSurfaceOpen} rectificationErrorMessage={rectificationErrorSessionId ? "" : rectificationErrorMessage} - starterThemes={starterThemes} - starterSuggestions={starterSuggestions} - startSuggestedConsultation={startSuggestedConsultation} - onboardingError={onboardingError} /> )} diff --git a/frontend/src/components/chat-answer-detail.tsx b/frontend/src/components/chat-answer-detail.tsx new file mode 100644 index 00000000..e20bfe1e --- /dev/null +++ b/frontend/src/components/chat-answer-detail.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { answerDetailCaption } from "@/lib/chat-answer-split"; + +export function AnswerDetailDisclosure({ + headings, + streaming = false, + children, +}: { + readonly headings: readonly string[]; + readonly streaming?: boolean; + readonly children?: ReactNode; +}) { + return ( +
+ + 完整分析 + {streaming ? "正在写…" : answerDetailCaption(headings)} + + {streaming ? null :
{children}
} +
+ ); +} diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx index fcd84a84..da5adbcd 100644 --- a/frontend/src/components/chat-message-content.tsx +++ b/frontend/src/components/chat-message-content.tsx @@ -2,9 +2,11 @@ import { memo, useEffect, useState, type ReactNode } from "react"; +import { AnswerDetailDisclosure } from "@/components/chat-answer-detail"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import { plainParagraphs } from "@/components/chat-message-paragraphs"; import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure"; +import { splitSpokenAndReport } from "@/lib/chat-answer-split"; import { splitStableMarkdown } from "@/lib/chat-markdown-split"; import type { TechniqueAuditRow } from "@/lib/consultation-agent-events"; import { @@ -94,8 +96,19 @@ export function ChatMessageContent({ const renderMarkdown = useMarkdownRenderer(); const split = splitSpokenAnswerAndTechniqueAudit(text); const rows = resolveTechniqueAuditRows(split, auditRows); - const spoken = split.spoken; + const layers = splitSpokenAndReport(split.spoken); + const spoken = layers.spoken; + const report = layers.report; const showFoldedAudit = rows.length > 0 && split.rows.length === 0; + const foldedAudit = showFoldedAudit ? : null; + const reportBody = !streaming && report + ? ( + <> +
{renderProse(report, renderMarkdown)}
+ {foldedAudit} + + ) + : null; return (
@@ -103,14 +116,16 @@ export function ChatMessageContent({
{streaming ? - : renderMarkdown - ? renderMarkdown(spoken) - : (plainParagraphs(spoken) ?? []).map((paragraph, index) => ( -

{paragraph}

- ))} + : renderProse(spoken, renderMarkdown)}
) : null} - {showFoldedAudit ? : null} + {report + ? ( + + {reportBody} + + ) + : foldedAudit}
); } diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index d42b8070..517ba009 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -1,21 +1,9 @@ "use client"; import { ArrowUpRight } from "lucide-react"; -import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; -import type { DailyStarlanguageState, Theme } from "@/lib/home-types"; +import type { DailyStarlanguageState } from "@/lib/home-types"; import type { RectificationEntrySummary } from "@/lib/rectification-entry"; -export type StarterHomeTheme = { - readonly id: string; - readonly label: string; - readonly prompt: string; -}; - -export type StarterHomeSuggestion = { - readonly theme: Theme; - readonly text: string; -}; - export type StarterHomeProps = { readonly starterGreeting: { readonly salutation: string; readonly question: string }; readonly dailyStarlanguage: DailyStarlanguageState; @@ -34,14 +22,6 @@ export type StarterHomeProps = { readonly rectificationError: string; readonly rectificationSurfaceOpen: boolean; readonly rectificationErrorMessage: string; - readonly starterThemes: readonly StarterHomeTheme[]; - readonly starterSuggestions: readonly StarterHomeSuggestion[]; - readonly startSuggestedConsultation: ( - text: string, - theme: Theme, - entrypoint?: ConsultationEntrypoint | null, - ) => void; - readonly onboardingError: string; }; export function StarterHome({ @@ -62,13 +42,9 @@ export function StarterHome({ rectificationError, rectificationSurfaceOpen, rectificationErrorMessage, - starterThemes, - starterSuggestions, - startSuggestedConsultation, - onboardingError, }: StarterHomeProps) { return ( -
+

{starterGreeting.salutation}

@@ -128,37 +104,6 @@ export function StarterHome({ {rectificationErrorMessage}

)} - -
-
-

从一个主题开始

-

{natalMinuteAvailable - ? "选择一个你现在想解决的问题。" - : "选择一个你现在想解决的问题;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}

-
-
- {starterSuggestions.map((item) => { - const theme = starterThemes.find((candidate) => candidate.id === item.theme); - return ( - - ); - })} -
-
- {onboardingError &&

个性化问题暂时不可用,已显示安全的默认问题。

}
); diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index 1cb4d806..0e70f609 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -33,7 +33,7 @@ import { selectedBirthPlace, } from "@/lib/home-profile"; import { resolveAyanamsa } from "@/lib/ayanamsa"; -import { createStartGreeting } from "@/lib/onboarding-client"; +import { createStartGreeting } from "@/lib/starter-greeting"; import { timestamp, type Account, diff --git a/frontend/src/lib/chat-answer-split.ts b/frontend/src/lib/chat-answer-split.ts new file mode 100644 index 00000000..f826bd8b --- /dev/null +++ b/frontend/src/lib/chat-answer-split.ts @@ -0,0 +1,60 @@ +/** + * Split a chat answer into the spoken layer (before the first H2) and the + * report skeleton (from that H2 onward). The cut is any ATX H2, so a drifted + * first heading still folds instead of dumping the whole body. + * + * Fenced `##` lines are not cuts. The fence rule matches `chat-markdown-split`. + */ + +export type SpokenAndReportSplit = Readonly<{ + spoken: string; + report: string; + headings: readonly string[]; +}>; + +const FENCE = /^\s{0,3}(`{3,}|~{3,})/; +const H2 = /^##\s+\S/; + +function extractHeadings(report: string): string[] { + const lines = report.split("\n"); + let insideFence = false; + const headings: string[] = []; + for (const line of lines) { + if (FENCE.test(line)) insideFence = !insideFence; + if (!insideFence && H2.test(line)) { + headings.push(line.replace(/^##\s+/, "").trim()); + } + } + return headings; +} + +export function splitSpokenAndReport(text: string): SpokenAndReportSplit { + const lines = text.split("\n"); + let insideFence = false; + let cut = -1; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (FENCE.test(line)) insideFence = !insideFence; + if (!insideFence && H2.test(line)) { + cut = index; + break; + } + } + if (cut < 0) return { spoken: text, report: "", headings: [] }; + const spoken = lines.slice(0, cut).join("\n").replace(/\s+$/u, ""); + const report = lines.slice(cut).join("\n"); + return { spoken, report, headings: extractHeadings(report) }; +} + +export function shortenAnswerHeading(heading: string): string { + if (heading === "统一参数与原始结构") return "统一参数"; + if (heading === "技法审计表") return "技法审计"; + return heading; +} + +export function answerDetailCaption(headings: readonly string[]): string { + if (headings.length === 0) return ""; + const labels = headings.slice(0, 3).map(shortenAnswerHeading); + if (headings.length <= 3) return labels.join("、"); + return `${labels.join("、")} 等 ${headings.length} 节`; +} diff --git a/frontend/src/lib/consultation-entrypoint.ts b/frontend/src/lib/consultation-entrypoint.ts index ea65f863..063ff2a2 100644 --- a/frontend/src/lib/consultation-entrypoint.ts +++ b/frontend/src/lib/consultation-entrypoint.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const consultationEntrypointSchema = z.enum([ "daily_starlanguage", "birth_time_rectification", + // 入口已于 2026-09-15 下线,枚举值保留用于读回历史会话 "guided_topic", ]); diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts index bda37930..69cf7538 100644 --- a/frontend/src/lib/consultation-thinking-plan.ts +++ b/frontend/src/lib/consultation-thinking-plan.ts @@ -289,7 +289,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo const activity = "Do not invent a thinking-process checklist. Activity, progress, and receipts are server-owned."; if (kind === "natal") { return [ - `After a 3-6 sentence spoken reply with no heading that answers the user's question, write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.foundation}, then ## {the Chinese label of each executed domain in tool order, such as 事业 / 财富 / 关系}, then ## ${REPORT_HEADING.audit}, then ## ${REPORT_HEADING.wrap}. The skeleton must not be omitted.`, + `After a 3-6 sentence spoken reply with no heading that answers the user's question (one conclusion, 2–3 short point-sentences of at most 30 characters each, then one next-step; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.foundation}, then ## {the Chinese label of each executed domain in tool order, such as 事业 / 财富 / 关系}, then ## ${REPORT_HEADING.audit}, then ## ${REPORT_HEADING.wrap}. The skeleton must not be omitted.`, "Parallel points such as today's transits, 适合推进, 需要避开, and candidate windows must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.", activity, secrets, @@ -297,7 +297,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo } if (kind === "daily") { return [ - `After a 3-6 sentence spoken reply with no heading that answers the user's question, write the rest with these exact Markdown H2 headings in order: ## ${DAILY_HEADING.trend}, then ## ${DAILY_HEADING.actAvoid}, then ## ${DAILY_HEADING.action}. Put the Technique Audit Table and the sentence 「探索性日提示,不是确定预测」 inside the last section. The skeleton must not be omitted.`, + `After a 3-6 sentence spoken reply with no heading that answers the user's question (one conclusion, 2–3 short point-sentences of at most 30 characters each, then one next-step; total ≤ 400 characters), write the rest with these exact Markdown H2 headings in order: ## ${DAILY_HEADING.trend}, then ## ${DAILY_HEADING.actAvoid}, then ## ${DAILY_HEADING.action}. Put the Technique Audit Table and the sentence 「探索性日提示,不是确定预测」 inside the last section. The skeleton must not be omitted.`, "Parallel points such as 适合推进 and 需要避开 must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.", activity, secrets, diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index e577bd30..9e2492fd 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -3,9 +3,9 @@ * * The loading screen owns every wait before the page appears. Phase "account" * fetches account, model catalog and the session list; phase "prepare" fetches - * the starter questions, today's starlanguage and the rectification entry - * summary in parallel. The page is revealed once every applicable item has - * settled, or when the prepare budget runs out — never with a spinner inside. + * today's starlanguage and the rectification entry summary in parallel. The + * page is revealed once every applicable item has settled, or when the prepare + * budget runs out — never with a spinner inside. */ import { parseSessionUrlQuery } from "./chat-session-url.ts"; @@ -68,8 +68,6 @@ export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000; export const SESSION_PREFETCH_COUNT = 5; export type BootstrapPrepareState = Readonly<{ - profileComplete: boolean; - onboardingSettled: boolean; dailyStarlanguageApplicable: boolean; dailyStarlanguageSettled: boolean; entrySummarySettled: boolean; @@ -81,7 +79,6 @@ export type BootstrapPrepareState = Readonly<{ export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean { if (!state.entrySummarySettled) return false; if (state.rectificationApplicable && !state.rectificationSettled) return false; - if (state.profileComplete && !state.onboardingSettled) return false; if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false; return true; } @@ -94,7 +91,7 @@ export function bootstrapRevealDelayMs(now: number, prepareStartedAt: number | n export function bootstrapLoadingCopy(phase: BootstrapPhase): Readonly<{ title: string; detail: string }> { return phase === "prepare" - ? { title: "正在准备对话", detail: "整理推荐问题、今日星语与对话记录" } + ? { title: "正在准备对话", detail: "整理今日星语与对话记录" } : { title: "正在载入账户", detail: "同步个人资料与对话记录" }; } diff --git a/frontend/src/lib/onboarding-cache-policy.ts b/frontend/src/lib/onboarding-cache-policy.ts deleted file mode 100644 index 349e32ba..00000000 --- a/frontend/src/lib/onboarding-cache-policy.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createHash } from "node:crypto"; - -// v5 drops the unrendered greeting and widens suggestions to every consultation -// domain, so every v4 payload cached in profiles must be regenerated once. -const ONBOARDING_VERSION = "ayanam-onboarding-v5"; -export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000; - -type OnboardingProfileInput = { - readonly name: string | null; - readonly birthDate: string | null; - readonly birthTime: string | null; - readonly reportedBirthTime?: string | null; - readonly activeBirthTime: string | null; - readonly birthTimeSource?: string | null; - readonly birthTimePeriod?: string | null; - readonly declaredWindowStart?: string | null; - readonly declaredWindowEnd?: string | null; - readonly birthTimeClue?: string | null; - readonly uncertaintyBeforeMinutes?: number | null; - readonly uncertaintyAfterMinutes?: number | null; - readonly birthTimeStatus: string | null; - readonly countryCode: string | null; - readonly provinceCode: string | null; - readonly cityCode: string | null; -}; - -export type OnboardingCacheIdentity = { - readonly readyVersion: string; - readonly pendingVersion: string; -}; - -type OnboardingCacheObservation = { - readonly identity: OnboardingCacheIdentity; - readonly observedVersion: string | null; - readonly generatedAtMs: number; - readonly nowMs: number; - readonly cachedPayload: Payload | null; -}; - -type OnboardingCacheDecision = - | { readonly kind: "ready"; readonly payload: Payload } - | { readonly kind: "pending" } - | { - readonly kind: "claim"; - readonly expectedVersion: string | null; - readonly pendingVersion: string; - }; - -export function createOnboardingCacheIdentity( - profile: OnboardingProfileInput, -): OnboardingCacheIdentity { - const fingerprint = createHash("sha256") - .update(JSON.stringify([ - profile.name, - profile.birthDate, - profile.birthTime, - profile.reportedBirthTime, - profile.activeBirthTime, - profile.birthTimeSource, - profile.birthTimePeriod, - profile.declaredWindowStart, - profile.declaredWindowEnd, - profile.birthTimeClue, - profile.uncertaintyBeforeMinutes, - profile.uncertaintyAfterMinutes, - profile.birthTimeStatus, - profile.countryCode, - profile.provinceCode, - profile.cityCode, - ])) - .digest("hex"); - - return { - readyVersion: `${ONBOARDING_VERSION}:${fingerprint}`, - pendingVersion: `${ONBOARDING_VERSION}:pending:${fingerprint}`, - }; -} - -export function decideOnboardingCache( - observation: OnboardingCacheObservation, -): OnboardingCacheDecision { - if (observation.observedVersion === observation.identity.readyVersion - && observation.cachedPayload !== null) { - return { kind: "ready", payload: observation.cachedPayload }; - } - - if (observation.observedVersion === observation.identity.pendingVersion - && Number.isFinite(observation.generatedAtMs) - && observation.nowMs - observation.generatedAtMs < ONBOARDING_CLAIM_TTL_MS) { - return { kind: "pending" }; - } - - return { - kind: "claim", - expectedVersion: observation.observedVersion, - pendingVersion: observation.identity.pendingVersion, - }; -} diff --git a/frontend/src/lib/onboarding-client.ts b/frontend/src/lib/onboarding-client.ts deleted file mode 100644 index f4cc49c7..00000000 --- a/frontend/src/lib/onboarding-client.ts +++ /dev/null @@ -1,299 +0,0 @@ -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 = { - 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 { - 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 { - 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 { - 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"; diff --git a/frontend/src/lib/onboarding-payload.ts b/frontend/src/lib/onboarding-payload.ts deleted file mode 100644 index f0f8c191..00000000 --- a/frontend/src/lib/onboarding-payload.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "zod"; -import { consultationDomainSchema, consultationDomainIds } from "./consultation-domain-registry"; -import { generalGuidedJyotishTopics } from "./guided-jyotish-topics"; - -const detachedStarterQuestionPattern = /印度占星|一般如何|通常(?:会)?(?:看|观察|分析|理解|包含)|哪些(?:因素|证据层)|如何划分/; - -const userCenteredStarterQuestionSchema = z.string() - .trim() - .min(4) - .max(80) - .refine((text) => text.includes("我"), "starter_question_must_be_first_person") - .refine((text) => !detachedStarterQuestionPattern.test(text), "starter_question_must_not_be_encyclopedic"); - -// Every domain the home screen renders needs a question, in registry order, so the -// page never has to fall back to a static prompt for part of the grid. -export const onboardingSuggestionThemes = consultationDomainIds; - -const onboardingSchema = z.object({ - suggestions: z.array(z.object({ - theme: consultationDomainSchema, - text: userCenteredStarterQuestionSchema, - })).refine( - (items) => items.length === onboardingSuggestionThemes.length - && items.every((item, index) => item.theme === onboardingSuggestionThemes[index]), - "suggestions_must_cover_every_domain_in_order", - ), -}); - -export type OnboardingPayload = z.infer; - -export const fallbackOnboardingPayload: OnboardingPayload = { - suggestions: generalGuidedJyotishTopics.map((topic) => ({ theme: topic.id, text: topic.prompt })), -}; - -class OnboardingJsonError extends Error { - readonly name = "OnboardingJsonError"; -} - -export function parseOnboardingPayload(value: unknown): OnboardingPayload | null { - const parsed = onboardingSchema.safeParse(value); - return parsed.success ? parsed.data : null; -} - -export function parseOnboardingText(text: string): OnboardingPayload | null { - const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); - const start = normalized.indexOf("{"); - const end = normalized.lastIndexOf("}"); - if (start < 0 || end <= start) throw new OnboardingJsonError("onboarding_json_missing"); - const parsed: unknown = JSON.parse(normalized.slice(start, end + 1)); - return parseOnboardingPayload(parsed); -} diff --git a/frontend/src/lib/onboarding-post.ts b/frontend/src/lib/onboarding-post.ts deleted file mode 100644 index 8d65e8f5..00000000 --- a/frontend/src/lib/onboarding-post.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { jsonForSupabaseSetupFailure } from "./api/service-unavailable.ts"; -import { - createOnboardingCacheIdentity, - decideOnboardingCache, -} from "./onboarding-cache-policy.ts"; -import { - fallbackOnboardingPayload, - type OnboardingPayload, - parseOnboardingPayload, - parseOnboardingText, -} from "./onboarding-payload.ts"; -import { - formatBirthDate, - hydrateDeclaredWindowDraft, - isDeclaredBirthProfileComplete, - type BirthTimeSource, - type BirthTimeStatus, -} from "./birth-time-intake-model.ts"; - -export type OnboardingProfileRow = { - readonly id: string; - readonly name: string | null; - readonly birth_date: string | Date | null; - readonly birth_time: string | null; - readonly reported_birth_time: string | null; - readonly active_birth_time: string | null; - readonly birth_time_source: string | null; - readonly birth_time_period: string | null; - readonly declared_window_start?: string | null; - readonly declared_window_end?: string | null; - readonly birth_time_clue: string | null; - readonly uncertainty_before_minutes: number | null; - readonly uncertainty_after_minutes: number | null; - readonly birth_time_status: string | null; - readonly country_code: string | null; - readonly province_code: string | null; - readonly city_code: string | null; - readonly latitude: number | null; - readonly longitude: number | null; - readonly timezone_offset: number | null; - readonly birth_place_label: string | null; - readonly timezone_id: string | null; - readonly onboarding_payload: unknown; - readonly onboarding_version: string | null; - readonly onboarding_generated_at: string | null; -}; - -type RepositoryError = { readonly message: string }; -type RepositoryResult = { - readonly data: Value | null; - readonly error: RepositoryError | null; -}; - -export type OnboardingClaimCommand = { - readonly userId: string; - readonly expectedVersion: string | null; - readonly expectedGeneratedAt: string | null; - readonly pendingVersion: string; - readonly claimedAt: string; -}; - -export type OnboardingCompletionCommand = { - readonly userId: string; - readonly expectedPendingVersion: string; - readonly readyVersion: string; - readonly payload: OnboardingPayload; - readonly generatedAt: string; -}; - -export interface OnboardingProfileRepository { - loadProfile(userId: string): Promise>; - claimProfile(command: OnboardingClaimCommand): Promise>; - completeProfile(command: OnboardingCompletionCommand): Promise>; -} - -type OnboardingSession = { - readonly userId: string | null; - readonly authError: boolean; - readonly repository: OnboardingProfileRepository; -}; - -type OnboardingPostDependencies = { - readonly openSession: () => Promise; - readonly generateText: (name: string, signal: AbortSignal) => Promise; - readonly generationTimeoutMs?: number; - readonly now: () => Date; - readonly warn: (message: string, detail: string) => void; -}; - -// One question per consultation domain, so the budget has to sit well above the -// three-question era's 18s while staying inside the route's maxDuration of 60s. -const DEFAULT_GENERATION_TIMEOUT_MS = 45_000; - -function normalizeBirthDate(value: string | Date | null): string { - return value instanceof Date ? formatBirthDate(value) : value ?? ""; -} - -function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean { - const persistedTime = profile.active_birth_time || profile.birth_time || ""; - const knownSources: readonly BirthTimeSource[] = [ - "hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import", - ]; - const source = knownSources.find((item) => item === profile.birth_time_source) - ?? (persistedTime ? "legacy_import" : ""); - const knownStatuses: readonly BirthTimeStatus[] = [ - "reported", "assessing", "rectifying", "candidate", "accepted", "confirmed", - ]; - const status = knownStatuses.find((item) => item === profile.birth_time_status) - ?? (persistedTime ? "confirmed" : ""); - const clock = (value: string | null | undefined) => value ? value.slice(0, 5) : ""; - const declaredWindow = hydrateDeclaredWindowDraft({ - period: profile.birth_time_period, - start: profile.declared_window_start, - end: profile.declared_window_end, - }); - const birthDraft = { - date: normalizeBirthDate(profile.birth_date), - time: clock(persistedTime), - reportedTime: clock(profile.reported_birth_time) - || (source === "legacy_import" ? clock(persistedTime) : ""), - birthTimeSource: source, - ...declaredWindow, - birthTimeClue: "", - uncertaintyBeforeMinutes: profile.uncertainty_before_minutes, - uncertaintyAfterMinutes: profile.uncertainty_after_minutes, - birthTimeStatus: status, - } as const; - const globalPlace = profile.birth_place_label && profile.timezone_id - ? { - label: profile.birth_place_label, - lat: profile.latitude ?? Number.NaN, - lon: profile.longitude ?? Number.NaN, - tz: profile.timezone_offset, - timezoneId: profile.timezone_id, - } - : null; - const placeComplete = globalPlace - ? isDeclaredBirthProfileComplete(birthDraft, globalPlace) - : Boolean(profile.country_code && profile.province_code && profile.city_code); - return Boolean(profile.name - && placeComplete - && isDeclaredBirthProfileComplete(birthDraft)); -} - -export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise { - return async function onboardingPost(): Promise { - let session: OnboardingSession; - try { - session = await dependencies.openSession(); - } catch (error) { - return jsonForSupabaseSetupFailure(error, "POST /api/onboarding"); - } - - if (session.authError || !session.userId) { - return Response.json( - { error: "请先登录", message: "登录后才能准备初始问题。" }, - { status: 401 }, - ); - } - - const loaded = await session.repository.loadProfile(session.userId); - if (loaded.error || !loaded.data) { - return Response.json( - { error: "无法读取用户档案", message: loaded.error?.message || "请重新登录后再试。" }, - { status: 503 }, - ); - } - const profile = loaded.data; - if (!hasCompleteBirthProfile(profile)) { - return Response.json( - { error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" }, - { status: 409 }, - ); - } - - const identity = createOnboardingCacheIdentity({ - name: profile.name, - birthDate: normalizeBirthDate(profile.birth_date), - birthTime: profile.birth_time, - reportedBirthTime: profile.reported_birth_time, - activeBirthTime: profile.active_birth_time, - birthTimeSource: profile.birth_time_source, - birthTimePeriod: profile.birth_time_period, - declaredWindowStart: profile.declared_window_start, - declaredWindowEnd: profile.declared_window_end, - birthTimeClue: profile.birth_time_clue, - uncertaintyBeforeMinutes: profile.uncertainty_before_minutes, - uncertaintyAfterMinutes: profile.uncertainty_after_minutes, - birthTimeStatus: profile.birth_time_status, - countryCode: profile.country_code, - provinceCode: profile.province_code, - cityCode: profile.city_code, - }); - const cachedPayload = parseOnboardingPayload(profile.onboarding_payload); - const generatedAtMs = profile.onboarding_generated_at === null - ? Number.NaN - : Date.parse(profile.onboarding_generated_at); - const now = dependencies.now(); - const decision = decideOnboardingCache({ - identity, - observedVersion: profile.onboarding_version, - generatedAtMs, - nowMs: now.getTime(), - cachedPayload, - }); - - switch (decision.kind) { - case "ready": - return Response.json({ ...decision.payload, source: "cache" }); - case "pending": - return Response.json({ ...fallbackOnboardingPayload, source: "pending" }); - case "claim": - break; - default: { - const exhaustiveDecision: never = decision; - throw exhaustiveDecision; - } - } - - const claim = await session.repository.claimProfile({ - userId: session.userId, - expectedVersion: decision.expectedVersion, - expectedGeneratedAt: profile.onboarding_generated_at, - pendingVersion: decision.pendingVersion, - claimedAt: now.toISOString(), - }); - if (claim.error) { - return Response.json( - { error: "暂时无法准备初始问题", message: claim.error.message }, - { status: 503 }, - ); - } - if (!claim.data) return Response.json({ ...fallbackOnboardingPayload, source: "pending" }); - - let payload = fallbackOnboardingPayload; - let source: "agent" | "fallback" = "fallback"; - const generationController = new AbortController(); - let generationTimer: ReturnType | undefined; - try { - const timeout = new Promise((resolve) => { - generationTimer = setTimeout(() => { - resolve(null); - generationController.abort(new DOMException("Onboarding generation timed out", "TimeoutError")); - }, dependencies.generationTimeoutMs ?? DEFAULT_GENERATION_TIMEOUT_MS); - }); - const text = await Promise.race([ - dependencies.generateText(profile.name ?? "", generationController.signal), - timeout, - ]); - const parsed = text === null ? null : parseOnboardingText(text); - if (parsed) { - payload = parsed; - source = "agent"; - } - } catch (error) { // no-excuse-ok: catch -- generation failure intentionally uses safe fallback. - dependencies.warn( - "[onboarding] agent generation failed; using safe fallback", - error instanceof Error ? error.message : "unknown error", - ); - } finally { - if (generationTimer !== undefined) clearTimeout(generationTimer); - } - - const completed = await session.repository.completeProfile({ - userId: session.userId, - expectedPendingVersion: identity.pendingVersion, - readyVersion: identity.readyVersion, - payload, - generatedAt: dependencies.now().toISOString(), - }); - if (completed.error) { - dependencies.warn("[onboarding] unable to cache generated content", completed.error.message); - return Response.json({ ...payload, source }); - } - return completed.data - ? Response.json({ ...payload, source }) - : Response.json({ ...fallbackOnboardingPayload, source: "pending" }); - }; -} diff --git a/frontend/src/lib/starter-greeting.ts b/frontend/src/lib/starter-greeting.ts new file mode 100644 index 00000000..0109b56e --- /dev/null +++ b/frontend/src/lib/starter-greeting.ts @@ -0,0 +1,72 @@ +type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night"; + +type GreetingVariant = { + readonly salutation: (name: string) => string; + readonly question: string; +}; + +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: "直接说说最在意的问题吧。" }, + ], +}; + +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()},从你此刻最关心的问题开始吧。`; +} diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index 0d7bcee7..be1e1213 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -17,7 +17,7 @@ export type { ConsultationInput } from "./consultation-workflow.ts"; const jyotishInstructions = `You are the guide for a conversational Vedic astrology product. ${productConversationVoice} ${natalSpokenReportContract} -Write in Simplified Chinese: a heading-free spoken opener first, then the skill Level 2 report skeleton for natal domain questions. Markdown tables are required for raw structure, Yogas, and the Technique Audit Table. +Write in Simplified Chinese: a heading-free spoken opener first (one conclusion, 2–3 short point-sentences of at most 30 characters each, one next-step; ≤ 400 characters), then the skill Level 2 report skeleton for natal domain questions. Markdown tables are required for raw structure, Yogas, and the Technique Audit Table. ${jyotishSkillMethodBlock} The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat. For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context. @@ -117,35 +117,6 @@ export function getGeneralJyotishAgent(model: ResolvedLanguageModel) { return agent; } - -const onboardingInstructions = `You create the first conversational turn for Jyotisha, a Vedic astrology chat product. -Write each suggested question the way a knowledgeable, reliable astrologer friend would hear it — not a form, not legal copy. No greeting field, no 亲爱的, no emoji. -This is onboarding, not a chart reading: do not calculate, infer, or claim placements, timing windows, personality traits, relationship outcomes, or career conclusions. -The suggested questions must stay inside what this product can later answer from a natal chart. Never suggest medical, legal, investment, death, diagnosis, pregnancy, or exact-date promises. -Return valid JSON only. Do not use Markdown fences, commentary, or hidden fields. -The JSON shape must be: -{"suggestions":[{"theme":"服务器给出的主题 id","text":"问题"}]} -The server lists the required themes. Return one question per listed theme, in exactly that order, with no extra, missing, renamed, or reordered themes. Do not add a greeting or any other field. -Write every question as the user's own first-person request and include “我”, such as “请帮我看看……”. Each question must ask for useful help with the user's situation, not for a lesson about astrology, and must stay under 40 Chinese characters. -Keep each question specific to its own theme so the set does not read as rewordings of one another. Never mention birth data, profile readiness, setup completion, or system processing. -Never generate detached or encyclopedic wording such as “印度占星一般如何……”, “通常会看哪些因素”, “包含哪些证据层”, or “如何划分主题”. -The questions must use everyday Simplified Chinese and be answerable as a later natal-chart consultation. Avoid jargon, fear, deterministic promises, medical/legal/investment claims, and unsupported precision.`; - -const onboardingAgents = new Map(); - -export function getOnboardingAgent(model: ResolvedLanguageModel) { - const cached = onboardingAgents.get(model.id); - if (cached) return cached; - const agent = new Agent({ - id: `jyotish-onboarding-guide-${model.id}`, - name: "Jyotisha Onboarding Guide", - model: model.model, - instructions: onboardingInstructions, - }); - onboardingAgents.set(model.id, agent); - return agent; -} - const dailyStarlanguageInstructions = `You rewrite one day's short reading card for Jyotisha, a Vedic astrology product. The server supplies every piece of evidence: ascendant, Moon sign, Vimshottari mahadasha/antardasha, Narayana sign period, divisional charts, today's transit triggers, and functional benefics/malefics. Read only that evidence. Never calculate, infer, or invent a placement, dasha, transit, or degree the server did not send. Return valid JSON only, with no Markdown fences, commentary, or extra fields: diff --git a/frontend/src/mastra/product-voice.ts b/frontend/src/mastra/product-voice.ts index ea3776cf..82e3a7a4 100644 --- a/frontend/src/mastra/product-voice.ts +++ b/frontend/src/mastra/product-voice.ts @@ -13,7 +13,7 @@ Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需 export const natalSpokenReportContract = `NATAL REPORT CONTRACT (spoken reply first, then skill Level 2 skeleton) The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 skeleton — not the opening paragraphs. -For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context). Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. Do not hide tables in a collapsed control as the only copy. +For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI folds everything from the first H2 into a collapsed 完整分析 control; you must still write the full skeleton in the body — the fold is visibility, not omission. Do not hide tables in a collapsed control as the only copy. Required body order after that opener, using these exact H2 headings: ## 统一参数与原始结构, then ## {Chinese domain label} for each executed domain, then ## 技法审计表, then ## 现代生活. Do not write a thinking-process checklist; Activity is server-owned. 1. Unified parameters and raw structure first (degrees, houses, vargas, Dasha boundaries, Shadbala/AV, functional benefic/malefic, Western layers the server delivered). diff --git a/frontend/src/mastra/skill-binding.ts b/frontend/src/mastra/skill-binding.ts index 739c846e..585cc930 100644 --- a/frontend/src/mastra/skill-binding.ts +++ b/frontend/src/mastra/skill-binding.ts @@ -111,7 +111,7 @@ function boundMethod(): string { const BOUND_METHOD_MARKER = ``; -export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block. +export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (one conclusion, 2–3 short point-sentences of at most 30 characters each, then one next-step; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block. ${boundMethod()} diff --git a/frontend/tests/api-service-unavailable-20260904.test.ts b/frontend/tests/api-service-unavailable-20260904.test.ts index a9b76871..4d59902e 100644 --- a/frontend/tests/api-service-unavailable-20260904.test.ts +++ b/frontend/tests/api-service-unavailable-20260904.test.ts @@ -26,7 +26,6 @@ const ROUTES_USING_HELPER = [ "src/app/api/models/route.ts", "src/app/api/birth-time-journey/route.ts", "src/app/api/birth-time-guide/route.ts", - "src/lib/onboarding-post.ts", ] as const; function walk(directory: string): string[] { @@ -203,7 +202,10 @@ test("rectification case refresh maps setup failures without calling the rest of )); }); -test("not-configured copy lives only in the shared helper, and the 16 routes call it", () => { +test("not-configured copy lives only in the shared helper, and the listed routes call it", () => { + // 原值: 测试名写 16 条,清单含 src/lib/onboarding-post.ts。 + // 新值: 15 条,建议问题接口删除。 + // 原因: /api/onboarding 下线。 const hits = walk(srcRoot) .filter((path) => readFileSync(path, "utf8").includes(NOT_CONFIGURED)) .map((path) => path.slice(srcRoot.length + 1).split("\\").join("/")); diff --git a/frontend/tests/chat-answer-detail.test.ts b/frontend/tests/chat-answer-detail.test.ts new file mode 100644 index 00000000..bd5e8481 --- /dev/null +++ b/frontend/tests/chat-answer-detail.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8"); +const disclosureSource = readFileSync(new URL("../src/components/chat-answer-detail.tsx", import.meta.url), "utf8"); +const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8"); +const onboardingSource = readFileSync(new URL("../src/components/onboarding-chat-message.tsx", import.meta.url), "utf8"); +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +test("the answer fold is a closed details element with a chapter caption", () => { + assert.match(disclosureSource, /
/); + assert.doesNotMatch(disclosureSource, /]*\sopen/); + assert.match(disclosureSource, /完整分析/); + assert.match(disclosureSource, /answerDetailCaption\(headings\)/); + assert.doesNotMatch(disclosureSource, />展开 { + assert.match(disclosureSource, /streaming \? "正在写…" : answerDetailCaption\(headings\)/); + assert.match(disclosureSource, /\{streaming \? null :
\{children\}<\/div>\}/); + assert.doesNotMatch(disclosureSource, /spinner|骨架|正在加载|InlineSpinner|aria-busy/i); + assert.match(contentSource, /reportBody = !streaming && report/); +}); + +test("ChatMessageContent splits spoken from report and only folds when a report exists", () => { + assert.match(contentSource, /splitSpokenAndReport\(split\.spoken\)/); + assert.match(contentSource, /report\s*\n\s*\? \(\s*\n\s* 0 && split\.rows\.length === 0/); +}); + +test("the folded technique audit moves inside the report fold when a report exists", () => { + assert.match(contentSource, /
\{renderProse\(report, renderMarkdown\)\}<\/div>/); + assert.match(contentSource, /\{foldedAudit\}/); + assert.match(contentSource, /const reportBody = !streaming && report/); + assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/); + assert.match(contentSource, /\{reportBody\}/); +}); + +test("spoken streaming still uses StreamingMarkdown so the memoised prefix stays intact", () => { + assert.match(contentSource, /streaming\s*\n\s*\? /); +}); + +test("copy still sends the original message text, not the spoken layer", () => { + assert.match(transcriptSource, /onCopy=\{\(\) => actionsRef\.current\.onCopy\(feedbackKey, message\.text\)\}/); +}); + +test("onboarding typewriter messages still go through ChatMessageContent", () => { + assert.match(onboardingSource, //); +}); + +test("answer-detail styles are a sibling of technique-audit, not the same class", () => { + assert.match(globalStyles, /\.answer-detail \{/); + assert.match(globalStyles, /\.answer-detail > summary \{/); + assert.match(globalStyles, /\.answer-detail-panel \{/); + assert.match(disclosureSource, /className="answer-detail"/); + assert.doesNotMatch(disclosureSource, /className="technique-audit"/); +}); + +test("the fold does not introduce a spinner or skeleton class", () => { + assert.doesNotMatch(disclosureSource, /spinner|skeleton|starter-loading|InlineSpinner/i); + assert.doesNotMatch(contentSource, /正在加载/); + assert.doesNotMatch(globalStyles, /\.answer-detail[^{]*spinner/i); +}); + +test("streaming copy is a quiet line, not a loading phrase", () => { + assert.match(disclosureSource, /正在写…/); + assert.doesNotMatch(disclosureSource, /正在加载/); +}); diff --git a/frontend/tests/chat-answer-split.test.ts b/frontend/tests/chat-answer-split.test.ts new file mode 100644 index 00000000..07946283 --- /dev/null +++ b/frontend/tests/chat-answer-split.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + answerDetailCaption, + shortenAnswerHeading, + splitSpokenAndReport, +} from "../src/lib/chat-answer-split.ts"; + +const NATAL = [ + "你更适合把已经积累的专业做成长期事业,而不是频繁换赛道。", + "", + "现有工作里还有没做完的深耕。", + "今明两年更像内部推进,而不是另起一摊。", + "下一步把你最想推进的那件事说具体。", + "", + "## 统一参数与原始结构", + "岁差与宫位。", + "", + "## 事业", + "第十宫判断。", + "", + "## 技法审计表", + "| 技法 | 状态 | 说明 |", + "", + "## 现代生活", + "先把职责做稳。", +].join("\n"); + +test("answers without an H2 stay fully spoken", () => { + const text = "今晚先把待确认的条款列成清单。"; + assert.deepEqual(splitSpokenAndReport(text), { spoken: text, report: "", headings: [] }); +}); + +test("empty text stays empty and unfolded", () => { + assert.deepEqual(splitSpokenAndReport(""), { spoken: "", report: "", headings: [] }); +}); + +test("the first ATX H2 is the cut", () => { + const split = splitSpokenAndReport(NATAL); + assert.match(split.spoken, /做成长期事业/); + assert.doesNotMatch(split.spoken, /## /); + assert.equal(split.report.startsWith("## 统一参数与原始结构"), true); + assert.match(split.report, /## 事业/); + assert.match(split.report, /## 技法审计表/); + assert.match(split.report, /## 现代生活/); +}); + +test("the report side keeps the exact bytes from the cut", () => { + const split = splitSpokenAndReport(`口语\n\n## 统一参数与原始结构\n岁差。\n`); + assert.equal(split.report, "## 统一参数与原始结构\n岁差。\n"); +}); + +test("spoken trailing blank lines are trimmed", () => { + const split = splitSpokenAndReport("结论。\n\n\n## 事业\n正文。"); + assert.equal(split.spoken, "结论。"); +}); + +test("an H2 on the first line leaves spoken empty", () => { + const split = splitSpokenAndReport("## 统一参数与原始结构\n岁差。"); + assert.equal(split.spoken, ""); + assert.equal(split.report.startsWith("## 统一参数与原始结构"), true); +}); + +test("a fenced backtick heading is not a cut", () => { + const text = [ + "先看这段。", + "", + "```md", + "## 统一参数与原始结构", + "```", + "", + "仍是口语。", + ].join("\n"); + const split = splitSpokenAndReport(text); + assert.equal(split.report, ""); + assert.equal(split.spoken, text); + assert.deepEqual(split.headings, []); +}); + +test("a fenced tilde heading is not a cut", () => { + const text = [ + "先看这段。", + "", + "~~~", + "## 事业", + "~~~", + ].join("\n"); + const split = splitSpokenAndReport(text); + assert.equal(split.report, ""); + assert.deepEqual(split.headings, []); +}); + +test("an H2 after a closed fence still cuts", () => { + const text = [ + "口语。", + "```", + "## 不是切点", + "```", + "## 事业", + "正文。", + ].join("\n"); + const split = splitSpokenAndReport(text); + assert.equal(split.spoken, "口语。\n```\n## 不是切点\n```"); + assert.equal(split.report.startsWith("## 事业"), true); + assert.deepEqual(split.headings, ["事业"]); +}); + +test("an unclosed fence keeps later H2s in the spoken layer", () => { + const text = "口语。\n```\n## 事业\n还在围栏里。"; + const split = splitSpokenAndReport(text); + assert.equal(split.report, ""); + assert.equal(split.spoken, text); +}); + +test("a fence indented by up to three spaces still counts", () => { + const text = "口语。\n ```\n## 事业\n ```\n## 财富\n正文。"; + const split = splitSpokenAndReport(text); + assert.deepEqual(split.headings, ["财富"]); + assert.equal(split.report.startsWith("## 财富"), true); +}); + +test("headings are collected in order and skip fenced H2s", () => { + const split = splitSpokenAndReport(NATAL); + assert.deepEqual(split.headings, ["统一参数与原始结构", "事业", "技法审计表", "现代生活"]); +}); + +test("技法审计表 and 现代生活 both land on the report side", () => { + const split = splitSpokenAndReport(NATAL); + const auditAt = split.report.indexOf("## 技法审计表"); + const wrapAt = split.report.indexOf("## 现代生活"); + assert.ok(auditAt >= 0); + assert.ok(wrapAt > auditAt); +}); + +test("an H1 or H3 is not a cut", () => { + const text = "# 标题\n口语。\n### 小节\n仍是口语。"; + const split = splitSpokenAndReport(text); + assert.equal(split.report, ""); + assert.equal(split.spoken, text); +}); + +test("a bare ## without a title is not a cut", () => { + const text = "口语。\n##\n仍是口语。"; + const split = splitSpokenAndReport(text); + assert.equal(split.report, ""); +}); + +test("## with extra spaces still cuts", () => { + const split = splitSpokenAndReport("口语。\n## 事业\n正文。"); + assert.deepEqual(split.headings, ["事业"]); +}); + +test("daily headings fold the same way as natal ones", () => { + const text = [ + "今天整体宜把已谈妥的细节落成文字。", + "", + "## 今日趋势", + "节奏偏收束。", + "", + "## 适合推进 / 需要避开", + "- **适合推进** 合同核对。", + "", + "## 一个行动", + "今晚只做一件。", + ].join("\n"); + const split = splitSpokenAndReport(text); + assert.match(split.spoken, /落成文字/); + assert.deepEqual(split.headings, ["今日趋势", "适合推进 / 需要避开", "一个行动"]); +}); + +test("shortenAnswerHeading compresses the two long skeleton titles", () => { + assert.equal(shortenAnswerHeading("统一参数与原始结构"), "统一参数"); + assert.equal(shortenAnswerHeading("技法审计表"), "技法审计"); + assert.equal(shortenAnswerHeading("事业"), "事业"); + assert.equal(shortenAnswerHeading("现代生活"), "现代生活"); +}); + +test("caption with one heading is just that heading", () => { + assert.equal(answerDetailCaption(["事业"]), "事业"); +}); + +test("caption with two headings joins them", () => { + assert.equal(answerDetailCaption(["统一参数与原始结构", "事业"]), "统一参数、事业"); +}); + +test("caption with three headings has no 等 N 节", () => { + assert.equal( + answerDetailCaption(["统一参数与原始结构", "事业", "技法审计表"]), + "统一参数、事业、技法审计", + ); +}); + +test("caption with more than three headings keeps the first three and the count", () => { + assert.equal( + answerDetailCaption(["统一参数与原始结构", "事业", "技法审计表", "现代生活", "附录"]), + "统一参数、事业、技法审计 等 5 节", + ); +}); + +test("caption with no headings is empty", () => { + assert.equal(answerDetailCaption([]), ""); +}); diff --git a/frontend/tests/chat-markdown-split.test.ts b/frontend/tests/chat-markdown-split.test.ts index fd8586e9..0402e56b 100644 --- a/frontend/tests/chat-markdown-split.test.ts +++ b/frontend/tests/chat-markdown-split.test.ts @@ -68,7 +68,9 @@ test("the streaming renderer parses the prefix and the tail as two separate docu assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/); assert.match(contentSource, /splitStableMarkdown\(text\)/); assert.match(contentSource, /streaming\s*\?\s* { + assert.doesNotMatch(starterHome, /starter-themes|starter-theme-card|starter-theme-accordion|starter-themes-heading/); + assert.doesNotMatch(starterHome, /从一个主题开始/); + assert.doesNotMatch(starterHome, /starterThemes|starterSuggestions|onboardingError/); + assert.match(starterHome, /className="starter-hero"/); + assert.match(starterHome, /className="product-entrypoints"/); +}); + +test("the homepage no longer requests /api/onboarding", () => { + for (const source of srcFiles) { + assert.doesNotMatch(source, /\/api\/onboarding/); + assert.doesNotMatch(source, /requestOnboardingWithRecovery/); + assert.doesNotMatch(source, /getOnboardingAgent/); + } + assert.doesNotMatch(page, /fetch\("\/api\/onboarding"/); +}); + +test("bootstrap prepare no longer waits on onboarding suggestions", () => { + assert.doesNotMatch(bootstrap, /onboardingSettled|profileComplete/); + assert.doesNotMatch(page, /onboardingSettled/); + assert.equal(bootstrapPrepareSettled({ + dailyStarlanguageApplicable: true, + dailyStarlanguageSettled: true, + entrySummarySettled: true, + }), true); + assert.equal(bootstrapPrepareSettled({ + dailyStarlanguageApplicable: true, + dailyStarlanguageSettled: false, + entrySummarySettled: true, + }), false); +}); + +test("StarterHome no longer receives suggestion props", () => { + assert.doesNotMatch(starterHome, /starterThemes|starterSuggestions|startSuggestedConsultation|onboardingError/); + assert.doesNotMatch(page, /starterThemes=\{|starterSuggestions=\{|onboardingError=\{/); + assert.match(page, /startSuggestedConsultation\(/); + assert.match(page, /startDailyStarlanguageConsultation/); +}); + +test("guided_topic remains readable for history but has no homepage emit point", () => { + assert.match(entrypoint, /入口已于 2026-09-15 下线,枚举值保留用于读回历史会话/); + assert.match(entrypoint, /"guided_topic"/); + assert.doesNotMatch(starterHome, /guided_topic/); + assert.doesNotMatch(page, /"guided_topic"/); +}); + +test("the empty home is greeting, two product cards, and the composer", () => { + const start = starterHome.indexOf('
{ + assert.match( + css, + /@media \(max-width: 767px\) \{[\s\S]*\.markdown-table table:has\(thead th:nth-child\(3\):last-child\) tbody tr/, + ); + assert.match( + css, + /\.markdown-table table:has\(thead th:nth-child\(3\):last-child\) tbody td:nth-child\(3\)/, + ); +}); + +test("four-or-more-column chat tables become horizontally scrollable on a phone", () => { + const mobile = css.slice(css.indexOf("@media (max-width: 767px)")); + assert.match( + mobile, + /\.conversation \.markdown-table table:not\(:has\(thead th:nth-child\(3\):last-child\)\) \{[\s\S]*width:\s*auto;[\s\S]*min-width:\s*max-content;/, + ); + assert.match( + mobile, + /\.conversation \.markdown-table:has\(table:not\(:has\(thead th:nth-child\(3\):last-child\)\)\) \{[\s\S]*overscroll-behavior-x:\s*contain;/, + ); +}); + +test("wide-table scroll hint uses canvas tokens so dark theme stays in family", () => { + const mobile = css.slice(css.indexOf("@media (max-width: 767px)")); + const hint = mobile.slice( + mobile.indexOf(".conversation .markdown-table:has(table:not(:has(thead th:nth-child(3):last-child)))"), + ); + const block = hint.slice(0, hint.indexOf(".conversation .markdown-table table:not")); + assert.match(block, /var\(--color-canvas\)/); + assert.doesNotMatch(block, /#[0-9a-fA-F]{3,8}/); +}); + +test("the conversation pane itself still forbids page-level sideways scrolling", () => { + assert.match(css, /\.conversation \{[^}]*overflow-x:\s*hidden;/); +}); + +test("global text inputs are 16px so iOS does not zoom on focus", () => { + const inputRule = ruleFor( + "input:not([type=\"radio\"]):not([type=\"checkbox\"]):not([class^=\"ant-\"])", + ); + assert.match(inputRule, /font-size:\s*var\(--type-body-md\)/); + assert.doesNotMatch(inputRule, /--type-body-sm/); +}); + +test("otp and composer keep their existing safe sizes", () => { + assert.match(ruleFor(".otp-input"), /font-size:\s*20px/); + assert.match(ruleFor(".composer textarea {"), /font-size:\s*var\(--type-body-md\)/); +}); + +test("personal-report tables are not retargeted by the chat wide-table rule", () => { + const mobile = css.slice(css.indexOf("@media (max-width: 767px)")); + const wide = mobile.match( + /\.conversation \.markdown-table table:not\(:has\(thead th:nth-child\(3\):last-child\)\) \{[^}]+\}/, + ); + assert.ok(wide); + assert.doesNotMatch(wide[0], /personal-report/); +}); diff --git a/frontend/tests/consultation-birth-time-mode.test.ts b/frontend/tests/consultation-birth-time-mode.test.ts index 810bbda7..41b50dc1 100644 --- a/frontend/tests/consultation-birth-time-mode.test.ts +++ b/frontend/tests/consultation-birth-time-mode.test.ts @@ -111,7 +111,7 @@ test("general agent runtime has no Jyotish skill package and no personal chart t const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); const generalFactory = mastra.slice( mastra.indexOf("export function getGeneralJyotishAgent"), - mastra.indexOf("const onboardingInstructions"), + mastra.indexOf("const dailyStarlanguageInstructions"), ); assert.doesNotMatch(generalFactory, /jyotishSkillBinding|jyotishSkillMethodBlock/); assert.doesNotMatch(generalFactory, /run-jyotish-consultation|createConsultationTools/); @@ -138,7 +138,7 @@ test("consult route validates mode before billing and general mode uses no chart assert.match(mastra, /not a gate for continuing this session/); const generalFactory = mastra.slice( mastra.indexOf("export function getGeneralJyotishAgent"), - mastra.indexOf("const onboardingInstructions"), + mastra.indexOf("const dailyStarlanguageInstructions"), ); assert.doesNotMatch(generalFactory, /jyotishSkillBinding|jyotishSkillMethodBlock/); assert.doesNotMatch(generalFactory, /run-jyotish-consultation|createConsultationTools/); @@ -153,7 +153,10 @@ test("homepage sends explicit modes and never routes an unverified minute throug assert.match(page, /unverified_birth_time/); assert.match(page, /declared_birth_window/); assert.match(page, /natalMinuteAvailable/); - assert.match(page, /personalConsultationAvailable/); + // 原值: 另锁 personalConsultationAvailable(主题卡在无分钟时换文案)。 + // 新值: 该派生值随主题卡删除。 + // 原因: 首页不再按能否做本命咨询切换主题卡。 + assert.doesNotMatch(page, /personalConsultationAvailable/); assert.doesNotMatch(page, /birthTimeStatus === "confirmed" \? "direct_chart" : "rectification"/); assert.match(route, /旧版生时校正入口已停用/); assert.ok(route.indexOf("旧版生时校正入口已停用") < route.indexOf("reserveConsultationModel(", route.indexOf("chatRequestSchema.safeParse"))); diff --git a/frontend/tests/consultation-domain-registry.test.ts b/frontend/tests/consultation-domain-registry.test.ts index 966be44b..055d53f8 100644 --- a/frontend/tests/consultation-domain-registry.test.ts +++ b/frontend/tests/consultation-domain-registry.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { consultationDomainIds, @@ -8,7 +9,10 @@ import { } from "../src/lib/consultation-domain-registry.ts"; import { consultationInputSchema } from "../src/mastra/consultation-workflow.ts"; import { chatSessionWriteSchema } from "../src/lib/chat-session-write-contract.ts"; -import { defaultGuidedJyotishTopics } from "../src/lib/guided-jyotish-topics.ts"; +import { + defaultGuidedJyotishTopics, + generalGuidedJyotishTopics, +} from "../src/lib/guided-jyotish-topics.ts"; const expected = ["career", "marriage", "wealth", "health", "education", "migration", "family", "annual", "timing", "general"]; @@ -35,6 +39,63 @@ test("server domain plans are allowlisted, ordered and deduplicated", () => { assert.throws(() => validateConsultationDomainPlan(["career", "prashna"]), /unsupported_consultation_domain/); }); +test("default guided topics derive every canonical domain with evidence and claim boundaries", () => { + // Moved from starter-questions.test.ts when homepage theme cards were removed. + const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8"); + assert.equal(consultationDomainIds.length, 10); + assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]); + assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/); + assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); + assert.equal(defaultGuidedJyotishTopics.length, consultationDomainRegistry.length); + + for (const [index, topic] of defaultGuidedJyotishTopics.entries()) { + const domain = consultationDomainRegistry[index]; + assert.equal(topic.id, domain.id); + assert.equal(topic.label, domain.label); + assert.equal(topic.prompt, domain.prompt); + assert.equal(topic.strictWorkflowRoute, domain.strictWorkflowRoute); + assert.deepEqual(topic.evidencePreview, [...domain.evidencePreview]); + assert.equal(topic.confidenceCap, domain.confidenceCap); + assert.equal(topic.claimBoundary, domain.claimBoundary); + } + + const domainById = new Map(consultationDomainRegistry.map((domain) => [domain.id, domain])); + assert.match(domainById.get("career")?.requiredLayers.join(" ") ?? "", /D10/); + assert.match(domainById.get("marriage")?.requiredLayers.join(" ") ?? "", /D9/); + assert.match(domainById.get("wealth")?.requiredLayers.join(" ") ?? "", /Ashtakavarga/); + assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/); +}); + +test("general guided topics keep user-centered prompts and the same claim boundaries", () => { + // Moved from starter-questions.test.ts. Homepage no longer renders these cards; + // the registry remains the contract for personal-report and no-minute copy. + assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); + assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [ + "请帮我梳理目前的事业方向和下一步重点。", + "请帮我看看我在关系中容易重复什么模式。", + "请帮我分析目前的财富重点、风险和更稳妥的选择。", + "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。", + "请帮我看看我更适合怎样学习,以及如何安排下一步。", + "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", + "请帮我看看家庭关系中最需要处理的问题和责任边界。", + "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。", + "请帮我看看目前适合推进什么,哪些事情需要再等等。", + "请结合我的情况,帮我找出现在最值得优先处理的三个问题。", + ]); + for (const topic of generalGuidedJyotishTopics) { + const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id); + assert.ok(personalTopic); + assert.equal(topic.label, personalTopic.label); + assert.equal(topic.strictWorkflowRoute, personalTopic.strictWorkflowRoute); + assert.deepEqual(topic.evidencePreview, personalTopic.evidencePreview); + assert.equal(topic.confidenceCap, personalTopic.confidenceCap); + assert.equal(topic.claimBoundary, personalTopic.claimBoundary); + assert.notEqual(topic.prompt, personalTopic.prompt); + } + assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt))); + assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt))); +}); + test("session persistence accepts every canonical domain and rejects product routes", () => { for (const theme of expected) { const parsed = chatSessionWriteSchema.safeParse({ title: "Test", theme, model_id: "test", messages: [], session_type: "consultation", rectification_case_id: null, updated_at: "2026-08-14T00:00:00.000Z" }); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 53bf3908..3ff7dff0 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -360,7 +360,10 @@ test("starter cards use transient pressed feedback instead of sticky hover shadi for (const rule of productHoverRules) assert.doesNotMatch(rule[1], /background\s*:/); assert.equal(starterHoverSurfaceRules.length, 0); assert.match(styles, /\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):active\)\s*\{[^}]*background\s*:/); - assert.match(styles, /\.starter-theme-card:not\(:disabled\):active\s*\{[^}]*background\s*:/); + // 原值: 另锁 .starter-theme-card:active 的背景。 + // 新值: 主题卡样式已删除。 + // 原因: 首页主题卡下线。 + assert.doesNotMatch(styles, /\.starter-theme-card/); const disabledProductRules = [...styles.matchAll( /\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:disabled\)\s*\{([^}]*)\}/g, @@ -421,18 +424,16 @@ test("starter homepage stays editorial and hides technical chart parameters", () const source = homeSurface; const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const start = source.indexOf('
start ? end : source.length); - assert.ok(start >= 0 && end > start); + assert.ok(start >= 0); assert.match(starterHomepage, /className="starter-hero"/); - assert.match(starterHomepage, /className="starter-theme-accordion"/); - assert.match(starterHomepage, /starterSuggestions\.map/); + assert.doesNotMatch(starterHomepage, /starter-theme-accordion|starterSuggestions/); assert.doesNotMatch(starterHomepage, /evidencePreview|birthTimeDisplay|Vimshottari|D1|D9/); - assert.match(source, /const starterSuggestions = starterThemes\.map/); assert.match(source, /composer-wrap-starter/); assert.match(styles, /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/); - assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints,[\s\S]*?\.starter-themes \{[\s\S]*?width: 100%;/); + assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints \{[\s\S]*?width: 100%;/); }); test("rectification 402 navigates to the membership page with the rectification source", () => { diff --git a/frontend/tests/consultation-voice-contract.test.ts b/frontend/tests/consultation-voice-contract.test.ts index b68c385a..68b5747f 100644 --- a/frontend/tests/consultation-voice-contract.test.ts +++ b/frontend/tests/consultation-voice-contract.test.ts @@ -20,6 +20,8 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton" assert.match(voice, /Skip an executed layer the question needs/); assert.match(voice, /你更适合把已经积累的专业能力做成长期事业/); assert.match(voice, /3–6 heading-free sentences/); + assert.match(voice, /one conclusion sentence, then 2–3 short point-sentences/); + assert.match(voice, /Spoken-layer total ≤ 400 Chinese characters/); assert.match(voice, /骨架不可省略,但必须以直接回应开场/); assert.match(mastra, /productConversationVoice/); @@ -42,13 +44,14 @@ test("product voice uses a spoken opener then the skill Level 2 report skeleton" assert.doesNotMatch(voice, /Do not paste the Technique Audit Table/); }); -test("general and onboarding get voice without the natal Level 2 skeleton", () => { +test("general and window get voice without the natal Level 2 skeleton", () => { + // 原值: 另切 onboardingInstructions,断言它没有 natal 骨架。 + // 新值: onboarding agent 已删除;general 与 window 仍无骨架。 + // 原因: 建议问题生成接口下线。 const generalStart = mastra.indexOf("const generalJyotishInstructions"); - const onboardingStart = mastra.indexOf("const onboardingInstructions"); const windowStart = mastra.indexOf("const windowJyotishInstructions"); const natalStart = mastra.indexOf("const jyotishInstructions"); - const general = mastra.slice(generalStart, onboardingStart); - const onboarding = mastra.slice(onboardingStart, mastra.indexOf("const onboardingAgents")); + const general = mastra.slice(generalStart, mastra.indexOf("const dailyStarlanguageInstructions")); const window = mastra.slice(windowStart, mastra.indexOf("export function getWindowJyotishAgent")); const natal = mastra.slice(natalStart, mastra.indexOf("export function getJyotishAgent")); assert.match(natal, /natalSpokenReportContract/); @@ -56,9 +59,7 @@ test("general and onboarding get voice without the natal Level 2 skeleton", () = assert.doesNotMatch(general, /natalSpokenReportContract/); assert.match(window, /productConversationVoice/); assert.doesNotMatch(window, /natalSpokenReportContract/); - assert.doesNotMatch(onboarding, /natalSpokenReportContract/); - assert.doesNotMatch(onboarding, /## 统一参数与原始结构/); - assert.match(onboarding, /knowledgeable, reliable astrologer friend/); + assert.doesNotMatch(mastra, /const onboardingInstructions/); }); test("consult user turn no longer dumps tool JSON that the model would parrot", () => { diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index ce54fe0e..d768dce3 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -85,19 +85,17 @@ test("default landing replaces a latest rectification session with an empty cons }); test("prepare phase settles only when every applicable item has an answer", () => { + // 原值: BootstrapPrepareState 含 profileComplete + onboardingSettled,未完成建议问题不得揭幕。 + // 新值: 揭幕只等入口摘要、今日星语(若适用)与校正会话(若适用)。 + // 原因: 主题卡与 /api/onboarding 已下线,建议问题不再挡首页。 const base = { - profileComplete: true, - onboardingSettled: true, dailyStarlanguageApplicable: true, dailyStarlanguageSettled: true, entrySummarySettled: true, }; assert.equal(bootstrapPrepareSettled(base), true); assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false); - assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false); assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false); - // Items that do not apply to this account never hold the reveal. - assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true); assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true); }); @@ -120,9 +118,11 @@ test("loading copy changes per phase so the single loading screen narrates progr test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => { assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/); - // The three secondary fetches start in the prepare phase, not after reveal. + // 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。 + // 新值: 2 个(今日星语 / 入口摘要)。 + // 原因: 建议问题请求已删除。 const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? []; - assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all key off bootstrapPhase"); + assert.equal(guards.length, 2, "daily starlanguage and entry summary must both key off bootstrapPhase"); assert.match(page, /setRectificationEntrySummarySettled\(true\);/); assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/); assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/); diff --git a/frontend/tests/model-catalog.test.ts b/frontend/tests/model-catalog.test.ts index e8a78122..68a84dac 100644 --- a/frontend/tests/model-catalog.test.ts +++ b/frontend/tests/model-catalog.test.ts @@ -5,7 +5,7 @@ import test from "node:test"; const catalog = readFileSync(new URL("../src/lib/model-catalog.ts", import.meta.url), "utf8"); const mastraModel = readFileSync(new URL("../src/mastra/model.ts", import.meta.url), "utf8"); const routes = Object.fromEntries( - ["reports", "onboarding", "birth-time-guide", "consult"].map((name) => [ + ["reports", "birth-time-guide", "consult"].map((name) => [ name, readFileSync(new URL(`../src/app/api/${name}/route.ts`, import.meta.url), "utf8"), ]), @@ -51,12 +51,14 @@ test("session model resolution is async and pins published or retired database v assert.doesNotMatch(routes.consult, /languageModelConfigurationMessage/); }); -test("reports, onboarding, and birth-time guide resolve the database catalog before creating agents", () => { - for (const name of ["reports", "onboarding", "birth-time-guide"] as const) { +test("reports and birth-time guide resolve the database catalog before creating agents", () => { + // 原值: 也断言 POST /api/onboarding 经 getOnboardingAgent 读目录。 + // 新值: 不再覆盖 onboarding 路由。 + // 原因: 建议问题生成接口已删除。 + for (const name of ["reports", "birth-time-guide"] as const) { assert.match(routes[name], /await loadLanguageModelCatalog\(\)/, `${name} must await the DB catalog`); assert.match(routes[name], /catalog\.models\.find/, `${name} must select from resolved DB models`); } assert.match(routes.reports, /createPersonalReportAgent\(model/); - assert.match(routes.onboarding, /getOnboardingAgent\(model\)/); assert.match(routes["birth-time-guide"], /getBirthTimeGuideAgent\(model\)/); }); diff --git a/frontend/tests/onboarding-cache-policy.test.ts b/frontend/tests/onboarding-cache-policy.test.ts deleted file mode 100644 index 16a778c7..00000000 --- a/frontend/tests/onboarding-cache-policy.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - createOnboardingCacheIdentity, - decideOnboardingCache, - ONBOARDING_CLAIM_TTL_MS, -} from "../src/lib/onboarding-cache-policy.ts"; - -const profileA = { - name: "林遥", - birthDate: "1990-06-15", - birthTime: "12:30", - activeBirthTime: "12:30", - birthTimeStatus: "confirmed", - countryCode: "CN", - provinceCode: "110000", - cityCode: "110100", -} as const; - -test("changed profile cannot reuse a ready cache from the previous profile", () => { - // Given: profile A has a valid ready cache. - const identityA = createOnboardingCacheIdentity(profileA); - const identityB = createOnboardingCacheIdentity({ ...profileA, name: "周宁" }); - - // When: profile B observes A's ready version. - const decision = decideOnboardingCache({ - identity: identityB, - observedVersion: identityA.readyVersion, - generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"), - nowMs: Date.parse("2026-07-19T10:00:01.000Z"), - cachedPayload: {}, - }); - - // Then: B must claim its own pending identity from the exact observed version. - assert.deepEqual(decision, { - kind: "claim", - expectedVersion: identityA.readyVersion, - pendingVersion: identityB.pendingVersion, - }); - assert.notEqual(identityA.readyVersion, identityB.readyVersion); - assert.doesNotMatch(identityB.readyVersion, /周宁|1990|12:30|110000/); -}); - -test("changed profile cannot wait on the previous profile's active pending claim", () => { - // Given: profile A has a fresh pending claim. - const identityA = createOnboardingCacheIdentity(profileA); - const identityB = createOnboardingCacheIdentity({ ...profileA, activeBirthTime: "12:45" }); - - // When: profile B observes A's pending version within the claim TTL. - const decision = decideOnboardingCache({ - identity: identityB, - observedVersion: identityA.pendingVersion, - generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"), - nowMs: Date.parse("2026-07-19T10:00:01.000Z"), - cachedPayload: null, - }); - - // Then: B claims immediately instead of returning A's pending response. - assert.deepEqual(decision, { - kind: "claim", - expectedVersion: identityA.pendingVersion, - pendingVersion: identityB.pendingVersion, - }); -}); - -test("period-only declaration fields participate in the onboarding cache identity", () => { - const earlyMorning = { - ...profileA, - birthTime: null, - activeBirthTime: null, - birthTimeStatus: "reported", - reportedBirthTime: null, - birthTimeSource: "period_only", - birthTimePeriod: "early_morning", - birthTimeClue: "凌晨或清晨", - uncertaintyBeforeMinutes: null, - uncertaintyAfterMinutes: null, - }; - const morning = { ...earlyMorning, birthTimePeriod: "morning" }; - const customWindow = { ...earlyMorning, declaredWindowStart: "14:30", declaredWindowEnd: "15:00" }; - - assert.notEqual( - createOnboardingCacheIdentity(earlyMorning).readyVersion, - createOnboardingCacheIdentity(morning).readyVersion, - ); - assert.notEqual( - createOnboardingCacheIdentity(earlyMorning).readyVersion, - createOnboardingCacheIdentity(customWindow).readyVersion, - ); -}); - -test("current profile accepts only valid ready content and an active current pending claim", () => { - // Given: one current profile identity and a valid cached payload. - const identity = createOnboardingCacheIdentity(profileA); - const payload = { greeting: "current" }; - const nowMs = Date.parse("2026-07-19T10:01:00.000Z"); - - // When/Then: exact ready and fresh pending identities retain their existing behavior. - assert.deepEqual(decideOnboardingCache({ - identity, - observedVersion: identity.readyVersion, - generatedAtMs: nowMs - 60_000, - nowMs, - cachedPayload: payload, - }), { kind: "ready", payload }); - assert.deepEqual(decideOnboardingCache({ - identity, - observedVersion: identity.pendingVersion, - generatedAtMs: nowMs - 60_000, - nowMs, - cachedPayload: null, - }), { kind: "pending" }); -}); - -test("invalid ready content and expired pending claims are reclaimed", () => { - // Given: the current profile sees unusable ready content or an expired pending claim. - const identity = createOnboardingCacheIdentity(profileA); - const nowMs = Date.parse("2026-07-19T10:03:00.000Z"); - const observations = [ - { - identity, - observedVersion: identity.readyVersion, - generatedAtMs: nowMs - 1_000, - nowMs, - cachedPayload: null, - }, - { - identity, - observedVersion: identity.pendingVersion, - generatedAtMs: nowMs - 180_000, - nowMs, - cachedPayload: null, - }, - ] as const; - - // When/Then: each stale state becomes a compare-and-set claim for the current identity. - for (const observation of observations) { - assert.deepEqual(decideOnboardingCache(observation), { - kind: "claim", - expectedVersion: observation.observedVersion, - pendingVersion: identity.pendingVersion, - }); - } -}); - -test("every selected profile input participates in the cache identity", () => { - // Given: the exact eight profile inputs used by onboarding generation/completeness. - const baseIdentity = createOnboardingCacheIdentity(profileA); - const mutations = [ - { field: "name", profile: { ...profileA, name: "周宁" } }, - { field: "birthDate", profile: { ...profileA, birthDate: "1991-06-15" } }, - { field: "birthTime", profile: { ...profileA, birthTime: "12:31" } }, - { field: "activeBirthTime", profile: { ...profileA, activeBirthTime: "12:45" } }, - { field: "birthTimeStatus", profile: { ...profileA, birthTimeStatus: "candidate" } }, - { field: "countryCode", profile: { ...profileA, countryCode: "TW" } }, - { field: "provinceCode", profile: { ...profileA, provinceCode: "310000" } }, - { field: "cityCode", profile: { ...profileA, cityCode: "310100" } }, - ] as const; - - // When/Then: mutating any one input changes both ready and pending identities. - for (const mutation of mutations) { - const changed = createOnboardingCacheIdentity(mutation.profile); - assert.notEqual(changed.readyVersion, baseIdentity.readyVersion, mutation.field); - assert.notEqual(changed.pendingVersion, baseIdentity.pendingVersion, mutation.field); - } -}); - -test("pending claim TTL is active through TTL minus one and reclaimable at TTL", () => { - // Given: the current profile owns the pending identity at a fixed time. - const identity = createOnboardingCacheIdentity(profileA); - const nowMs = Date.parse("2026-07-19T10:03:00.000Z"); - - // When/Then: the exact boundary preserves the existing strict-less-than policy. - assert.deepEqual(decideOnboardingCache({ - identity, - observedVersion: identity.pendingVersion, - generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS + 1, - nowMs, - cachedPayload: null, - }), { kind: "pending" }); - assert.deepEqual(decideOnboardingCache({ - identity, - observedVersion: identity.pendingVersion, - generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS, - nowMs, - cachedPayload: null, - }), { - kind: "claim", - expectedVersion: identity.pendingVersion, - pendingVersion: identity.pendingVersion, - }); -}); - -test("pending claims with null or invalid generation timestamps are reclaimed", () => { - // Given: null and invalid timestamps have both been normalized to non-finite milliseconds. - const identity = createOnboardingCacheIdentity(profileA); - const observations = [Number.NaN, Date.parse("not-a-timestamp")]; - - // When/Then: neither timestamp can keep a pending claim active. - for (const generatedAtMs of observations) { - assert.deepEqual(decideOnboardingCache({ - identity, - observedVersion: identity.pendingVersion, - generatedAtMs, - nowMs: Date.parse("2026-07-19T10:03:00.000Z"), - cachedPayload: null, - }), { - kind: "claim", - expectedVersion: identity.pendingVersion, - pendingVersion: identity.pendingVersion, - }); - } -}); diff --git a/frontend/tests/onboarding-client.test.ts b/frontend/tests/onboarding-client.test.ts deleted file mode 100644 index 62e583fe..00000000 --- a/frontend/tests/onboarding-client.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - OnboardingAuthenticationError, - OnboardingRequestError, - requestOnboardingWithRecovery, -} from "../src/lib/onboarding-client.ts"; -import { onboardingSuggestionThemes } from "../src/lib/onboarding-payload.ts"; - -const personalizedOnboarding = { - suggestions: onboardingSuggestionThemes.map((theme, index) => ({ - theme, - text: `请帮我看看${theme}方向第${index + 1}个重点。`, - })), - source: "cache", -} as const; - -test("default request deadline leaves enough room for server-side Agent generation", async () => { - const originalFetch = globalThis.fetch; - const originalSetTimeout = globalThis.setTimeout; - let observedRequestDeadline: number | null = null; - globalThis.fetch = () => Promise.resolve(Response.json(personalizedOnboarding)); - globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => { - observedRequestDeadline ??= Number(delay); - return originalSetTimeout(callback, delay, ...args); - }) as typeof globalThis.setTimeout; - - try { - await requestOnboardingWithRecovery( - new AbortController().signal, - () => undefined, - ); - } finally { - globalThis.fetch = originalFetch; - globalThis.setTimeout = originalSetTimeout; - } - - // One question per consultation domain takes far longer than the three-question era, - // so a single attempt must outlast the route's own generation budget. - assert.ok(observedRequestDeadline !== null); - assert.ok(observedRequestDeadline >= 45_000, `request deadline was only ${observedRequestDeadline}ms`); -}); - -test("returns personalized cache content after a timeout and pending response", async () => { - // Given: the first request times out, the second is provisional, and the third is terminal. - const originalFetch = globalThis.fetch; - let requestCount = 0; - let slowCount = 0; - globalThis.fetch = (_input, init) => { - requestCount += 1; - if (requestCount === 1) { - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); - }); - } - if (requestCount === 2) { - return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); - } - return Promise.resolve(Response.json(personalizedOnboarding)); - }; - - try { - // When: bounded recovery runs with test-sized delays. - const content = await requestOnboardingWithRecovery( - new AbortController().signal, - () => { - slowCount += 1; - }, - { requestTimeoutMs: 5, retryDelayMs: 0, maxAttempts: 3 }, - ); - - // Then: slow fallback is shown once and terminal personalized content wins. - assert.deepEqual(content, { suggestions: personalizedOnboarding.suggestions }); - assert.equal(slowCount, 1); - assert.equal(requestCount, 3); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("reports slow onboarding only once across repeated request timeouts", async () => { - // Given: every bounded request waits until its child timeout aborts it. - const originalFetch = globalThis.fetch; - let slowCount = 0; - globalThis.fetch = (_input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); - }); - - try { - // When: all attempts time out. - await assert.rejects( - requestOnboardingWithRecovery( - new AbortController().signal, - () => { - slowCount += 1; - }, - { requestTimeoutMs: 2, retryDelayMs: 0, maxAttempts: 3 }, - ), - (error: unknown) => error instanceof OnboardingRequestError && error.reason === "timeout", - ); - - // Then: the UI fallback signal is emitted once for the whole sequence. - assert.equal(slowCount, 1); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("throws an authentication error without retrying a 401 response", async () => { - // Given: the onboarding endpoint rejects the session. - const originalFetch = globalThis.fetch; - let requestCount = 0; - globalThis.fetch = () => { - requestCount += 1; - return Promise.resolve(Response.json({}, { status: 401 })); - }; - - try { - // When: recovery receives the authentication response. - await assert.rejects( - requestOnboardingWithRecovery( - new AbortController().signal, - () => undefined, - { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 }, - ), - OnboardingAuthenticationError, - ); - - // Then: authentication is terminal and no retry is attempted. - assert.equal(requestCount, 1); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("throws a typed pending error after the bounded attempts are exhausted", async () => { - // Given: every response remains provisional. - const originalFetch = globalThis.fetch; - globalThis.fetch = () => Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); - - try { - // When: the pending response consumes every attempt. - // Then: the caller receives a typed terminal failure. - await assert.rejects( - requestOnboardingWithRecovery( - new AbortController().signal, - () => undefined, - { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 }, - ), - (error: unknown) => error instanceof OnboardingRequestError && error.reason === "pending", - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("throws a typed HTTP error after non-authentication failures are exhausted", async () => { - // Given: the endpoint remains unavailable. - const originalFetch = globalThis.fetch; - let requestCount = 0; - globalThis.fetch = () => { - requestCount += 1; - return Promise.resolve(Response.json({}, { status: 503 })); - }; - - try { - // When: bounded recovery exhausts the failed responses. - await assert.rejects( - requestOnboardingWithRecovery( - new AbortController().signal, - () => undefined, - { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 2 }, - ), - (error: unknown) => error instanceof OnboardingRequestError - && error.reason === "http" - && error.status === 503, - ); - - // Then: no request exceeds the policy bound. - assert.equal(requestCount, 2); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("rejects a partially covered terminal response with a typed error", async () => { - // Given: a cached payload from the era when only three domains were generated. - const originalFetch = globalThis.fetch; - globalThis.fetch = () => Promise.resolve(Response.json({ - source: "cache", - greeting: "林遥,欢迎回来。", - suggestions: [ - { theme: "career", text: "请帮我看看事业方向。" }, - { theme: "marriage", text: "请帮我看看关系模式。" }, - { theme: "timing", text: "请帮我看看时机安排。" }, - ], - })); - - try { - // When: the response crosses the client boundary. - // Then: invalid external data cannot enter the page as onboarding content. - await assert.rejects( - requestOnboardingWithRecovery( - new AbortController().signal, - () => undefined, - { requestTimeoutMs: 20, retryDelayMs: 0, maxAttempts: 3 }, - ), - (error: unknown) => error instanceof OnboardingRequestError && error.reason === "invalid-response", - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("parent cancellation aborts the child request without reporting slow state", async () => { - // Given: a request is in flight with a long child timeout. - const originalFetch = globalThis.fetch; - const parent = new AbortController(); - let markStarted: ((signal: AbortSignal) => void) | null = null; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - let slowCount = 0; - globalThis.fetch = (_input, init) => new Promise((_resolve, reject) => { - assert.ok(init?.signal); - markStarted?.(init.signal); - init.signal.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); - }); - - try { - // When: the page lifecycle is cancelled before the request timeout. - const request = requestOnboardingWithRecovery( - parent.signal, - () => { - slowCount += 1; - }, - { requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 }, - ); - const childSignal = await started; - parent.abort(new DOMException("page unmounted", "AbortError")); - - // Then: cancellation reaches the child and produces no slow fallback signal. - await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); - assert.equal(childSignal?.aborted, true); - assert.equal(slowCount, 0); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("parent cancellation interrupts response body parsing", async () => { - // Given: headers arrived, but the onboarding JSON body is still streaming. - const originalFetch = globalThis.fetch; - const parent = new AbortController(); - let markBodyRead: (() => void) | null = null; - const bodyRead = new Promise((resolve) => { - markBodyRead = resolve; - }); - globalThis.fetch = (_input, init) => { - assert.ok(init?.signal); - const childSignal = init.signal; - const body = new ReadableStream({ - start(controller) { - childSignal.addEventListener("abort", () => controller.error(childSignal.reason), { once: true }); - }, - pull() { - markBodyRead?.(); - }, - }); - return Promise.resolve(new Response(body, { headers: { "content-type": "application/json" } })); - }; - - try { - // When: the page unmounts while the body is being consumed. - const request = requestOnboardingWithRecovery( - parent.signal, - () => undefined, - { requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3 }, - ); - await bodyRead; - parent.abort(new DOMException("page unmounted", "AbortError")); - - // Then: parsing is cancelled with the lifecycle reason, not converted into a schema error. - await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("parent cancellation interrupts a pending-response retry delay", async () => { - // Given: the first response is pending and the next retry has a long delay. - const originalFetch = globalThis.fetch; - const parent = new AbortController(); - let requestCount = 0; - globalThis.fetch = () => { - requestCount += 1; - return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); - }; - - try { - // When: the page unmounts after the provisional response enters its retry wait. - const request = requestOnboardingWithRecovery(parent.signal, () => undefined, { - requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3, - }); - await new Promise((resolve) => setImmediate(resolve)); - parent.abort(new DOMException("page unmounted", "AbortError")); - - // Then: the delay is cancelled and no second request starts. - await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); - assert.equal(requestCount, 1); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts index 43e321f1..da736e80 100644 --- a/frontend/tests/onboarding-presentation.test.ts +++ b/frontend/tests/onboarding-presentation.test.ts @@ -5,31 +5,15 @@ import { createOnboardingFallbackGreeting, createStartGreeting, createStartGreetingParts, - isCurrentOnboardingRequest, - onboardingProfileFingerprint, - onboardingRequestIdentity, -} from "../src/lib/onboarding-client.ts"; +} from "../src/lib/starter-greeting.ts"; import { homeSurface } from "./home-surface.ts"; -const completeProfile = { - name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", - birthTimeSource: "hospital_record", birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "出生证明", birthTimeStatus: "confirmed", - uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0, rectificationCaseId: "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", -} as const; - -test("rejects stale A presentation and derives terminal and fallback greetings from profile B", () => { - // Given: one account starts onboarding for a complete persisted profile A. - const firstIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(completeProfile)); - - // When: the name changes before that request completes and profile B is presented. - const changedProfile = { ...completeProfile, name: "周宁" }; - const currentIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(changedProfile)); - const terminalGreeting = createStartGreeting(changedProfile.name, new Date(2026, 6, 19, 8), 0); - const fallbackGreeting = createOnboardingFallbackGreeting(changedProfile.name); - - // Then: stale work is rejected and neither presentation path can retain A's name. - assert.notEqual(currentIdentity, firstIdentity); - assert.equal(isCurrentOnboardingRequest(currentIdentity, firstIdentity), false); +test("terminal and fallback greetings follow the current profile name", () => { + // 原值: 同时锁 onboardingRequestIdentity / isCurrentOnboardingRequest,避免建议问题串号。 + // 新值: 只保留问候语随当前称呼变化。 + // 原因: 建议问题请求已删除,身份去重不再有消费点。 + const terminalGreeting = createStartGreeting("周宁", new Date(2026, 6, 19, 8), 0); + const fallbackGreeting = createOnboardingFallbackGreeting("周宁"); for (const greeting of [terminalGreeting, fallbackGreeting]) { assert.match(greeting, /周宁/); assert.doesNotMatch(greeting, /林遥/); diff --git a/frontend/tests/onboarding-route-fake.ts b/frontend/tests/onboarding-route-fake.ts deleted file mode 100644 index 726f8b41..00000000 --- a/frontend/tests/onboarding-route-fake.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { - OnboardingClaimCommand, - OnboardingCompletionCommand, - OnboardingProfileRepository, - OnboardingProfileRow, -} from "../src/lib/onboarding-post.ts"; - -type ProfilePatch = Partial>; - -export class StatefulOnboardingProfileRepository implements OnboardingProfileRepository { - private row: OnboardingProfileRow; - private nextClaimInterference: ProfilePatch | null = null; - - constructor(row: OnboardingProfileRow) { - this.row = structuredClone(row); - } - - setProfile(patch: ProfilePatch): void { - this.row = { ...this.row, ...structuredClone(patch) }; - } - - interfereBeforeNextClaim(patch: ProfilePatch): void { - this.nextClaimInterference = structuredClone(patch); - } - - snapshot(): OnboardingProfileRow { - return structuredClone(this.row); - } - - async loadProfile(userId: string) { - return this.row.id === userId - ? { data: this.snapshot(), error: null } - : { data: null, error: null }; - } - - async claimProfile(command: OnboardingClaimCommand) { - if (this.nextClaimInterference) { - this.setProfile(this.nextClaimInterference); - this.nextClaimInterference = null; - } - const ownsObservedRow = this.row.id === command.userId - && this.row.onboarding_version === command.expectedVersion - && this.row.onboarding_generated_at === command.expectedGeneratedAt; - if (!ownsObservedRow) return { data: null, error: null }; - - this.row = { - ...this.row, - onboarding_version: command.pendingVersion, - onboarding_generated_at: command.claimedAt, - }; - return { data: { id: this.row.id }, error: null }; - } - - async completeProfile(command: OnboardingCompletionCommand) { - const ownsPendingRow = this.row.id === command.userId - && this.row.onboarding_version === command.expectedPendingVersion; - if (!ownsPendingRow) return { data: null, error: null }; - - this.row = { - ...this.row, - onboarding_payload: structuredClone(command.payload), - onboarding_version: command.readyVersion, - onboarding_generated_at: command.generatedAt, - }; - return { data: { id: this.row.id }, error: null }; - } -} - -export function completeProfileRow( - patch: ProfilePatch = {}, -): OnboardingProfileRow { - return { - id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a", - name: "林遥", - birth_date: "1990-06-15", - birth_time: "12:30", - reported_birth_time: "12:30", - active_birth_time: "12:30", - birth_time_source: "legacy_import", - birth_time_period: null, - declared_window_start: null, - declared_window_end: null, - birth_time_clue: null, - uncertainty_before_minutes: null, - uncertainty_after_minutes: null, - birth_time_status: "confirmed", - country_code: "CN", - province_code: "110000", - city_code: "110100", - latitude: 39.9042, - longitude: 116.4074, - timezone_offset: 8, - birth_place_label: null, - timezone_id: "Asia/Shanghai", - onboarding_payload: null, - onboarding_version: null, - onboarding_generated_at: null, - ...structuredClone(patch), - }; -} diff --git a/frontend/tests/onboarding-route.test.ts b/frontend/tests/onboarding-route.test.ts deleted file mode 100644 index 4ac3d8e6..00000000 --- a/frontend/tests/onboarding-route.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import { z } from "zod"; -import { createOnboardingCacheIdentity } from "../src/lib/onboarding-cache-policy.ts"; -import { createOnboardingPost } from "../src/lib/onboarding-post.ts"; -import { - fallbackOnboardingPayload, - onboardingSuggestionThemes, - parseOnboardingPayload, -} from "../src/lib/onboarding-payload.ts"; -import { - completeProfileRow, - StatefulOnboardingProfileRepository, -} from "./onboarding-route-fake.ts"; - -// Both payloads cover every consultation domain; the marker is what tells one -// profile's generated content from the other's when checking for stale leaks. -function payloadCovering(marker: string) { - return { - suggestions: onboardingSuggestionThemes.map((theme) => ({ - theme, - text: `请帮我看看${theme}方向的${marker}重点。`, - })), - }; -} - -const payloadA = payloadCovering("甲组"); -const payloadB = payloadCovering("乙组"); - -function generatedText(payload: ReturnType): string { - return JSON.stringify(payload); -} - -function deferred() { - let settle: (value: Value) => void = () => undefined; - const promise = new Promise((resolve) => { - settle = resolve; - }); - return { promise, resolve: settle } as const; -} - -const responseBodySchema = z.object({ - suggestions: z.array(z.object({ theme: z.string(), text: z.string() })), - source: z.enum(["agent", "cache", "fallback", "pending"]), -}).strict(); - -async function responseBody(response: Response): Promise> { - return responseBodySchema.parse(await response.json()); -} - -function createPost( - repository: StatefulOnboardingProfileRepository, - generateText: (name: string) => Promise, -) { - return createOnboardingPost({ - openSession: async () => ({ - userId: repository.snapshot().id, - authError: false, - repository, - }), - generateText, - now: () => new Date("2026-07-19T10:00:00.000Z"), - warn: () => undefined, - }); -} - -test("the safe fallback satisfies the payload schema it is meant to replace", () => { - // The fallback is built from the domain registry, so a registry prompt that stops - // being first-person would otherwise ship a payload the schema itself rejects. - assert.notEqual(parseOnboardingPayload(fallbackOnboardingPayload), null); - assert.deepEqual( - fallbackOnboardingPayload.suggestions.map((item) => item.theme), - [...onboardingSuggestionThemes], - ); -}); - -test("no onboarding surface generates or transports a greeting", () => { - const instructions = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); - const onboardingPrompt = instructions.slice( - instructions.indexOf("const onboardingInstructions"), - instructions.indexOf("const onboardingAgents")); - const route = readFileSync(new URL("../src/app/api/onboarding/route.ts", import.meta.url), "utf8"); - const payload = readFileSync(new URL("../src/lib/onboarding-payload.ts", import.meta.url), "utf8"); - const client = readFileSync(new URL("../src/lib/onboarding-client.ts", import.meta.url), "utf8"); - - // Given: the hero renders a locally composed salutation, so a generated greeting has - // nowhere to go and must not cost tokens. - assert.doesNotMatch(onboardingPrompt, /"greeting"/); - assert.match(onboardingPrompt, /Do not add a greeting/); - assert.doesNotMatch(payload, /greeting/); - assert.doesNotMatch(client, /greeting: (?:parsed|z\.string)/); - - // And: the route names the required themes so the Agent covers the whole grid. - assert.match(route, /onboardingSuggestionThemes/); -}); - -test("slow Agent generation is aborted and a terminal fallback is cached before the client deadline", async () => { - const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); - let generationAborted = false; - const dependencies = { - openSession: async () => ({ - userId: repository.snapshot().id, - authError: false, - repository, - }), - generateText: (_name: string, signal?: AbortSignal) => new Promise((_resolve, reject) => { - signal?.addEventListener("abort", () => { - generationAborted = true; - reject(signal.reason); - }, { once: true }); - }), - generationTimeoutMs: 5, - now: () => new Date("2026-07-19T10:00:00.000Z"), - warn: () => undefined, - }; - const deadline = new Promise<"deadline">((resolve) => { - setTimeout(() => resolve("deadline"), 50); - }); - - const outcome = await Promise.race([createOnboardingPost(dependencies)(), deadline]); - - assert.notEqual(outcome, "deadline"); - assert.ok(outcome instanceof Response); - assert.equal(generationAborted, true); - const body = await responseBody(outcome); - assert.equal(body.source, "fallback"); - assert.deepEqual(repository.snapshot().onboarding_payload, { suggestions: body.suggestions }); -}); - - -test("detached Agent questions are rejected in favor of user-centered fallbacks", async () => { - const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); - const post = createPost(repository, async () => JSON.stringify({ - suggestions: onboardingSuggestionThemes.map((theme) => ({ - theme, - text: `印度占星一般会从哪些因素理解${theme}?`, - })), - })); - - const body = await responseBody(await post()); - - assert.equal(body.source, "fallback"); - assert.ok(body.suggestions.every((item) => /我/.test(item.text))); - assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text))); -}); - -test("the home receives one question per consultation domain, never a partial set", async () => { - // Given: the Agent answers with only the three themes the old contract required. - const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); - const post = createPost(repository, async () => JSON.stringify({ - suggestions: [ - { theme: "career", text: "请帮我看看事业方向。" }, - { theme: "marriage", text: "请帮我看看关系模式。" }, - { theme: "timing", text: "请帮我看看时机安排。" }, - ], - })); - - // When: the partial answer crosses the payload boundary. - const body = await responseBody(await post()); - - // Then: it is rejected wholesale, and the fallback still covers every domain in order - // so no home card is left without a question. - assert.equal(body.source, "fallback"); - assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]); -}); - -test("a reordered set is rejected so questions cannot land under the wrong theme", async () => { - // Given: every domain is present, but two themes are swapped against their questions. - const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); - const swapped = [...onboardingSuggestionThemes]; - [swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!]; - const post = createPost(repository, async () => JSON.stringify({ - suggestions: swapped.map((theme) => ({ theme, text: `请帮我看看${theme}方向的重点。` })), - })); - - const body = await responseBody(await post()); - - assert.equal(body.source, "fallback"); - assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]); -}); - -test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => { - const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ - birth_date: new Date(1990, 5, 15), - })); - const post = createPost(repository, async () => generatedText(payloadA)); - - const response = await post(); - const body = await responseBody(response); - - assert.equal(response.status, 200); - assert.deepEqual(body, { ...payloadA, source: "agent" }); -}); - -test("period-only birth declaration can generate the home starter questions without a concrete minute", async () => { - const repository = new StatefulOnboardingProfileRepository({ - ...completeProfileRow({ - birth_time: null, - active_birth_time: null, - birth_time_status: "reported", - }), - reported_birth_time: null, - birth_time_source: "period_only", - birth_time_period: "early_morning", - birth_time_clue: "家人只记得凌晨或清晨", - uncertainty_before_minutes: null, - uncertainty_after_minutes: null, - }); - let generationCount = 0; - const post = createPost(repository, async () => { - generationCount += 1; - return generatedText(payloadA); - }); - - const response = await post(); - const body = await responseBody(response); - - assert.equal(response.status, 200); - assert.equal(generationCount, 1); - assert.deepEqual(body, { ...payloadA, source: "agent" }); -}); - -test("global place with an IANA timezone can enter home before a numeric offset is resolved", async () => { - const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ - name: "jesse", - birth_date: "1955-02-24", - birth_time: null, - reported_birth_time: null, - active_birth_time: null, - birth_time_source: "period_only", - birth_time_period: "evening", - birth_time_clue: "大约晚上七点左右,可能前后差四十五分钟。", - birth_time_status: null, - country_code: "US", - province_code: null, - city_code: null, - latitude: 37.7879363, - longitude: -122.4075201, - timezone_offset: null, - birth_place_label: "旧金山, 加利福尼亚州, 美国", - timezone_id: "America/Los_Angeles", - })); - let generationCount = 0; - const response = await createPost(repository, async () => { - generationCount += 1; - return generatedText(payloadA); - })(); - - assert.equal(response.status, 200); - assert.equal(generationCount, 1); -}); - -test("stale A generation returns pending after profile B replaces its claim", async () => { - // Given: A owns a claim whose generation remains in flight. - const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); - const generationA = deferred(); - const generationAStarted = deferred(); - const post = createPost( - repository, - async (name) => { - if (name === "林遥") { - generationAStarted.resolve(); - return generationA.promise; - } - return generatedText(payloadB); - }, - ); - const responseA = post(); - await generationAStarted.promise; - - // When: the persisted profile changes to B, B claims/completes, then A finishes. - repository.setProfile({ name: "周宁" }); - const bodyB = await responseBody(await post()); - generationA.resolve(generatedText(payloadA)); - const bodyA = await responseBody(await responseA); - - // Then: B remains cached and A is provisional, never a stale terminal payload. - assert.deepEqual(bodyB, { ...payloadB, source: "agent" }); - assert.equal(bodyA.source, "pending"); - assert.doesNotMatch(JSON.stringify(bodyA), /甲组/); - assert.deepEqual(repository.snapshot().onboarding_payload, payloadB); -}); - -test("profile B replaces profile A ready cache instead of returning A content", async () => { - // Given: A has a ready cache, then the persisted profile changes to B. - const identityA = createOnboardingCacheIdentity({ - name: "林遥", birthDate: "1990-06-15", birthTime: "12:30", activeBirthTime: "12:30", - birthTimeStatus: "confirmed", countryCode: "CN", provinceCode: "110000", cityCode: "110100", - }); - const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ - onboarding_payload: payloadA, - onboarding_version: identityA.readyVersion, - onboarding_generated_at: "2026-07-19T09:59:00.000Z", - })); - repository.setProfile({ name: "周宁" }); - const post = createPost(repository, async () => generatedText(payloadB)); - - // When: B requests onboarding through the real handler seam. - const body = await responseBody(await post()); - - // Then: B is generated and cached; A's ready payload is never returned. - assert.deepEqual(body, { ...payloadB, source: "agent" }); - assert.deepEqual(repository.snapshot().onboarding_payload, payloadB); -}); - -test("profile B replaces profile A active pending claim instead of waiting on A", async () => { - // Given: A has a fresh pending claim, then B changes the active birth time. - const profile = completeProfileRow(); - const identityA = createOnboardingCacheIdentity({ - name: profile.name, - birthDate: profile.birth_date instanceof Date - ? profile.birth_date.toISOString().slice(0, 10) - : profile.birth_date, - birthTime: profile.birth_time, - activeBirthTime: profile.active_birth_time, birthTimeStatus: profile.birth_time_status, - countryCode: profile.country_code, provinceCode: profile.province_code, cityCode: profile.city_code, - }); - const repository = new StatefulOnboardingProfileRepository({ - ...profile, - onboarding_version: identityA.pendingVersion, - onboarding_generated_at: "2026-07-19T09:59:30.000Z", - }); - repository.setProfile({ name: "周宁", active_birth_time: "12:45" }); - let generatedFor = ""; - const post = createPost(repository, async (name) => { - generatedFor = name; - return generatedText(payloadB); - }); - - // When: B requests onboarding within A's TTL. - const body = await responseBody(await post()); - - // Then: B claims and completes immediately rather than receiving pending for A. - assert.equal(generatedFor, "周宁"); - assert.deepEqual(body, { ...payloadB, source: "agent" }); -}); - -for (const interference of [ - { name: "observed version", patch: { onboarding_version: "concurrent-version" } }, - { name: "observed timestamp", patch: { onboarding_generated_at: "2026-07-19T09:58:00.000Z" } }, -] as const) { - test(`claim loses when a concurrent writer changes the ${interference.name}`, async () => { - // Given: another writer changes one observed CAS field just before the claim. - const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ - onboarding_version: "legacy-ready", - onboarding_generated_at: "2026-07-19T09:59:00.000Z", - })); - repository.interfereBeforeNextClaim(interference.patch); - let generationCount = 0; - const post = createPost(repository, async () => { - generationCount += 1; - return generatedText(payloadA); - }); - - // When: the handler attempts its observed-row claim. - const body = await responseBody(await post()); - - // Then: compare-and-set loses provisionally and generation never starts. - assert.equal(body.source, "pending"); - assert.equal(generationCount, 0); - }); -} diff --git a/frontend/tests/skill-binding.test.ts b/frontend/tests/skill-binding.test.ts index 62c80f79..e9b93d0b 100644 --- a/frontend/tests/skill-binding.test.ts +++ b/frontend/tests/skill-binding.test.ts @@ -139,10 +139,6 @@ test("only chart-answering agents bind the method", () => { ); const general = mastra.slice( mastra.indexOf("const generalJyotishInstructions"), - mastra.indexOf("const onboardingInstructions"), - ); - const onboarding = mastra.slice( - mastra.indexOf("const onboardingInstructions"), mastra.indexOf("const dailyStarlanguageInstructions"), ); const daily = mastra.slice( @@ -152,7 +148,10 @@ test("only chart-answering agents bind the method", () => { assert.match(chart, /\.\.\.jyotishSkillBinding\(\)/); assert.match(chart, /\$\{jyotishSkillMethodBlock\}/); - for (const [name, source] of [["general", general], ["onboarding", onboarding], ["daily", daily]] as const) { + // 原值: 另切 onboardingInstructions,断言它也不绑方法。 + // 新值: onboarding agent 已删除;general 与 daily 仍不绑。 + // 原因: 建议问题生成接口下线。 + for (const [name, source] of [["general", general], ["daily", daily]] as const) { assert.doesNotMatch(source, /jyotishSkillBinding|jyotishSkillMethodBlock/, name); } }); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index aaee6e5a..2a3ed93c 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -1,19 +1,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { - consultationDomainIds, - consultationDomainRegistry, -} from "../src/lib/consultation-domain-registry.ts"; -import { - defaultGuidedJyotishTopics, - generalGuidedJyotishTopics, -} from "../src/lib/guided-jyotish-topics.ts"; - import { homeSurface as pageSource } from "./home-surface.ts"; const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); -const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8"); function sourceBetween(source: string, startMarker: string, endMarker: string) { const start = source.indexOf(startMarker); @@ -23,97 +13,25 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) { return source.slice(start, end); } -test("keeps starter questions visible while the user edits a draft", () => { - // Given: the empty-session starter block and its render guard. - const guardStart = pageSource.indexOf("{profileComplete && presetMessageFinished"); - // Former value: pageSource.indexOf("(onboardingPending ?", guardStart) — the guard used to - // branch into a `starter-loading` block. Home now reveals only after the starter questions - // settle (or time out), so the guard leads straight into . - const onboardingBranch = pageSource.indexOf(" { assert.doesNotMatch(pageSource, /setOnboardingJustCompleted\(true\)/); assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/); }); -test("default starter questions derive every canonical domain with evidence and claim boundaries", () => { - assert.match(pageSource, /defaultGuidedJyotishTopics/); - assert.match(pageSource, /starterSuggestions\.map/); - assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/); - assert.match(pageSource, /void startSuggestedConsultation\(item\.text, item\.theme, "guided_topic"\)/); - assert.equal(consultationDomainIds.length, 10); - assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]); - assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/); - assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); - assert.equal(defaultGuidedJyotishTopics.length, consultationDomainRegistry.length); - - for (const [index, topic] of defaultGuidedJyotishTopics.entries()) { - const domain = consultationDomainRegistry[index]; - assert.equal(topic.id, domain.id); - assert.equal(topic.label, domain.label); - assert.equal(topic.prompt, domain.prompt); - assert.equal(topic.strictWorkflowRoute, domain.strictWorkflowRoute); - assert.deepEqual(topic.evidencePreview, [...domain.evidencePreview]); - assert.equal(topic.confidenceCap, domain.confidenceCap); - assert.equal(topic.claimBoundary, domain.claimBoundary); - } - - const domainById = new Map(consultationDomainRegistry.map((domain) => [domain.id, domain])); - assert.match(domainById.get("career")?.requiredLayers.join(" ") ?? "", /D10/); - assert.match(domainById.get("marriage")?.requiredLayers.join(" ") ?? "", /D9/); - assert.match(domainById.get("wealth")?.requiredLayers.join(" ") ?? "", /Ashtakavarga/); - assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/); -}); - -test("profiles without a usable birth minute receive user-centered starter prompts", () => { - assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); - assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [ - "请帮我梳理目前的事业方向和下一步重点。", - "请帮我看看我在关系中容易重复什么模式。", - "请帮我分析目前的财富重点、风险和更稳妥的选择。", - "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。", - "请帮我看看我更适合怎样学习,以及如何安排下一步。", - "请帮我分析现阶段是否适合迁居、置业或考虑海外发展。", - "请帮我看看家庭关系中最需要处理的问题和责任边界。", - "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。", - "请帮我看看目前适合推进什么,哪些事情需要再等等。", - "请结合我的情况,帮我找出现在最值得优先处理的三个问题。", - ]); - for (const topic of generalGuidedJyotishTopics) { - const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id); - assert.ok(personalTopic); - assert.equal(topic.label, personalTopic.label); - assert.equal(topic.strictWorkflowRoute, personalTopic.strictWorkflowRoute); - assert.deepEqual(topic.evidencePreview, personalTopic.evidencePreview); - assert.equal(topic.confidenceCap, personalTopic.confidenceCap); - assert.equal(topic.claimBoundary, personalTopic.claimBoundary); - assert.notEqual(topic.prompt, personalTopic.prompt); - } - assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt))); - assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt))); - assert.match(pageSource, /const starterThemes = personalConsultationAvailable \? themes : generalGuidedJyotishTopics/); - // The birth-time boundary is disclosed next to the topics the reader is about to pick from. - const themeSectionHeading = sourceBetween( - pageSource, - 'id="starter-themes-heading"', - "starter-theme-accordion"); - assert.match(themeSectionHeading, /natalMinuteAvailable/); - assert.match(themeSectionHeading, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/); +test("profiles without a usable birth minute still start daily fortune from the homepage card", () => { + // 原值: 无可用分钟时主题卡换 generalGuidedJyotishTopics,边界句写在「从一个主题开始」小标题。 + // 新值: 主题卡下线;无分钟账号只剩问候语、今日运势卡、生时校正卡。 + // 原因: 2026-09-15 产品决定删除低价值入口,不做替代。 assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/); assert.match(pageSource, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/); assert.doesNotMatch(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/); + assert.doesNotMatch(pageSource, /starter-themes-heading|starter-theme-accordion|starterSuggestions/); }); -test("homepage daily and topic cards start a consultation instead of filling the composer", () => { +test("homepage daily card starts a consultation instead of filling the composer", () => { + // 原值: 同时锁今日星语卡与主题卡 `guided_topic` 点击。 + // 新值: 只覆盖今日星语卡。 + // 原因: 主题卡入口已下线,`guided_topic` 不再有发出点。 const starterSend = sourceBetween( pageSource, "async function startSuggestedConsultation(", @@ -121,7 +39,7 @@ test("homepage daily and topic cards start a consultation instead of filling the ); assert.match(pageSource, /onClick=\{startDailyStarlanguageConsultation\}/); - assert.match(pageSource, /onClick=\{\(\) => void startSuggestedConsultation\(item\.text, item\.theme, "guided_topic"\)\}/); + assert.doesNotMatch(pageSource, /"guided_topic"/); assert.doesNotMatch(pageSource, /draftDailyStarlanguageQuestion/); assert.doesNotMatch(pageSource, /onClick=\{\(\) => chooseSuggestedQuestion\(item\.text, item\.theme\)\}/); assert.match(starterSend, /await startNewChat\(\)/); diff --git a/frontend/tests/starter-theme-layout-contract.test.ts b/frontend/tests/starter-theme-layout-contract.test.ts deleted file mode 100644 index 7a6a295d..00000000 --- a/frontend/tests/starter-theme-layout-contract.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; - -const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - -test("starter questions use readable responsive cards instead of a compressed flex row", () => { - assert.match(css, /\.starter-theme-accordion \{[^}]*display: grid;[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/); - assert.match(css, /@media \(min-width: 768px\) and \(max-width: 900px\)[\s\S]*?\.starter-theme-accordion \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/); - assert.match(css, /@media \(max-width: 767px\)[\s\S]*?\.starter-theme-accordion \{[^}]*grid-template-columns: 1fr;/); - assert.match(css, /\.starter-theme-card \{[^}]*min-height: 156px;[^}]*border: 1px solid var\(--color-border\);[^}]*border-radius: var\(--radius-lg\);/); - assert.doesNotMatch(css, /\.starter-theme-accordion \{[^}]*display: flex;/); -});