对话页换成组件库,桌面旁边可以打开本命盘(#106)。
输入框里左边选模型、右边发送。回答下的赞、踩、复制、重新生成和追问改用组件库按钮。不满意原因和兑换窗改用对话框,句子不变。桌面顶栏可以打开当前人物的本命盘,能拖宽,刷新后还在;手机没有这个入口。当前会话和流式状态从 Home() 移进已有会话 store,useState 从 33 减到 25。流式、停止、排队、撤回、退点和寒暄免费没有改。 自测:tsc 退出码 0;lint 0 error、122 条警告;定向测试 275 通过、失败 0、取消 0;npm run build 退出码 0,/ 仍是 Static。首屏 gzip-9 639127,相对基线 634528 多 0.72%。隐私标记测试退出码 0。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
e4e240bf86
commit
d25dfdbe1a
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘
|
||||
|
||||
- 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。
|
||||
- 回答下面的赞、踩、复制、重新生成仍是原来的四个动作。「不满意原因」和兑换窗仍是原来的文案。
|
||||
- 桌面对话顶栏可以打开当前人物的本命盘,能拖宽,也能收起。刷新后还在。手机上看不到这个入口。
|
||||
- 发消息、停止、排队、撤回、退点和寒暄免费没有改。追问仍是原来的依次浮出。
|
||||
- Skill 版本不变(BUG-1434)。
|
||||
|
||||
## 2026-10-25 — 建档、清单和天空第一次出现时会动一下
|
||||
|
||||
- 建档的句子和卡片、起步问题、追问、档案行,第一次出现时轻轻上浮。按下按钮、问题卡和关系标签时会轻微缩小。
|
||||
|
||||
@@ -19430,3 +19430,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-1-shell-20261023`。
|
||||
|
||||
## BUG-1434 | 对话页换成组件库,旁边可以打开本命盘
|
||||
|
||||
- 状态:enhancement
|
||||
- 首次发现:2026-10-25
|
||||
- 最近更新:2026-10-25
|
||||
- 影响面:首页对话输入框、模型选择、回答下的操作、追问、不满意原因、兑换窗、桌面对话旁边的本命盘。星盘页本身不在这次里。
|
||||
- 用户现象:输入框里左边选模型,右边发送。价格只在模型菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,刷新后还在。手机上看不到这个入口。
|
||||
- 触发条件:打开首页对话。本命盘按钮在视口已判定为桌面(不小于 768)之后出现。打开后面板才去取本命盘。
|
||||
- 根因:不是缺陷。组件库迁移的对话页这一张(任务书 K2,决策 D4、D5、D7、D8、D9)。
|
||||
- 修复:输入框、模型菜单、回答操作、追问、不满意原因、兑换窗和跳到最新改用已有的组件库零件。当前会话、流式和反馈状态从 `Home()` 移进已有的会话 store,`Home()` 的 useState 从 33 减到 25,useRef 仍是 37。本命盘放在可拖分栏里,宽 280–560,默认 360,开着和宽度记在本机;绘制复用星盘页的图,第一次打开才载入。手机不显示入口,也不把「开着」写成关掉。流式逐字、停止、排队、撤回、退点和寒暄免费没有改。
|
||||
- 验证:见 `docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md`。没有浏览器登录态,不把发过一条对话或拖过本命盘写成已经在浏览器里做过。
|
||||
- 防复发:`frontend/tests/chat-chart-panel-state.test.ts` 锁住本机记忆、宽度夹取、手机无入口,以及页面不把可拖分栏和星盘绘制打进同步包。对话回归锁住流式、停止、排队、撤回、退点和寒暄免费。增长合同锁住 `Home()` 的 hook 数不升。白名单测试锁住名单只减不增。
|
||||
- 相关记录:issue #106。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K2。前置 #105 的外壳、#99 的启程动效。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-2-chat-20261023`。
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# PROGRESS · 组件库 K2 对话页(2026-10-25)
|
||||
|
||||
工单 #106。分支 `codex/ui-kit-2-chat-20261023`。基线 `origin/staging` `e4e240bf863b49e75bf9c7e1ba54b40cb1265f64`(#105 新侧栏和 #99 启程动效已在这个提交里)。只做任务书 **K2 对话页**。K0、K1 已完成,K3 及之后没做。
|
||||
|
||||
## 结论
|
||||
|
||||
输入框里左边选模型,右边发送。各模型的价格只写在菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,也能收起,刷新后还在。手机上看不到这个入口。发消息、停止、排队、撤回、退点和寒暄免费没有改。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 输入框改用组件库的组合输入。底边左边是模型菜单,只显示名字;「N 点」只在菜单项里。右边是圆形发送,和框的底边对齐。停止、排队召回和「内容由 AI 生成」仍在。模型菜单走外壳已经在用的延后载入:库还没到时,按钮上仍是当前模型名,点开还没有菜单。
|
||||
- 赞、踩、复制、重新生成是图标按钮,各有提示。不满意原因和兑换窗改用对话框,句子不改。追问是描边小按钮,仍用启程进场依次浮出(260ms,间隔 64ms),没有另写时长。跳到最新和今日星语入口改用组件库按钮,原来的尺寸类还在。
|
||||
- 当前会话、咨询阶段、停止中的标记、排队中的会话和请求、流式回答、赞踩和「已复制」从 `Home()` 移进已有的会话 store。消息列表本来就在会话列表里,跟着同一份 store。`Home()` 的 useState 从 33 减到 25。useRef 开工时是 37,做完仍是 37,没有新加。`page.tsx` 从 1419 行到 1450 行,粗护栏是 1567。`useSessionManagement` 仍在 `useRectificationSurface` 前面。
|
||||
- 桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560,默认 360。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。收起不把宽度写成 0。手机视口还没定、或小于 768 时不显示按钮,也不因为藏起来就把「开着」写成关掉。
|
||||
- 面板里是当前人物的本命盘,复用星盘页的绘制。只取本命分盘这一层。缓存里的盘必须是同一个人,否则重新取。没有骨架,也没有「正在加载」。不标星盘页那一次「画出来」的标记。可拖分栏和星盘绘制都在第一次打开后面才载入。外层对话网格没有改,里面用一列重复原来的行高,次级页面不包这一列。
|
||||
- 流式逐字仍在 `chat-message-row.tsx`,这个文件没有改。消息进场仍是已有的 Motion。扣点和落库没有改。
|
||||
- 白名单去掉 9 个已经不再违规的文件,没有加长,也没有改测试里写死的基线名单。
|
||||
- 没有改 `.gitea/**`,没有改生产部署,没有动数据库,没有给 `scripts/jyotish_api_server.py` 加方法。没有重做 #99 的动效时长。
|
||||
|
||||
## 断言改动(原值 / 新值 / 原因)
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 输入框 | `<Textarea` 直接放在表单里,并带 `ref={inputRef}` | `<InputGroupTextarea`,仍绑定同一份草稿和同一个 ref | 输入框改用组合输入。聚焦和草稿订阅不变 |
|
||||
| 跳到最新 | `<button` | `<Button`,仍是 `type="button"` | 改用组件库按钮。类名、焦点环和 44px 目标不变 |
|
||||
| 今日星语 | `<button className="starter-entry"` | `<Button className="starter-entry"` | 改用组件库按钮。36px 胶囊和 44px 点按区仍由原来的类决定 |
|
||||
| 兑换窗 | 自绘遮罩 `account-modal-overlay`,并用 `createPortal` | `Dialog`,内容类名仍是 `account-modal paywall-modal` | 改用组件库对话框。文案和这些类名留下 |
|
||||
| 不满意原因 | 文件顶部静态引入,只在挂载容器里找文案 | 先建好测试文档再动态引入,容器和 `document.body` 都找 | 对话框在模块加载时才决定把内容挂到 body。测试文档必须先就位。提交、原因顺序和补充说明仍在 |
|
||||
| 白名单 | 含首页和 8 个对话零件 | 这 9 个移出 | 它们已经改用组件库。名单只许变短 |
|
||||
|
||||
移出的 9 个文件:`page.tsx`、`chat-composer.tsx`、`chat-message-actions.tsx`、`conversation-follow-ups.tsx`、`jump-to-latest-button.tsx`、`model-selector.tsx`、`onboarding-redeem-paywall.tsx`、`reply-down-reason.tsx`、`starter-home.tsx`。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error |
|
||||
| 定向测试 | 在 `frontend` 下 `node --import tsx --test` 下面列出的文件 | 275 条,通过 275,失败 0,取消 0 |
|
||||
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 8.2s,TypeScript 17.3s。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
|
||||
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `e4e240bf`:36 个文件,缺失 0,634528 字节。本单:36 个文件,缺失 0,639127 字节,多 4599(+0.72%)。±2% 上限 647219 |
|
||||
|
||||
定向测试文件:`account-dialog-inert-20260918.test.ts`、`ai-generated-notice-20261014.test.ts`、`character-remaining-contract.test.ts`、`chat-chart-panel-state.test.ts`、`chat-composer-queue.test.ts`、`chat-message-actions.test.ts`、`chat-navigation-a11y-contract.test.ts`、`chat-stream-layout.test.ts`、`composer-isolation-contract.test.ts`、`consult-first-frame-and-pacing-20260928.test.tsx`、`consultation-entrypoint.test.ts`、`consultation-recovery.test.ts`、`consultation-smalltalk-ui.test.tsx`、`consultation-smalltalk.test.ts`、`home-shell-growth-contract.test.ts`、`home-streaming-render-split.test.ts`、`home-warm-return-lifecycle.test.tsx`、`membership-page.test.ts`、`onboarding-checklist-20261022.test.ts`、`rectification-agentic-entry.test.ts`、`rectification-grounding-regenerate-20260927.test.ts`、`reply-quality-20260930.test.tsx`、`session-conversation-layout.test.ts`、`session-list-lifecycle.test.tsx`、`starter-entry-soften.test.ts`、`stream-text-response.test.ts`、`ui-kit-whitelist.test.ts`。
|
||||
|
||||
`Home()` hook:开工 useState 33、useRef 37、`page.tsx` 1419 行。做完 useState 25、useRef 37、1450 行。上限是 useState 36、useRef 37、1567 行。
|
||||
|
||||
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 测试用的假文档补了 `documentElement`、`dataset`、`style.setProperty`、`requestAnimationFrame` 和 `children`。组件库对话框要这些才能把内容挂出来。产品页面没有为测试改行为。
|
||||
- 不满意原因的测试改成先建好文档再载入组件。同一份原因、补充说明和提交仍会发出去。
|
||||
- 第一次构建把模型菜单静态打进首页脚本,gzip-9 是 655070(38 个文件,+20542,+3.24%,超出 ±2%)。模型菜单改走外壳已有的延后载入后再构建。延后载入后的首屏里,`dropdown-menu`、`chat-chart-split`、`chat-natal-chart`、`react-resizable-panels` 都是 0。`ResizablePanelGroup` 与 `VedicChartSvg` 的首屏命中数和基线一样(1 和 2);多出来的绘制和分栏只在未写进首页 HTML 的脚本里。库还没到时,模型按钮上仍是当前模型名。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 没有登录态。没有在浏览器里发过一条对话,没有点过停止,没有拖过本命盘。本机记忆由测试读写 `chat_chart_panel_open` 和 `chat_chart_panel_width` 证明。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「对话」一节。
|
||||
@@ -21,3 +21,11 @@
|
||||
2. 顶栏点数是硬币图标加数字。读屏能听到「点」,眼睛看不到这个字。
|
||||
3. 设置里仍是个人资料、账户与点数、通用设置。退出登录要再确认一次。反馈与投诉仍能打开。
|
||||
4. 建完档的账户,右上角启程清单还在,点数按钮的左边。
|
||||
|
||||
## 对话
|
||||
|
||||
1. 输入框里,左边是模型名字,右边是圆形发送。点开模型,每一项旁边才有「N 点」。顶栏不再放模型选择。
|
||||
2. 发出一条消息,可以停止。停止后该退的点仍退。寒暄不扣点。回答被撤下时仍不扣点。
|
||||
3. 回答下面可以赞、踩、复制。点踩之后可以选原因,也可以什么都不选就提交。兑换窗的句子和以前一样。
|
||||
4. 桌面上,点数右边有一个打开本命盘的按钮。打开后是当前人物的本命盘。拖右缘,宽度停在大约 280 到 560 像素。收起后再打开,宽度还在。刷新页面,开着和宽度都还在。
|
||||
5. 把窗口改成大约 375 像素宽。顶栏没有打开本命盘的按钮。
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
|
||||
|
||||
## 对话页(2026-10-25)
|
||||
|
||||
输入框仍是一个框。框底左边是模型菜单,只显示模型名;价格「N 点」只在菜单里。菜单用外壳已有的延后载入,不进首屏;库还没到时按钮上仍是模型名。右边是圆形发送,和框的底边对齐。回答下面赞、踩、复制、重新生成是 26px 的图标按钮,各有一句提示。不满意原因和兑换窗用组件库对话框,句子不改。追问是描边小按钮,仍按启程进场依次浮出(260ms,间隔 64ms),不另写一套时长。
|
||||
|
||||
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。可拖分栏和星盘绘制都在第一次打开后面才载入,不进首屏。
|
||||
|
||||
## 新用户启程(2026-10-25)
|
||||
|
||||
首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[
|
||||
"src/app/(app)/page.tsx",
|
||||
"src/app/error.tsx",
|
||||
"src/app/global-error.tsx",
|
||||
"src/components/ayanamsa-preference-field.tsx",
|
||||
@@ -19,20 +18,14 @@
|
||||
"src/components/chart-page/chart-type-picker.tsx",
|
||||
"src/components/chart-page/chart-vedic-tab.tsx",
|
||||
"src/components/chart-profile-form.tsx",
|
||||
"src/components/chat-composer.tsx",
|
||||
"src/components/chat-message-actions.tsx",
|
||||
"src/components/consultation-run-timeline.tsx",
|
||||
"src/components/conversation-follow-ups.tsx",
|
||||
"src/components/email-otp-login.tsx",
|
||||
"src/components/ephemeris/ephemeris-page.tsx",
|
||||
"src/components/home-bootstrap-error.tsx",
|
||||
"src/components/home-onboarding-shell.tsx",
|
||||
"src/components/jump-to-latest-button.tsx",
|
||||
"src/components/legal/legal-consent-gate.tsx",
|
||||
"src/components/legal/login-consent.tsx",
|
||||
"src/components/markdown-code-block.tsx",
|
||||
"src/components/model-selector.tsx",
|
||||
"src/components/onboarding-redeem-paywall.tsx",
|
||||
"src/components/people/people-page.tsx",
|
||||
"src/components/person-relation-field.tsx",
|
||||
"src/components/personal-report/personal-report-center.tsx",
|
||||
@@ -48,8 +41,6 @@
|
||||
"src/components/rectification-composer-reopen.tsx",
|
||||
"src/components/rectification-range-delivery.tsx",
|
||||
"src/components/rectification-segment-delivery.tsx",
|
||||
"src/components/reply-down-reason.tsx",
|
||||
"src/components/starter-home.tsx",
|
||||
"src/components/synastry/synastry-add-dialog.tsx",
|
||||
"src/components/synastry/synastry-picker.tsx",
|
||||
"src/components/synastry/synastry-report-view.tsx"
|
||||
|
||||
@@ -29,12 +29,10 @@ import {
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
|
||||
import { declaredBirthTime } from "@/lib/rectification-surface-state";
|
||||
import {
|
||||
toggleChatMessageFeedback,
|
||||
type ChatMessageFeedback,
|
||||
} from "@/components/chat-message-actions";
|
||||
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
|
||||
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
|
||||
import { ChatProfilePicker } from "@/components/chat-profile-picker";
|
||||
import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame";
|
||||
import { ModelSelector } from "@/components/model-selector";
|
||||
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
|
||||
import { ChatComposer } from "@/components/chat-composer";
|
||||
@@ -47,6 +45,8 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
|
||||
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
|
||||
import { personRelationLabel } from "@/lib/person-relation";
|
||||
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
|
||||
@@ -146,7 +146,6 @@ import {
|
||||
type Profile,
|
||||
type ReplyOutcome,
|
||||
type RequestError,
|
||||
type StreamingReply,
|
||||
type Theme,
|
||||
} from "@/lib/home-types";
|
||||
import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
|
||||
@@ -225,17 +224,48 @@ export default function Home() {
|
||||
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>(() => warmStart()?.daily ?? { kind: "pending" });
|
||||
const [accountError, setAccountError] = useState("");
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => warmStart()?.modelCatalog ?? null);
|
||||
const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? "");
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => {
|
||||
const warm = warmStart();
|
||||
useSessionStore.getState().mountHomeConversation(warm?.landing.activeSessionId ?? "");
|
||||
return warm?.modelCatalog ?? null;
|
||||
});
|
||||
const {
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
consultationPhase,
|
||||
setConsultationPhase,
|
||||
cancellationPending,
|
||||
setCancellationPending,
|
||||
pendingSessionId,
|
||||
setPendingSessionId,
|
||||
pendingRequestId,
|
||||
setPendingRequestId,
|
||||
streamingReply,
|
||||
setStreamingReply,
|
||||
messageFeedback,
|
||||
setMessageFeedback,
|
||||
copiedMessageKey,
|
||||
setCopiedMessageKey,
|
||||
} = useSessionStore(useShallow((state) => ({
|
||||
activeSessionId: state.activeSessionId,
|
||||
setActiveSessionId: state.setActiveSessionId,
|
||||
consultationPhase: state.consultationPhase,
|
||||
setConsultationPhase: state.setConsultationPhase,
|
||||
cancellationPending: state.cancellationPending,
|
||||
setCancellationPending: state.setCancellationPending,
|
||||
pendingSessionId: state.pendingSessionId,
|
||||
setPendingSessionId: state.setPendingSessionId,
|
||||
pendingRequestId: state.pendingRequestId,
|
||||
setPendingRequestId: state.setPendingRequestId,
|
||||
streamingReply: state.streamingReply,
|
||||
setStreamingReply: state.setStreamingReply,
|
||||
messageFeedback: state.messageFeedback,
|
||||
setMessageFeedback: state.setMessageFeedback,
|
||||
copiedMessageKey: state.copiedMessageKey,
|
||||
setCopiedMessageKey: state.setCopiedMessageKey,
|
||||
})));
|
||||
const draftTheme = useRef<Theme | null>(null);
|
||||
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
|
||||
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
|
||||
const [cancellationPending, setCancellationPending] = useState(false);
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||||
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||||
@@ -1190,7 +1220,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatChartFrame>
|
||||
<DailyStarlanguageBinder
|
||||
accountId={accountId}
|
||||
bootstrapPhase={bootstrapPhase}
|
||||
@@ -1256,6 +1286,7 @@ export default function Home() {
|
||||
label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}
|
||||
onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })}
|
||||
/>
|
||||
<ChatChartToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1414,6 +1445,6 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ChatChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1022,6 +1022,15 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
it back. `clip` creates no scroll container, so it cannot happen again. */
|
||||
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
/* Home spans the outer rows with one frame, then repeats the same templates
|
||||
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
|
||||
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; }
|
||||
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; }
|
||||
.chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-natal-chart { height: 100%; min-width: 0; overflow: auto; padding: var(--space-3); }
|
||||
.chat-header-actions .chat-chart-toggle { width: 32px; height: 32px; }
|
||||
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
|
||||
.chat-header > div { min-width: 0; }
|
||||
/* One line, 46px tall. The chart chip stays beside the title and ellipsizes
|
||||
@@ -2343,6 +2352,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
|
||||
.conversation.is-empty {
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import { useUiStore } from "@/stores/ui-store";
|
||||
|
||||
type SplitComponent = (props: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
children: ReactNode;
|
||||
}) => ReactNode;
|
||||
|
||||
export function ChatChartToggle() {
|
||||
const { isMobile, ready } = useSidebar();
|
||||
const open = useUiStore((state) => state.chatChartOpen);
|
||||
const setOpen = useUiStore((state) => state.setChatChartOpen);
|
||||
if (!ready || isMobile) return null;
|
||||
const label = open ? "收起本命盘" : "打开本命盘";
|
||||
const Icon = open ? PanelRightClose : PanelRightOpen;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="chat-chart-toggle"
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell spanning every row of `.chat-panel`. The inner `.chat-column`
|
||||
* repeats today's row template so secondary pages can keep the outer grid.
|
||||
* The split (and the chart drawing) load the first time the panel opens.
|
||||
*/
|
||||
export function ChatChartFrame({ children }: { children: ReactNode }) {
|
||||
const { isMobile, ready } = useSidebar();
|
||||
const subjectId = useCurrentSubject();
|
||||
const open = useUiStore((state) => state.chatChartOpen);
|
||||
const width = useUiStore((state) => state.chatChartWidth);
|
||||
const hydrated = useUiStore((state) => state.chatChartHydrated);
|
||||
const hydrate = useUiStore((state) => state.hydrateChatChart);
|
||||
const setOpen = useUiStore((state) => state.setChatChartOpen);
|
||||
const setWidth = useUiStore((state) => state.setChatChartWidth);
|
||||
const [Split, setSplit] = useState<SplitComponent | null>(null);
|
||||
const desktop = ready && !isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktop || !hydrated || !open || Split) return;
|
||||
let cancelled = false;
|
||||
void import("@/components/chat-chart-split").then((mod) => {
|
||||
if (!cancelled) setSplit(() => mod.ChatChartSplit);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [Split, desktop, hydrated, open]);
|
||||
|
||||
const column = <div className="chat-column">{children}</div>;
|
||||
if (!Split || !desktop) {
|
||||
return <div className="chat-chart-frame">{column}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="chat-chart-frame">
|
||||
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth}>
|
||||
{column}
|
||||
</Split>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ChatNatalChart } from "@/components/chat-natal-chart";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-panel-state";
|
||||
|
||||
export function ChatChartSplit({
|
||||
open,
|
||||
width,
|
||||
subjectId,
|
||||
onOpenChange,
|
||||
onWidth,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
if (open && panel.isCollapsed()) panel.expand();
|
||||
if (!open && !panel.isCollapsed()) panel.collapse();
|
||||
}, [open]);
|
||||
|
||||
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
|
||||
if (!meta.isUserInteraction) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
if (panel.isCollapsed()) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(true);
|
||||
const pixels = panel.getSize().inPixels;
|
||||
if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels));
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel id="chat-column" defaultSize="100%">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="chat-chart-resize-handle" />
|
||||
<ResizablePanel
|
||||
id="chat-natal"
|
||||
panelRef={panelRef}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
minSize={CHAT_CHART_WIDTH_MIN}
|
||||
maxSize={CHAT_CHART_WIDTH_MAX}
|
||||
defaultSize={open ? width : 0}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
<ChatNatalChart subjectId={subjectId} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { useSyncExternalStore } from "react";
|
||||
import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupTextarea,
|
||||
} from "@/components/ui/input-group";
|
||||
import { CharacterRemaining } from "@/components/character-remaining";
|
||||
import { characterRemainingVisible } from "@/lib/character-remaining";
|
||||
import {
|
||||
@@ -47,8 +51,7 @@ type ChatComposerProps = {
|
||||
readonly queued?: { text: string; onRecall: () => void };
|
||||
/**
|
||||
* Controls that belong to the composer itself (model picker, chart subject).
|
||||
* They render on the composer's own bottom row; there is no longer a strip
|
||||
* below the composer holding a permanent 44px of chrome.
|
||||
* They sit on the left of the field's bottom row. The send button is on the right.
|
||||
*/
|
||||
readonly toolbar?: ReactNode;
|
||||
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
@@ -91,48 +94,50 @@ export function ChatComposer({
|
||||
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
|
||||
<p className="composer-queue__text">{queued.text}</p>
|
||||
</div>
|
||||
<button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
|
||||
<Button className="composer-queue__recall" type="button" variant="ghost" onClick={queued.onRecall}>
|
||||
{COMPOSER_QUEUE_RECALL_LABEL}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<form className="composer" onSubmit={onSubmit}>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
aria-label={inputLabel}
|
||||
aria-describedby={describedByIds}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
maxLength={maxLength}
|
||||
disabled={inputDisabled}
|
||||
value={draft}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div className="composer-row">
|
||||
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
|
||||
<div className="composer-row-end">
|
||||
{showRemaining ? (
|
||||
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
|
||||
) : null}
|
||||
{stopVisible ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label={stopLabel}
|
||||
title={stopTitle}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InputGroup className="border-0 bg-transparent shadow-none ring-0">
|
||||
<InputGroupTextarea
|
||||
ref={inputRef}
|
||||
aria-label={inputLabel}
|
||||
aria-describedby={describedByIds}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
maxLength={maxLength}
|
||||
disabled={inputDisabled}
|
||||
value={draft}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<InputGroupAddon align="block-end" className="composer-row">
|
||||
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
|
||||
<div className="composer-row-end">
|
||||
{showRemaining ? (
|
||||
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
|
||||
) : null}
|
||||
{stopVisible ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label={stopLabel}
|
||||
title={stopTitle}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</form>
|
||||
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export type ChatMessageFeedback = "up" | "down";
|
||||
|
||||
export function toggleChatMessageFeedback(
|
||||
@@ -11,6 +15,15 @@ export function toggleChatMessageFeedback(
|
||||
return current === requested ? undefined : requested;
|
||||
}
|
||||
|
||||
function ActionTip({ label, button }: { label: string; button: ReactElement }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessageActions({
|
||||
feedback,
|
||||
copied = false,
|
||||
@@ -29,39 +42,62 @@ export function ChatMessageActions({
|
||||
}>) {
|
||||
return (
|
||||
<div className="message-actions" aria-label="Agent 回答操作">
|
||||
<button
|
||||
aria-label="赞"
|
||||
aria-pressed={feedback === "up"}
|
||||
className={feedback === "up" ? "is-active" : ""}
|
||||
title="赞"
|
||||
type="button"
|
||||
onClick={() => onFeedback("up")}
|
||||
>
|
||||
<ThumbsUp aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="踩"
|
||||
aria-pressed={feedback === "down"}
|
||||
className={feedback === "down" ? "is-active" : ""}
|
||||
title="踩"
|
||||
type="button"
|
||||
onClick={() => onFeedback("down")}
|
||||
>
|
||||
<ThumbsDown aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="复制回答" title="复制" type="button" onClick={onCopy}>
|
||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
</button>
|
||||
<ActionTip
|
||||
label="赞"
|
||||
button={(
|
||||
<Button
|
||||
aria-label="赞"
|
||||
aria-pressed={feedback === "up"}
|
||||
className={feedback === "up" ? "is-active" : ""}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onFeedback("up")}
|
||||
>
|
||||
<ThumbsUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<ActionTip
|
||||
label="踩"
|
||||
button={(
|
||||
<Button
|
||||
aria-label="踩"
|
||||
aria-pressed={feedback === "down"}
|
||||
className={feedback === "down" ? "is-active" : ""}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onFeedback("down")}
|
||||
>
|
||||
<ThumbsDown aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<ActionTip
|
||||
label={copied ? "已复制" : "复制"}
|
||||
button={(
|
||||
<Button aria-label="复制回答" size="icon-sm" type="button" variant="ghost" onClick={onCopy}>
|
||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
aria-label="重新生成回答"
|
||||
disabled={!canRegenerate}
|
||||
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
</button>
|
||||
<ActionTip
|
||||
label={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
|
||||
button={(
|
||||
<Button
|
||||
aria-label="重新生成回答"
|
||||
disabled={!canRegenerate}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchChartView } from "@/lib/chart-view-client";
|
||||
import type { ChartViewResponse, NorthIndianChart } from "@/lib/chart-view-contract";
|
||||
import { peekChartPage } from "@/lib/secondary-page-data";
|
||||
|
||||
type ChartDrawing = typeof import("@/components/personal-report/vedic-chart-svg").VedicChartSvg;
|
||||
|
||||
function natalChart(view: ChartViewResponse | null, subjectId: string): NorthIndianChart | null {
|
||||
if (!view || view.status !== "ok" || view.subjectId !== subjectId) return null;
|
||||
const varga = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0];
|
||||
return varga?.chart ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current person's D1, drawn with the chart page's SVG. Loaded only after the
|
||||
* panel opens. No skeleton and no "正在加载": the region stays quiet until the
|
||||
* drawing is ready. Do not mark `data-onboarding-draw` here; that key belongs
|
||||
* to the chart page's first draw.
|
||||
*/
|
||||
export function ChatNatalChart({ subjectId }: { subjectId: string }) {
|
||||
const peeked = peekChartPage();
|
||||
const cached = peeked?.kind === "view" ? natalChart(peeked.view, subjectId) : null;
|
||||
const [remote, setRemote] = useState<{ subjectId: string; chart: NorthIndianChart | null } | null>(null);
|
||||
const [Drawing, setDrawing] = useState<ChartDrawing | null>(null);
|
||||
const chart = remote?.subjectId === subjectId ? remote.chart ?? cached : cached;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void import("@/components/personal-report/vedic-chart-svg").then((mod) => {
|
||||
if (!cancelled) setDrawing(() => mod.VedicChartSvg);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
const controller = new AbortController();
|
||||
void fetchChartView({ layers: ["varga"], signal: controller.signal, subjectId })
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setRemote({ subjectId, chart: natalChart(result.body, subjectId) });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, [cached, subjectId]);
|
||||
|
||||
return (
|
||||
<div className="chat-natal-chart">
|
||||
{chart && Drawing ? (
|
||||
<Drawing chart={chart} ariaLabel="本命盘" />
|
||||
) : (
|
||||
<div role="img" aria-label="本命盘" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConversationFollowUps({
|
||||
questions,
|
||||
disabled = false,
|
||||
@@ -13,9 +15,11 @@ export function ConversationFollowUps({
|
||||
return (
|
||||
<div className="conversation-follow-ups" aria-label="可以继续问" data-onboarding-anchor="follow-up">
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={question}
|
||||
className="conversation-follow-up"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-onboarding-press=""
|
||||
@@ -24,7 +28,7 @@ export function ConversationFollowUps({
|
||||
onClick={() => onSelect(question)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* The "back to the newest turn" control both chat surfaces show once the
|
||||
* reader has scrolled away from the bottom. It hangs off the composer's top
|
||||
@@ -11,15 +13,16 @@ import { ArrowDown } from "lucide-react";
|
||||
export function JumpToLatestButton({ onClick }: Readonly<{ onClick: () => void }>) {
|
||||
return (
|
||||
<div className="jump-to-latest">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="jump-to-latest__button"
|
||||
aria-label="跳到最新"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ArrowDown aria-hidden="true" />
|
||||
跳到最新
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu-lazy";
|
||||
|
||||
type ModelSelectorProps = {
|
||||
readonly models: readonly PublicLanguageModel[];
|
||||
@@ -19,69 +24,35 @@ export function ModelSelector({
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const groupName = useId();
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId);
|
||||
const unavailable = models.length === 0;
|
||||
|
||||
function moveRadioSelection(event: KeyboardEvent<HTMLInputElement>, index: number) {
|
||||
const step = event.key === "ArrowDown" || event.key === "ArrowRight"
|
||||
? 1
|
||||
: event.key === "ArrowUp" || event.key === "ArrowLeft"
|
||||
? -1
|
||||
: 0;
|
||||
if (step === 0 || models.length < 2) return;
|
||||
|
||||
event.preventDefault();
|
||||
const nextIndex = (index + step + models.length) % models.length;
|
||||
const nextModel = models[nextIndex];
|
||||
if (!nextModel) return;
|
||||
onSelect(nextModel.id);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="model-selector-trigger"
|
||||
type="button"
|
||||
disabled={disabled || unavailable}
|
||||
aria-label={selectedModel ? `当前模型:${selectedModel.label},点击切换` : "模型暂不可用"}
|
||||
>
|
||||
<b>{selectedModel?.label ?? "暂不可用"}</b>
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner className="model-selector-positioner" side="top" align="start" sideOffset={8} collisionPadding={12}>
|
||||
<Popover.Popup className="model-selector-popup">
|
||||
<Popover.Title className="model-selector-title">选择模型</Popover.Title>
|
||||
<fieldset className="model-selector-options">
|
||||
<legend className="sr-only">选择当前对话使用的模型</legend>
|
||||
{models.map((model, index) => (
|
||||
<label className="model-selector-option" data-selected={model.id === selectedModelId ? "" : undefined} key={model.id}>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={model.id}
|
||||
checked={model.id === selectedModelId}
|
||||
onChange={() => onSelect(model.id)}
|
||||
onClick={() => {
|
||||
if (model.id === selectedModelId) onSelect(model.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(event) => moveRadioSelection(event, index)}
|
||||
/>
|
||||
<span className="model-selector-copy">
|
||||
<b>{model.label}</b>
|
||||
</span>
|
||||
<Check className="model-selector-check" aria-hidden="true" />
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="model-selector-popup" side="top" align="start" sideOffset={8}>
|
||||
<DropdownMenuLabel className="model-selector-title">选择模型</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedModelId}
|
||||
onValueChange={(modelId) => onSelect(modelId)}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<DropdownMenuRadioItem className="model-selector-option" key={model.id} value={model.id}>
|
||||
<span className="model-selector-copy">
|
||||
<b>{model.label}</b>
|
||||
<small>{model.creditCost} 点</small>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import { Gift, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
|
||||
import { notifyError, notifySuccess } from "@/lib/notify";
|
||||
|
||||
@@ -70,24 +75,18 @@ export function OnboardingRedeemPaywall({
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = (
|
||||
<div className="account-modal-overlay" onMouseDown={close}>
|
||||
<section
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => { if (!next) close(); }}>
|
||||
<DialogContent
|
||||
className="account-modal paywall-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
showCloseButton={false}
|
||||
aria-labelledby="onboarding-paywall-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") close();
|
||||
else keepFocusWithin(event.nativeEvent, event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<header className="account-modal-header">
|
||||
<h2 id="onboarding-paywall-title">解锁完整咨询</h2>
|
||||
<button className="dialog-close" aria-label="关闭" type="button" onClick={close} disabled={pending}>
|
||||
<DialogTitle id="onboarding-paywall-title">解锁完整咨询</DialogTitle>
|
||||
<Button className="dialog-close" aria-label="关闭" type="button" variant="ghost" size="icon-sm" onClick={close} disabled={pending}>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="paywall-intro">
|
||||
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
|
||||
@@ -100,7 +99,7 @@ export function OnboardingRedeemPaywall({
|
||||
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
|
||||
<label htmlFor="onboarding-redeem-code">兑换码</label>
|
||||
<div>
|
||||
<input
|
||||
<Input
|
||||
id="onboarding-redeem-code"
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
@@ -111,22 +110,20 @@ export function OnboardingRedeemPaywall({
|
||||
}}
|
||||
placeholder="输入完整兑换码"
|
||||
/>
|
||||
<button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
|
||||
<Button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
|
||||
{pending ? "兑换中" : unlocked ? "已解锁" : "兑换并继续"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="paywall-footer">
|
||||
<span>{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"}</span>
|
||||
{unlocked ? (
|
||||
<button className="button-primary" type="button" onClick={close}>开始咨询</button>
|
||||
<Button className="button-primary" type="button" onClick={close}>开始咨询</Button>
|
||||
) : (
|
||||
<button className="button-secondary" type="button" onClick={onOpenBilling}>查看套餐与会员</button>
|
||||
<Button className="button-secondary" type="button" variant="outline" onClick={onOpenBilling}>查看套餐与会员</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
if (typeof document === "undefined") return overlay;
|
||||
return createPortal(overlay, document.body);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
|
||||
import { notifyError, notifySuccess } from "@/lib/notify";
|
||||
import { saveReplyQualityReason } from "@/lib/reply-ratings";
|
||||
@@ -34,42 +41,44 @@ export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reply-down-reason" role="group" aria-label="不满意原因">
|
||||
<div className="reply-down-reason-head">
|
||||
<span>哪里不满意?可以不选</span>
|
||||
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
|
||||
</div>
|
||||
<div className="reply-down-reason-chips">
|
||||
{REASONS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={picked.has(key)}
|
||||
onClick={() => setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="reply-down-reason-note"
|
||||
type="text"
|
||||
value={note}
|
||||
maxLength={REPLY_QUALITY_NOTE_MAX}
|
||||
placeholder="补充一句(可不填)"
|
||||
aria-label="补充说明"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="reply-down-reason-foot">
|
||||
<span>会把这一轮对话发给我们排查</span>
|
||||
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
|
||||
{state === "failed" ? "再试一次" : "提交"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||
<DialogContent className="reply-down-reason" showCloseButton={false} aria-label="不满意原因">
|
||||
<DialogTitle>哪里不满意?可以不选</DialogTitle>
|
||||
<div className="reply-down-reason-chips">
|
||||
{REASONS.map(([key, label]) => (
|
||||
<Toggle
|
||||
key={key}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={picked.has(key)}
|
||||
aria-pressed={picked.has(key)}
|
||||
onPressedChange={() => setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</Toggle>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="reply-down-reason-note"
|
||||
type="text"
|
||||
value={note}
|
||||
maxLength={REPLY_QUALITY_NOTE_MAX}
|
||||
placeholder="补充一句(可不填)"
|
||||
aria-label="补充说明"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="reply-down-reason-foot">
|
||||
<DialogDescription>会把这一轮对话发给我们排查</DialogDescription>
|
||||
<Button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
|
||||
{state === "failed" ? "再试一次" : "提交"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { MoonStar } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* The empty state is split across two slots on purpose.
|
||||
*
|
||||
@@ -65,8 +67,9 @@ export function StarterEntries({
|
||||
return (
|
||||
<div className="starter-entries">
|
||||
<div className="starter-entry-row">
|
||||
<button
|
||||
<Button
|
||||
className="starter-entry"
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
|
||||
disabled={productEntrypointsDisabled}
|
||||
@@ -74,7 +77,7 @@ export function StarterEntries({
|
||||
>
|
||||
<MoonStar aria-hidden="true" />
|
||||
<span>{dailyLabel}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{!natalMinuteAvailable && (
|
||||
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Desktop chat natal panel. localStorage, same reason as the sidebar width:
|
||||
* `/` is static, so a cookie read on the server would drop that.
|
||||
* The phone has no panel, and hiding it must not write `open` false.
|
||||
*/
|
||||
|
||||
export const CHAT_CHART_OPEN_STORAGE_KEY = "chat_chart_panel_open";
|
||||
export const CHAT_CHART_WIDTH_STORAGE_KEY = "chat_chart_panel_width";
|
||||
export const CHAT_CHART_WIDTH_MIN = 280;
|
||||
export const CHAT_CHART_WIDTH_MAX = 560;
|
||||
export const CHAT_CHART_WIDTH_DEFAULT = 360;
|
||||
|
||||
export type ChatChartStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function chatChartStorage(): ChatChartStorage | null {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
return localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clampChatChartWidth(width: number): number {
|
||||
if (!Number.isFinite(width)) return CHAT_CHART_WIDTH_DEFAULT;
|
||||
return Math.min(CHAT_CHART_WIDTH_MAX, Math.max(CHAT_CHART_WIDTH_MIN, Math.round(width)));
|
||||
}
|
||||
|
||||
export function readStoredChatChartOpen(
|
||||
storage: ChatChartStorage | null = chatChartStorage(),
|
||||
): boolean {
|
||||
if (storage === null) return false;
|
||||
try {
|
||||
return storage.getItem(CHAT_CHART_OPEN_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredChatChartWidth(
|
||||
storage: ChatChartStorage | null = chatChartStorage(),
|
||||
): number {
|
||||
if (storage === null) return CHAT_CHART_WIDTH_DEFAULT;
|
||||
try {
|
||||
const raw = storage.getItem(CHAT_CHART_WIDTH_STORAGE_KEY);
|
||||
if (raw === null || raw === "") return CHAT_CHART_WIDTH_DEFAULT;
|
||||
return clampChatChartWidth(Number(raw));
|
||||
} catch {
|
||||
return CHAT_CHART_WIDTH_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredChatChartOpen(
|
||||
open: boolean,
|
||||
storage: ChatChartStorage | null = chatChartStorage(),
|
||||
): void {
|
||||
if (storage === null) return;
|
||||
try {
|
||||
storage.setItem(CHAT_CHART_OPEN_STORAGE_KEY, open ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredChatChartWidth(
|
||||
width: number,
|
||||
storage: ChatChartStorage | null = chatChartStorage(),
|
||||
): void {
|
||||
if (storage === null) return;
|
||||
try {
|
||||
storage.setItem(CHAT_CHART_WIDTH_STORAGE_KEY, String(clampChatChartWidth(width)));
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallbac
|
||||
import { isListedSidebarSession } from "@/lib/session-list-filter";
|
||||
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
|
||||
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type SessionListBoot = {
|
||||
readonly sessions: ChatSession[];
|
||||
@@ -170,7 +171,10 @@ export async function loadSessionList(signal: AbortSignal, forceCatalog = false)
|
||||
}
|
||||
|
||||
export function SessionListProvider({ children }: { children: ReactNode }) {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [listOwner] = useState(() => Symbol("session-list"));
|
||||
useSessionStore.getState().claimSessionList(listOwner);
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const setSessions = useSessionStore((state) => state.setSessions);
|
||||
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
|
||||
@@ -1,12 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
/** Skeleton. Conversation state moves here in a later issue, not in K0. */
|
||||
import type { ChatMessageFeedback } from "@/components/chat-message-actions";
|
||||
import type { ChatSession, StreamingReply } from "@/lib/home-types";
|
||||
|
||||
export type ConsultationPhase = "undo" | "streaming" | "recovering" | null;
|
||||
|
||||
function apply<T>(current: T, update: SetStateAction<T>): T {
|
||||
return typeof update === "function" ? (update as (value: T) => T)(current) : update;
|
||||
}
|
||||
|
||||
type SessionState = {
|
||||
placeholder: true;
|
||||
sessions: ChatSession[];
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
activeSessionId: string;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
consultationPhase: ConsultationPhase;
|
||||
setConsultationPhase: Dispatch<SetStateAction<ConsultationPhase>>;
|
||||
cancellationPending: boolean;
|
||||
setCancellationPending: Dispatch<SetStateAction<boolean>>;
|
||||
pendingSessionId: string | null;
|
||||
setPendingSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
pendingRequestId: string | null;
|
||||
setPendingRequestId: Dispatch<SetStateAction<string | null>>;
|
||||
streamingReply: StreamingReply | null;
|
||||
setStreamingReply: Dispatch<SetStateAction<StreamingReply | null>>;
|
||||
messageFeedback: Record<string, ChatMessageFeedback>;
|
||||
setMessageFeedback: Dispatch<SetStateAction<Record<string, ChatMessageFeedback>>>;
|
||||
copiedMessageKey: string | null;
|
||||
setCopiedMessageKey: Dispatch<SetStateAction<string | null>>;
|
||||
warmSeeded: boolean;
|
||||
/** Reset the fields Home() used to own, once per Home mount. Sessions stay. */
|
||||
mountHomeConversation: (activeSessionId: string) => void;
|
||||
/** Reset sessions and the conversation fields when a new list provider mounts. */
|
||||
claimSessionList: (owner: symbol) => void;
|
||||
};
|
||||
|
||||
export const useSessionStore = create<SessionState>(() => ({
|
||||
const emptyConversation = {
|
||||
activeSessionId: "",
|
||||
consultationPhase: null as ConsultationPhase,
|
||||
cancellationPending: false,
|
||||
pendingSessionId: null as string | null,
|
||||
pendingRequestId: null as string | null,
|
||||
streamingReply: null as StreamingReply | null,
|
||||
copiedMessageKey: null as string | null,
|
||||
warmSeeded: false,
|
||||
};
|
||||
|
||||
let claimedOwner: symbol | null = null;
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
placeholder: true,
|
||||
sessions: [],
|
||||
...emptyConversation,
|
||||
messageFeedback: {},
|
||||
setSessions: (update) => set((state) => ({ sessions: apply(state.sessions, update) })),
|
||||
setActiveSessionId: (update) => set((state) => ({ activeSessionId: apply(state.activeSessionId, update) })),
|
||||
setConsultationPhase: (update) => set((state) => ({ consultationPhase: apply(state.consultationPhase, update) })),
|
||||
setCancellationPending: (update) => set((state) => ({ cancellationPending: apply(state.cancellationPending, update) })),
|
||||
setPendingSessionId: (update) => set((state) => ({ pendingSessionId: apply(state.pendingSessionId, update) })),
|
||||
setPendingRequestId: (update) => set((state) => ({ pendingRequestId: apply(state.pendingRequestId, update) })),
|
||||
setStreamingReply: (update) => set((state) => ({ streamingReply: apply(state.streamingReply, update) })),
|
||||
setMessageFeedback: (update) => set((state) => ({ messageFeedback: apply(state.messageFeedback, update) })),
|
||||
setCopiedMessageKey: (update) => set((state) => ({ copiedMessageKey: apply(state.copiedMessageKey, update) })),
|
||||
mountHomeConversation: (activeSessionId) => set({
|
||||
...emptyConversation,
|
||||
activeSessionId,
|
||||
messageFeedback: {},
|
||||
warmSeeded: true,
|
||||
}),
|
||||
claimSessionList: (owner) => {
|
||||
if (claimedOwner === owner) return;
|
||||
claimedOwner = owner;
|
||||
set({
|
||||
sessions: [],
|
||||
...emptyConversation,
|
||||
messageFeedback: {},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,19 +2,33 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import {
|
||||
CHAT_CHART_WIDTH_DEFAULT,
|
||||
clampChatChartWidth,
|
||||
readStoredChatChartOpen,
|
||||
readStoredChatChartWidth,
|
||||
writeStoredChatChartOpen,
|
||||
writeStoredChatChartWidth,
|
||||
} from "@/lib/chat-chart-panel-state";
|
||||
import {
|
||||
readStoredSidebarWidth,
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
writeStoredSidebarWidth,
|
||||
} from "@/lib/sidebar-state";
|
||||
|
||||
/** Skeleton plus the desktop sidebar width. Home() does not read this store. */
|
||||
/** Skeleton plus desktop sidebar width and the chat natal panel. */
|
||||
type UiState = {
|
||||
placeholder: true;
|
||||
sidebarWidth: number;
|
||||
sidebarWidthHydrated: boolean;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
hydrateSidebarWidth: () => void;
|
||||
chatChartOpen: boolean;
|
||||
chatChartWidth: number;
|
||||
chatChartHydrated: boolean;
|
||||
setChatChartOpen: (open: boolean) => void;
|
||||
setChatChartWidth: (width: number) => void;
|
||||
hydrateChatChart: () => void;
|
||||
};
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
@@ -32,4 +46,23 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
sidebarWidthHydrated: true,
|
||||
});
|
||||
},
|
||||
chatChartOpen: false,
|
||||
chatChartWidth: CHAT_CHART_WIDTH_DEFAULT,
|
||||
chatChartHydrated: false,
|
||||
setChatChartOpen: (open) => {
|
||||
writeStoredChatChartOpen(open);
|
||||
set({ chatChartOpen: open, chatChartHydrated: true });
|
||||
},
|
||||
setChatChartWidth: (width) => {
|
||||
const next = clampChatChartWidth(width);
|
||||
writeStoredChatChartWidth(next);
|
||||
set({ chatChartWidth: next, chatChartHydrated: true });
|
||||
},
|
||||
hydrateChatChart: () => {
|
||||
set({
|
||||
chatChartOpen: readStoredChatChartOpen(),
|
||||
chatChartWidth: readStoredChatChartWidth(),
|
||||
chatChartHydrated: true,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -66,7 +66,11 @@ test("the account dialog and onboarding paywall portal to document.body, outside
|
||||
assert.match(overlay, /<AlertDialog open/);
|
||||
assert.match(read("../src/components/ui/alert-dialog.tsx"), /AlertDialogPrimitive\.Portal/);
|
||||
assert.match(read("../src/components/ui/dialog.tsx"), /DialogPrimitive\.Portal/);
|
||||
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
|
||||
// 原值:兑换窗 `createPortal(overlay, document.body)`
|
||||
// 新值:兑换窗用 Dialog,传送由 DialogPrimitive.Portal 完成
|
||||
// 原因:K2 兑换窗改用组件库 Dialog,仍挂到 body,不留在会被 inert 的页面里
|
||||
assert.match(paywall, /<Dialog open/);
|
||||
assert.doesNotMatch(paywall, /createPortal\(/);
|
||||
|
||||
// 原值:renderToString 得到 role="dialog",且没有 inert
|
||||
// 新值:注销走 AlertDialog,组件库根把角色写成 alert-dialog;页面源码不写 inert
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CHAT_CHART_OPEN_STORAGE_KEY,
|
||||
CHAT_CHART_WIDTH_DEFAULT,
|
||||
CHAT_CHART_WIDTH_MAX,
|
||||
CHAT_CHART_WIDTH_MIN,
|
||||
CHAT_CHART_WIDTH_STORAGE_KEY,
|
||||
clampChatChartWidth,
|
||||
readStoredChatChartOpen,
|
||||
readStoredChatChartWidth,
|
||||
writeStoredChatChartOpen,
|
||||
writeStoredChatChartWidth,
|
||||
type ChatChartStorage,
|
||||
} from "../src/lib/chat-chart-panel-state.ts";
|
||||
|
||||
function memoryStorage(seed: Record<string, string> = {}): ChatChartStorage & { values: Map<string, string> } {
|
||||
const values = new Map(Object.entries(seed));
|
||||
return {
|
||||
values,
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => { values.set(key, String(value)); },
|
||||
};
|
||||
}
|
||||
|
||||
test("chat natal panel remembers open and width on this machine", () => {
|
||||
const storage = memoryStorage();
|
||||
assert.equal(readStoredChatChartOpen(storage), false);
|
||||
assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT);
|
||||
|
||||
writeStoredChatChartOpen(true, storage);
|
||||
writeStoredChatChartWidth(480, storage);
|
||||
assert.equal(storage.values.get(CHAT_CHART_OPEN_STORAGE_KEY), "1");
|
||||
assert.equal(readStoredChatChartOpen(storage), true);
|
||||
assert.equal(readStoredChatChartWidth(storage), 480);
|
||||
|
||||
writeStoredChatChartOpen(false, storage);
|
||||
assert.equal(readStoredChatChartOpen(storage), false);
|
||||
assert.equal(readStoredChatChartWidth(storage), 480);
|
||||
});
|
||||
|
||||
test("chat natal panel width stays between 280 and 560", () => {
|
||||
assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN);
|
||||
assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX);
|
||||
assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT);
|
||||
const storage = memoryStorage();
|
||||
writeStoredChatChartWidth(12, storage);
|
||||
assert.equal(storage.values.get(CHAT_CHART_WIDTH_STORAGE_KEY), String(CHAT_CHART_WIDTH_MIN));
|
||||
assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT);
|
||||
});
|
||||
|
||||
test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => {
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
|
||||
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /<ChatChartToggle \/>/);
|
||||
assert.match(page, /<ChatChartFrame>/);
|
||||
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
|
||||
assert.match(frame, /打开本命盘/);
|
||||
assert.match(frame, /收起本命盘/);
|
||||
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
|
||||
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
|
||||
assert.match(split, /ResizablePanelGroup/);
|
||||
assert.match(split, /collapsible/);
|
||||
assert.doesNotMatch(split, /data-onboarding-draw/);
|
||||
});
|
||||
@@ -285,13 +285,16 @@ test("the jump-to-latest control stays keyboard reachable and labelled", () => {
|
||||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
|
||||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||||
const buttonTag = sourceBetween(jumpSource, "<button", ">");
|
||||
// 原值:`<button`
|
||||
// 新值:`<Button`,仍是 type="button"
|
||||
// 原因:K2「跳到最新」改用组件库 Button,类名、焦点环和 44px 目标不变
|
||||
const buttonTag = sourceBetween(jumpSource, "<Button", ">");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||||
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
||||
|
||||
// Given: a real button in document order, never removed from the tab sequence.
|
||||
assert.match(jumpSource, /<button\b/);
|
||||
assert.match(jumpSource, /<Button\b/);
|
||||
assert.match(buttonTag, /type="button"/);
|
||||
assert.doesNotMatch(buttonTag, /tabIndex/);
|
||||
assert.doesNotMatch(buttonTag, /aria-hidden/);
|
||||
|
||||
@@ -64,7 +64,10 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
|
||||
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
|
||||
|
||||
// Then: only the composer subtree reads the value that changes on every keystroke.
|
||||
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
|
||||
// 原值:`<Textarea` 直接放在表单里
|
||||
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
|
||||
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
|
||||
assert.match(composerSource, /<InputGroupTextarea[\s\S]*?value=\{draft\}/);
|
||||
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
|
||||
});
|
||||
|
||||
@@ -78,7 +81,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
|
||||
// When: the page still focuses the same textarea element through its own ref.
|
||||
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
||||
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
||||
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
||||
// 原值:`<Textarea ref={inputRef}`
|
||||
// 新值:`<InputGroupTextarea ref={inputRef}`
|
||||
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
|
||||
assert.match(composerSource, /<InputGroupTextarea\n\s*ref=\{inputRef\}/);
|
||||
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
|
||||
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
|
||||
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
|
||||
|
||||
@@ -64,10 +64,13 @@ test("the redeem paywall opens on the first send refused for credits, not when t
|
||||
});
|
||||
|
||||
test("the onboarding paywall reuses the shared account dialog header", () => {
|
||||
// 原值:自绘遮罩 `account-modal-overlay`
|
||||
// 新值:`DialogContent` 使用 `account-modal paywall-modal`,标题仍在 `account-modal-header`
|
||||
// 原因:K2 兑换窗改用组件库 Dialog,文案和这些类名留下
|
||||
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
|
||||
assert.match(onboardingPaywallSource, /<\/header>/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /className="account-modal-overlay"/);
|
||||
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
|
||||
assert.match(onboardingPaywallSource, /className="dialog-close"/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ class HostElement {
|
||||
readonly nodeType = 1;
|
||||
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
|
||||
readonly childNodes: HostElement[] = [];
|
||||
readonly style = {};
|
||||
readonly style = {
|
||||
setProperty() {},
|
||||
getPropertyValue() { return ""; },
|
||||
removeProperty() { return ""; },
|
||||
};
|
||||
open = false;
|
||||
get disabled() { return this.hasAttribute("disabled"); }
|
||||
isConnected = true;
|
||||
@@ -22,6 +26,8 @@ class HostElement {
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
clientWidth = 1440;
|
||||
offsetWidth = 1440;
|
||||
readonly scrollCalls: { top?: number }[] = [];
|
||||
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
|
||||
/** No layout here: every box is the origin. Components that position from rects still run. */
|
||||
@@ -33,10 +39,17 @@ class HostElement {
|
||||
if (wanted.every(name => classes.includes(name))) return this;
|
||||
return this.parentNode?.closest(selector) ?? null;
|
||||
}
|
||||
get parentElement() { return this.parentNode; }
|
||||
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
|
||||
matches() { return false; }
|
||||
querySelector() { return null; }
|
||||
querySelectorAll() { return []; }
|
||||
getRootNode() { return this.ownerDocument; }
|
||||
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
|
||||
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
|
||||
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
||||
readonly attributes = new Map<string, string>();
|
||||
readonly dataset: Record<string, string> = {};
|
||||
parentNode: HostElement | null = null;
|
||||
textContent = "";
|
||||
get nodeValue() { return this.textContent; }
|
||||
@@ -68,9 +81,12 @@ class HostElement {
|
||||
class HostDocument {
|
||||
readonly nodeType = 9;
|
||||
readonly body: HostElement;
|
||||
readonly documentElement: HostElement;
|
||||
activeElement: HostElement | null = null;
|
||||
constructor() {
|
||||
this.documentElement = new HostElement("HTML", this);
|
||||
this.body = new HostElement("BODY", this);
|
||||
this.documentElement.appendChild(this.body);
|
||||
}
|
||||
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
|
||||
getElementById() { return null; }
|
||||
@@ -85,6 +101,12 @@ export function createClientLifecycleHarness() {
|
||||
const document = new HostDocument();
|
||||
const frames = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
let frameId = 0;
|
||||
const requestAnimationFrame = (callback: () => void) => {
|
||||
const id = ++frameId;
|
||||
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
|
||||
const window = {
|
||||
document,
|
||||
innerWidth: 1440,
|
||||
@@ -92,21 +114,19 @@ export function createClientLifecycleHarness() {
|
||||
clearTimeout,
|
||||
getComputedStyle: () => ({ direction: "ltr" }),
|
||||
HTMLElement: HostElement,
|
||||
Element: HostElement,
|
||||
Node: HostElement,
|
||||
HTMLIFrameElement: class {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
requestAnimationFrame(callback: () => void) {
|
||||
const id = ++frameId;
|
||||
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); },
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
};
|
||||
document.defaultView = window;
|
||||
// tsx's ESM parser binds its runtime to global `self` when that binding
|
||||
// exists. A partial window makes `cn` fail to parse. Next still calls
|
||||
// `self.setTimeout`, so `self` is the real global, which already has it.
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
|
||||
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
|
||||
key, Object.getOwnPropertyDescriptor(globalThis, key),
|
||||
]));
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ReplyDownReason } from "../src/components/reply-down-reason";
|
||||
import { PRIVACY } from "../src/lib/legal-documents";
|
||||
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
|
||||
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
|
||||
@@ -141,7 +140,13 @@ test("the privacy draft says what a 👎 keeps, why, for how long, and that 注
|
||||
});
|
||||
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
|
||||
// 原值:文件顶部静态引入 ReplyDownReason,只在挂载容器里找文案
|
||||
// 新值:先建好测试文档再动态引入,并同时查找 document.body
|
||||
// 原因:组件库 Dialog 在模块加载时才决定把内容挂到 body;测试文档必须先就位
|
||||
const loadReason = () => import("../src/components/reply-down-reason");
|
||||
const visibleNodes = (h: Harness) => [...h.elements(), ...h.elements(h.document.body)];
|
||||
const visibleText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
|
||||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => visibleNodes(h).find(predicate);
|
||||
|
||||
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
||||
@@ -158,10 +163,11 @@ test("不满意原因 is optional: submitting with nothing picked still thanks t
|
||||
try {
|
||||
const key = `${SESSION}:message-5`;
|
||||
void persistReplyRating(key, "down", "第三答:注意作息。");
|
||||
const { ReplyDownReason } = await loadReason();
|
||||
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
|
||||
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
|
||||
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
|
||||
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
|
||||
assert.ok(visibleText(h).includes("哪里不满意?可以不选"));
|
||||
assert.ok(visibleText(h).includes("会把这一轮对话发给我们排查"));
|
||||
assert.deepEqual(visibleNodes(h).filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
|
||||
await h.idle();
|
||||
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
|
||||
@@ -188,6 +194,7 @@ test("picked reasons and the note are sent; an unsaved conversation sends nothin
|
||||
}) as typeof fetch;
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
const { ReplyDownReason } = await loadReason();
|
||||
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
|
||||
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
|
||||
|
||||
@@ -13,8 +13,11 @@ function starterEntryBlock(): string {
|
||||
return css.slice(start, end);
|
||||
}
|
||||
|
||||
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
|
||||
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
|
||||
test("starter entries stay a 36px pill with a 44px hit area", () => {
|
||||
// 原值:`<button className="starter-entry"`
|
||||
// 新值:`<Button className="starter-entry"`
|
||||
// 原因:K2 今日星语改用组件库 Button,36px 胶囊和 44px 点按区仍由 `.starter-entry` 决定
|
||||
assert.match(starterHome, /<Button[\s\S]*className="starter-entry"/);
|
||||
// 原值: 2 新值: 1(生时校正 pill 删除) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
|
||||
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 1);
|
||||
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);
|
||||
|
||||
Reference in New Issue
Block a user