Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435c8e19f9 | ||
|
|
537beb2934 | ||
|
|
2dea3c2994 | ||
|
|
532a18ccac | ||
|
|
fd34df9b65 | ||
|
|
88a03808a9 |
@@ -1,5 +1,23 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-29 — 咨询回答快约三分之一
|
||||
|
||||
- 咨询里写回答那一步,模型的思考量默认改为「低」:对比测试里思考量少约一半,一轮回答从约 34 秒降到约 22 秒,回答质量逐份并排读过没有变差(issue #134,产品选 A)。
|
||||
- 只影响写回答那一步;排盘、查资料那几步不变。
|
||||
- 需要退回时,在服务器设 `CONSULT_ANSWER_REASONING_EFFORT=off` 即恢复原来的请求。
|
||||
|
||||
## 2026-10-28 — 切换人物时看得出是谁的盘
|
||||
|
||||
- 「当前星盘」按钮上写名字、出生日期时间和城市;名单每行写关系和出生时间、城市。
|
||||
- 每一行右边「⋯」可以直接编辑这个人,或删除别人(确认框和星盘档案一样);底部「新增一个人」直接打开新增表单。
|
||||
- 对话右边的盘面顶部也能切换,规则和对话顶栏一样:已经说过话再换人会新开一个对话。
|
||||
- Skill 版本不变(issue #150)。
|
||||
|
||||
## 2026-10-28 — 生时校正复述不再把日期说两遍
|
||||
|
||||
- 说「2016 年 9 月上大学」这类自带日期的事,回复以前会写成「2016-09 2016 年 9 月上大学」,现在只说一遍。
|
||||
- Skill 版本不变(BUG-1496,issue #161)。
|
||||
|
||||
## 2026-10-28 — 生时校正对话说得更清楚
|
||||
|
||||
- 开场那句会说明是按什么时间开始找的(出生记录、你填的大概时间,或你给的时间段)。
|
||||
|
||||
@@ -20447,3 +20447,33 @@
|
||||
- 关联:BUG-1494。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
|
||||
|
||||
## BUG-1496 | 生时校正复述把日期说了两遍(「2016-09 2016 年 9 月上大学」)
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署后 staging 复测)
|
||||
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #161;issue #148 验收时在 staging 实测发现)
|
||||
- 影响面:`frontend/src/lib/rectification-agentic/v9/host-fallback.ts::evidenceRecapLine`。所有证据轮复述都经过它:本轮入账复述(`acceptedRecapLines`)、每条回复的入账清单(`recordedEvidenceByTurn`)、收束卡「我说错了,改一条」的事件列表(`fixableEvidenceLines`)。
|
||||
- 现象:用户说「2016 年 9 月上大学。」,回复「记下了:2016-09 2016 年 9 月上大学。」
|
||||
- 触发条件:事件说法里自带日期,且年份和「年」之间有空格,或写成 ISO / 斜杠日期。
|
||||
- 根因:拼复述时,判断「事件说法已带日期」的规则只认不带空格的 `\d{4}年`(BUG-1136 加的拼接规则),漏掉「2016 年」「2016-09」「2016/9」,于是又在前面加了一次日期标签。
|
||||
- 修复:改为识别 `\d{4}\s*年` 或 `\d{4}[-/.]\d{1,2}`;事件说法里有这样的日期就只用事件说法,没带日期的照旧加标签。
|
||||
- 验证:`tests/rectification-host-fallback.test.ts` 新增「the recap does not repeat a date the event phrase already carries」,修复前红、修复后绿;覆盖带空格年份、不带空格、ISO、斜杠四种写法,以及不带日期、中文标签、非年份数字(「600 分」)三种不变的情况。`rectification-message-cleanup-20261001`、`rectification-grounding-20260927`、`rectification-event-date-bounds-20261002` 同时通过。
|
||||
- 防复发:上面那条测试固定了「带空格的年份」这一例。拼复述只在 `evidenceRecapLine` 一处做。
|
||||
- 关联:BUG-1136。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rectification-recap-date-dup-20261028`。
|
||||
|
||||
## BUG-1498 | staging 门禁 Python 快速门失败:`test_daily_starlanguage_does_not_eat_yesterday_cache` 网关调用 0 次
|
||||
|
||||
- 状态:resolved(测试隔离 + 回归测试;以修复后第一轮 staging 门禁通过为准)
|
||||
- 首次发现 / 最近更新:2026-10-28(BUG-1481 验证段首次提到)/ 2026-10-28(staging 门禁 run 2017、2018 连续两次失败)
|
||||
- 影响面:只影响 CI 门禁,不影响线上。失败的是 `tests/test_vedastro_snapshot_cache.py::test_daily_starlanguage_does_not_eat_yesterday_cache`;同样的机制可能打到任何用前台 VedAstro 线程池的测试。
|
||||
- 现象:`assert calls["gateway"] == 1` 得到 `0 == 1`;同一轮其余 1094 项通过。run 2016 这一项通过,run 2017、2018 失败,三次 Python 代码相同;本机单独跑或整套跑都通过。
|
||||
- 触发条件:整套测试一起跑,前面某个测试留在前台线程池里的任务,在下一个测试开始之后才跑完。
|
||||
- 根因:`scripts/vedastro_foreground.py::_FOREGROUND_VEDASTRO_EXECUTOR` 是模块级线程池,整个 pytest 进程共用(`jyotish_api_server` 还按名字另外导入了一份)。任务跑完后,`_remember_gateway_outcome` 把结果写进「写的那一刻」环境变量指向的缓存目录。上一个测试留下的任务跑完时,环境变量已经指向下一个测试的隔离目录,于是一次失败被记成下一个测试「今天已经失败过」,`_failed_today` 命中,网关一次都没调用。线程池被占满时,任务排队到超时被取消,也会得到同样的 0 次。#130 给每个测试单独建目录,挡不住这种跨测试写入,所以 BUG-1481 那次的判断(共用目录)不是根因。
|
||||
- 修复:`tests/conftest.py::_isolate_foreground_vedastro_executor`。每个测试换一个新的前台线程池(两个模块里的名字都换);测试结束时丢掉排队的任务,正在跑的最多等 10 秒,所以剩下的任务一定在本测试的目录还有效时写完。生产代码没有改。
|
||||
- 验证:新增 `tests/test_vedastro_executor_isolation_20261028.py`:第一个测试留下一个 0.3 秒的任务,第二个测试断言它已经在上一个测试收尾时跑完、而且没有写进自己的目录。没有 fixture 时红(`assert 0 == 1`),加上后绿。`test_vedastro_snapshot_cache.py`、`test_vedastro_negative_day_20261028.py`、`test_vedastro_consultation_snapshot.py` 一起 19 项通过;本机完整快速门(`run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime`)通过。
|
||||
- 防复发:上面那组顺序测试固定了「任务不跨测试」。以后新增模块级线程池的,要放进同一个 fixture。
|
||||
- 关联:BUG-1481(第一次记下这个失败)、BUG-1479(#130,负缓存与每测试目录)。
|
||||
- 复发自:BUG-1481 记下的同一项失败。当时的处理(每测试单独目录)没拦住,因为根因是后台任务跨测试写入,不在目录共用上。
|
||||
- 修复版本:分支 `codex/bug-1498-executor-isolation`。
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rectification-d60-two-way-20261028.md` | `PROGRESS-rectification-d60-two-way-20261028.md`(执行方写) | **D60 换格要不要进生时校正:双向对比(issue #149)**:纳迪 / D150 已在 BUG-1240 证伪,本单只测 D60。A 组生产 V9、B 组加 D60 换格判据,同一批 v5 77 例看「有没有帮助」和「有没有伤害」;先登记再跑,G1 三引擎对账、G2 找回记录分钟、G3 不伤真值、G4 安慰剂、G5 换岁差噪声地板;另做 20 例模型参与的端到端对话对比(报告项)。离线研究、生产零改动。预留 BUG-1497 | 待领取 | 本地 Grok 执行 |
|
||||
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
@@ -545,3 +546,4 @@
|
||||
| `TASK-home-crash-saved-people-20261027.md` | `PROGRESS-home-crash-saved-people-20261027.md` | **#115 首页崩溃**:存过别人的账户一进首页就「页面出错了」——人物列表的资料缺两个字段,启程引导一读就报错;入口补齐默认值(BUG-1464) | 待验收 | `codex/home-crash-saved-people-20261027` |
|
||||
| `TASK-upstream-sync9-20261028.md` | `PROGRESS-upstream-sync9-20261028.md` | **第 1 次打回(快速门 1 败:Darakaraka 旧测试锁错值,见 `TASK-upstream-sync9-fix-20261028.md`)** · **#141 上游同步第九轮**:甲批九项已做完并推 `codex/upstream-sync9-20261028`;校正 20 条和 900 条与上一份回放逐条相同;乙批仍等 #137 | 待验收 | `codex/upstream-sync9-20261028` |
|
||||
| `TASK-upstream-sync9b-20261028.md` | `PROGRESS-upstream-sync9b-20261028.md` | **第 1 次打回(冻结契约排除清单删了第八轮两条,见 `TASK-upstream-sync9b-fix-20261028.md`)** · **#159 上游同步第九轮乙批(产品选 A)**:Sarpa Drekkana 补成五段、第 22 号 Drekkana 改为 D3 第 8 宫;改冻结文件 `varga.py`,校正 20 + 900 与上一份逐条相同后按 ERR-110 重新冻结。Narayana 小运强度口径(B2)不做,另问产品 | 待验收 | `codex/upstream-sync9b-20261028` |
|
||||
| — | — | **#134 咨询答题默认低思考量(产品选 A)**:`CONSULT_ANSWER_REASONING_EFFORT` 不设时默认 `low`,设 `off` 恢复原请求体(Claude 直接执行) | 已推 staging | `codex/answer-effort-default-20261029` |
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# TASK · D60 换格要不要进生时校正:双向对比(研究单,2026-10-28,issue #149)
|
||||
|
||||
## 基线
|
||||
|
||||
- `origin/staging` @ `7721aa9a`(写作时 head;开工时以最新 `origin/staging` 为准,在 PROGRESS 里写下实际 SHA)。
|
||||
- 分支 `codex/rectification-d60-two-way-20261028`,工作树 `.worktrees/rectification-d60-two-way-20261028`。
|
||||
- 执行方:本地 Grok(Windows + Docker),可以调用模型。验收方:Claude。
|
||||
- **离线研究,不改生产代码**:不动 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 里的冻结文件,也不动 `scripts/varga.py`、`scripts/active_rectification_event_engine.py`、`scripts/rectification/*`。D60 计分只写在 `scripts/research/` 下的新文件里,生产链路一行不改。
|
||||
- 数据:`references/real_case_calibration/minute_rectification_holdout_v5.json`(77 例 Rodden AA 公开名人,964 件事,244 件日精度)。口径沿用数据集声明的 `ayanamsa = raman`、`node_mode = mean`,换口径只在 G5 里做。
|
||||
|
||||
## 先读
|
||||
|
||||
- `docs/research/rectification_nadi_seconds_2026_10_05.md`(BUG-1240):纳迪 / D150 秒级已经证伪。N3:±10 窗口里纳迪找回记录分钟 5/77,生产 25/77;N4:只换岁差,D150 有 73/73 例换段。**本单不重开 BUG-1240**,只测那次没测的 D60。
|
||||
- `docs/tasks/TASK-rectification-nadi-seconds-research-20261005.md`:本单照它的写法(先登记、真值不可见、安慰剂、负结果照写)。
|
||||
- `docs/research/holdout_v5_build_2026_09_29.md`:v5 基线;六题回放后头名 0.64、真值在区间内 76/77。
|
||||
- `skills/jyotish-birth-time-rectification/references/technique-routing.md`:现行口径「D60 只作参考,不驱动结论」。
|
||||
- `AGENTS.md` §1–§4:这是「技法可靠性判断」,适用高严谨模式和 MEVG。
|
||||
|
||||
## 起因
|
||||
|
||||
竞品宣传「证据够密时 D60 与纳迪分盘反向校准,收束时刻可以到秒」(#142 评论 0)。纳迪那一半已经在 BUG-1240 证伪。D60 每格 0.5°,上升约 1.5–2.5 分钟走完一格,从没在已知出生时间的人身上测过它能不能帮着分开候选。产品要求先跑对比、出报告,再决定做不做(#149,jesse 2026-10-28)。
|
||||
|
||||
## 两个组
|
||||
|
||||
- **A 组(现状)**:生产 V9 引擎原样,六题回放协议同 v5 基线。
|
||||
- **B 组(加 D60)**:A 组之上,多一条 D60 换格判据。窗口内上升的 D60 格每换一次,候选分成两段;用户给出日精度事件时,按预先登记的 D60 规则给两段打分,淘汰规则与 A 组的换升判据同一套。只在研究脚本里实现。
|
||||
|
||||
## 「双向」是什么
|
||||
|
||||
同一批例子,两个方向都要看:
|
||||
|
||||
1. **有没有帮助**:B 组是否更常找回记录分钟、区间是否更窄。
|
||||
2. **有没有伤害**:B 组是否更常把真值排除在区间外、是否在安慰剂日期上也一样「有帮助」(说明是噪声)、换岁差后结论是否还站得住。
|
||||
|
||||
只报第 1 个方向、不报第 2 个方向的数字,一律不算数。
|
||||
|
||||
## 过门标准(先登记再跑)
|
||||
|
||||
规则、阈值、种子先写进 `docs/research/d60_two_way_preregistration_2026_10_28.json`,单独 commit,然后才能第一次在 v5 上跑。PROGRESS 里写明登记 commit 早于第一次计分。
|
||||
|
||||
| 门 | 内容 | 过门条件 |
|
||||
| --- | --- | --- |
|
||||
| G1 三引擎对账 | 在 77 例的记录时间上,本仓 D60 上升格与 PyJHora、jyotishganit 逐例比对;VedAstro 能拿到就加上,拿不到写 `blocked` | 本仓与至少一个外部引擎一致 ≥ 95%;不一致的例子逐条列出。G1 不过,后面全部停 |
|
||||
| G2 找回记录分钟 | ±10 分钟窗口、六题回放后,头名与记录分钟相差 ≤ 2 分钟的比例,B 减 A | 整例 bootstrap 5000 次,提升的下界 > 0 |
|
||||
| G3 不伤真值 | 真值仍在交付区间内的例数 | B 组不少于 A 组(基线 76/77) |
|
||||
| G4 安慰剂 | 把日精度事件按 BUG-1240 的 P1(±30–180 天平移)和 P2(跨例换日期)做两套安慰剂,B 组的提升重算一遍 | 真实日期的提升必须显著高于两种安慰剂(单侧置换 9999 次,p < 0.05) |
|
||||
| G5 噪声地板 | 只换岁差 Raman→Lahiri / KP / True Chitra,记录时间上 D60 上升格变化的例数比例 | 任一换法超过 20%:对外文案不得出现「到秒 / 分钟级精确」,即使 G2–G4 全过 |
|
||||
|
||||
取整组(记录分钟是 5 的倍数,52 例)和非取整组(25 例)分开列;判门用合并样本。
|
||||
|
||||
## 模型参与的端到端对比(报告项,不判门)
|
||||
|
||||
在 G1 通过后做,用来看真实对话里差别有多大:
|
||||
|
||||
- 从 77 例里按种子 `20261028` 抽 20 例(取整 / 非取整按比例)。
|
||||
- 每例跑两次完整校正对话:A 组一次、B 组一次,顺序按种子随机,防止先后影响。
|
||||
- 校正 agent 用生产同一个模型和同一份提示;「用户」由另一个模型扮演,只能看到该例 v5 里的事件清单(日期、领域、原文摘要),**看不到出生时间**,回答必须来自清单,清单里没有的就答「不记得」。
|
||||
- 每次记录:轮数、最终区间宽度、头名与记录分钟的差、真值是否在区间内、出现了几次反证题(#148 验收要的三项一并记下)。
|
||||
- 模型温度固定,种子固定;每例每组跑 1 次,有余量再跑第 2 次看稳定性。
|
||||
- 只保存对话可见文本和上面的指标,不保存模型的思维链。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. **先登记再跑**:G2–G5 的规则、阈值、种子先 commit。看过结果后改的任何东西只能写进「事后」一栏,不能用来判门。
|
||||
2. **真值不可见**:排序器、D60 打分、扮演用户的模型都不得读取 `true_minute` 或出生资料里的 `time`。新增测试断言这一点,沿用 `truth_hidden_from_ranker` 的检查方式。
|
||||
3. **必须有安慰剂**:每个「有帮助」的数字都要同时报安慰剂下的同一数字。
|
||||
4. **不写「秒级准确率」**:准确率只用「与记录时间相差 ≤1 / ≤2 分钟」口径。
|
||||
5. 生产代码零改动;冻结文件零改动;`PYTHONHASHSEED=0`;所有 JSON 两次复跑逐字节一致(模型对话那部分除外,写明)。
|
||||
6. 只用 v5 已有的公开名人数据;不新增人物,不访问 astro.com。外部引擎只在本机跑,遵守各自许可证(PyJHora 是 AGPL:只在研究脚本里调用,不复制进生产代码)。
|
||||
7. 模型密钥只从本机环境变量读,不写进仓库、日志或报告。
|
||||
8. 负结果完整写出。不过门就写不过门,不调阈值凑通过。
|
||||
|
||||
## MEVG(AGENTS.md §3)
|
||||
|
||||
报告里必须有这两行,做不到写 `blocked`:
|
||||
|
||||
- **MEVG / Global Web Evidence**:收集公开资料里「用 D60 做生时校正」的说法与依据(经典文本出处、知名占星师的方法说明、论坛案例),按 A(经典原文 / 可复现数据)B(有案例的专业说明)C(无依据的宣传)分级。竞品宣传归 C。资料之间冲突时写明怎么取舍。
|
||||
- **Real Case Calibration**:就是上面的 v5 77 例结果。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `docs/research/d60_two_way_preregistration_2026_10_28.json`(先 commit)。
|
||||
- `scripts/research/d60_two_way_run.py` 与 `tests/test_d60_two_way_research.py`(真值不可见、种子固定、三引擎对账函数)。
|
||||
- `docs/research/d60_two_way_results_2026_10_28.json`。
|
||||
- 报告 `docs/research/rectification_d60_two_way_2026_10_28.md`:开头一张结论表(G1–G5 过 / 不过 + 关键数字 + 对照),然后是模型端到端对比表、MEVG 两行、事后一栏、建议(做 / 不做 / 只做到哪一步)。
|
||||
- `docs/tasks/PROGRESS-rectification-d60-two-way-20261028.md`:每一步的 commit 与复跑核对。
|
||||
- 报告摘要贴到 issue #149。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
G1 > G2 > G3 > G5 > G4 > 端到端对比 > MEVG 的论坛案例部分。
|
||||
|
||||
- G1、G2、G3、G5 缺一项都不能下结论。
|
||||
- 没做的写 `not_started`,不得写成结论。
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-d60-two-way-20261028 .worktrees/rectification-d60-two-way-20261028 origin/staging
|
||||
cd .worktrees/rectification-d60-two-way-20261028
|
||||
export PYTHONHASHSEED=0
|
||||
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
|
||||
python3 -m pytest -q tests/test_nadi_seconds_research.py tests/test_holdout_v5_schema.py tests/test_varga_resolution_research.py tests/test_repo_privacy_markers.py # 基线
|
||||
python3 -c "import swisseph; print(swisseph.version)"
|
||||
```
|
||||
|
||||
长任务每完成一个门就 commit 一次检查点。研究分支只 commit,不推 staging;推送由验收方完成。
|
||||
|
||||
## BUG 编号
|
||||
|
||||
开工时核对 `docs/BUG_HISTORY.md` 当前最大号(写作时是 BUG-1496)。本单预留 **BUG-1497**:「D60 换格是否能帮助生时校正,从未在已知出生时间的例子上检验」,执行方以 `investigating` 登记。通过的改为 `resolved` 并另立实现单;不通过的改为 `closed_by_design`,附对外口径。
|
||||
|
||||
关联:BUG-1240、BUG-560、BUG-1090、BUG-1091。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 真机走查 · 咨询回答默认低思考量(issue #134)
|
||||
|
||||
| # | 操作 | 应该看到 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 用一张星盘问一个具体问题(如「今年事业怎么样」) | 回答正常出来,比以前快一截(以前常要半分钟以上) |
|
||||
| 2 | 连问两三个追问 | 每轮都正常回答,引用的宫位、大运和以前一样具体 |
|
||||
| 3 | 问一个时间段(如「明年上半年」) | 照常出时段分析,没有空答或中途断掉 |
|
||||
+3
-2
@@ -408,9 +408,10 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
### Chat header
|
||||
|
||||
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 添加一个人`, which opens `/people`. At 20 people that row is absent.
|
||||
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 新增一个人`, which opens `/people?add=1` (the add form). At 20 people that row is absent.
|
||||
- Issue #150 (2026-10-28): after the name the trigger shows `· {date time · city}` (`.chat-profile-picker-detail`, secondary ink, ellipsizes first) once the selected person is loaded and complete; the accessible name becomes `当前星盘:{名字},{date time · city}`. The phone chat header (≤640px) hides the detail. Each row is a two-column grid (`.chat-profile-picker-row`: the choice, then a 44×44 `⋯` menu `.chat-profile-picker-more`). The row copy is the name plus a pill badge (`.chat-profile-picker-badge`: muted canvas, caption size; 本人 or the relation, empty reads 其他) over `date time · city`. The menu holds `编辑` (`/people?self=1` or `/people?edit=<id>`, which opens that person's edit dialog) and, for others only, `删除`: the same count read, AlertDialog sentence and DELETE as the people page (`lib/person-delete.ts`), falling back to self if the current person was deleted. Delete is disabled while an answer is being written. The chat-side chart panel (`.chat-chart-switcher`) carries the same picker full width above the drawing; the drawing sits behind its own memo so the picker's repaints never redraw it.
|
||||
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
|
||||
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整,补全后才能看盘` and cannot be chosen (its `⋯` still offers 编辑). A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
|
||||
- The chart page birth line is a button (`.chart-birth-line`) under the person's name in the page head (`.chart-page-head-meta`, 13px tertiary, as in the signed prototype; it left the top bar on 2026-10-28, BUG-1470, because on a phone it wrapped the bar to two lines): `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. The editor's code is not in `/chart`'s first load: it loads on the click and is warmed when the pen row is hovered or focused, and nothing is drawn while it arrives (no placeholder; BUG-1469). The people page's add / edit form likewise stays out of `/people`'s first load and is fetched right after the page mounts.
|
||||
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
|
||||
|
||||
@@ -297,7 +297,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
## 星盘页人名与就地改资料(2026-10-21)
|
||||
|
||||
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
|
||||
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 新增一个人」,直接打开星盘档案的新增表单。满 20 人不出现这一行。
|
||||
- (issue #150,2026-10-28)按钮上写「称呼 · 出生日期 时间 · 城市」;手机对话顶栏放不下,只写称呼。名单每行:名字 + 灰底标签(本人写「本人」,别人写关系,没填过写「其他」),下面一行「日期 时间 · 城市」。每行右边「⋯」:「编辑」去星盘档案打开这个人的编辑框;别人还有「删除」,确认框和星盘档案同一句「将同时删除 N 个对话和 M 份报告,无法恢复。」回答还没写完时删除项写「这段回答结束后再删」,点不了。本人没有删除。对话右边的盘面顶部也有这颗按钮,规则和对话顶栏一样。
|
||||
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
|
||||
- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。
|
||||
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
|
||||
@@ -487,7 +488,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 列表没读出来 | 人物列表暂时读不出来 |
|
||||
| 其他人暂时读不到 | 暂时读不到其他人 |
|
||||
| 重试 | 重试 |
|
||||
| 资料不完整 | 这份资料还不完整 |
|
||||
| 资料不完整 | 这份资料还不完整,补全后才能看盘 |
|
||||
| 每行更多 | 编辑 / 删除(本人只有编辑) |
|
||||
| 回答还没结束时删除 | 这段回答结束后再删 |
|
||||
| 名单底部 | 新增一个人 |
|
||||
| 资料已删除 | 资料已删除 · {原来的名字} |
|
||||
| 删除后不能再发 | 这份资料已经删除,这段对话只能看。 |
|
||||
|
||||
|
||||
@@ -1215,8 +1215,21 @@ export default function Home() {
|
||||
},
|
||||
};
|
||||
|
||||
// One picker, two places: the chat header and the top of the chart panel (issue #150).
|
||||
const subjectPicker = activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
|
||||
<ChatProfilePicker
|
||||
session={activeSession}
|
||||
library={chartLibrary}
|
||||
status={chatProfileStatus}
|
||||
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
|
||||
onBindEmpty={bindEmptyChatSubject}
|
||||
onOpenNew={openChatBoundToProfile}
|
||||
onReload={reloadChatProfiles}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ChatChartFrame>
|
||||
<ChatChartFrame switcher={subjectPicker}>
|
||||
<DailyStarlanguageBinder
|
||||
accountId={accountId}
|
||||
bootstrapPhase={bootstrapPhase}
|
||||
@@ -1244,19 +1257,9 @@ export default function Home() {
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
|
||||
<ChatProfilePicker
|
||||
session={activeSession}
|
||||
library={chartLibrary}
|
||||
status={chatProfileStatus}
|
||||
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
|
||||
onBindEmpty={bindEmptyChatSubject}
|
||||
onOpenNew={openChatBoundToProfile}
|
||||
onReload={reloadChatProfiles}
|
||||
/>
|
||||
) : activeSession ? (
|
||||
{subjectPicker ?? (activeSession ? (
|
||||
<span className="chat-header-chart">{sessionChartLabel(activeSession, chartLibrary)}</span>
|
||||
) : null}
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<OnboardingChecklistSlot />
|
||||
|
||||
@@ -1041,7 +1041,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i
|
||||
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%; display: flex; flex-direction: row; }
|
||||
.chat-chart-frame > .chat-column { flex: 1 1 auto; }
|
||||
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; border-left: 1px solid var(--color-border); }
|
||||
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; display: flex; flex-direction: column; border-left: 1px solid var(--color-border); }
|
||||
/* Issue #150: the same picker as the chat header, full width above the drawing. */
|
||||
.chat-chart-switcher { flex: 0 0 auto; min-width: 0; padding: var(--space-3) var(--space-3) 0; }
|
||||
.chat-chart-switcher .chat-profile-picker-trigger { width: 100%; max-width: none; border-radius: var(--radius-md); }
|
||||
.chat-chart-split .chat-natal-chart { flex: 1 1 auto; height: auto; }
|
||||
/* Same 6px transparent strip as the sidebar edge, on the panel's left edge. */
|
||||
button.chat-chart-resize-handle,
|
||||
button.chat-chart-resize-handle:hover,
|
||||
@@ -1078,6 +1082,10 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
|
||||
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; gap: 4px; margin: 0; padding: 0 10px 0 12px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, border-color 120ms ease-out; }
|
||||
.chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Issue #150: 「· date time · city」 after the name; it gives way first when space runs out. */
|
||||
.chat-profile-picker-detail { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-ink-secondary); }
|
||||
/* A phone chat header has room for the name only; the list still shows the details. */
|
||||
@media (max-width: 640px) { .chat-header .chat-profile-picker-detail { display: none; } }
|
||||
.chat-profile-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-add { margin-top: var(--space-2); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
|
||||
.chat-profile-picker-add a { min-height: 44px; display: flex; align-items: center; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink); font-size: var(--type-body-sm); text-decoration: none; }
|
||||
@@ -1098,6 +1106,12 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
|
||||
.chat-profile-picker-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.chat-profile-picker-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; }
|
||||
.chat-profile-picker-copy small { color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
|
||||
.chat-profile-picker-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; gap: var(--space-1); }
|
||||
.chat-profile-picker-heading { min-width: 0; display: flex; align-items: center; gap: 6px; }
|
||||
.chat-profile-picker-badge { flex: 0 0 auto; padding: 1px 6px; border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.35; }
|
||||
.chat-profile-picker-more { width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; }
|
||||
.chat-profile-picker-more svg { width: 18px; height: 18px; }
|
||||
.chat-profile-picker-more:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); }
|
||||
.chat-profile-picker-option[data-selected] .chat-profile-picker-check { opacity: 1; }
|
||||
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
@@ -2190,6 +2204,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); }
|
||||
.chat-profile-picker-add a:hover { background: var(--color-canvas-soft); }
|
||||
.chat-profile-picker-more:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
|
||||
@@ -15,6 +15,7 @@ type SplitComponent = (props: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
switcher?: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
}) => ReactNode;
|
||||
@@ -70,7 +71,7 @@ export function ChatChartToggle() {
|
||||
* starts open (issue #114 Q8 A). Phones load the bottom sheet instead, the
|
||||
* first time it is asked for.
|
||||
*/
|
||||
export function ChatChartFrame({ children }: { children: ReactNode }) {
|
||||
export function ChatChartFrame({ children, switcher }: { children: ReactNode; switcher?: ReactNode }) {
|
||||
const { isMobile, ready } = useSidebar();
|
||||
const subjectId = useCurrentSubject();
|
||||
const open = useUiStore((state) => state.chatChartOpen);
|
||||
@@ -117,7 +118,7 @@ export function ChatChartFrame({ children }: { children: ReactNode }) {
|
||||
<div className="chat-chart-frame">
|
||||
{column}
|
||||
{Split && desktop ? (
|
||||
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth} />
|
||||
<Split open={open} width={width} subjectId={subjectId} switcher={switcher} onOpenChange={setOpen} onWidth={setWidth} />
|
||||
) : null}
|
||||
{Sheet && phone ? <Sheet open={sheetOpen} onOpenChange={setSheetOpen} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactNode } from "react";
|
||||
|
||||
import { ChatNatalChart } from "@/components/chat-natal-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,17 +12,23 @@ import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-pan
|
||||
* never remounts the chat (BUG-1448). Its left edge drags or takes the arrow
|
||||
* keys; dragging below the minimum closes the panel (BUG-1456). Memoized: its
|
||||
* props are store values, so a Home render alone never redraws the chart (issue #123).
|
||||
* `switcher` (issue #150) is a fresh element on every Home render, so the drawing
|
||||
* sits behind its own memo and only the switcher repaints.
|
||||
*/
|
||||
const StableNatalChart = memo(ChatNatalChart);
|
||||
|
||||
export const ChatChartSplit = memo(function ChatChartSplit({
|
||||
open,
|
||||
width,
|
||||
subjectId,
|
||||
switcher,
|
||||
onOpenChange,
|
||||
onWidth,
|
||||
}: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
switcher?: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
}) {
|
||||
@@ -45,7 +51,8 @@ export const ChatChartSplit = memo(function ChatChartSplit({
|
||||
aria-label="调整本命盘宽度"
|
||||
{...resize}
|
||||
/>
|
||||
<ChatNatalChart subjectId={subjectId} />
|
||||
{switcher ? <div className="chat-chart-switcher">{switcher}</div> : null}
|
||||
<StableNatalChart subjectId={subjectId} />
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,18 +162,22 @@ export function PeoplePage() {
|
||||
setPeople(nextPeople);
|
||||
const url = new URL(window.location.href);
|
||||
const add = url.searchParams.get("add") === "1";
|
||||
const openSelf = url.searchParams.get("self") === "1";
|
||||
if (add || openSelf) {
|
||||
// `?edit=<id>` comes from the switcher's 编辑 (issue #150); an id that is gone opens nothing.
|
||||
const editId = url.searchParams.get("self") === "1" ? "self" : url.searchParams.get("edit");
|
||||
const editRow = editId ? nextPeople.find((person) => person.id === editId) : undefined;
|
||||
if (add || editId) {
|
||||
url.searchParams.delete("add");
|
||||
url.searchParams.delete("self");
|
||||
url.searchParams.delete("edit");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
if (openSelf) {
|
||||
const selfRow = nextPeople.find((person) => person.role === "self");
|
||||
setSelectedId("self");
|
||||
setDraft(selfRow?.profile ?? emptyProfile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setView("detail");
|
||||
if (editId) {
|
||||
if (editRow) {
|
||||
setSelectedId(editRow.id);
|
||||
setDraft(editRow.profile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setView("detail");
|
||||
}
|
||||
} else if (nextPeople.filter((person) => person.role === "other").length >= MAX_OTHER_SUBJECTS) {
|
||||
notifyInfo(LIMIT_COPY);
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Plus } from "lucide-react";
|
||||
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { navigateInApp } from "@/lib/app-navigation";
|
||||
import { invalidateSubjectCatalog, readCurrentSubjectId, setCurrentSubject } from "@/lib/current-subject";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
import {
|
||||
PERSON_ADD_HREF,
|
||||
PERSON_DELETE_COPY,
|
||||
deletePerson,
|
||||
fetchPersonDeleteUsage,
|
||||
personDeleteDescription,
|
||||
personEditHref,
|
||||
type PersonDeleteUsage,
|
||||
} from "@/lib/person-delete";
|
||||
import {
|
||||
chatProfilePickerRows,
|
||||
type ChatProfileCatalogStatus,
|
||||
@@ -39,11 +63,15 @@ export function SubjectSwitcher({
|
||||
}: SubjectSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<{ row: ChatProfilePickerRow; usage: PersonDeleteUsage } | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
|
||||
// Issue #86 said “5 people”. The live archive cap is 20 (`MAX_SAVED_SUBJECTS`).
|
||||
const showAddPerson = (status === "ready" || status === "degraded") && library.length < MAX_SAVED_SUBJECTS;
|
||||
// Issue #150: the trigger says whose chart, when and where, not only the name.
|
||||
const selectedRow = rows.find((row) => row.selected && row.complete);
|
||||
const triggerDetail = selectedRow?.detail ?? "";
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
@@ -63,7 +91,38 @@ export function SubjectSwitcher({
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ChatProfilePickerRow) {
|
||||
setOpen(false);
|
||||
navigateInApp(personEditHref(row.id));
|
||||
}
|
||||
|
||||
async function askDelete(row: ChatProfilePickerRow) {
|
||||
if (row.role === "self" || requestInFlight) return;
|
||||
const usage = await fetchPersonDeleteUsage(row.id);
|
||||
if (!usage) {
|
||||
notifyError(PERSON_DELETE_COPY.usageUnavailable);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setPendingDelete({ row, usage });
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = pendingDelete?.row;
|
||||
setPendingDelete(null);
|
||||
if (!target) return;
|
||||
if (!(await deletePerson(target.id))) {
|
||||
notifyError(PERSON_DELETE_COPY.failed);
|
||||
return;
|
||||
}
|
||||
// Same order as the people page: fall back to 本人 first, then reread the list.
|
||||
if (readCurrentSubjectId() === target.id) setCurrentSubject("self");
|
||||
invalidateSubjectCatalog();
|
||||
onReload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
@@ -77,9 +136,10 @@ export function SubjectSwitcher({
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`当前星盘:${label}`}
|
||||
aria-label={`当前星盘:${label}${triggerDetail ? `,${triggerDetail}` : ""}`}
|
||||
>
|
||||
<span className="chat-profile-picker-name">{label}</span>
|
||||
{triggerDetail ? <span className="chat-profile-picker-detail">· {triggerDetail}</span> : null}
|
||||
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -116,17 +176,75 @@ export function SubjectSwitcher({
|
||||
requestInFlight={requestInFlight}
|
||||
pending={pendingId === row.id}
|
||||
onChoose={() => void choose(row)}
|
||||
onEdit={() => edit(row)}
|
||||
onDelete={() => void askDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddPerson ? (
|
||||
<div className="chat-profile-picker-add">
|
||||
<a href="/people"><Plus aria-hidden="true" />添加一个人</a>
|
||||
<AppLink href={PERSON_ADD_HREF} onClick={() => setOpen(false)}><Plus aria-hidden="true" />新增一个人</AppLink>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{pendingDelete ? (
|
||||
<AlertDialog open onOpenChange={(next) => { if (!next) setPendingDelete(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{PERSON_DELETE_COPY.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{personDeleteDescription(pendingDelete.usage)}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{PERSON_DELETE_COPY.cancel}</AlertDialogCancel>
|
||||
<AlertDialogAction className="danger-primary" onClick={() => void confirmDelete()}>{PERSON_DELETE_COPY.confirm}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
row,
|
||||
requestInFlight,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
readonly row: ChatProfilePickerRow;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly onEdit: () => void;
|
||||
readonly onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="chat-profile-picker-more" aria-label={`${row.name} 更多操作`}>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>编辑</DropdownMenuItem>
|
||||
{/* 本人 is never deleted; nobody is while an answer is still being written. */}
|
||||
{row.role === "other" ? (
|
||||
<DropdownMenuItem disabled={requestInFlight} onClick={onDelete}>
|
||||
{requestInFlight ? "这段回答结束后再删" : "删除"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RowCopy({ row, detail }: { readonly row: ChatProfilePickerRow; readonly detail?: string }) {
|
||||
return (
|
||||
<span className="chat-profile-picker-copy">
|
||||
<span className="chat-profile-picker-heading">
|
||||
<b>{row.name}</b>
|
||||
<span className="chat-profile-picker-badge">{row.badge}</span>
|
||||
</span>
|
||||
<small>{detail ?? row.detail}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,26 +254,26 @@ function ProfileRow({
|
||||
requestInFlight,
|
||||
pending,
|
||||
onChoose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
readonly row: ChatProfilePickerRow;
|
||||
readonly locked: boolean;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly onChoose: () => void;
|
||||
readonly onEdit: () => void;
|
||||
readonly onDelete: () => void;
|
||||
}) {
|
||||
let main;
|
||||
if (!row.complete) {
|
||||
return (
|
||||
main = (
|
||||
<div className="chat-profile-picker-option" aria-disabled="true">
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
<RowCopy row={row} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (locked && !row.selected) {
|
||||
return (
|
||||
} else if (locked && !row.selected) {
|
||||
main = (
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
@@ -164,29 +282,29 @@ function ProfileRow({
|
||||
aria-label={`${row.name},新建会话并使用此人物`}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
|
||||
</span>
|
||||
<RowCopy row={row} detail={requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"} />
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
main = (
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<RowCopy row={row} />
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="chat-profile-picker-row">
|
||||
{main}
|
||||
<RowMenu row={row} requestInFlight={requestInFlight} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readCurrentSubjectId } from "@/lib/current-subject";
|
||||
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
|
||||
import { sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
import { selectedBirthPlace, sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession, Profile } from "@/lib/home-types";
|
||||
import { personRelationLabel } from "@/lib/person-relation";
|
||||
|
||||
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed" | "degraded";
|
||||
|
||||
@@ -19,6 +20,9 @@ export type ChatProfilePickerRow = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly name: string;
|
||||
/** 本人, or the relation label (empty shows 其他). Issue #150. */
|
||||
readonly badge: string;
|
||||
/** Complete: 「date time · city」. Incomplete: why it can't be picked. */
|
||||
readonly detail: string;
|
||||
readonly complete: boolean;
|
||||
readonly selected: boolean;
|
||||
@@ -98,6 +102,15 @@ export function chatSubjectSendBlocked(input: {
|
||||
return !input.library.some((record) => record.id === id && record.role === "other");
|
||||
}
|
||||
|
||||
export const INCOMPLETE_ROW_DETAIL = "这份资料还不完整,补全后才能看盘";
|
||||
|
||||
/** 「1990-03-12 07:30 · 杭州」; parts that are missing are left out. */
|
||||
export function subjectBirthLine(profile: Profile): string {
|
||||
const when = [profile.date.trim(), profile.time.trim()].filter(Boolean).join(" ");
|
||||
const place = selectedBirthPlace(profile)?.label.trim() ?? "";
|
||||
return [when, place].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function rowName(record: ChartLibraryRecord): string {
|
||||
return record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料");
|
||||
}
|
||||
@@ -117,7 +130,8 @@ export function chatProfilePickerRows(input: {
|
||||
id: "self",
|
||||
role: "self",
|
||||
name: "我",
|
||||
detail: "这份资料还不完整",
|
||||
badge: "本人",
|
||||
detail: INCOMPLETE_ROW_DETAIL,
|
||||
complete: false,
|
||||
selected: input.selectedId === "self",
|
||||
});
|
||||
@@ -134,9 +148,8 @@ function toRow(record: ChartLibraryRecord, selectedId: string): ChatProfilePicke
|
||||
id,
|
||||
role: record.role,
|
||||
name: rowName(record),
|
||||
detail: complete
|
||||
? (record.role === "self" ? "本人" : (record.profile.date || "其他人"))
|
||||
: "这份资料还不完整",
|
||||
badge: record.role === "self" ? "本人" : personRelationLabel(record.profile.relation),
|
||||
detail: complete ? subjectBirthLine(record.profile) : INCOMPLETE_ROW_DETAIL,
|
||||
complete,
|
||||
selected: id === selectedId,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Deleting someone from outside the people page (issue #150, the switcher's 删除).
|
||||
* Same reads, writes and words as the people page's own delete: count first, then
|
||||
* confirm with the count, then DELETE. 本人 is never deletable.
|
||||
*/
|
||||
|
||||
export type PersonDeleteUsage = { readonly sessions: number; readonly reports: number; readonly synastry?: number };
|
||||
|
||||
export const PERSON_DELETE_COPY = {
|
||||
title: "确认删除",
|
||||
usageUnavailable: "暂时查不到这个人的对话数量,稍后再试",
|
||||
failed: "删除失败,请重试",
|
||||
cancel: "取消",
|
||||
confirm: "确认删除",
|
||||
} as const;
|
||||
|
||||
export function personDeleteDescription(usage: PersonDeleteUsage): string {
|
||||
const synastry = usage.synastry ? `和 ${usage.synastry} 份合盘` : "";
|
||||
return `将同时删除 ${usage.sessions} 个对话和 ${usage.reports} 份报告${synastry},无法恢复。`;
|
||||
}
|
||||
|
||||
function countOk(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
/** Null when the count can't be read; the caller says so and deletes nothing. */
|
||||
export async function fetchPersonDeleteUsage(id: string): Promise<PersonDeleteUsage | null> {
|
||||
if (!id || id === "self") return null;
|
||||
try {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { cache: "no-store" });
|
||||
const payload = await response.json().catch(() => null) as { usage?: PersonDeleteUsage } | null;
|
||||
const usage = payload?.usage;
|
||||
if (!response.ok || !usage || !countOk(usage.sessions) || !countOk(usage.reports)) return null;
|
||||
return usage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePerson(id: string): Promise<boolean> {
|
||||
if (!id || id === "self") return false;
|
||||
try {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function personEditHref(id: string): string {
|
||||
return id === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
export const PERSON_ADD_HREF = "/people?add=1";
|
||||
@@ -71,11 +71,16 @@ export function lastCompletedPublicTool(
|
||||
return last;
|
||||
}
|
||||
|
||||
const PHRASE_HAS_OWN_DATE = /\d{4}\s*年|\d{4}[-/.]\d{1,2}/;
|
||||
|
||||
export function evidenceRecapLine(item: HostFallbackRecap): string {
|
||||
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
|
||||
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
|
||||
if (!phrase) return label;
|
||||
if (/\d{4}年/.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
|
||||
// BUG-1496: the phrase already carries its own date, so the label would say
|
||||
// it twice (「2016-09 2016 年 9 月上大学」). Spaced years and ISO / slash dates
|
||||
// count too, not only an unspaced 「2016年」.
|
||||
if (PHRASE_HAS_OWN_DATE.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
|
||||
return phrase;
|
||||
}
|
||||
// BUG-1136: the recap is now the server's on every evidence turn; a
|
||||
|
||||
@@ -177,22 +177,26 @@ export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
|
||||
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
|
||||
|
||||
/**
|
||||
* Answer-step reasoning effort. Unset or blank keeps today's request body.
|
||||
* An illegal value warns once and is treated as unset.
|
||||
* Answer-step reasoning effort. Unset or blank means "low" (issue #134: the
|
||||
* A/B halved reasoning tokens and cut answer time ~37% with no quality loss).
|
||||
* "off" sends no reasoning_effort, i.e. the pre-#134 request body.
|
||||
* An illegal value warns once and is treated as "off".
|
||||
*/
|
||||
const CONSULT_ANSWER_REASONING_EFFORTS = ["low", "medium", "high", "max"] as const;
|
||||
export const CONSULT_ANSWER_REASONING_EFFORT_DEFAULT: ProviderReasoningEffort = "low";
|
||||
let consultAnswerReasoningEffortWarned = false;
|
||||
|
||||
export function consultAnswerReasoningEffort(): ProviderReasoningEffort | undefined {
|
||||
const raw = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
if (raw === undefined || raw.trim() === "") return undefined;
|
||||
if (raw === undefined || raw.trim() === "") return CONSULT_ANSWER_REASONING_EFFORT_DEFAULT;
|
||||
const value = raw.trim();
|
||||
if (value === "off") return undefined;
|
||||
if ((CONSULT_ANSWER_REASONING_EFFORTS as readonly string[]).includes(value)) {
|
||||
return value as ProviderReasoningEffort;
|
||||
}
|
||||
if (!consultAnswerReasoningEffortWarned) {
|
||||
consultAnswerReasoningEffortWarned = true;
|
||||
console.warn(`CONSULT_ANSWER_REASONING_EFFORT=${value} is not low, medium, high, or max; leaving reasoning effort unset.`);
|
||||
console.warn(`CONSULT_ANSWER_REASONING_EFFORT=${value} is not off, low, medium, high, or max; leaving reasoning effort unset.`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -51,9 +51,12 @@ test("an illegal effort warns once and stays unset", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("unset effort keeps the pre-change request bodies", async () => {
|
||||
// 原值: 不设环境变量时请求体与改动前逐字节相同
|
||||
// 新值: 设成 "off" 时请求体与改动前逐字节相同;不设时默认 "low"(见下一条)
|
||||
// 原因: issue #134 产品选 A,答题步默认低思考量;"off" 是一行回退
|
||||
test("off keeps the pre-change request bodies", async () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = "off";
|
||||
try {
|
||||
const natal = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(MODEL),
|
||||
@@ -76,6 +79,43 @@ test("unset effort keeps the pre-change request bodies", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("unset or blank effort defaults to low", () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
try {
|
||||
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
assert.equal(consultAnswerReasoningEffort(), "low");
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = " ";
|
||||
assert.equal(consultAnswerReasoningEffort(), "low");
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = "off";
|
||||
assert.equal(consultAnswerReasoningEffort(), undefined);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("unset effort puts low on the answer step only", async () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
try {
|
||||
const natal = await captureStreamBodies({
|
||||
settings: consultationGenerationSettings(MODEL),
|
||||
prepareStep: (input) => consultationNatalPrepareStep({
|
||||
stepNumber: input.stepNumber,
|
||||
answerReady: input.stepNumber >= 1,
|
||||
model: MODEL,
|
||||
}),
|
||||
steps: 2,
|
||||
}) as Record<string, unknown>[];
|
||||
assert.deepEqual(natal[0], fixture.natal[0]);
|
||||
assert.equal(natal[1]?.reasoning_effort, "low");
|
||||
assert.deepEqual(withoutEffort(natal[1]), fixture.natal[1]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("low effort is only on the answer step", async () => {
|
||||
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
|
||||
process.env.CONSULT_ANSWER_REASONING_EFFORT = "low";
|
||||
|
||||
@@ -98,9 +98,12 @@ test("the person control is a button, and add-person follows the live cap of 20"
|
||||
// 新值:Lucide `Plus` 加文案「添加一个人」
|
||||
// 原因:K1 去掉手写加号。入口仍去人物页,人数上限仍是 MAX_SAVED_SUBJECTS
|
||||
assert.match(switcher, /<Plus /);
|
||||
assert.match(switcher, /添加一个人/);
|
||||
assert.doesNotMatch(switcher, /+ 添加一个人/);
|
||||
assert.match(switcher, /href="\/people"/);
|
||||
// 原值:「添加一个人」,`href="/people"`
|
||||
// 新值:「新增一个人」,`href={PERSON_ADD_HREF}`(/people?add=1,直接打开新增表单)
|
||||
// 原因:issue #150 原型(2026-10-28 jesse 同意)第 4 条
|
||||
assert.match(switcher, /新增一个人/);
|
||||
assert.doesNotMatch(switcher, /+ (添加|新增)一个人/);
|
||||
assert.match(switcher, /href=\{PERSON_ADD_HREF\}/);
|
||||
assert.match(switcher, /MAX_SAVED_SUBJECTS/);
|
||||
assert.doesNotMatch(switcher, /library\.length < 5/);
|
||||
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
|
||||
|
||||
@@ -65,7 +65,10 @@ test("the chat page loads the natal split only when the panel opens, and phones
|
||||
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>/);
|
||||
// 原值:`<ChatChartFrame>`
|
||||
// 新值:`<ChatChartFrame switcher={subjectPicker}>`
|
||||
// 原因:issue #150 第 5 条,盘面顶部放同一个人物切换
|
||||
assert.match(page, /<ChatChartFrame switcher=\{subjectPicker\}>/);
|
||||
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
|
||||
assert.match(frame, /打开本命盘/);
|
||||
assert.match(frame, /收起本命盘/);
|
||||
|
||||
@@ -86,10 +86,15 @@ test("the picker lists self and owned others, with self selected by default", ()
|
||||
const rows = chatProfilePickerRows({ status: "ready", library: [self, other, incomplete], selectedId: "self" });
|
||||
assert.deepEqual(rows.map((row) => row.id), ["self", "other-1", "other-2"]);
|
||||
assert.equal(rows[0]?.selected, true);
|
||||
assert.equal(rows[0]?.detail, "本人");
|
||||
assert.equal(rows[1]?.detail, "1990-01-02");
|
||||
// 原值:detail 是「本人」/「1990-01-02」/「这份资料还不完整」
|
||||
// 新值:身份挪进 badge;detail 是「日期 时间 · 城市」,不完整的说明补全后才能看盘
|
||||
// 原因:issue #150 第 2 条,每一行写关系和出生时间、城市
|
||||
assert.equal(rows[0]?.badge, "本人");
|
||||
assert.equal(rows[0]?.detail, "1990-01-02 08:30 · 示例城市");
|
||||
assert.equal(rows[1]?.badge, "其他");
|
||||
assert.equal(rows[1]?.detail, "1990-01-02 08:30 · 示例城市");
|
||||
assert.equal(rows[2]?.complete, false);
|
||||
assert.equal(rows[2]?.detail, "这份资料还不完整");
|
||||
assert.equal(rows[2]?.detail, "这份资料还不完整,补全后才能看盘");
|
||||
});
|
||||
|
||||
test("an empty consultation binds the chosen person and does not open another session", () => {
|
||||
@@ -251,7 +256,10 @@ test("the header control is a button with popover keyboard, focus return, and a
|
||||
assert.match(pickerSource, /type="button"/);
|
||||
assert.match(pickerSource, /aria-expanded=\{open\}/);
|
||||
assert.match(pickerSource, /aria-controls=\{listId\}/);
|
||||
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}`\}/);
|
||||
// 原值:aria-label 只有称呼
|
||||
// 新值:称呼后接「,出生日期时间 · 城市」(选中的资料完整时)
|
||||
// 原因:issue #150 第 1 条,按钮上不只写名字
|
||||
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}\$\{triggerDetail \? `,\$\{triggerDetail\}` : ""\}`\}/);
|
||||
assert.match(pickerSource, /event\.key !== "Escape"/);
|
||||
// 原值:triggerRef.current?.focus()。
|
||||
// 新值:triggerRef.current?.focus({ preventScroll: true })。
|
||||
|
||||
@@ -104,3 +104,19 @@ test("case snapshot receipts expose host_fallback origin from the durable phase"
|
||||
assert.match(route, /answer_origin: "host_fallback"/);
|
||||
assert.match(route, /answer\.host_fallback/);
|
||||
});
|
||||
|
||||
// BUG-1496: a phrase that already carries its date must not get the label in
|
||||
// front again. Staging showed 「记下了:2016-09 2016 年 9 月上大学。」.
|
||||
test("the recap does not repeat a date the event phrase already carries", async () => {
|
||||
const { evidenceRecapLine } = await import("../src/lib/rectification-agentic/v9/host-fallback.ts");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016 年 9 月上大学" }), "2016 年 9 月上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016年9月上大学" }), "2016年9月上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2024-04-07", event_phrase: "2024-04-07 入职" }), "2024-04-07 入职");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016/9 上大学" }), "2016/9 上大学");
|
||||
// Unchanged: a phrase without its own date still gets the label.
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "上大学" }), "2016-09 上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016年", event_phrase: "入学" }), "2016年入学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016 年 9 月", event_phrase: "去北京工作" }), "2016 年 9 月去北京工作");
|
||||
// A number that is not a year is not a date.
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "考了 600 分" }), "2016-09 考了 600 分");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SubjectSwitcher } from "../src/components/subject-switcher.tsx";
|
||||
import { INCOMPLETE_ROW_DETAIL, chatProfilePickerRows, subjectBirthLine } from "../src/lib/chat-profile-picker-model.ts";
|
||||
import { emptyProfile, type ChartLibraryRecord, type Profile } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
PERSON_ADD_HREF,
|
||||
PERSON_DELETE_COPY,
|
||||
deletePerson,
|
||||
fetchPersonDeleteUsage,
|
||||
personDeleteDescription,
|
||||
personEditHref,
|
||||
} from "../src/lib/person-delete.ts";
|
||||
|
||||
// Issue #150: the person switcher says when and where, carries 编辑 / 删除, and
|
||||
// also sits on top of the chat-side chart. Every person here is made up.
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const switcherSource = read("../src/components/subject-switcher.tsx");
|
||||
const peopleSource = read("../src/components/people/people-page.tsx");
|
||||
const splitSource = read("../src/components/chat-chart-split.tsx");
|
||||
const pageSource = read("../src/app/(app)/page.tsx");
|
||||
const css = read("../src/app/globals.css");
|
||||
|
||||
function person(name: string, patch: Partial<Profile> = {}): Profile {
|
||||
return {
|
||||
...emptyProfile,
|
||||
name,
|
||||
date: "1991-04-05",
|
||||
birthTimeSource: "family_exact",
|
||||
birthTimeStatus: "confirmed",
|
||||
time: "07:30",
|
||||
birthPlaceLabel: "示例市",
|
||||
latitude: 30.2,
|
||||
longitude: 120.1,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffset: 8,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function record(id: string, role: "self" | "other", profile: Profile): ChartLibraryRecord {
|
||||
return { id, role, relationship: role === "self" ? "self" : "other", updatedAt: 1, profile };
|
||||
}
|
||||
|
||||
const library = [
|
||||
record("self", "self", person("小甲")),
|
||||
record("p-1", "other", person("小乙", { relation: "partner" })),
|
||||
record("p-2", "other", person("小丙")),
|
||||
record("p-3", "other", person("小丁", { date: "", time: "" })),
|
||||
];
|
||||
|
||||
test("each row carries a relation badge and 「date time · city」; an incomplete one says why it can't be picked", () => {
|
||||
const rows = chatProfilePickerRows({ status: "ready", library, selectedId: "p-1" });
|
||||
assert.deepEqual(rows.map((row) => [row.name, row.badge, row.detail]), [
|
||||
["小甲", "本人", "1991-04-05 07:30 · 示例市"],
|
||||
["小乙", "伴侣", "1991-04-05 07:30 · 示例市"],
|
||||
["小丙", "其他", "1991-04-05 07:30 · 示例市"],
|
||||
["小丁", "其他", INCOMPLETE_ROW_DETAIL],
|
||||
]);
|
||||
assert.equal(INCOMPLETE_ROW_DETAIL, "这份资料还不完整,补全后才能看盘");
|
||||
// Missing parts drop out instead of leaving a dangling separator.
|
||||
assert.equal(subjectBirthLine(person("x", { time: "" })), "1991-04-05 · 示例市");
|
||||
assert.equal(subjectBirthLine(person("x", { birthPlaceLabel: "" })), "1991-04-05 07:30");
|
||||
});
|
||||
|
||||
test("the trigger names the person, then when and where", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SubjectSwitcher library={library} status="ready" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
|
||||
);
|
||||
assert.match(markup, /aria-label="当前星盘:小乙,1991-04-05 07:30 · 示例市"/);
|
||||
assert.match(markup, /chat-profile-picker-name">小乙<\/span><span class="chat-profile-picker-detail">· 1991-04-05 07:30 · 示例市<\/span>/);
|
||||
// No detail while the list is still loading, so the trigger never shows a stale person's birth.
|
||||
const loading = renderToStaticMarkup(
|
||||
<SubjectSwitcher library={[]} status="loading" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
|
||||
);
|
||||
assert.doesNotMatch(loading, /chat-profile-picker-detail/);
|
||||
});
|
||||
|
||||
test("the row menu edits anyone, deletes only others, and holds delete while an answer is written", () => {
|
||||
assert.match(switcherSource, /aria-label=\{`\$\{row\.name\} 更多操作`\}/);
|
||||
assert.match(switcherSource, /<DropdownMenuItem onClick=\{onEdit\}>编辑<\/DropdownMenuItem>/);
|
||||
assert.match(switcherSource, /row\.role === "other" \? \(\s*<DropdownMenuItem disabled=\{requestInFlight\}/);
|
||||
assert.match(switcherSource, /if \(row\.role === "self" \|\| requestInFlight\) return;/);
|
||||
assert.equal(personEditHref("self"), "/people?self=1");
|
||||
assert.equal(personEditHref("p-1"), "/people?edit=p-1");
|
||||
assert.equal(PERSON_ADD_HREF, "/people?add=1");
|
||||
});
|
||||
|
||||
test("delete from the switcher uses the people page's count, words and order", () => {
|
||||
// The same sentence the people page renders inline.
|
||||
assert.match(peopleSource, /将同时删除 \{pendingDelete\.sessions\} 个对话和 \{pendingDelete\.reports\} 份报告\{pendingDelete\.synastry \? `和 \$\{pendingDelete\.synastry\} 份合盘` : ""\},无法恢复。/);
|
||||
assert.equal(personDeleteDescription({ sessions: 3, reports: 1 }), "将同时删除 3 个对话和 1 份报告,无法恢复。");
|
||||
assert.equal(personDeleteDescription({ sessions: 0, reports: 0, synastry: 2 }), "将同时删除 0 个对话和 0 份报告和 2 份合盘,无法恢复。");
|
||||
assert.match(peopleSource, /const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";/);
|
||||
assert.equal(PERSON_DELETE_COPY.usageUnavailable, "暂时查不到这个人的对话数量,稍后再试");
|
||||
assert.match(peopleSource, /notifyError\("删除失败,请重试"\)/);
|
||||
assert.equal(PERSON_DELETE_COPY.failed, "删除失败,请重试");
|
||||
// Fall back to 本人 before rereading the list, as the people page does.
|
||||
assert.match(switcherSource, /if \(readCurrentSubjectId\(\) === target\.id\) setCurrentSubject\("self"\);\s*invalidateSubjectCatalog\(\);\s*onReload\(\);/);
|
||||
});
|
||||
|
||||
test("the count read and the DELETE refuse 本人 and bad answers", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||
if (String(input).endsWith("/bad")) return new Response(JSON.stringify({ usage: { sessions: -1, reports: 0 } }), { status: 200 });
|
||||
if (init?.method === "DELETE") return new Response(null, { status: 204 });
|
||||
return new Response(JSON.stringify({ usage: { sessions: 2, reports: 1 } }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
assert.deepEqual(await fetchPersonDeleteUsage("p-1"), { sessions: 2, reports: 1 });
|
||||
assert.equal(await fetchPersonDeleteUsage("bad"), null);
|
||||
assert.equal(await fetchPersonDeleteUsage("self"), null);
|
||||
assert.equal(await deletePerson("self"), false);
|
||||
assert.equal(await deletePerson("p-1"), true);
|
||||
assert.deepEqual(calls, ["GET /api/chart-profiles/p-1", "GET /api/chart-profiles/bad", "DELETE /api/chart-profiles/p-1"]);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("the people page opens someone's editor from ?edit=<id>", () => {
|
||||
assert.match(peopleSource, /url\.searchParams\.get\("self"\) === "1" \? "self" : url\.searchParams\.get\("edit"\)/);
|
||||
assert.match(peopleSource, /url\.searchParams\.delete\("edit"\);/);
|
||||
assert.match(peopleSource, /if \(editRow\) \{\s*setSelectedId\(editRow\.id\);\s*setDraft\(editRow\.profile\);\s*setEditing\(true\);/);
|
||||
});
|
||||
|
||||
test("the chat-side chart carries the same picker as the header, and the drawing stays memoized", () => {
|
||||
assert.match(pageSource, /const subjectPicker = activeSession\?\.sessionType === "consultation" \|\| activeSession\?\.sessionType === "synastry_report" \? \(\s*<ChatProfilePicker/);
|
||||
assert.match(pageSource, /\{subjectPicker \?\? \(activeSession \? \(/);
|
||||
assert.match(splitSource, /const StableNatalChart = memo\(ChatNatalChart\);/);
|
||||
assert.match(splitSource, /<div className="chat-chart-switcher">\{switcher\}<\/div>/);
|
||||
});
|
||||
|
||||
test("phone widths: 44px row actions, the header keeps only the name, nothing scrolls sideways", () => {
|
||||
assert.match(css, /\.chat-profile-picker-more \{ width: 44px; height: 44px;/);
|
||||
assert.match(css, /\.chat-profile-picker-row \{ min-width: 0; display: grid; grid-template-columns: minmax\(0, 1fr\) 44px;/);
|
||||
assert.match(css, /@media \(max-width: 640px\) \{ \.chat-header \.chat-profile-picker-detail \{ display: none; \} \}/);
|
||||
assert.match(css, /\.chat-profile-picker-detail \{[^}]*min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;/);
|
||||
assert.match(css, /\.chat-profile-picker-popup \{[^}]*max-width: min\(320px, calc\(100vw - var\(--space-6\)\)\)/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -58,6 +59,41 @@ def _isolate_vedastro_snapshot_cache(tmp_path_factory, monkeypatch) -> None:
|
||||
clear_validate_memory()
|
||||
|
||||
|
||||
_FOREGROUND_EXECUTOR_MODULES = (
|
||||
"scripts.vedastro_foreground",
|
||||
"vedastro_foreground",
|
||||
"scripts.jyotish_api_server",
|
||||
"jyotish_api_server",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_foreground_vedastro_executor(monkeypatch):
|
||||
"""A fresh foreground pool per test, drained at teardown (BUG-1498).
|
||||
|
||||
A task left running stores its outcome under whatever cache directory the
|
||||
environment names when it finishes, which would be the next test's.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from scripts import vedastro_foreground
|
||||
|
||||
executor = ThreadPoolExecutor(
|
||||
max_workers=vedastro_foreground._FOREGROUND_VEDASTRO_WORKERS,
|
||||
thread_name_prefix="jyotish-vedastro-fg-test",
|
||||
)
|
||||
for name in _FOREGROUND_EXECUTOR_MODULES:
|
||||
module = sys.modules.get(name)
|
||||
if module is not None and hasattr(module, "_FOREGROUND_VEDASTRO_EXECUTOR"):
|
||||
monkeypatch.setattr(module, "_FOREGROUND_VEDASTRO_EXECUTOR", executor)
|
||||
yield
|
||||
# Queued work is dropped; running work gets a bounded wait so a stuck stub cannot hang the run.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
deadline = time.monotonic() + 10
|
||||
for thread in list(getattr(executor, "_threads", ())):
|
||||
thread.join(max(deadline - time.monotonic(), 0))
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items) -> None:
|
||||
for item in items:
|
||||
if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""BUG-1498: a foreground VedAstro task never outlives the test that started it.
|
||||
|
||||
The two tests run in file order. The first leaves a slow task on the foreground
|
||||
executor; the second checks that the task finished before it began, so it could
|
||||
not write into the second test's cache directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from scripts import vedastro_foreground as foreground
|
||||
|
||||
_SEEN: list[str] = []
|
||||
|
||||
|
||||
def _slow_task() -> None:
|
||||
time.sleep(0.3)
|
||||
_SEEN.append(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", ""))
|
||||
|
||||
|
||||
def test_a_leaves_slow_work_on_the_foreground_executor() -> None:
|
||||
_SEEN.clear()
|
||||
foreground._FOREGROUND_VEDASTRO_EXECUTOR.submit(_slow_task)
|
||||
|
||||
|
||||
def test_b_does_not_see_the_previous_tests_work() -> None:
|
||||
mine = os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"]
|
||||
assert len(_SEEN) == 1, "the earlier test's task finished during its own teardown"
|
||||
time.sleep(0.5)
|
||||
assert mine not in _SEEN, "a leftover task wrote into this test's cache directory"
|
||||
Reference in New Issue
Block a user