Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca62fda43 | ||
|
|
f4c53e7f9f | ||
|
|
6a94edf200 | ||
|
|
ad1dc89664 | ||
|
|
6350943152 |
@@ -1,5 +1,14 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
|
||||
|
||||
- 给人物档案里的其他人出报告时,后台只能按出报告要用的那些列读取,不能改档案(BUG-1063)。
|
||||
- 对话输入框下方,以及报告标题下,标明由 AI 生成、仅供参考。报告结尾原有的免责还在。Skill 版本不变(BUG-1347)。
|
||||
|
||||
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
|
||||
|
||||
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
|
||||
|
||||
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
|
||||
|
||||
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
|
||||
|
||||
+63
-33
@@ -4243,9 +4243,9 @@
|
||||
|
||||
## BUG-283 | 今日星语每次进入首页都停在“没能生成”
|
||||
|
||||
- 状态:regressed
|
||||
- 状态:superseded(由 BUG-299 取代)
|
||||
- 首次发现:2026-08-18
|
||||
- 最近更新:2026-08-19
|
||||
- 最近更新:2026-10-14
|
||||
- 影响面:`/` 首页“今日星语”卡片、`POST /api/daily-starlanguage`、进程内缓存与浏览器当日缓存
|
||||
- 用户现象:资料完整的账号每次打开首页,卡片都显示“今天的星语没能生成,稍后再回来看看;这里不会用通用文案顶替。”,没有当日内容。
|
||||
- 触发条件:出生时间可用于个人星盘的账号进入首页。冷路径或换实例后必现。
|
||||
@@ -13636,7 +13636,7 @@
|
||||
|
||||
## BUG-906 | 星盘 SVG 把 CSS 的 height:auto 写成了属性
|
||||
|
||||
- 状态:investigating(分支已修,定向回归见进度记录;未部署,不标 resolved)
|
||||
- 状态:investigating(已部署 staging `82c8aeb0`,真机欠;不标 resolved)
|
||||
- 首次发现:2026-09-16
|
||||
- 最近更新:2026-09-25
|
||||
- 影响面:星盘页西洋盘 `WesternWheelSvg`、报告与星盘页共用的 `VedicChartSvg`。
|
||||
@@ -13648,11 +13648,11 @@
|
||||
- 防复发:星盘 SVG 合同禁止再写 `height="auto"`。高度自适应只放在已有的 CSS 规则里;离线导出要长度时写在导出序列化,不写回组件属性。
|
||||
- 相关记录:无。引入提交为星盘页 `830799fa` 与报告盘面 `2fdcb14f`,不是已编号缺陷的复发。
|
||||
- 复发自:无
|
||||
- 修复版本:本分支 `codex/console-noise-20260916`,待提交与部署核对。
|
||||
- 修复版本:已部署 staging `82c8aeb0`,真机欠。
|
||||
|
||||
## BUG-907 | 没有后台咨询任务时,状态查询仍返回 404
|
||||
|
||||
- 状态:investigating(分支已修,定向回归见进度记录;未部署,不标 resolved)
|
||||
- 状态:investigating(已部署 staging `82c8aeb0`,真机欠;不标 resolved)
|
||||
- 首次发现:2026-09-16
|
||||
- 最近更新:2026-09-25
|
||||
- 影响面:`GET /api/consult/status` 无参查询,以及首页启动、页面重新可见、`online` 时的 `fetchActiveConsultationStatus`。
|
||||
@@ -13664,7 +13664,7 @@
|
||||
- 防复发:无参空结果不得再返回 404。带参「咨询请求不存在」必须保持 404。客户端读无参状态必须先看 204/404,不得先解析没有 body 的 204。
|
||||
- 相关记录:BUG-276(带参 404 与刷新重放,本条不改那条语义)。控制台第三条 `reportAllChanges` 读取 `startTime` 的报错是浏览器扩展注入脚本,非本仓代码,不加 web-vitals、不加 try/catch;验证方法是无痕窗口。
|
||||
- 复发自:无
|
||||
- 修复版本:本分支 `codex/console-noise-20260916`,待提交与部署核对。
|
||||
- 修复版本:已部署 staging `82c8aeb0`,真机欠。
|
||||
|
||||
## BUG-1001 | 校正会话标题仍写日期,同一天多条无法区分
|
||||
|
||||
@@ -14366,18 +14366,18 @@
|
||||
|
||||
## BUG-1063 | 他人报告 worker 用服务角色读 `chart_profiles`,但服务角色对这张表没有任何权限
|
||||
|
||||
- 状态:investigating(静态证据;本轮不修,授权范围需要产品 / 安全决定)
|
||||
- 首次发现 / 最近更新:2026-09-27 / 2026-09-27
|
||||
- 状态:fixed-pending-verify(本分支已修;部署并真机给别人出一份报告前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-09-27 / 2026-10-14
|
||||
- 影响面:报告 worker 为「星盘档案里的其他人」生成报告(`personal-report-worker-subject.ts` → `loadSubjectBirth(admin, …)` → `from("chart_profiles").select(CHART_SUBJECT_SELECT)`)。
|
||||
- 现象(推断,未在真实库复现):选别人生成的报告,worker 读人物资料失败,按 `subject_unavailable` → `profile_incomplete`(可重试)处理,报告可能一直排队或最终失败退款。
|
||||
- 触发条件:BUG-1031 让 worker 改走 `loadSubjectBirth` 之后(`4d801e53` 起)的他人报告。
|
||||
- 根因(已确认的事实):`20260718100000_repair_missing_chart_profiles.sql` 对 `service_role` 执行 `revoke all on table public.chart_profiles`,之后没有任何迁移把它授回;admin 客户端在自托管模式下是 `set local role service_role`(`admin-client-core.ts`)。BYPASSRLS 只绕过行级策略,不给表权限。未在真实库验证 staging 是否另有手工授权。
|
||||
- 修复:未修。可选做法是给 `service_role` 授 `chart_profiles` 的逐列 `SELECT`(只列 `CHART_SUBJECT_SELECT` 的列),或让 worker 走 SECURITY DEFINER 的只读函数;两者都扩大服务端能读到的他人出生资料范围,需要决定。
|
||||
- 验证:—(建议先在 staging 库只读执行 `select has_table_privilege('service_role','public.chart_profiles','select')` 确认)
|
||||
- 防复发:新增的服务角色读取路径必须有真实 PostgreSQL 用例,mock 客户端测不出表权限。
|
||||
- 根因(已确认):`20260718100000_repair_missing_chart_profiles.sql` 对 `service_role` 执行 `revoke all on table public.chart_profiles`,之后没有迁移把读权限授回。后台 admin 客户端在自托管模式下是 `set local role service_role`。BYPASSRLS 只绕过行级策略,不给表权限。改授权前,本地 PostgreSQL 用服务角色按 `CHART_SUBJECT_SELECT` 读取返回 `42501` `permission denied for table chart_profiles`。
|
||||
- 修复:`20261014010000_chart_profiles_service_role_select.sql` 只给 `service_role` 授 `CHART_SUBJECT_SELECT` 那些列的 SELECT。不授写,不改 RLS,不动 `authenticated` 已有授权。产品 2026-10-14 选择逐列只读,不建 SECURITY DEFINER 函数。
|
||||
- 验证:`frontend/tests/database-report-worker-subject.test.ts` 在授权后读到虚构人物档案;`frontend/tests/chart-profiles-service-role-select-20261014.test.ts` 锁住选择列都在授权列里,且迁移不授写、不改策略。全量 `npm run test:db` 与 Windows 已知失败名单比对,见进度记录。
|
||||
- 防复发:上述两则测试。新增服务角色读取路径必须有真实 PostgreSQL 用例,mock 客户端测不出表权限。
|
||||
- 相关记录:BUG-1031、BUG-1062
|
||||
- 复发自:无
|
||||
- 修复版本:待定
|
||||
- 修复版本:分支 `codex/beta-main-path-20261014`,尚未合入 staging。真机给别人出一份报告后才 resolved。
|
||||
|
||||
## BUG-1070 | 普通对话替用户和当事人说心思:反差写到人身上、是非题被改写成「你是否需要」
|
||||
|
||||
@@ -14540,7 +14540,7 @@
|
||||
|
||||
## BUG-1080 | 新账号首次建盘:看不到群星过场,只看到「解锁完整咨询」兑换弹窗
|
||||
|
||||
- 状态:investigating(修复已在分支 `codex/birth-sky-pacing-20260928`,待部署 staging 后真机确认;过场没出现的确切原因未在真实环境取证)
|
||||
- 状态:investigating(修复 `dec36bce` 已部署 staging,真机欠;过场没出现的确切原因未在真实环境取证)
|
||||
- 首次发现 / 最近更新:2026-09-28 / 2026-09-28
|
||||
- 影响面:0 点数、无订阅的新账号,首页 onboarding 保存本人出生地之后的那一刻(`TASK-birth-sky-followup-20260928` F4 群星过场)。
|
||||
- 现象:产品在 staging 真机新建账号、走完 onboarding 后,没有看到群星汇聚过场,只看到「解锁完整咨询」兑换弹窗。
|
||||
@@ -14552,7 +14552,7 @@
|
||||
- 验证:`frontend/tests/birth-sky-polish-20260928.test.ts`(兑换弹窗只由被拒的发送打开、第一次兑换弹窗第二次 billing、UI 预览不弹、402 后草稿退回、过场 portal 到 body);`membership-page.test.ts` 改写的时机断言;全量前端测试失败清单与基线逐条相同。无头 Chrome 最小复现见上。
|
||||
- 防复发:首页不得在「本人刚保存出生地」这一刻自动弹出任何弹窗;顶层 `<dialog>` 也要 portal 到 `SidebarInset` 之外;过场是否真的放了,部署后按 `docs/testing/birth-sky-cover-checklist.md` 新账号条目真机走一遍(若仍看不到过场,优先查 3 秒预算内 `/api/birth-sky` 的耗时)。
|
||||
- 相关记录:BUG-968(`SidebarInset` inert 与 portal 约定)、BUG-434(兑换弹窗样式);`TASK-birth-sky-followup-20260928` F4、`TASK-birth-sky-polish-20260928` P4。
|
||||
- 修复版本:分支 `codex/birth-sky-pacing-20260928`(本条所在提交),未部署。
|
||||
- 修复版本:已部署 staging `dec36bce`,真机欠。
|
||||
|
||||
## BUG-1081 | 全站没有任何地方能改本人的出生资料(星盘档案只给他人「编辑」)
|
||||
|
||||
@@ -15127,7 +15127,7 @@
|
||||
|
||||
## BUG-1128 | 每次部署后浏览器重新下载全部 JS 与宋体字体
|
||||
|
||||
- 状态:字体部分 resolved(待部署,staging 两次部署地址不变待核);JS 部分 blocked(`supportsImmutableAssets` 在自托管构建下不生效,见 BLOCKED.md)
|
||||
- 状态:字体部分 resolved(已部署 staging `8112f62b`,真机欠);JS 部分 blocked(`supportsImmutableAssets` 在自托管构建下不生效,见 BLOCKED.md)
|
||||
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
|
||||
- 影响面:`frontend/next.config.ts`(`deploymentId`)、`src/app/fonts/serif-sc/`。
|
||||
- 现象:进站慢、标题宋体很久才出来;staging 一天部署多次,用户几乎每次都是冷缓存。
|
||||
@@ -15137,7 +15137,7 @@
|
||||
- 验证:本地生产构建:CSS 里的切片地址为 `url(/fonts/serif-sc/jyotisha-serif-sc-00.1812be0085.woff2)`,不带 `?dpl`;`next start` 返回 `Cache-Control: public, max-age=31536000, immutable`。`tests/test_serif_font_slices.py` 新增「文件名哈希与内容一致、public 旁有 OFL」;`serif-headings-contract.test.ts` 新增 next.config 长期缓存断言;`font-stack-loadable-contract.test.ts` 按 public 解析绝对地址。
|
||||
- 防复发:要长期缓存的静态资源不走 Next 打包(打包资源带每次部署都变的 `?dpl=`),放 public/、文件名带内容哈希、配 immutable 响应头,并用测试钉住「名字对得上内容」。JS 的每次部署失效要等部署方式支持 immutable assets(adapter)后再议,不得为此去掉 `deploymentId`(BUG-936)。
|
||||
- 相关记录:BUG-936(引入 `deploymentId` 的原因)、BUG-737
|
||||
- 修复版本:分支 `codex/home-first-load-20260930`(`8112f62b`)
|
||||
- 修复版本:已部署 staging `8112f62b`,真机欠。JS 部分仍 blocked。
|
||||
|
||||
## BUG-1129 | 加载屏标题用宋体,JS 就绪前先下载 3~4 个字体切片
|
||||
|
||||
@@ -16723,7 +16723,7 @@
|
||||
|
||||
## BUG-1244 | 普通对话首轮按固定小标题汇报,读起来不像聊天
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-conversational-answer-20261006`;部署和真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `e62d0a32`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:产品看普通对话首轮,反馈像机器在汇报。任务书 `docs/tasks/TASK-consult-conversational-answer-20261006.md`。
|
||||
- 影响面:普通咨询本命 / 无出生分钟 / 申报时段的首轮写作指令、续写提示、思考计划里的「这周可以做什么」、没有标题时的写作行。不改证据卡、工具、模型、输出长度上限、答题时钟、计费。每日星语和「深入看今日」的骨架不动。生时校正的口气不动。
|
||||
@@ -16738,7 +16738,7 @@
|
||||
|
||||
## BUG-1245 | 提示词里的行动示范被回答逐字照抄
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-conversational-answer-20261006`;部署和真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `e62d0a32`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:同上任务书。产品指出示范里的具体做法会出现在真实回答里。
|
||||
- 影响面:`product-voice.ts` 的 Good 示范。不改计算,不改证据卡。
|
||||
@@ -16753,7 +16753,7 @@
|
||||
|
||||
## BUG-1252 | iPhone 上回答右侧一条空白,输出时正文抖动
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-readable-20261006`;真机确认前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `52d14e8f`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:产品 iPhone 真机截图(staging,普通对话婚恋题)。
|
||||
- 影响面:所有对话里的助手回答正文(普通咨询、生时校正旁白);首次引导气泡另有规则,未动。
|
||||
@@ -16768,7 +16768,7 @@
|
||||
|
||||
## BUG-1253 | 情感等回答把内部分析清单原样念给用户,看不懂
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-readable-20261006`;无模型凭据,真机复核前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `52d14e8f`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:同 BUG-1252 截图。
|
||||
- 影响面:普通咨询各领域回答的措辞;工具结果里的精简清单、通用读法;首轮与追问轮指令。不改证据卡、工具、打分。
|
||||
@@ -16782,7 +16782,7 @@
|
||||
|
||||
## BUG-1254 | staging 门禁偶发红:数据库测试排队等 PostgreSQL 槽位超时
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/postgres-fixture-queue-20261006`;需在门禁上连续通过才标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `52d14e8f`;门禁 run 1747、1750、1753、1756、1759、1762 连续六次通过;再观察前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
|
||||
- 来源:门禁 run 1746(`171a3f25`)「Frontend and database tests」红;同日 run 1742(`da226db1`)同一步红,随后同代码的 run 1743(`e62d0a32`)通过。
|
||||
- 影响面:Gitea `backend-quality-gate.yml` 的 `npm test --prefix frontend`;`frontend/tests/helpers/postgres-fixture.ts`。与被测提交的改动无关时也会红,阻断部署。
|
||||
@@ -16798,7 +16798,7 @@
|
||||
|
||||
## BUG-1255 | 回答口吻与首页不一致、读心一刀切禁掉;人设规则互相打架
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/consult-voice-sister-20261007`;无模型凭据,真机复核前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `b8385adb`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品审阅当前回答规则清单后的决定(非线上报错)。
|
||||
- 影响面:普通咨询三种模式共用的口吻(`product-voice.ts` 的 `productConversationVoice`)、本命合同的 Never 列表、追问指令、`mastra/index.ts` 末尾一句。校正 Agent 口吻、首页开场语不动。
|
||||
@@ -16813,7 +16813,7 @@
|
||||
|
||||
## BUG-1256 | 普通对话系统提示 5.5 万字符:同一形状说 4 遍、方法书带进工程记录与冲突的输出合同
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/prompt-slim-20261007`;部署与真机前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `c76fdda7`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品问「提示词规则 2 万多字能不能优化」,Claude 实测体量后产品授权直接执行,并提供临时模型 key 做改前改后对比。
|
||||
- 影响面:本命对话与申报时段对话的系统提示(`mastra/index.ts`、`product-voice.ts` 合同、`consultation-thinking-plan.ts` 的 natal 标题规则、`skill-binding.ts` 的方法块)。不改证据卡、工具、领域清单、用户轮指令、模型与时钟。
|
||||
@@ -16827,7 +16827,7 @@
|
||||
|
||||
## BUG-1257 | 生时校正口吻与普通对话不一致;开场 / 只读轮整本 Skill 进模型
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-voice-slim-20261007`;部署与真机前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `61d6f248`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品要求把生时校正也按普通对话的方式优化、口吻统一由 Claude 直接做。
|
||||
- 影响面:`mastra/agentic-rectification.ts` 两版系统提示的开头;`v9/skill-slice.ts` 的按轮次切片。不改校正打分、出卡、采用门、工具、服务器写的固定文案、Skill 文件。
|
||||
@@ -16842,7 +16842,7 @@
|
||||
|
||||
## BUG-1258 | 生时校正服务器固定句口气生硬(「已记录。」回执、「请……」、内部词)
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-copy-voice-20261007`;真机前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `6ec8efd5`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:BUG-1257 后产品要求把服务器写死的提示语也改成知心姐姐口吻;Claude 出 15 句样稿,产品认可后全量改。
|
||||
- 影响面:`user-copy.ts`、`v9/choice-action.ts`、`v9/post-adopt-verdict.ts`、`v9/adopt-narration.ts`(及其旁白提示词里引用的同一句)、`v9/run-diagnostic.ts` 的重复文案、`rectification-chat-choice-run.ts` 兜底、`rectification-agentic-chat.tsx` 两句输入框提示。不改系统报错、写给模型的规则、题库与盘型说明、数字与诚实边界。
|
||||
@@ -16856,7 +16856,7 @@
|
||||
|
||||
## BUG-1247 | 首页生时校正按钮与文字口令:入口在对话之外、对话里只靠固定口令跳转
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `11333460`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。
|
||||
- 现象:首页有独立「生时校正」pill;普通对话里只有打出「生时校正 / 先完成生时校正」这类原话才会新开一条校正会话,原问题只剩一行展示。
|
||||
@@ -16868,7 +16868,7 @@
|
||||
|
||||
## BUG-1248 | 对话里没有「推得不准」时的校正提议
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `11333460`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。
|
||||
- 现象:用户在普通对话里说过去的事对不上、出生时间是估的,agent 没有任何校正相关的指令或入口。
|
||||
@@ -16878,7 +16878,7 @@
|
||||
|
||||
## BUG-1249 | 校正不知道是从哪条对话开始的
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `11333460`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。
|
||||
- 修复:打开接口在 `intent=homepage` 且带 `sourceSessionId` 时,把该会话里最新一张 `offered` 提议卡改为 `opened` 并写入 `caseId` / `rectificationSessionId`(调用者自己的客户端,`messages` 为本人可更新列;best effort)。新 `GET /api/rectification/cases/[caseId]/source` 在本人最近 20 条咨询会话里找绑定该 case 的提议卡,刷新后仍能找回来源。不改表。
|
||||
@@ -16887,7 +16887,7 @@
|
||||
|
||||
## BUG-1250 | 校正做完让用户「新建对话」,原问题和上下文丢失
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `11333460`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。
|
||||
- 修复:`POST /api/rectification/cases/[caseId]/source` 由服务端读 case 的 `accepted_time` 判断是否改了时间,在来源对话末尾追加一条分隔消息(`rectificationReturn`,每个 case 一次)并把提议卡标为 `returned`;浏览器重读该会话并切过去。校正面「回到刚才的对话」按钮(仅有来源时);采用后核对结束(`verified_idle`)或 case 终态时,在本视图做过操作的校正 2.5 s 后自动回去(`useRectificationAutoReturn`),历史打开的已结束 case 不自动跳。分隔线下「按新时间重新看」走 `onFollowUp`,点击才发送。分隔消息不显示操作行、不参与「重新生成」。
|
||||
@@ -16896,7 +16896,7 @@
|
||||
|
||||
## BUG-1251 | 改了出生时间后,模型仍读得到旧时间下的回答
|
||||
|
||||
- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `11333460`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。
|
||||
- 修复:`storedConsultationTurns` 跳过分隔消息;最近一次改时间的分隔线之前的助手回答,在模型历史里前缀「(以下回答按校正前的出生时间,不要沿用其中的盘面判断)」;会话摘要早于改时间时同样加这句。
|
||||
@@ -16905,7 +16905,7 @@
|
||||
|
||||
## BUG-1259 | 新对话里还没发第一句就换模型,提示「云端同步失败」
|
||||
|
||||
- 状态:fixed-pending-verify(真机复核前不标 resolved)
|
||||
- 状态:fixed-pending-verify(已部署 staging `0ed45a4b`,真机欠;不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-07 / 2026-10-07
|
||||
- 来源:产品 iPhone 真机截图(staging 首页,新对话未发消息时切到 gpt-5.6-luna)。
|
||||
- 影响面:`use-session-management.ts` 的 `selectSessionModel` → `patchSessionModel` → `PATCH /api/sessions/{id}`;首页 / 新建的空对话。
|
||||
@@ -16917,7 +16917,7 @@
|
||||
- 验证:`session-list-filter.test.ts` 新增 1 条源码合同(守卫在任何 PATCH 之前、清失败标记并 return、create 写入 `model_id`);去掉修复后该测试失败、恢复后通过。全量 4988 项 fail 24,与 `11333460` 逐条一致;tsc 0、lint 0 error;build `/` Static,首屏 gzip 568,343 → 568,362 B。
|
||||
- 防复发:上述测试;以后凡是对会话发 PATCH 的入口,先判断会话是否已落库(BUG-989 的「第一问才落库」)。
|
||||
- 相关记录:BUG-989、BUG-928。
|
||||
- 修复版本:待发布
|
||||
- 修复版本:已部署 staging `0ed45a4b`,真机欠。
|
||||
|
||||
## BUG-1260 | D5/D6/D8/D11 分盘映射与 PVR 6.2 不一致
|
||||
|
||||
@@ -17885,6 +17885,21 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1329 | 流式回答期间首页侧栏每帧整栏重渲染
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #59。任务书 `docs/tasks/TASK-home-sidebar-rerender-20261013.md`。
|
||||
- 现象:回答逐字出来时,左侧会话列表跟着每一帧重画。会话越多越重。外观、顺序和点击结果没有变。
|
||||
- 触发条件:首页已登录,回答按帧写入。每一帧都会让首页再渲染一次。
|
||||
- 根因:BUG-971 切断了「首页订阅自己的注册」这条反馈,但注册仍然每次渲染都发布一份新对象。会话回调和可见列表每次渲染都是新的,外壳就跟着提交一次。不是回归,是当时留下的开销。
|
||||
- 修复:外壳拿到的是稳定包装,点下去仍执行首页最新一次渲染的回调。注册只在会话、当前项、账户或开关这些数据变了才发布;列表逐条对象没变就不重建行。可见列表按当前人物记忆。侧栏上下文对象也不再每次新建。没有运行时开关。
|
||||
- 验证:同一套计数测试。改前 60 帧注册 60 次、侧栏提交 60 次;改后注册 0 次、提交 0 次。换当前项、改标题、换人各发布 1 次。包装函数身份不变,调用记到的是最新一次渲染。详见 `docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`。2026-10-14 补了一条旧检查:账户菜单里的充值仍须打开 billing,来源仍是 account-menu,只是改认稳定包装的写法。
|
||||
- 防复发:外壳注册须按数据变化发布。任何流式会话面要有侧栏提交次数的按帧断言。`frontend/tests/home-sidebar-rerender.test.tsx` 在 60 帧内要求注册 0 次、侧栏提交 0 次。
|
||||
- 相关记录:BUG-971、BUG-725、BUG-473、BUG-744、BUG-745、BUG-746。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/home-sidebar-rerender-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
@@ -17915,3 +17930,18 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1347 | 对话和报告阅读页没有标明内容由 AI 生成
|
||||
|
||||
- 状态:fixed-pending-verify(代码与组件测试已过;真机看对话和报告前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:内测主路径。产品 2026-10-14 同意文案。
|
||||
- 现象:协议里写了回答和报告由人工智能生成,报告结尾也有免责。普通对话、生时校正的输入框下,以及报告标题下,都没有一行能直接看见的标识。
|
||||
- 触发条件:打开对话或打开一份报告。
|
||||
- 根因:合规那一轮只写进协议和生成正文的结尾,没有放到界面上。
|
||||
- 修复:`ChatComposer` 在输入框下方加一行说明,普通对话和生时校正一起生效,不改首页状态数。报告的 Markdown 阅读页和结构化阅读页在标题下加一行。中英文文案按产品同意的句子。不改报告生成正文,结尾免责保留。下载的 PDF 与 Word 目前没有这套导出;Markdown 下载有字节锁,本轮不改下载文件。
|
||||
- 验证:`frontend/tests/ai-generated-notice-20261014.test.ts`。tsc 与 lint 见进度记录。
|
||||
- 防复发:上述测试锁住两处对话都走同一个组件里的这行,以及报告中英文标题下的句子。
|
||||
- 相关记录:BUG-1063
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/beta-main-path-20261014`,尚未合入 staging。
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# PROGRESS · 内测主路径:给别人出报告、AI 生成标识、状态板清账(issue #73)
|
||||
|
||||
- 分支:`codex/beta-main-path-20261014`
|
||||
- 实际基线:`origin/staging` `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`
|
||||
- 任务书基线写成 `48a2fadd`,计划评论里的 base 是 `13bde11539bc99f76330376145c756da22d149e3`。开工时远端 staging 已是上面的 `f4c53e7f`。方向没有改,不需要拍板。
|
||||
- 产品 2026-10-14 已同意标识文案,并指定 BUG-1063 用逐列只读授权(任务书 §11)。
|
||||
- 开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1346。本单新增 BUG-1347。BUG-1246 在本文件里没有对应标题,没有改。
|
||||
- 未改 `page.tsx`。没有授写权限,没有改 RLS,没有改 `authenticated` 现有授权。Skill 版本不变。
|
||||
|
||||
## 做了什么
|
||||
|
||||
T0:给别人出报告的后台在授权之前读不到档案表。同一条数据库用例失败,错误原文是 `permission denied for table chart_profiles`,代码 `42501`。这不是绿的,所以继续做 T1。
|
||||
|
||||
T1:新迁移只给 `service_role` 增加档案表上、报告读档所需各列的 `SELECT`。合同测试核对选择列都在授权里,并且新文件里没有写授权、没有策略改动、没有改登录用户授权。BUG-1063 记为 fixed-pending-verify:部署并真机给别人出一份报告之前不标 resolved。
|
||||
|
||||
T2:对话输入框下方一行中文说明「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」报告的两种阅读页在标题下加「本报告由 AI 生成,仅供参考。」英文阅读用「This report is AI-generated and for reference only.」英文对话那句写在文案口径里,界面是中文,不另画一行。没有单独的公开分享页,两种阅读页都带上了这行。DESIGN、VOICE 同提交更新。
|
||||
|
||||
T2b 让掉:仓库里没有 PDF / DOCX 导出器。Markdown 下载是按字节锁定的,没有改生成正文,也没有往下载文件里写元数据。记在 BUG-1347。
|
||||
|
||||
T3–T5:状态行只改措辞和部署证据。下列提交都已在 `origin/staging` 上:`82c8aeb0`(906、907)、`dec36bce`(1080)、`8112f62b`(1128 字体部分)、`e62d0a32`(1244、1245)、`52d14e8f`(1252、1253、1254)、`b8385adb`(1255)、`c76fdda7`(1256)、`61d6f248`(1257)、`6ec8efd5`(1258)、`11333460`(1247–1251)、`0ed45a4b`(1259)。仍是 fixed-pending-verify 或 investigating,没有改成 resolved。1254 写明门禁 run 1747、1750、1753、1756、1759、1762 连续六次通过,再观察前不标 resolved。BUG-283 状态改为 superseded(由 BUG-299 取代),根因原文保留。
|
||||
|
||||
T6:`scratchpad/beta-smoke-checklist-20261014.md` 不在这棵树上。冒烟清单已经在 `docs/testing/beta-smoke-20261014.md`,本单只补了第 4、8、9 行(对话标识、他人报告、报告标题中英文标识)。
|
||||
|
||||
## 断言三栏
|
||||
|
||||
没有改既有断言。新增用例只加期望。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0,约 51 秒。中途一次失败是测试里 `createRef()` 没写元素类型,已改成文本框类型后重跑通过 |
|
||||
| 规范 | `npm run lint --prefix frontend` | 退出码 0,0 error,125 条原有 warning。本单改的文件没有新增 warning |
|
||||
| 定向测试 | `tsx --test`:AI 标识、授权合同、输入框合同、报告阅读 | tests 51,pass 51,fail 0,cancelled 0,退出码 0,约 3.3 秒 |
|
||||
| 数据库 | `npm run test:db --prefix frontend` | 退出码 1。tests 82,pass 69,fail 13,cancelled 0,约 1390 秒。13 条失败的名字与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的。本单那条是 ok 54 |
|
||||
| 全量前端测试 / 构建 / Python 快速门 | 未跑 | 留给验收。本单不改 `scripts/` |
|
||||
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 退出码 0,约 59 秒 |
|
||||
|
||||
## 偏离
|
||||
|
||||
- 任务书点名的数据库文件可以新建 `database-report-worker-subject.test.ts`,用的是这个名字,没有改旧的 `database-chart-subject.test.ts`。
|
||||
- 英文对话说明没有画在界面上,因为对话外壳只有中文。句子留在 VOICE。
|
||||
- T2b 按让步顺序跳过。
|
||||
- 冒烟草稿不在树里,改的是已经落盘的清单,没有另起一份。
|
||||
@@ -0,0 +1,70 @@
|
||||
# PROGRESS · 流式回答时侧栏不再跟着每帧重画(issue #59)
|
||||
|
||||
- 分支:`codex/home-sidebar-rerender-20261013`
|
||||
- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`
|
||||
- 任务书基线写成 `77057a819`,计划评论里的 base 是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。开工时远端 staging 已是上面的 `66cd6008`。A/B 就跑在这个提交上,没有另开只读工作树(本轮不能离开当前工作树)。方向没有改,不需要拍板。
|
||||
- `docs/BUG_HISTORY.md` 在这个基线上停在 BUG-1322。BUG-1323~1328、1345、1346 属于还没合入的别的分支,本单只用 BUG-1329。
|
||||
- 未改 `page.tsx`。没有运行时开关。没有启用 React Compiler。Skill 版本不变。浏览器 A/B 未跑。
|
||||
|
||||
## 做了什么
|
||||
|
||||
外壳拿到的点击函数不再每次渲染换新,点下去仍执行首页刚渲染的那一版。注册只在数据变了才发给侧栏:会话行要逐条还是同一个对象才算没变,没变就不重建行。可见会话按当前人物记忆。侧栏自己的上下文对象也不再每次新建。卸载首页时仍会清掉注册。
|
||||
|
||||
## 断言三栏
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `frontend/tests/session-list-lifecycle.test.tsx` 外壳「打开资料」函数 | `assert.notEqual(onOpenProfile, initialCallback)` | `assert.equal(...)`;再改一版后仍是同一个函数,调用记录从 `[1]` 加强到 `[1, 2]` | 包装函数身份稳定。调用必须落到最新一次渲染,不能只删掉「不相等」 |
|
||||
|
||||
`HomeProbe` 仍是每次渲染新回调、新数组,没有改成常量。
|
||||
|
||||
## 基线失败(改代码之前,同一测试,3 次,退出码都是 1)
|
||||
|
||||
失败停在 `assert.equal(duringFrames.registers, 0)`,实际是 60。60 帧里侧栏提交也是 60。换当前项、改标题、换人各注册 1 次;只改回调版本也会再注册 1 次。剔除函数后的内容摘要:
|
||||
|
||||
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时三次(毫秒) | 中位 | 内容摘要 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 30 条虚构会话 | 60 | 60 | 2774.964 / 2778.961 / 4202.085 | 2778.961 | `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
|
||||
| 100 条虚构会话 | 60 | 60 | 10530.828 / 11799.253 / 12284.103 | 11799.253 | `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
|
||||
|
||||
更早一次探针把注册函数也做成每次渲染新建,60 帧数到 120 次注册、60 次提交。那是清理函数跟着新函数又清了一次,不是产品路径。交付用的测试和生产一样,用提供者给出的稳定注册函数,所以改前是 60 而不是 120。
|
||||
|
||||
## 改后(同一测试,3 次,退出码都是 0)
|
||||
|
||||
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时中位 | 换当前项 | 改标题 | 只改回调版本 | 换人 | 内容摘要 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 30 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
|
||||
| 100 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
|
||||
|
||||
三次摘要都与上表相同。门槛达到:60 帧内注册 0、侧栏提交 0,内容摘要逐字节一致。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| 规范 | `npm run lint --prefix frontend` | 0 error,126 条原有 warning。本单文件没有新增 warning,没有 `eslint-disable` |
|
||||
| 定向测试 | `tsx --test` 下表 10 个文件 | tests 94,pass 94,fail 0,cancelled 0 |
|
||||
| 数据库 | 未跑 | 本单不碰数据库 |
|
||||
| 浏览器 | 未跑 | 浏览器 A/B 未跑。步骤在 `docs/testing/home-sidebar-rerender-20261013.md` |
|
||||
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 通过 |
|
||||
|
||||
定向文件:`home-sidebar-rerender`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`sidebar-state`、`secondary-page-entry`、`account-dialog-inert-20260918`、`home-shell-growth-contract`、`home-streaming-render-split`、`chat-profile-picker`。
|
||||
|
||||
全量 `npm test`、`next build`、Python 快速门未跑,留给验收。
|
||||
|
||||
## 2026-10-14 修复单
|
||||
|
||||
按修复单合并了最新 `origin/staging`。只改了 `frontend/tests/chat-navigation-a11y-contract.test.ts` 里账户菜单「充值」那一条源码断言,改成认稳定包装的写法,仍然要求打开 billing 且来源是 `account-menu`。业务代码没有改。
|
||||
|
||||
又搜了一遍 `onOpenProfile`、`onOpenGeneral`、`onOpenLogout`、`onNewChat`、`onSelectSession`、`onLoadMore`。测试里其余命中的是侧栏组件自己的入口,或校正点数不足时打开充值,不是这条旧写法,没有改。
|
||||
|
||||
和上一版 `63509431` 比,网页业务目录里多出来的只有合并测试线带入的校正提速(`engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning。本单改的测试文件没有新增 warning |
|
||||
| 这条旧检查 | `tsx --test tests/chat-navigation-a11y-contract.test.ts` | 13 通过,0 失败,0 取消 |
|
||||
|
||||
全量 `npm test` 仍留给验收。
|
||||
@@ -332,7 +332,7 @@
|
||||
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
|
||||
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
|
||||
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
|
||||
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
|
||||
| `TASK-beta-main-path-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待验收 | `codex/beta-main-path-20261014` |
|
||||
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
|
||||
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
|
||||
| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — |
|
||||
@@ -470,8 +470,8 @@
|
||||
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-node-runtime-resources-fix-20261014.md` | `PROGRESS-node-runtime-resources-20261013.md` | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-fix-20261014.md` | — | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待验收 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-fix-20261014.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待验收 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` |
|
||||
| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` |
|
||||
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
| 1 | 新人第一次进站 | 一个从没用过的邮箱;手机 Safari | 打开登录页 → 勾选同意 → 输入邮箱 → 发验证码 → 输入固定码 → 填出生资料(城市、日期、时间) | 不要求设密码;建盘后先看到群星汇聚过场,**不先弹兑换窗**;过场后进入首页 | BUG-1080、1152;`staging-otp-skip-password-20261001.md`、`birth-sky-cover-checklist.md` | | | |
|
||||
| 2 | 首页 | 第 1 条的账号 | 停留首页、刷新一次 | 不卡在「正在载入账户」;今日星语有当天内容(不是「没写出来」);会话列表正常 | BUG-936、283 / 299;`home-first-load-20260930.md`、`session-list-rebuild-20260921.md` | | | |
|
||||
| 3 | 寒暄不扣点 | 记下当前点数 | 新对话发「你好」 | 一句简短回应,不排盘、不出长解读;点数不变 | BUG-976、977;`consult-smalltalk-fastpath-20260920.md` | | | |
|
||||
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句 | 回答几段人话、不念内部清单;输入框下方有「回答由 AI 生成,仅供参考……」一行;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
|
||||
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句;再打开一条生时校正看输入框 | 回答几段人话、不念内部清单;两处输入框下方都有「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」,窄屏不换行、不挡住发送;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、1347、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
|
||||
| 5 | 明说要校正 | 档案出生时间是「大约」 | 新对话说「帮我做一下生时纠正」;另开一个对话说「这个解读我不认同」 | 第一句直接进入校正,不先写解读;第二句**不**进入校正,只改读法 | BUG-1306、1317;`rectification-explicit-entry-20261012.md` 第 1、1b、4、9 步 | | | |
|
||||
| 6 | 校正开场 | 第 5 条进入的校正 | 看开场三句 | 说出填的时间离最近换段差几分钟、两边哪几件事不同;离得近时有「这里连本命上升都换」;不出现秒、「记录时间是错的」 | BUG-1307、1309;`rectification-nearest-boundary-opening-20261012.md` | | | |
|
||||
| 7 | 人物档案不串人 | 人物档案里加一位家人 | 切换到家人 → 设为默认 → 回到本人 → 再切家人 | 每次显示的资料都是对应的人,名字、出生资料不串 | BUG-1030;`people-archive-p1-20260924.md` | | | |
|
||||
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、内测主路径单 T1/T2 | | | |
|
||||
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;英文版无中文残留;结尾免责在 | BUG-1312~1316;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
|
||||
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、1347、内测主路径单 T1/T2 | | | |
|
||||
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;中文标题下有「本报告由 AI 生成,仅供参考。」,英文标题下是 “This report is AI-generated and for reference only.”;英文版无中文残留;结尾免责在 | BUG-1312~1316、1347;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
|
||||
| 10 | 反馈与注销 | 任一测试账号 | 对一条回答点踩并写一句反馈 → 后台「反馈」页查看;账户设置里申请注销 → 再撤销 | 后台能看到反馈;注销申请显示 7 天冷静期,撤销后账户恢复 | `surface-feedback-20260929.md`、`legal-consent-20260930.md`、`compliance-launch-20260930.md` | | | |
|
||||
| 11 | 限流与排队(容量单合入后) | 两台设备同一账号 | 1 分钟内连发 7 次提问;另找 3 个账号同时做校正 | 第 7 次得到一句「太快了,稍等一下」类提示,不扣点;同时校正的人看到「排一下队」后照常继续,不报错 | 内测容量单 T1/T2 | | | |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 真机走查 · 回答生成时侧栏不再跟着闪(BUG-1329,issue #59)
|
||||
|
||||
浏览器 A/B 未跑。部署到 staging 后,在 `https://staging.jyotisha.chat` 登录一个没有真实会话内容需要露出的账号,按顺序看:
|
||||
|
||||
1. 电脑浏览器打开首页。左侧会话列表的样子、顺序、分组和以前一样。
|
||||
2. 发一个普通问题,等回答一个字一个字出来。这段时间左侧列表不跟着闪、不跟着整栏重画。回答本身仍是逐字出现。
|
||||
3. 回答过程中点另一条会话,能打开那一条。点当前这条的菜单,改名、置顶、分享、删除仍可用,改的是刚刚点的那一条。
|
||||
4. 右上角账户菜单仍能打开资料、通用、账单和退出。
|
||||
5. 换一个人物后,左侧列表换成那个人的会话,不串到别人的。
|
||||
6. 手机宽度再看一遍第 2 步。侧栏抽屉的开关和以前一样。
|
||||
|
||||
任何一步不对,在 issue #59 里写是哪一步、哪台设备,并把标签改回 `agent/待规划`。不要贴出真实会话内容。
|
||||
@@ -0,0 +1,18 @@
|
||||
# A/B · 回答生成时侧栏重画(BUG-1329,issue #59)
|
||||
|
||||
同一测试 `frontend/tests/home-sidebar-rerender.test.tsx`,同一输入。A 是改代码前的 `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 已被后续提交盖过)。B 是分支 `codex/home-sidebar-rerender-20261013`。各跑 3 次。会话都是虚构的「Synthetic session N」。没有真实会话内容。
|
||||
|
||||
度量是 60 帧里的注册次数、侧栏提交次数、这 60 帧的 Profiler 耗时合计。内容用剔除函数字段后的 SHA-256 比较。
|
||||
|
||||
| 档 | A 注册 / 提交 / 耗时中位 | B 注册 / 提交 / 耗时中位 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 30 条 | 60 / 60 / 2778.961 毫秒 | 0 / 0 / 0 | 两边都是 `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
|
||||
| 100 条 | 60 / 60 / 11799.253 毫秒 | 0 / 0 / 0 | 两边都是 `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
|
||||
|
||||
A 的三次 30 条耗时:2774.964、2778.961、4202.085。100 条:10530.828、11799.253、12284.103。B 的三次都是 0。
|
||||
|
||||
正确性:换当前项后 B 注册 1 次,外壳看到新的当前项。改一行标题后标题更新。换成另一人的会话后列表换掉。只改回调版本不再注册,点「打开会话」和「改名」记到的都是第 4 版。A 在只改回调版本时还会再注册 1 次;B 不注册,但调用结果相同。
|
||||
|
||||
判定:B 在 60 帧内注册 0、侧栏提交 0,内容摘要一致,`session-list-lifecycle` 全绿。可以交付。
|
||||
|
||||
浏览器 A/B 未跑。
|
||||
+3
-2
@@ -339,11 +339,11 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
|
||||
### Input and composer
|
||||
|
||||
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
|
||||
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
|
||||
- **No tool strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has. A single line of copy (`.composer-ai-notice`, 2026-10-14) sits under the box: it is not a tool row, has no 44px slot, and on a narrow screen it stays one line and ellipsizes instead of wrapping over the send control.
|
||||
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
|
||||
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
|
||||
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
|
||||
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
|
||||
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot. The only thing under the box is the one-line AI notice, not another control.
|
||||
- **Inner field corners:** the textarea inside `.composer` has `border-radius: 0`. Rounding stays on the outer `.composer` only. The field has no horizontal padding, so a radius on the textarea itself clips the first glyph's left edge (BUG-1311).
|
||||
|
||||
### Answer start anchor
|
||||
@@ -551,6 +551,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
`children` through unchanged. Registering fresh controls/rows must not render
|
||||
their producer again: that feedback loop can starve client navigation.
|
||||
Business changes still publish current callbacks; Home unmount clears them.
|
||||
流式期间侧栏不随回答帧重渲染(BUG-1329)。
|
||||
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
|
||||
product. `/` passes `controls`; the secondary layout does not, and without it
|
||||
the same markup renders read-only. There used to be a second component for
|
||||
|
||||
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
星盘、星历、报告列表几秒内就会出现,等待时只放轨道环动画,不在屏幕上写「这一张盘还没拿到。」一类句子;这些句子只给读屏器。首页不再写「上次那次校正还没完成,可以在历史对话里接着做。」。
|
||||
|
||||
## AI 生成标识(2026-10-14)
|
||||
|
||||
对话输入框下方一行,普通对话和生时校正共用,不进回答正文:「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」英文壳还没有,对应英文先定下:「AI-generated. For reference only; not medical, legal or financial advice.」
|
||||
|
||||
报告标题下(中文阅读页、结构化阅读页、英文版)一行:「本报告由 AI 生成,仅供参考。」英文:「This report is AI-generated and for reference only.」报告结尾原有的免责保留,不改生成正文。
|
||||
|
||||
## 协议与同意(2026-09-30)
|
||||
|
||||
- 勾选框:「我已阅读并同意《用户协议》和《隐私政策》」;未勾选就提交:「请先阅读并勾选同意,再继续。」(只在框下一行,不弹 toast)。
|
||||
|
||||
@@ -1850,6 +1850,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.composer { width: min(760px, 100%); display: flex; flex-direction: column; gap: var(--space-2); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; padding: var(--space-3) var(--space-3) var(--space-2); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.composer-ai-notice { width: min(760px, 100%); margin: var(--space-2) auto 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-overline); line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* The composer's own bottom row. It replaces .composer-footer, a strip below
|
||||
the composer that held a permanent 44px whether or not it had anything to
|
||||
show. The row only exists when there is a toolbar or a count to render. */
|
||||
@@ -4352,6 +4353,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.personal-report-ai-notice {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.personal-report-focus-card {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
serverComposerDraftSnapshot,
|
||||
subscribeComposerDraft,
|
||||
} from "@/lib/composer-draft";
|
||||
import { CHAT_AI_NOTICE_ZH } from "@/lib/ai-generated-notice";
|
||||
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
|
||||
|
||||
export const composerRemainingId = "composer-character-remaining";
|
||||
@@ -133,6 +134,7 @@ export function ChatComposer({
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ReportDocument,
|
||||
type ReportDocumentV2,
|
||||
} from "@/lib/personal-report-contract";
|
||||
import { reportAiNotice } from "@/lib/ai-generated-notice";
|
||||
import { reportThemeLabel } from "@/lib/personal-report-progress";
|
||||
|
||||
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
|
||||
@@ -191,6 +192,7 @@ function ReportCover({ document, d1 }: { document: ReportDocument; d1: ChartV1 |
|
||||
<div>
|
||||
<p className="personal-report-kicker">个人报告</p>
|
||||
<h1>{document.subject.displayName}</h1>
|
||||
<p className="personal-report-ai-notice">{reportAiNotice("zh")}</p>
|
||||
<p className="personal-report-deck">{document.executiveSummary.headline}</p>
|
||||
</div>
|
||||
<dl className="personal-report-metadata">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseReportChartBlock,
|
||||
toNorthIndianChart,
|
||||
} from "@/lib/report-chart-block";
|
||||
import { reportAiNotice } from "@/lib/ai-generated-notice";
|
||||
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
@@ -69,10 +70,12 @@ function markdownComponents(
|
||||
h1: ({ children }) => {
|
||||
const title = flattenText(children).trim();
|
||||
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
|
||||
const notice = byline.shown ? null : reportAiNotice(language);
|
||||
byline.shown = true;
|
||||
return (
|
||||
<>
|
||||
<h1>{children}</h1>
|
||||
{notice ? <p className="personal-report-ai-notice">{notice}</p> : null}
|
||||
{line ? <p className="personal-report-md-byline">{line}</p> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
@@ -169,7 +170,13 @@ export function SidebarProvider({
|
||||
else setOpen(!open);
|
||||
}, [isMobile, open, openMobile, setOpen, setOpenMobile]);
|
||||
|
||||
const context: SidebarProviderContextValue = {
|
||||
const setInsetTrigger = useCallback((element: HTMLButtonElement | null) => {
|
||||
insetTriggerRef.current = element;
|
||||
}, []);
|
||||
const setSidebarSurface = useCallback((element: HTMLElement | null) => {
|
||||
sidebarSurfaceRef.current = element;
|
||||
}, []);
|
||||
const context = useMemo<SidebarProviderContextValue>(() => ({
|
||||
state: open ? "expanded" : "collapsed",
|
||||
open,
|
||||
setOpen,
|
||||
@@ -179,9 +186,12 @@ export function SidebarProvider({
|
||||
ready,
|
||||
isMobile,
|
||||
toggleSidebar,
|
||||
setInsetTrigger: (element) => { insetTriggerRef.current = element; },
|
||||
setSidebarSurface: (element) => { sidebarSurfaceRef.current = element; },
|
||||
};
|
||||
setInsetTrigger,
|
||||
setSidebarSurface,
|
||||
}), [
|
||||
open, openMobile, viewport, ready, isMobile, setOpen, setOpenMobile, toggleSidebar,
|
||||
setInsetTrigger, setSidebarSurface,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={context}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
import type { ChatSession } from "@/lib/home-types";
|
||||
import type { Account } from "@/lib/home-types";
|
||||
@@ -43,6 +43,68 @@ type HomeShellRegistrationParams = {
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
type LatestShellActions = {
|
||||
sessions: readonly ChatSession[];
|
||||
loadMoreSessions: () => void;
|
||||
renameSession: (session: ChatSession) => void | Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
};
|
||||
|
||||
type PublishedShellInputs = {
|
||||
hydrated: boolean;
|
||||
account: Account;
|
||||
profileName: string;
|
||||
accountMenuOpen: boolean;
|
||||
modalOpen: boolean;
|
||||
activeSessionId: string | undefined;
|
||||
rectificationOpeningSessionId: string | null;
|
||||
rectificationSurfaceOpen: boolean;
|
||||
creatingSession: boolean;
|
||||
pendingSessionId: string | null;
|
||||
cancellationPending: boolean;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
sessionMenuId: string | null;
|
||||
sessionsCursor: string | null;
|
||||
accountTrigger: RefObject<HTMLButtonElement | null>;
|
||||
chartLibrary: readonly ChartLibraryRecord[];
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
function sameVisibleSessions(left: readonly ChatSession[], right: readonly ChatSession[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShellInputs) {
|
||||
return previous.hydrated === next.hydrated
|
||||
&& previous.account === next.account
|
||||
&& previous.profileName === next.profileName
|
||||
&& previous.accountMenuOpen === next.accountMenuOpen
|
||||
&& previous.modalOpen === next.modalOpen
|
||||
&& previous.activeSessionId === next.activeSessionId
|
||||
&& previous.rectificationOpeningSessionId === next.rectificationOpeningSessionId
|
||||
&& previous.rectificationSurfaceOpen === next.rectificationSurfaceOpen
|
||||
&& previous.creatingSession === next.creatingSession
|
||||
&& previous.pendingSessionId === next.pendingSessionId
|
||||
&& previous.cancellationPending === next.cancellationPending
|
||||
&& previous.modelCatalog === next.modelCatalog
|
||||
&& previous.sessionMenuId === next.sessionMenuId
|
||||
&& previous.sessionsCursor === next.sessionsCursor
|
||||
&& previous.accountTrigger === next.accountTrigger
|
||||
&& previous.chartLibrary === next.chartLibrary
|
||||
&& sameVisibleSessions(previous.visibleSessions, next.visibleSessions);
|
||||
}
|
||||
|
||||
export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
const {
|
||||
account,
|
||||
@@ -75,6 +137,27 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
} = params;
|
||||
const profileName = profile.name;
|
||||
const latestRef = useRef<LatestShellActions | null>(null);
|
||||
const publishedRef = useRef<PublishedShellInputs | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// No dependency list: this has to land after every render and before the
|
||||
// publish effect, so a later click runs this render's callbacks (BUG-725).
|
||||
latestRef.current = {
|
||||
sessions,
|
||||
loadMoreSessions,
|
||||
renameSession,
|
||||
selectSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
togglePinnedSession,
|
||||
openAccountDialog,
|
||||
setSessionMenuId,
|
||||
setAccountMenuOpen,
|
||||
setPendingSessionDeletion,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// The inline dead-screen script stands down only after the app has revealed.
|
||||
@@ -84,12 +167,59 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
document.documentElement.dataset.hydrated = "1";
|
||||
}, [hydrated]);
|
||||
|
||||
const stableControls = useMemo(() => ({
|
||||
onLoadMore: () => { latestRef.current?.loadMoreSessions(); },
|
||||
onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); },
|
||||
onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); },
|
||||
onRename: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void latestRef.current?.renameSession(session);
|
||||
},
|
||||
onShare: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void latestRef.current?.shareSession(session);
|
||||
},
|
||||
onDelete: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) latestRef.current?.setPendingSessionDeletion(session);
|
||||
},
|
||||
onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); },
|
||||
onNewChat: () => { void latestRef.current?.startNewChat(); },
|
||||
onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); },
|
||||
onOpenProfile: () => { latestRef.current?.openAccountDialog("profile"); },
|
||||
onOpenGeneral: () => { latestRef.current?.openAccountDialog("general"); },
|
||||
onOpenBilling: () => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); },
|
||||
onOpenLogout: () => { latestRef.current?.openAccountDialog("logout"); },
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !account) {
|
||||
publishedRef.current = null;
|
||||
registerShellControls(null);
|
||||
return;
|
||||
}
|
||||
const name = profile.name.trim();
|
||||
const next: PublishedShellInputs = {
|
||||
hydrated,
|
||||
account,
|
||||
profileName,
|
||||
accountMenuOpen,
|
||||
modalOpen,
|
||||
activeSessionId,
|
||||
rectificationOpeningSessionId,
|
||||
rectificationSurfaceOpen,
|
||||
creatingSession,
|
||||
pendingSessionId,
|
||||
cancellationPending,
|
||||
modelCatalog,
|
||||
sessionMenuId,
|
||||
sessionsCursor,
|
||||
accountTrigger,
|
||||
chartLibrary,
|
||||
visibleSessions,
|
||||
};
|
||||
if (publishedRef.current && samePublishedInputs(publishedRef.current, next)) return;
|
||||
publishedRef.current = next;
|
||||
const name = profileName.trim();
|
||||
const email = account.user.email || "尚未读取邮箱";
|
||||
registerShellControls({
|
||||
escapeBlocked: accountMenuOpen || modalOpen,
|
||||
@@ -112,42 +242,34 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
hasMore: Boolean(sessionsCursor),
|
||||
onLoadMore: loadMoreSessions,
|
||||
onLoadMore: stableControls.onLoadMore,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
},
|
||||
onMenuSessionChange: stableControls.onMenuSessionChange,
|
||||
onTogglePinned: stableControls.onTogglePinned,
|
||||
onRename: stableControls.onRename,
|
||||
onShare: stableControls.onShare,
|
||||
onDelete: stableControls.onDelete,
|
||||
},
|
||||
onAccountMenuOpenChange: setAccountMenuOpen,
|
||||
onNewChat: () => { void startNewChat(); },
|
||||
onSelectSession: selectSession,
|
||||
onOpenProfile: () => openAccountDialog("profile"),
|
||||
onOpenGeneral: () => openAccountDialog("general"),
|
||||
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
|
||||
onOpenLogout: () => openAccountDialog("logout"),
|
||||
onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange,
|
||||
onNewChat: stableControls.onNewChat,
|
||||
onSelectSession: stableControls.onSelectSession,
|
||||
onOpenProfile: stableControls.onOpenProfile,
|
||||
onOpenGeneral: stableControls.onOpenGeneral,
|
||||
onOpenBilling: stableControls.onOpenBilling,
|
||||
onOpenLogout: stableControls.onOpenLogout,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending,
|
||||
chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog,
|
||||
openAccountDialog, pendingSessionId, profile.name,
|
||||
chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog,
|
||||
pendingSessionId, profileName,
|
||||
rectificationOpeningSessionId, rectificationSurfaceOpen,
|
||||
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
|
||||
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
|
||||
shareSession, startNewChat, togglePinnedSession, visibleSessions,
|
||||
registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions,
|
||||
]);
|
||||
|
||||
useEffect(() => () => registerShellControls(null), [registerShellControls]);
|
||||
useEffect(() => () => {
|
||||
publishedRef.current = null;
|
||||
registerShellControls(null);
|
||||
}, [registerShellControls]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
writeSessionUrl,
|
||||
} from "@/lib/chat-session-url";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject";
|
||||
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
|
||||
@@ -181,8 +181,9 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
|
||||
}
|
||||
|
||||
const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session)
|
||||
&& sessionMatchesSubject(session, readCurrentSubjectId())));
|
||||
const subjectId = useCurrentSubject();
|
||||
const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session)
|
||||
&& sessionMatchesSubject(session, subjectId))), [sessions, subjectId]);
|
||||
|
||||
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReportLanguage } from "./report-language.ts";
|
||||
|
||||
/** Visible label under the chat field. The product shell is Chinese. */
|
||||
export const CHAT_AI_NOTICE_ZH = "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。";
|
||||
|
||||
/** English equivalent, locked for a future English shell. Not shown in the Chinese UI. */
|
||||
export const CHAT_AI_NOTICE_EN = "AI-generated. For reference only; not medical, legal or financial advice.";
|
||||
|
||||
export const REPORT_AI_NOTICE_ZH = "本报告由 AI 生成,仅供参考。";
|
||||
export const REPORT_AI_NOTICE_EN = "This report is AI-generated and for reference only.";
|
||||
|
||||
export function reportAiNotice(language: ReportLanguage): string {
|
||||
return language === "en" ? REPORT_AI_NOTICE_EN : REPORT_AI_NOTICE_ZH;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- BUG-1063: the report worker reads another person's archive through
|
||||
-- service_role (loadSubjectBirth → CHART_SUBJECT_SELECT). The 2026-07-18
|
||||
-- repair revoked every privilege on chart_profiles and never granted SELECT
|
||||
-- back. Column-level SELECT only, matching CHART_SUBJECT_SELECT. No write,
|
||||
-- no RLS change, no change to authenticated grants.
|
||||
begin;
|
||||
|
||||
grant select (
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
name,
|
||||
birth_date,
|
||||
reported_birth_time,
|
||||
birth_time_source,
|
||||
birth_time_period,
|
||||
declared_window_start,
|
||||
declared_window_end,
|
||||
uncertainty_before_minutes,
|
||||
uncertainty_after_minutes,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone_id,
|
||||
timezone_offset,
|
||||
birth_place_label,
|
||||
birth_place_type,
|
||||
birth_place_provider,
|
||||
birth_place_provider_id,
|
||||
ayanamsa,
|
||||
active_birth_time,
|
||||
active_birth_date,
|
||||
active_birth_timezone_offset,
|
||||
birth_time_status,
|
||||
gender
|
||||
) on table public.chart_profiles to service_role;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createElement, createRef } from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ChatComposer } from "../src/components/chat-composer.tsx";
|
||||
import { PersonalReportDocumentView } from "../src/components/personal-report/personal-report-document-view.tsx";
|
||||
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
|
||||
import {
|
||||
CHAT_AI_NOTICE_EN,
|
||||
CHAT_AI_NOTICE_ZH,
|
||||
REPORT_AI_NOTICE_EN,
|
||||
REPORT_AI_NOTICE_ZH,
|
||||
} from "../src/lib/ai-generated-notice.ts";
|
||||
import type { LegacyReportDocumentV1 } from "../src/lib/personal-report-contract.ts";
|
||||
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as LegacyReportDocumentV1;
|
||||
|
||||
function composerHtml(): string {
|
||||
return renderToStaticMarkup(createElement(ChatComposer, {
|
||||
inputRef: createRef<HTMLTextAreaElement>(),
|
||||
value: "",
|
||||
inputLabel: "输入你的问题",
|
||||
placeholder: "想聊什么都可以",
|
||||
maxLength: 500,
|
||||
inputDisabled: false,
|
||||
submitLabel: "发送",
|
||||
submitBlocked: false,
|
||||
stopVisible: false,
|
||||
stopLabel: "停止回答",
|
||||
stopTitle: "停止回答",
|
||||
onSubmit() {},
|
||||
onChange() {},
|
||||
onKeyDown() {},
|
||||
onStop() {},
|
||||
}));
|
||||
}
|
||||
|
||||
test("both chat surfaces share the composer notice, under the field and not over the send control", () => {
|
||||
assert.equal(CHAT_AI_NOTICE_ZH, "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。");
|
||||
assert.equal(CHAT_AI_NOTICE_EN, "AI-generated. For reference only; not medical, legal or financial advice.");
|
||||
const html = composerHtml();
|
||||
const noticeAt = html.indexOf("composer-ai-notice");
|
||||
const sendAt = html.indexOf("发送");
|
||||
assert.ok(noticeAt > sendAt, "the notice follows the send control");
|
||||
assert.match(html, new RegExp(CHAT_AI_NOTICE_ZH));
|
||||
assert.match(page, /<ChatComposer/);
|
||||
assert.match(rectification, /<ChatComposer/);
|
||||
assert.doesNotMatch(page, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
|
||||
assert.doesNotMatch(rectification, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
|
||||
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*white-space:\s*nowrap/);
|
||||
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*overflow:\s*hidden/);
|
||||
assert.doesNotMatch(styles, /\.composer-footer\s*\{/);
|
||||
});
|
||||
|
||||
test("report readers show the AI notice in Chinese and English under the title", () => {
|
||||
assert.equal(REPORT_AI_NOTICE_ZH, "本报告由 AI 生成,仅供参考。");
|
||||
assert.equal(REPORT_AI_NOTICE_EN, "This report is AI-generated and for reference only.");
|
||||
const chinese = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
|
||||
markdown: "# 虚构报告\n\n一段说明。",
|
||||
language: "zh",
|
||||
}));
|
||||
const english = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
|
||||
markdown: "# Fictional report\n\nA paragraph.",
|
||||
language: "en",
|
||||
}));
|
||||
const document = renderToStaticMarkup(createElement(PersonalReportDocumentView, { document: fixture }));
|
||||
assert.ok(chinese.indexOf("<h1>") < chinese.indexOf(REPORT_AI_NOTICE_ZH));
|
||||
assert.match(english, new RegExp(REPORT_AI_NOTICE_EN.replaceAll(".", "\\.")));
|
||||
assert.match(document, new RegExp(REPORT_AI_NOTICE_ZH));
|
||||
assert.match(document, /id="report-disclaimer"/);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
|
||||
|
||||
// BUG-1063: every column loadSubjectBirth selects from chart_profiles must
|
||||
// have a service_role SELECT grant after the 2026-07-18 revoke.
|
||||
const REVOKE = "20260718100000";
|
||||
const directory = new URL("../supabase/migrations/", import.meta.url);
|
||||
const grantFile = new URL(
|
||||
"../supabase/migrations/20261014010000_chart_profiles_service_role_select.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function serviceRoleChartSelectGrants(): Set<string> {
|
||||
const granted = new Set<string>();
|
||||
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
|
||||
if (name.slice(0, 14) < REVOKE) continue;
|
||||
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
|
||||
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.chart_profiles\s+to\s+([a-z_, ]+);/gi;
|
||||
for (const match of sql.matchAll(pattern)) {
|
||||
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
|
||||
for (const column of match[2]!.split(",")) {
|
||||
const trimmed = column.trim();
|
||||
if (trimmed) granted.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return granted;
|
||||
}
|
||||
|
||||
test("service_role chart_profiles select covers CHART_SUBJECT_SELECT and nothing writable", () => {
|
||||
const sql = readFileSync(grantFile, "utf8").replace(/--.*$/gm, "");
|
||||
assert.doesNotMatch(sql, /grant\s+(insert|update|delete)\b/i);
|
||||
assert.doesNotMatch(sql, /create\s+policy|alter\s+policy|enable\s+row\s+level\s+security/i);
|
||||
assert.doesNotMatch(sql, /to\s+authenticated/i);
|
||||
const missing = CHART_SUBJECT_SELECT.split(",").map((column) => column.trim()).filter((column) => !serviceRoleChartSelectGrants().has(column));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
@@ -39,7 +39,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr
|
||||
assert.match(sidebarSource, /<SidebarMenuLink\n\s*className="report-nav-button"/);
|
||||
assert.doesNotMatch(sidebarSource, /window\.location/);
|
||||
|
||||
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
|
||||
// 原值: /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/
|
||||
// 新值: /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/
|
||||
// 原因: 侧栏按钮改成稳定包装,点下去仍打开充值,来源仍是账户菜单。不得放宽成只查 "billing"。
|
||||
assert.match(pageSource, /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/);
|
||||
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
|
||||
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||||
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
|
||||
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
|
||||
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
// BUG-1063: the report worker reads another person's archive through
|
||||
// service_role. Fictional rows only.
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
const SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const;
|
||||
|
||||
function applyMigrations(fixture: PostgresFixture): void {
|
||||
const result = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
test("service_role can read another person's chart profile for a report", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE);
|
||||
try {
|
||||
applyMigrations(fixture);
|
||||
const owner = randomUUID();
|
||||
const subjectId = randomUUID();
|
||||
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
|
||||
values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`);
|
||||
fixture.psql(`insert into public.chart_profiles (
|
||||
id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time,
|
||||
birth_time_source, latitude, longitude, timezone_id, timezone_offset,
|
||||
birth_place_label, ayanamsa, birth_time_status
|
||||
) values (
|
||||
'${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(),
|
||||
'虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1,
|
||||
'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported'
|
||||
);`);
|
||||
|
||||
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
|
||||
const probe = await admin
|
||||
.from("chart_profiles")
|
||||
.select(CHART_SUBJECT_SELECT)
|
||||
.eq("id", subjectId)
|
||||
.maybeSingle();
|
||||
assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed");
|
||||
|
||||
const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string };
|
||||
assert.equal(loaded.name, "虚构乙");
|
||||
|
||||
for (const column of CHART_SUBJECT_SELECT.split(",")) {
|
||||
assert.equal(
|
||||
fixture.psql(`select has_column_privilege('service_role', 'public.chart_profiles', '${column}', 'select')`),
|
||||
"t",
|
||||
column,
|
||||
);
|
||||
}
|
||||
for (const privilege of ["insert", "update", "delete"]) {
|
||||
assert.equal(
|
||||
fixture.psql(`select has_table_privilege('service_role', 'public.chart_profiles', '${privilege}')`),
|
||||
"f",
|
||||
privilege,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fixture.psql("select has_table_privilege('authenticated', 'public.chart_profiles', 'select')"),
|
||||
"t",
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPool(serviceUrl);
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react";
|
||||
|
||||
import { AppSidebar } from "../src/components/app-sidebar.tsx";
|
||||
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
|
||||
import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
|
||||
import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
SessionListProvider,
|
||||
sessionListSidebarModel,
|
||||
useSessionList,
|
||||
useShellRegistration,
|
||||
type ShellRegistration,
|
||||
} from "../src/lib/session-list-context.tsx";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
const account: Account = {
|
||||
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
|
||||
profile: { ...emptyProfile, name: "Synthetic" },
|
||||
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
|
||||
rectificationPriceCredits: 0, activeSubscription: null,
|
||||
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
|
||||
};
|
||||
const trigger = { current: null };
|
||||
const charts: never[] = [];
|
||||
|
||||
function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession {
|
||||
return {
|
||||
id: `session-${index}`,
|
||||
title,
|
||||
theme: "general",
|
||||
modelId: "test",
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1_700_000_000_000 + index,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: null,
|
||||
chartProfileName: null,
|
||||
chartProfileRole: null,
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function registrationData(registration: ShellRegistration | null) {
|
||||
if (!registration) return null;
|
||||
return {
|
||||
activeSessionId: registration.activeSessionId,
|
||||
openingSessionId: registration.openingSessionId,
|
||||
escapeBlocked: registration.escapeBlocked,
|
||||
insetClassName: registration.insetClassName,
|
||||
insetInert: registration.insetInert,
|
||||
account: registration.sidebarAccount ?? null,
|
||||
sessions: registration.sidebarSessions ?? [],
|
||||
newChatDisabled: registration.controls.newChatDisabled,
|
||||
creatingSession: registration.controls.creatingSession,
|
||||
accountMenuOpen: registration.controls.accountMenuOpen,
|
||||
hasMore: registration.controls.sessionControls.hasMore ?? false,
|
||||
menuSessionId: registration.controls.sessionControls.menuSessionId,
|
||||
disabled: registration.controls.sessionControls.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadSha(data: ReturnType<typeof registrationData>) {
|
||||
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
||||
}
|
||||
|
||||
async function runProbe(sessionCount: number) {
|
||||
const harness = createClientLifecycleHarness();
|
||||
resetSubjectCatalogForTests();
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const storageValues = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storageValues.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storageValues.delete(key); },
|
||||
clear: () => { storageValues.clear(); },
|
||||
key: (index: number) => Array.from(storageValues.keys())[index] ?? null,
|
||||
get length() { return storageValues.size; },
|
||||
} satisfies Storage,
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: FakeIntersectionObserver,
|
||||
});
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
const payload = url === "/api/account" ? account
|
||||
: url === "/api/chart-profiles" ? { profiles: [] }
|
||||
: { sessions: [], nextCursor: null };
|
||||
return new Response(JSON.stringify(payload), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false };
|
||||
const selectRevisions: number[] = [];
|
||||
const renameRevisions: number[] = [];
|
||||
const driver: {
|
||||
setFrame: (frame: number) => void;
|
||||
setActive: (id: string) => void;
|
||||
retitle: () => void;
|
||||
switchPerson: () => void;
|
||||
setRevision: (revision: number) => void;
|
||||
} = {
|
||||
setFrame: () => {},
|
||||
setActive: () => {},
|
||||
retitle: () => {},
|
||||
switchPerson: () => {},
|
||||
setRevision: () => {},
|
||||
};
|
||||
let latestShell: ShellRegistration | null = null;
|
||||
const currentShell = (): ShellRegistration | null => latestShell;
|
||||
const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index));
|
||||
const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")];
|
||||
|
||||
function HomeProbe() {
|
||||
const list = useSessionList();
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null);
|
||||
const [sessions, setSessions] = useState(initialSessions);
|
||||
const [revision, setRevision] = useState(0);
|
||||
// Production passes SessionListProvider's stable registrar. A fresh wrapper
|
||||
// every render would rerun the unmount cleanup and publish twice per frame.
|
||||
const registerShellControls = useCallback((registration: ShellRegistration | null) => {
|
||||
if (metrics.recording) metrics.registers += 1;
|
||||
list.registerShellControls(registration);
|
||||
}, [list]);
|
||||
useHomeShellRegistration({
|
||||
account: list.account,
|
||||
accountMenuOpen: false,
|
||||
accountTrigger: trigger,
|
||||
activeSessionId: activeSessionId ?? undefined,
|
||||
cancellationPending: false,
|
||||
chartLibrary: charts,
|
||||
creatingSession: false,
|
||||
hydrated: list.settled,
|
||||
loadMoreSessions: () => {},
|
||||
modalOpen: false,
|
||||
modelCatalog: null,
|
||||
openAccountDialog: () => {},
|
||||
pendingSessionId: null,
|
||||
profile: { ...emptyProfile, name: "Synthetic" },
|
||||
rectificationOpeningSessionId: null,
|
||||
rectificationSurfaceOpen: false,
|
||||
registerShellControls,
|
||||
renameSession: () => { renameRevisions.push(revision); },
|
||||
selectSession: () => { selectRevisions.push(revision); },
|
||||
sessionMenuId: null,
|
||||
sessions,
|
||||
sessionsCursor: null,
|
||||
setAccountMenuOpen: () => {},
|
||||
setPendingSessionDeletion: () => {},
|
||||
setSessionMenuId: () => {},
|
||||
shareSession: () => {},
|
||||
startNewChat: () => {},
|
||||
togglePinnedSession: () => {},
|
||||
// New array every render. Element identity stays until a real edit.
|
||||
visibleSessions: sessions.filter(() => true),
|
||||
});
|
||||
void frame;
|
||||
useEffect(() => {
|
||||
driver.setFrame = setFrame;
|
||||
driver.setActive = setActive;
|
||||
driver.retitle = () => setSessions((current) => current.map((session, index) => (
|
||||
index === 0 ? { ...session, title: "Renamed synthetic" } : session
|
||||
)));
|
||||
driver.switchPerson = () => setSessions(otherPerson);
|
||||
driver.setRevision = setRevision;
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||
if (!metrics.recording) return;
|
||||
if (phase !== "mount" && phase !== "update") return;
|
||||
metrics.commits += 1;
|
||||
metrics.actualDuration += actualDuration;
|
||||
};
|
||||
|
||||
function Shell({ children }: { children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const registration = useShellRegistration();
|
||||
const model = sessionListSidebarModel(list, registration);
|
||||
useEffect(() => { latestShell = registration; });
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
<Profiler id="sidebar" onRender={onSidebarRender}>
|
||||
<AppSidebar
|
||||
sessions={model.sessions}
|
||||
activeSessionId={registration?.activeSessionId ?? null}
|
||||
openingSessionId={registration?.openingSessionId ?? null}
|
||||
account={model.account}
|
||||
settled={list.settled}
|
||||
signedOut={list.signedOut}
|
||||
controls={registration?.controls}
|
||||
/>
|
||||
</Profiler>
|
||||
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetSubjectCatalogForTests();
|
||||
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
};
|
||||
|
||||
try {
|
||||
await harness.render(
|
||||
<SessionListProvider>
|
||||
<Shell><HomeProbe /></Shell>
|
||||
</SessionListProvider>,
|
||||
);
|
||||
await harness.idle();
|
||||
assert.equal(harness.errors.length, 0);
|
||||
assert.ok(latestShell, "hydrated home registers once before the frame run");
|
||||
metrics.recording = true;
|
||||
metrics.registers = 0;
|
||||
metrics.commits = 0;
|
||||
metrics.actualDuration = 0;
|
||||
for (let frame = 1; frame <= 60; frame += 1) {
|
||||
await harness.update(() => driver.setFrame(frame));
|
||||
}
|
||||
const duringFrames = {
|
||||
registers: metrics.registers,
|
||||
commits: metrics.commits,
|
||||
actualDuration: Number(metrics.actualDuration.toFixed(3)),
|
||||
};
|
||||
metrics.registers = 0;
|
||||
metrics.commits = 0;
|
||||
await harness.update(() => driver.setActive("session-active"));
|
||||
const afterActiveRegisters = metrics.registers;
|
||||
const afterActiveCommits = metrics.commits;
|
||||
const afterActive = registrationData(currentShell());
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.retitle());
|
||||
const afterTitleRegisters = metrics.registers;
|
||||
const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.setRevision(4));
|
||||
const afterRevisionRegisters = metrics.registers;
|
||||
currentShell()?.controls.onSelectSession("session-0");
|
||||
currentShell()?.controls.sessionControls.onRename("session-0");
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.switchPerson());
|
||||
const afterSwitchRegisters = metrics.registers;
|
||||
const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
|
||||
const report = {
|
||||
sessions: sessionCount,
|
||||
duringFrames,
|
||||
afterActiveRegisters,
|
||||
afterActiveCommits,
|
||||
afterActiveSha: payloadSha(afterActive),
|
||||
afterActive,
|
||||
afterTitleRegisters,
|
||||
renamedTitle,
|
||||
afterRevisionRegisters,
|
||||
selectRevisions,
|
||||
renameRevisions,
|
||||
afterSwitchRegisters,
|
||||
otherTitle,
|
||||
};
|
||||
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
|
||||
assert.equal(duringFrames.registers, 0);
|
||||
assert.equal(duringFrames.commits, 0);
|
||||
assert.equal(afterActiveRegisters, 1);
|
||||
assert.equal(afterActive?.activeSessionId, "session-active");
|
||||
assert.equal(afterTitleRegisters, 1);
|
||||
assert.equal(renamedTitle, "Renamed synthetic");
|
||||
assert.equal(afterRevisionRegisters, 0);
|
||||
assert.deepEqual(selectRevisions, [4]);
|
||||
assert.deepEqual(renameRevisions, [4]);
|
||||
assert.equal(afterSwitchRegisters, 1);
|
||||
assert.equal(otherTitle, "Other person session");
|
||||
return report;
|
||||
} finally {
|
||||
await harness.close();
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionCount of [30, 100]) {
|
||||
test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => {
|
||||
await runProbe(sessionCount);
|
||||
});
|
||||
}
|
||||
@@ -182,9 +182,16 @@ test("shell receives current callbacks, account and session state without freezi
|
||||
assert.equal(app.shell?.activeSessionId, "revision-1");
|
||||
assert.equal(app.shell?.insetInert, true);
|
||||
assert.equal(app.shell?.escapeBlocked, true);
|
||||
assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
// 原值:assert.notEqual(onOpenProfile, initialCallback),每次注册都是新函数。
|
||||
// 新值:包装函数身份不变;再改一版后仍是同一个函数,调用记到的是当前 revision。
|
||||
// 原因:外壳拿稳定包装,点下去执行 Home 最新一次渲染的回调(BUG-971 / BUG-1329)。
|
||||
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
app.shell?.controls.onOpenProfile();
|
||||
assert.deepEqual(app.callbackRevisions, [1]);
|
||||
await app.revise(2);
|
||||
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
app.shell?.controls.onOpenProfile();
|
||||
assert.deepEqual(app.callbackRevisions, [1, 2]);
|
||||
await app.harness.update(() => {
|
||||
app.list.setAccount({ ...account, credits: 27 });
|
||||
app.list.setSessions([row]);
|
||||
|
||||
Reference in New Issue
Block a user