perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Second batch from the staging UX audit (BUG-248..253). - css: expose the 32 palette tokens through @theme. Seven utilities including text-ink, text-danger and text-warning compiled to no CSS at all, so 30 call sites had been silently inert (BUG-248) - chat: move the composer into its own component behind a draft store, so a keystroke no longer re-renders a 2723-line component, and persist the draft across reloads (BUG-249) - chat: load gsap, react-markdown and thinking-orbs on demand. First Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250) - chat: route the five in-app destinations through router.push, and keep the five auth redirects and the bootstrap retry as hard loads on purpose (BUG-251) - a11y: announce reply completion, and move the live region out of the aria-busy subtree that was likely suppressing even the start announcement (BUG-252) - docs: give the 27 collided bug ids unique numbers and repair their inbound references; require search rather than a full read of a 3690-line file (BUG-253) Verified: tsc, eslint, next build, and 1592 assertions across the 199 non-database test files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -142,7 +142,7 @@ Deployment safety rules:
|
||||
|
||||
## 7. Bug History Workflow Hard Constraint
|
||||
|
||||
任何包含“Bug、报错、失败、异常、回归、无法保存、无法删除、状态码错误、线上故障”等现象的任务,开始诊断前必须完整读取并搜索:
|
||||
任何包含“Bug、报错、失败、异常、回归、无法保存、无法删除、状态码错误、线上故障”等现象的任务,开始诊断前必须用报错原文、接口路径、状态码、模块名和用户操作强制检索,并完整读完命中的记录。该文件体量已不允许通读全文,但检索不是可选步骤,命中记录的防复发措施是否仍然存在必须逐条确认:
|
||||
|
||||
- `docs/BUG_HISTORY.md`
|
||||
|
||||
|
||||
+161
-74
@@ -10,7 +10,8 @@
|
||||
2. 找到相似记录时,先验证既有防复发措施是否仍存在,再定位新的回归入口。
|
||||
3. 修复必须包含与风险相称的自动化测试;生产问题还要记录部署版本和脱敏后的生产验证。
|
||||
4. 完成后更新已有记录,或按下方模板添加新记录。复发问题必须填写 `复发自`,不能伪装成无关的新问题。
|
||||
5. 不记录姓名、出生资料、邮箱、用户/案例 ID、Cookie、JWT、密码、密钥、完整请求体或模型原文。
|
||||
5. BUG 编号必须唯一。追加新记录前先搜索本文件当前最大编号,并从其后继续分配;出现重复编号时保留较早记录的编号,为后加入的记录分配新编号,并同步修正指向它的 `相关记录` 与 `复发自`。
|
||||
6. 不记录姓名、出生资料、邮箱、用户/案例 ID、Cookie、JWT、密码、密钥、完整请求体或模型原文。
|
||||
|
||||
## 状态定义
|
||||
|
||||
@@ -347,7 +348,7 @@
|
||||
- 修复版本:待提交
|
||||
|
||||
## BUG-020 | 生时校正把语言问答包装成高阻力卡片表单
|
||||
## BUG-018 | 生时校正把语言问答包装成高阻力卡片表单
|
||||
## BUG-221 | 生时校正把语言问答包装成高阻力卡片表单
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -364,7 +365,7 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-021 | 自然语言真实事件在分钟评分入口被静默丢弃
|
||||
## BUG-019 | 自然语言真实事件在分钟评分入口被静默丢弃
|
||||
## BUG-222 | 自然语言真实事件在分钟评分入口被静默丢弃
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -381,11 +382,11 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-022 | 生时校正入口等待首轮请求完成后才切换会话
|
||||
- 相关记录:BUG-008、BUG-009、BUG-015、BUG-016、BUG-018
|
||||
- 复发自:BUG-018
|
||||
- 相关记录:BUG-008、BUG-009、BUG-015、BUG-016、BUG-221
|
||||
- 复发自:BUG-221
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-020 | 生时校正入口等待首轮请求完成后才切换会话
|
||||
## BUG-223 | 生时校正入口等待首轮请求完成后才切换会话
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -402,11 +403,11 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-023 | 初始化地址保存后的加载提示仍指向生时评估
|
||||
- 相关记录:BUG-016、BUG-018、BUG-019
|
||||
- 相关记录:BUG-016、BUG-221、BUG-222
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-021 | 初始化地址保存后的加载提示仍指向生时评估
|
||||
## BUG-224 | 初始化地址保存后的加载提示仍指向生时评估
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -423,11 +424,11 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-024 | 侧栏会话标题与更多操作被拆成两块
|
||||
- 相关记录:BUG-020
|
||||
- 相关记录:BUG-223
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-022 | 侧栏会话标题与更多操作被拆成两块
|
||||
## BUG-225 | 侧栏会话标题与更多操作被拆成两块
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -444,7 +445,7 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-025 | 健康压力追问被数据库误判为校正操作冲突
|
||||
## BUG-023 | 健康压力追问被数据库误判为校正操作冲突
|
||||
## BUG-226 | 健康压力追问被数据库误判为校正操作冲突
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -493,11 +494,11 @@
|
||||
- 修复版本:待提交(production gate)
|
||||
|
||||
## BUG-028 | 生时校正收到具体经历后仍重复泛问
|
||||
- 相关记录:BUG-007、BUG-018、BUG-019
|
||||
- 相关记录:BUG-007、BUG-221、BUG-222
|
||||
- 复发自:BUG-007
|
||||
- 修复版本:待提交(测试 Supabase smoke 通过)
|
||||
|
||||
## BUG-024 | 生时校正收到具体经历后仍重复泛问
|
||||
## BUG-227 | 生时校正收到具体经历后仍重复泛问
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -509,12 +510,12 @@
|
||||
- 修复:会话组件改用独立的可见叙事函数,优先承接用户最近一条具体但不完整的经历并只追问缺失项;编排器把下一轮仅含日期或仅含内容的补充合并回最近一条待澄清事件,同时用 `correctsEvidenceIds` 保留 append-only 修订链;下一问从技术包允许领域中优先选择尚未回答的领域,并同步覆盖公开 evidence request。
|
||||
- 验证:相关自然语言抽取、叙事、编排、路由、公开案例回放与端到端测试共 111 条全部通过;回归覆盖“先说离家工作、再补 2023 年 3 月”后合并为 `2023-03 · 离开家去北京开始工作`、具体事件缺日期时回复必须复述该事件并询问年月、关系领域回答后下一问切换到事业领域。目标文件 ESLint 与补丁检查通过。
|
||||
- 防复发:保持“服务端叙事是用户可见回复真相源”的纯函数测试;每条待澄清证据必须测试跨轮补全与修订链;下一领域必须测试排除有效 recap 已覆盖领域;端到端断言不得只绑定泛化提示词。
|
||||
- 相关记录:BUG-018、BUG-019
|
||||
- 复发自:BUG-019
|
||||
- 相关记录:BUG-221、BUG-222
|
||||
- 复发自:BUG-222
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-029 | 生时校正仍有未回答区分领域时提前结束
|
||||
## BUG-025 | 生时校正仍有未回答区分领域时提前结束
|
||||
## BUG-228 | 生时校正仍有未回答区分领域时提前结束
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -526,16 +527,16 @@
|
||||
- 修复:停滞结束前增加未回答建议领域门禁;仍有可区分领域时继续自然追问,全部建议领域已覆盖后才允许按范围停滞安全结束。证据数量上限和无可区分问题终止保持不变。
|
||||
- 验证:编排器回归锁定学业、搬迁、事业三轮后必须继续询问尚未覆盖的关系领域,补充关系事件后才保存未确认范围;本地真实流程修复前复现为两轮即结束且范围仍为 `04:30–06:30`。
|
||||
- 防复发:停滞终止测试必须同时断言候选范围未变化和技术包建议领域已全部回答,不能只按事件条数结束。
|
||||
- 相关记录:BUG-019、BUG-028
|
||||
- 相关记录:BUG-019、BUG-028(编号存疑:BUG-019 疑为重复编号合并前的旧编号,可能应指 BUG-021)
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-030 | 职位和管理职责变化未识别为事业证据
|
||||
- 相关记录:BUG-019、BUG-024
|
||||
- 相关记录:BUG-222、BUG-227
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-026 | 职位和管理职责变化未识别为事业证据
|
||||
## BUG-229 | 职位和管理职责变化未识别为事业证据
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -552,11 +553,11 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-031 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约
|
||||
- 相关记录:BUG-024、BUG-025
|
||||
- 相关记录:BUG-227、BUG-228
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-027 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约
|
||||
## BUG-230 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-22
|
||||
@@ -568,16 +569,16 @@
|
||||
- 修复:新增向前迁移,在范围完成或主动放弃的同一数据库事务中将已收费状态幂等转换为 `released`、退回点数并更新动作回执;只有通过分钟门禁且用户明确确认的分钟保留收费。评分载荷新增可选 `summary`,Python API 规范化并限制长度,候选输入契约升级为 `rectification-candidate-input-v2`,把具体事件摘要纳入 canonical hash。用户终态文案明确说明未纠正成功、本次不计费、候选代表时间不会替换当前排盘时间。
|
||||
- 验证:前端生时纠正路由、编排器、存储和旅程引擎聚焦测试 77 条通过;Python 对话数据库契约、事件 API 与候选评分测试 67 条通过。回归明确断言事件摘要变化会改变 canonical input hash,但 `can_apply`、`confirmation_allowed` 和 `can_narrow_to_minute` 仍保持关闭;SQL 契约断言范围结束与放弃均在终态事务内退款,且迁移不写入 `active_birth_time`。
|
||||
- 防复发:任何新增事件评分字段都必须进入前端载荷、Python 规范化输入和 canonical hash;任何非 `confirmed` 终态都必须有幂等计费回收断言;不得用候选范围中点或代表时间替换用户的当前排盘分钟。真正分钟确认仍依赖独立来源审计、sealed blind replay、准确率/误确认率及 ±1/2/5 分钟稳定性门禁。
|
||||
- 相关记录:BUG-019、BUG-028、BUG-029
|
||||
- 相关记录:BUG-019、BUG-028、BUG-029(编号存疑:BUG-019 疑为重复编号合并前的旧编号,可能应指 BUG-021)
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-032 | 生时校正覆盖历史回答且完成后无法返回原问题
|
||||
- 相关记录:BUG-019、BUG-024、BUG-025
|
||||
- 相关记录:BUG-222、BUG-227、BUG-228
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-028 | 生时校正覆盖历史回答且完成后无法返回原问题
|
||||
## BUG-231 | 生时校正覆盖历史回答且完成后无法返回原问题
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -589,16 +590,16 @@
|
||||
- 修复:controller 为当前 case 维护交错的 `assistant/user` 消息序列,成功提交后原子追加用户原话与新 Agent 回复,聊天区只按该序列渲染;可见层优先采用 Agent 原始 narrative,中间轮 prompt 带入最新事件并要求先具体回应再追问,编排器保留通过校验的 Agent 文本;删除完成态自动 continuation,只在用户点击时领取,并按本地 handoff、持久化 return session、最近普通咨询 session 的顺序寻找真实返回目标。
|
||||
- 验证:聚焦 controller、Agent narrative、visible narrative、orchestrator 和首页 handoff 测试 89 条通过,覆盖 `assistant → user → assistant` 历史、具体事件进入 prompt、旧模板不覆盖 Agent 回答、仅点击后返回来源 consultation session。组件 SSR 测试在当前 Node 环境仍被仓库既有 GSAP ESM `registerPlugin` 加载错误阻断,尚未进入业务断言。
|
||||
- 防复发:主聊天区不得从 evidence recap 反推当前会话消息;任何 Agent narrative 后处理不得抹掉已校验的具体回应;完成态 continuation 不得自动领取,也不得以当前 rectification session 作为来源会话兜底。若需要刷新后永久恢复每轮 Agent 原文,必须扩展服务端 turn/RPC 持久化契约,不能用当前 narrative 冒充完整历史。
|
||||
- 相关记录:BUG-018、BUG-019、BUG-028
|
||||
- 复发自:BUG-018、BUG-028
|
||||
- 相关记录:BUG-018、BUG-019、BUG-028(编号存疑:BUG-018、BUG-019 疑为重复编号合并前的旧编号,可能应指 BUG-020、BUG-021)
|
||||
- 复发自:BUG-018、BUG-028(编号存疑:BUG-018 可能应指 BUG-020)
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-033 | 生时校正缺少连续事件语义导致模板式跳问
|
||||
- 相关记录:BUG-018、BUG-019、BUG-024
|
||||
- 复发自:BUG-018、BUG-024
|
||||
- 相关记录:BUG-221、BUG-222、BUG-227
|
||||
- 复发自:BUG-221、BUG-227
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-029 | 生时校正缺少连续事件语义导致模板式跳问
|
||||
## BUG-232 | 生时校正缺少连续事件语义导致模板式跳问
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -615,11 +616,11 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-034 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断
|
||||
- 相关记录:BUG-024、BUG-025、BUG-028
|
||||
- 复发自:BUG-024、BUG-028
|
||||
- 相关记录:BUG-227、BUG-228、BUG-231
|
||||
- 复发自:BUG-227、BUG-231
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-030 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断
|
||||
## BUG-233 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -636,10 +637,10 @@
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-035 | 生时校正发送错误内容后无法撤回修改
|
||||
- 相关记录:BUG-028、BUG-029
|
||||
- 相关记录:BUG-231、BUG-232
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-031 | 生时校正发送错误内容后无法撤回修改
|
||||
## BUG-234 | 生时校正发送错误内容后无法撤回修改
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -651,15 +652,15 @@
|
||||
- 修复:复用普通 session 的安全语义,在真正发起校正命令前提供 2.5 秒撤回窗口;发送后立即显示用户气泡和停止按钮,撤回时取消定时任务、移除临时气泡、把原文恢复到输入框并重新聚焦。只有窗口结束后才调用校正接口,因此成功撤回的本轮不会生成、不会写入证据历史,也不计入校正任务。
|
||||
- 验证:新增真实 Chromium 回归断言,覆盖发送、停止、草稿恢复和延时后未调用 `answer`;本地登录账号手测中,唯一测试文本撤回后仍保留在输入框,等待超过窗口后未进入“正在核对”、未出现在用户历史消息中。目标文件 ESLint 与 `git diff --check` 通过;独立 Node 组件套件当前仍被既有 GSAP Node 导入错误阻断。
|
||||
- 防复发:撤回必须发生在业务命令发出前;不得把客户端 `fetch.abort()` 误认为服务端事务已取消。
|
||||
- 相关记录:BUG-018、BUG-032
|
||||
- 相关记录:BUG-018、BUG-032(编号存疑:BUG-018 疑为重复编号合并前的旧编号,可能应指 BUG-020)
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-036 | Agent 化生时校正上线前被旧模板组件断言阻断
|
||||
- 相关记录:BUG-018、BUG-028
|
||||
- 相关记录:BUG-221、BUG-231
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-032 | 连续事件补充回答丢失状态并重新进入日期模板
|
||||
## BUG-235 | 连续事件补充回答丢失状态并重新进入日期模板
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -683,11 +684,11 @@
|
||||
- 修复:为 evidence request 增加向后兼容的 follow-up 状态机,记录问题类型和目标事件 ID;narrative Agent 被要求在追问已有事实时输出对应状态;进度装饰只维护确定性的下一领域,不再覆盖 Agent 可见问题对应的 follow-up 目标;服务端按状态将日期或细节 append-only 合并到目标事件并保留 `correctsEvidenceIds`,只有 `new_event` 才创建独立事件;细节合并改为单事件解析后覆盖组合摘要,避免标点导致错误拆分。
|
||||
- 验证:叙事、编排与存储聚焦测试 81/81 通过;真实两轮编排回归覆盖 Agent 先追问已有事件细节并持久化目标,然后“2017-05 参加工作”后回答“正式工作”仍保留 `2017-05`、形成“参加工作;正式工作”并不再出现日期模板;旧 turn 缺少 follow-up 时仍按兼容路径处理。目标文件 ESLint、相关 TypeScript 检查和 `git diff --check` 通过。
|
||||
- 防复发:每个中间轮必须断言追问类型、目标 evidence ID 和下一轮的事件合并结果;模型回退时默认显式 `new_event`,不得把已有事件细节当成新事实;跨轮测试必须断言用户可见回复不重复已回答的日期问题。
|
||||
- 相关记录:BUG-024、BUG-029、BUG-030
|
||||
- 复发自:BUG-029
|
||||
- 相关记录:BUG-227、BUG-232、BUG-233
|
||||
- 复发自:BUG-232
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-033 | 首次进入生时校正长期停留在建立记录
|
||||
## BUG-236 | 首次进入生时校正长期停留在建立记录
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -710,10 +711,10 @@
|
||||
- 修复:首轮叙事的两次校验尝试共享 10 秒中止信号,并关闭 Mastra SDK 内部重试;10 秒内生成成功仍使用 Agent 自然回答,超时或两次校验失败则使用现有的安全、可评分 fallback 完成建档。中间轮和最终总结不受该首轮时限影响。
|
||||
- 验证:新增回归断言,确保首轮两次叙事尝试共享同一个 `AbortSignal`;目标 narrative 测试、ESLint、TypeScript 与生产构建通过后记录最终结果。
|
||||
- 防复发:首轮建档不得无限等待模型,也不得同时开启 SDK 重试和业务校验重试;耗时预算必须覆盖整个首轮生成,而不是每次尝试单独重新计时。
|
||||
- 相关记录:BUG-030
|
||||
- 相关记录:BUG-233
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-034 | 生时校正入口旧快照重复 start 导致 409
|
||||
## BUG-237 | 生时校正入口旧快照重复 start 导致 409
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -737,11 +738,11 @@
|
||||
- 修复:首次 `start` 收到 409 后刷新账户状态;如果刷新得到可恢复 case,自动使用最新 `caseId` 和 `turnVersion` 执行 `resume`,不重复扣点;如果刷新后仍没有声明匹配的 case,则保留原错误,避免未经用户确认自动放弃旧校正记录。
|
||||
- 验证:`frontend/tests/consultation-entrypoint.test.ts` 23/23 通过,覆盖 409、刷新账户、使用最新 case 版本恢复;目标文件 ESLint 与 `git diff --check` 通过。完整套件仍有既有 CSS 断言和数据库权限测试失败,与本修复无关。
|
||||
- 防复发:生时校正入口必须把 `start` 视为可恢复的幂等操作;收到 case 冲突时先刷新 durable 状态,再决定恢复或提示用户;不得直接再次扣费、创建第二个 case,或在声明不一致时静默删除旧 case。
|
||||
- 相关记录:BUG-030、BUG-033
|
||||
- 相关记录:BUG-233、BUG-236
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-035 | 服务层重复 start 未在扣费前复用同一声明的未完成 case
|
||||
## BUG-238 | 服务层重复 start 未在扣费前复用同一声明的未完成 case
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -765,11 +766,11 @@
|
||||
- 修复:在 reserve 前增加账户级未完成 case 查询;同一 `declaredBirthInput` 直接返回已有公开 turn,不重复计算或扣费;声明不一致继续稳定返回冲突。并发竞态在释放本次预留后重新读取账户,复用同声明的胜出 case。
|
||||
- 验证:`frontend/tests/conversational-rectification-orchestrator.test.ts` 32/32 通过,覆盖同声明重复 start 复用、声明不一致冲突和既有预留重试;后续需补充线上部署后的真实 smoke。
|
||||
- 防复发:start 幂等性必须同时在入口、编排器和 durable RPC 三层成立;任何新增 actionId 的 start 都必须先检查账户级未完成 case,不能把数据库冲突当成正常流程。
|
||||
- 相关记录:BUG-030、BUG-033、BUG-034
|
||||
- 复发自:BUG-034
|
||||
- 相关记录:BUG-233、BUG-236、BUG-237
|
||||
- 复发自:BUG-237
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-036 | 旧数据库校验器把首条新事件回答误报为 409
|
||||
## BUG-239 | 旧数据库校验器把首条新事件回答误报为 409
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -792,10 +793,10 @@
|
||||
- 修复:保存任何 turn 时都移除语义冗余的 `followUp: new_event`,兼容旧校验器;`event_date` 与 `event_detail` 仍等待对应数据库迁移后持久化。
|
||||
- 验证:直接 RPC 探针确认旧格式为 `true`、带 `followUp` 为 `false`;新增 store 回归测试覆盖首轮与后续 turn 的兼容投影。
|
||||
- 防复发:新增可选持久化字段时,默认语义必须能向旧数据库降级;数据库迁移未应用前不得把兼容字段直接写入 durable RPC。
|
||||
- 相关记录:BUG-032、BUG-034、BUG-035
|
||||
- 相关记录:BUG-235、BUG-237、BUG-238
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-037 | 非评分回答绕过 Agent 并显示固定澄清模板
|
||||
## BUG-240 | 非评分回答绕过 Agent 并显示固定澄清模板
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-23
|
||||
@@ -848,11 +849,11 @@
|
||||
- 修复:非评分分支先用当前候选技术包、完整事件账本、最新用户原话和未决证据调用现有 narrative Agent;状态机只在后台固定本轮追问属于 `event_date`、`event_detail` 还是 `new_event`,不再替代 Agent 的可见措辞。模型或技术包调用失败时仍保留确定性安全文案,避免正常澄清变成 5xx。
|
||||
- 验证:编排回归 32/32 通过;相关叙事、编排和存储测试 81/81 通过。新增断言确认无年月事件会得到 Agent 针对该事件生成的单一自然问题,并持久化 `event_date + evidenceId`,不再出现旧固定模板;未来事件、换方向和已有评分事件的行为保持兼容。
|
||||
- 防复发:任何能继续对话的业务分支都必须优先经过 narrative Agent;状态机可以决定证据目标和可评分性,但不得直接占用正常用户回复。确定性模板只能作为模型或技术计算失败时的技术兜底。
|
||||
- 相关记录:BUG-029、BUG-030、BUG-032
|
||||
- 复发自:BUG-029
|
||||
- 相关记录:BUG-232、BUG-233、BUG-235
|
||||
- 复发自:BUG-232
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-038 | 全球地点字段未迁移导致账户余额接口整体 500
|
||||
## BUG-241 | 全球地点字段未迁移导致账户余额接口整体 500
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -863,10 +864,10 @@
|
||||
- 修复:GET 检测缺列或 schema cache 错误后,回退到迁移前的 profile 字段集合;全球地点字段在响应中返回空值,余额、出生时间状态和未完成校正 case 继续正常读取。迁移应用后自动使用完整查询。
|
||||
- 验证:服务角色直接查询确认修复前错误为 `42703`;`frontend/tests/account-api.test.ts` 增加旧表回退回归,另运行 TypeScript、目标 ESLint 与本地登录接口 smoke。
|
||||
- 防复发:向账户初始化查询增加非关键资料字段时,应用发布必须兼容迁移前后的数据库形状;不能让可选地点元数据阻断余额与核心账户状态。
|
||||
- 相关记录:BUG-034、BUG-035
|
||||
- 相关记录:BUG-237、BUG-238
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-039 | 全球地点迁移漏掉服务角色列权限导致账户保存 500
|
||||
## BUG-242 | 全球地点迁移漏掉服务角色列权限导致账户保存 500
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -877,10 +878,10 @@
|
||||
- 修复:在全球地点迁移中为服务角色补齐六个新字段的最小列级读取、插入和更新权限,不恢复表级宽权限。
|
||||
- 验证:迁移权限静态回归、生产事务 dry-run、正式授权、字段权限查询和真实登录 PATCH smoke。
|
||||
- 防复发:扩展服务端账户 upsert 字段时,必须同时审计 authenticated 自助保存和 service_role 并发读取/upsert 两条权限链。
|
||||
- 相关记录:BUG-034、BUG-038
|
||||
- 相关记录:BUG-237、BUG-241
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-040 | 选择全球地点后重复搜索且候选列表被卡片裁切
|
||||
## BUG-243 | 选择全球地点后重复搜索且候选列表被卡片裁切
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -891,10 +892,10 @@
|
||||
- 修复:地点完整性改为接受“合法坐标 + IANA timezoneId”,数字 offset 可暂时为空;选中地点时使在途搜索序列失效并清空候选;出生时间过渡卡片允许候选列表溢出显示。
|
||||
- 验证:地点选择、出生资料完整性和全球地点目标测试覆盖 nullable offset、缺失 timezoneId、选择竞态保护与候选列表可见性。
|
||||
- 防复发:前端地点完整性必须与位置 API 的 nullable offset 合同一致;选择类异步组件必须在 commit selection 时废弃旧请求,浮层祖先不得无意裁切。
|
||||
- 相关记录:BUG-038、BUG-039
|
||||
- 相关记录:BUG-241、BUG-242
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-041 | 全球地点资料已保存但初始化接口仍判定未完成
|
||||
## BUG-244 | 全球地点资料已保存但初始化接口仍判定未完成
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -905,10 +906,10 @@
|
||||
- 修复:服务端资料投影补齐全球地点字段;完整性判断接受“有效全球地点”或“旧中国行政区地点”,并复用出生资料共享校验。
|
||||
- 验证:新增旧金山 `period_only + timezoneOffset null + America/Los_Angeles` 回归用例,确认可生成首页初始问题。
|
||||
- 防复发:读取全球地点的服务端流程不得继续以中国行政区代码作为唯一地点完成条件。
|
||||
- 相关记录:BUG-038、BUG-040
|
||||
- 相关记录:BUG-241、BUG-243
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-042 | 全球地点缺少数字时区偏移导致生时校正入口 409
|
||||
## BUG-245 | 全球地点缺少数字时区偏移导致生时校正入口 409
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -919,10 +920,10 @@
|
||||
- 修复:生时校正读取资料后复用现有本地时区服务,按出生日期和已申报时间或时间段参考时刻解析历史 offset;进入持久化合同前把经纬度规范为六位小数;旧 case 导入走同一路径。
|
||||
- 验证:旧金山 `1955-02-24 + evening + America/Los_Angeles` 回归确认请求历史时区服务、得到 `-8`、规范化 Geoapify 坐标后进入校正。
|
||||
- 防复发:资料保存可以暂缺 offset,但任何进入分钟计算的路径必须先通过 IANA 历史时区解析;外部地理编码坐标必须在进入耐久 JSON 合同前规范化,不能把 offset null 或坐标精度问题误报为资料缺失。
|
||||
- 相关记录:BUG-040、BUG-041
|
||||
- 相关记录:BUG-243、BUG-244
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-043 | 全球出生地点在兄弟接口中被误判、冲突或静默退化
|
||||
## BUG-246 | 全球出生地点在兄弟接口中被误判、冲突或静默退化
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-24
|
||||
@@ -933,7 +934,7 @@
|
||||
- 修复:抽取共享历史时区解析器,兼容数据库 snake_case 与浏览器 camelCase 资料,并按实际申报/确认时间调用本地 IANA 历史时区服务;账户恢复使用已存 case 的不可变 offset 作为仅用于匹配的 fallback,并把坐标统一到六位耐久精度;咨询在扣点前使用最终 active time 补算 offset;journey 入口和 case loader 在严格解析前补算;今日星语、合盘和遗留评估优先使用真实全球经纬度与时区,中国行政区仅作兼容 fallback。
|
||||
- 验证:账户 nullable offset + 七位坐标可恢复旧金山已有 case,时区 ID 或地点 ID 不一致仍拒绝恢复;verified consultation 使用最终 active time 解析 `-8` 且解析失败不调用扣点;journey、今日星语、合盘和遗留评估的全球地点回归通过。两组聚焦套件共 84/84 通过,目标文件 ESLint、TypeScript `--noEmit` 与 `git diff --check` 通过。
|
||||
- 防复发:出生地点完成性与计算就绪性必须分层;资料层可保存 IANA timezoneId 且 offset 暂空,但所有星盘计算入口必须统一补算历史 offset。不得在新接口复制中国专用地点解析,也不得用未经规范化的外部坐标直接比较持久化声明。
|
||||
- 相关记录:BUG-040、BUG-041、BUG-042
|
||||
- 相关记录:BUG-243、BUG-244、BUG-245
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-044 | 数据库出生地点声明校验落后导致创建校正记录误报 409
|
||||
@@ -947,7 +948,7 @@
|
||||
- 修复:新增向前迁移同步数据库地点声明白名单,接受全球地点身份、IANA 时区来源及坐标字段;地点身份允许 `city`、`cityCode` 或 `placeId` 任一存在,同时保留旧中国地点兼容和数值边界校验。
|
||||
- 验证:数据库迁移单独执行成功;使用新的 actionId 对本地接口真实 smoke 返回 `200 active`,创建可恢复 case,首轮 narrative 非空;相同 actionId 重放仍返回同一 case,账户积分只从 100 扣至 99;无待交接内容时 handoff 按合同返回 `204`。相关持久化与全球地点测试 28/28 通过。
|
||||
- 防复发:任何扩展 `declaredBirthInput.birthplace` 的应用字段都必须同步更新数据库 JSON 校验函数并增加迁移文本回归断言;不得把声明校验失败笼统诊断为旧 case 冲突。
|
||||
- 相关记录:BUG-034、BUG-035、BUG-042、BUG-043
|
||||
- 相关记录:BUG-237、BUG-238、BUG-245、BUG-246
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-045 | 数据库追问合同落后导致第一条回答误报 409
|
||||
@@ -961,7 +962,7 @@
|
||||
- 修复:在 Supabase 项目 `vtvnfqmonbfuxmqkqdlc` 单独执行已有向前迁移,使数据库 evidence request 校验与当前 TypeScript 持久化合同一致;未执行其他迁移,也未重新部署应用。
|
||||
- 验证:对原 case 使用新 actionId 重试第一条回答返回 HTTP 200,`turnVersion` 从 0 递增到 1,narrative 和目标 `event_detail` follow-up 正常返回;用同一 actionId 重放返回相同 turn,账户积分保持 99。服务端日志两次均为 `actionKind=answer`、`resultCategory=success`、`billingState=unchanged`;持久化与迁移聚焦测试 27/27 通过。
|
||||
- 防复发:应用扩展 durable JSON 合同时,数据库迁移必须进入环境迁移账本并在发布验收中执行第一条真实写入 smoke;不能只用迁移文件存在或静态测试通过代替远端数据库合同验证。
|
||||
- 相关记录:BUG-034、BUG-035、BUG-044
|
||||
- 相关记录:BUG-237、BUG-238、BUG-044
|
||||
- 修复版本:数据库向前迁移已应用;应用代码待提交(本地可测)
|
||||
|
||||
## BUG-046 | 生时校正刷新丢失首条引导并堆叠历史用户气泡
|
||||
@@ -975,7 +976,7 @@
|
||||
- 修复:新会话从首条 Agent narrative 开始持久化完整消息序列,每次回答后保存真实的 `Agent → 用户 → Agent` transcript;resume 接口按 turn version 读取 narrative,并用 evidence 的 source turn 关联用户原文,返回耐久的交替历史;页面优先用数据库历史修复旧 session。无法可靠恢复用户原文的残缺旧 turn 不再伪造模板回复,相邻 Agent 状态只保留最新一条;最末级 legacy fallback 也只恢复最近一组可靠消息,不再展开全部累计 evidence。
|
||||
- 验证:client、controller、route 聚焦测试 52/52 通过;component 非 Chromium 测试 8/8 通过;TypeScript `--noEmit`、目标文件 ESLint 与 `git diff --check` 通过。按用户要求未运行 Chrome/Playwright。
|
||||
- 防复发:聊天历史必须以逐轮 durable transcript 或可验证的 turn/evidence 关联为真源;累计业务证据只能用于评分与摘要,不能被前端推断成消息列表,也不能为缺失历史生成看似真实的 Agent 文案。
|
||||
- 相关记录:BUG-024、BUG-029、BUG-032、BUG-045
|
||||
- 相关记录:BUG-024、BUG-029、BUG-032、BUG-045(编号存疑:BUG-024、BUG-029、BUG-032 无法判定指向原记录还是 BUG-227、BUG-232、BUG-235)
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-047 | 相对日期缺少上下文解析并触发模板式重复追问
|
||||
@@ -989,7 +990,7 @@
|
||||
- 修复:对 `event_date` 和 `event_detail` 追问使用目标事件或最近明确事件作为年份锚点,将“来年、次年、第二年、翌年”和“同年、当年、那年”的月份上下文化后再提取,同时保留用户原始文本;允许带自然描述的日期补充完成原事件,但仅在纯日期、同领域或低信息细节时合并,避免把“搬家”等明确新事件吞入上一条澄清;普通澄清优先显示 narrative Agent 的自然回答,确定性模板仅保留为模型失败兜底。
|
||||
- 验证:事件提取与编排聚焦测试 59/59 通过,覆盖“2022年12月正式退学 → 来年1月彻底离校”解析为 `2023-01`、带描述日期补充、相对日期无上下文时不臆造年份,以及不同领域新事件不误合并。目标 TypeScript、ESLint 与补丁检查通过;按用户要求未运行 Chrome/Playwright。
|
||||
- 防复发:事件状态机只负责持久化目标、证据质量与评分边界,不得替代 Agent 的可见对话;相对日期必须在明确的当前事件上下文内解析,缺少可靠锚点时保持待澄清;澄清合并必须同时判断日期、摘要和领域连续性。
|
||||
- 相关记录:BUG-029、BUG-032、BUG-037
|
||||
- 相关记录:BUG-232、BUG-235、BUG-240
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-048 | 生时校正消息更新后没有自动滚动到最新内容
|
||||
@@ -1018,7 +1019,7 @@
|
||||
- 重跑边界:新增独立、幂等的 `regenerate` 命令,使用上一轮已经持久化的用户原文和事件台账重新生成当前 Agent narrative;不重新提取事件、不追加用户消息、不重复评分、不改变候选范围、不扣点,并在当前消息列表和刷新后的耐久历史中替换原 Agent 回答。
|
||||
- 验证:TypeScript `--noEmit` 通过;叙事、Controller、client、route、orchestrator 聚焦测试 119/119 通过,覆盖悬空提问补全、无 payload 重跑、消息原位替换、证据与候选保持不变;组件静态/SSR 检查 9 项通过;目标 ESLint 与 `git diff --check` 通过。测试过滤器未按预期排除文件内的真实 Chromium 用例,该用例误启动后以 `SIGTRAP` 退出,未作为本次 UI 验收依据,且不再重试。
|
||||
- 防复发:结构化回答校验必须区分“出现疑问相关词”与“存在完整可回答的问题”;任何重新生成操作都必须与 evidence mutation、评分和计费分离,并通过相同 action receipt 保持幂等。
|
||||
- 相关记录:BUG-029、BUG-046、BUG-047、BUG-048
|
||||
- 相关记录:BUG-232、BUG-046、BUG-047、BUG-048
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-050 | 生时校正重跑期间重复显示旧回答和新思考气泡
|
||||
@@ -1047,7 +1048,7 @@
|
||||
- 修复:当上一轮可见问题明确包含原因、表现或“哪些方面”等细节追问语义时,即使 metadata 错标为 `new_event`,也把回答合并回最近一条带日期的有效事件;同时移除“这件事很有用,但还差时间定位”的固定话术,保留不假定上下文的简短兜底。
|
||||
- 验证:orchestrator 回归覆盖“1972年12月退学 → 追问压力原因但 metadata 错标 → 经济负担导致无法继续”,断言沿用原日期、合并为同一有效事件且不再出现固定索时模板;目标 ESLint、TypeScript 与补丁检查。
|
||||
- 防复发:事件追问的可见语义必须能够兜底结构化 metadata 的偶发错标;已有日期事件的原因、性质和具体表现回答不得强制再次提供日期。
|
||||
- 相关记录:BUG-029、BUG-047
|
||||
- 相关记录:BUG-232、BUG-047
|
||||
- 复发自:BUG-047
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -1124,7 +1125,7 @@
|
||||
- 修复:单条事件落入 `other` 时,优先调用 narrative Agent 结合最近事件上下文返回允许的语义领域;模型不可用、超时或仍返回 `other` 时才保留确定性结果。首轮和中间轮叙事连续失败统一返回可重试服务错误且不保存本轮,只有最终确认阶段保留包含安全边界与三类表的确定性 fallback。
|
||||
- 验证:新增“2020年4月去石油化工研究院实习做研究员”语义分类回归,确认保存为 `career` 并可评分;新增中间轮不合格输出回归,确认返回 `service_unavailable`、不保存事件、不推进版本且不产生模板消息。事件提取、叙事、编排和路由定向测试 134/134 通过。
|
||||
- 防复发:正则只负责低成本初筛和模型不可用时的降级,不得覆盖 Agent 对自然语言事件的语义判断;非最终轮生成失败不得用业务模板伪装成成功回答;候选时间、分盘事实、未来事件和重复计分仍由程序校验。
|
||||
- 相关记录:BUG-029、BUG-032、BUG-047、BUG-053
|
||||
- 相关记录:BUG-232、BUG-235、BUG-047、BUG-053
|
||||
- 复发自:BUG-053
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -1228,7 +1229,7 @@
|
||||
- 修复:仅在明确指向既有事件的 `event_date` 追问中,从目标事件继承年份或年月;允许兼容的年到月、月到日精度提升,并继续以 `correctsEvidenceIds` 保存修订链。普通新事件不猜测缺失年份。
|
||||
- 验证:新增 `2026-07 · 决定开公司 → 7 月 10 号` 回归,断言生成 `2026-07-10` 修订、保留原事件、沿用原摘要且不再次询问日期归属。
|
||||
- 防复发:无年份月日只能在定向日期追问且存在可靠目标日期时补全;日期精度提升必须与目标已有年份或月份前缀一致。
|
||||
- 相关记录:BUG-024、BUG-047、BUG-062
|
||||
- 相关记录:BUG-227、BUG-047、BUG-062
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
## BUG-064 | 窄候选被本地稳定性前置门控阻断导致长对话无法进入 VedAstro 验证
|
||||
@@ -1305,7 +1306,7 @@
|
||||
- 修复:复用现有 turn 与 event evidence 存储,每轮向 Agent 注入同一 case 最近 40 条连续问答;同时在共享 follow-up 合同和数据库 JSON 校验中持久化 `prompt`、`answerMode` 与 `proposedDate`。Orchestrator 在事件提取前确定性处理 yes/no:肯定时把候选日期作为 append-only correction 合并到目标 evidence,否定时不生成事件并把同一目标切换为开放式日期追问。模型成功返回时,其 follow-up 不再被提取器的自动澄清覆盖;Narrative Agent 拒绝缺少结构化候选、重复已解决问题或继续指向已完成 evidence 的输出。
|
||||
- 验证:回归覆盖“2020年10月吗?→是的”、单独“不是”、“不是,是2021年10月”、刷新/resume 后候选日期仍存在,以及同一 actionId 重放不重复写入;断言确认词不成为独立事件、修正 lineage 正确、否定后切换为 `free_text`,并断言叙事 prompt 末尾连续包含上一条 Assistant 问题和当前 User 回答。
|
||||
- 防复发:任何承接式回答必须以持久化会话历史为第一语境;确认或否认是否改变业务事实必须由持久化 follow-up 状态和确定性状态机执行,不得再从自然语言文案反推候选日期。
|
||||
- 相关记录:BUG-032、BUG-063、BUG-067
|
||||
- 相关记录:BUG-235、BUG-063、BUG-067
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -2779,7 +2780,7 @@
|
||||
5. 新增 Case Service + 五个 API:`POST /api/rectification/cases/open`(homepage resume-or-create / session 精确恢复 / new 安全冲突)、`GET entry-summary`(首页 CTA 真值)、`GET cases/[caseId]`(脱敏投影,绝不返回 baseline_birth_snapshot)、`POST close`、`POST upgrade-skill`;profile 不完整不建案;错误不泄露身份、出生资料或 DB 原文。
|
||||
- 验证:新增 `rectification-v9-contracts.test.ts`(12)、`rectification-v9-case-service.test.ts`(14)、`rectification-v9-migration.test.ts`(17 静态合同)本地共 43 项全部通过;`rectification-v9-database.test.ts`(真实 PostgreSQL 迁移 apply/re-apply、open 原子+幂等+resume、profile 门禁、所有权、terminal 只读、evidence 生命周期、backfill 状态分布/单 active/结果映射/幂等)在本机因无 Docker 按环境 skip(5 项),待远端完整 runner 执行;既有生时校正相关测试 60/60 通过;`tsc --noEmit` 对本 lane 文件零错误(全量 6 个既有错误全部位于未触碰文件);目标 ESLint 0 error 0 warning;`git diff --check` 通过;`database-local-business.test.ts` 精确 public 表清单同步新增 5 张 v9 表并断言新迁移 applied(BUG-127/144 防复发)。
|
||||
- 防复发:Case 状态、resumable/terminal、shouldStartOpening 必须只由服务端决定;侧边栏必须以精确 sessionId 恢复并校验所有权;open/create 必须走 requestId 幂等账本 + 同用户 advisory 串行化;新增 self-hosted 业务表必须同时更新全迁移 applied ledger 与精确 public 表集合,且只进 `frontend/supabase/migrations`;新页面/新 API 必须同步能力审计;浏览器任何入参都不允许携带 userId、出生资料、range 或权限决定。
|
||||
- 相关记录:BUG-004、BUG-027、BUG-033、BUG-067、BUG-068、BUG-069、BUG-072、BUG-074、BUG-085、BUG-086、BUG-095、BUG-112、BUG-113、BUG-114、BUG-115、BUG-116、BUG-117、BUG-118、BUG-119、BUG-120、BUG-121、BUG-127、BUG-143、BUG-144
|
||||
- 相关记录:BUG-004、BUG-027、BUG-033、BUG-067、BUG-068、BUG-069、BUG-072、BUG-074、BUG-085、BUG-086、BUG-095、BUG-112、BUG-113、BUG-114、BUG-115、BUG-116、BUG-117、BUG-118、BUG-119、BUG-120、BUG-121、BUG-127、BUG-143、BUG-144(编号存疑:BUG-033 无法判定指向原记录还是 BUG-236)
|
||||
- 复发自:无(新 Agentic Domain 主链;旧防线缺失的直接原因见下)
|
||||
- 修复版本:本地 staging 候选(未 push / deploy)
|
||||
|
||||
@@ -3511,7 +3512,7 @@
|
||||
- 相关记录:BUG-177、BUG-198、BUG-206
|
||||
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 与健康检查验收为准)
|
||||
|
||||
## BUG-209 | 用户选择准确出生时间后仍停留 reported,个人报告固定返回 birth_time_not_usable
|
||||
## BUG-247 | 用户选择准确出生时间后仍停留 reported,个人报告固定返回 birth_time_not_usable
|
||||
|
||||
- 状态:resolved(本地候选,待 staging 迁移、精确 SHA 发布与登录态报告验收)
|
||||
- 首次发现:2026-08-16
|
||||
@@ -3687,4 +3688,90 @@
|
||||
- 验证:`npx next build` 退出码 0;构建产物显示用户端 CSS chunk 184,968 字节且不含任何后台规则,后台规则独立成 5,358 字节 chunk 且仅被 `.next/server/app/admin/` 下 18 个 client-reference-manifest 引用。以 `react-dom/server` 配 `ServerInsertedHTMLContext` 实测 registry 服务端输出 90,132 字节含主题色 `#85432f` 的 `<style id="antd-cssinjs">`(修复前为 0)。`npm ci --dry-run` 报告锁文件同步。standalone 产物仍包含 Python skill 资产。未能以真实管理员会话做端到端 HTTP 验证(需 Postgres 与登录态)。
|
||||
- 防复发:引入 CSS-in-JS UI 库时必须同时接入 SSR 样式提取;路由段专用样式不得写入根布局引入的全局样式表。
|
||||
- 相关记录:BUG-213
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-248 | 七个设计 token 类名编译不出任何 CSS,报告页文字颜色长期未生效
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`text-ink`、`text-ink-secondary`、`text-ink-tertiary`、`text-danger`、`text-warning`、`bg-canvas`、`hover:bg-canvas-muted` 共 7 个类名,30 处调用点,分布在 `personal-report-page.tsx`(17 处)、`reports/[reportId]/` 的 error、loading、not-found 页、`generate-personal-report-button.tsx` 与 `app/page.tsx`。
|
||||
- 用户现象:个人报告页与报告子页的次级说明文字、错误提示、警示文字全部沿用正文墨色,本该更浅或本该是红色、琥珀色的层次完全没有出现。因为页面并未报错也没有明显错版,问题从未被当成 Bug 报上来。
|
||||
- 根因:Tailwind v4 只从 `@theme` 构建工具类命名空间。`globals.css` 把 32 个 `--color-*` 设计 token 定义在普通 `:root` 里,而顶部的 `@theme inline` 块只暴露了 `--color-background`、`--color-foreground`、`--color-muted-foreground` 等另一套 shadcn 别名。于是 `text-ink` 这类按 token 名书写的类名不生成任何规则,静默失效——类名拼写正确、编辑器不报错、构建也不报错。
|
||||
- 修复:在既有 `@theme inline` 块内追加 32 行,把全部 `:root` 调色板 token 以同名别名暴露出来(`--color-ink: var(--color-ink);`)。未改动任何 token 的取值,未新增或删除 `:root` 声明,未改动任何 `.tsx`。沿用 `inline` 而非新开裸 `@theme`:块内既有的 `--color-background: var(--color-canvas)` 依赖 `inline` 才能转发到调色板而不是被 Tailwind 接管,拆成两个块只会多出一套结构。
|
||||
- 验证:以 `@tailwindcss/postcss` 编译真实样式表前后对比,产物 216,466 → 218,532 字节、311 → 318 条工具类规则,`diff -u` 显示**新增 57 行、删除 0 行**,新增的正是上述 7 个类且无一遗漏,既有 `text-primary`、`text-muted-foreground`、`bg-background` 输出逐字未变。另以 headless Chrome 取 `getComputedStyle` 实测:修复前 13 个探针有 9 个取到错误值(四个文字类都落回正文墨色 `rgb(29,29,31)`,三个背景类落到透明),修复后全部为预期值。新增 `frontend/tests/design-token-contract.test.ts` 遍历 `src/` 下所有颜色工具类并与 `@theme` 命名空间交叉核对,已在修复前的样式表上确认该测试会失败。`tsc --noEmit` 清洁;读取 `globals.css` 的 25 个测试文件共 253 条断言全绿。
|
||||
- 防复发:新增设计 token 必须同时进入 `@theme`,否则按 token 名书写的工具类会静默失效。合同测试须交叉核对"`src/` 中实际使用的工具类"与"`@theme` 暴露的命名空间",仅断言类名字符串存在无法发现这类缺陷。
|
||||
- 相关记录:BUG-215 同属"断言了文本却没断言真实产物"的一类
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-249 | 每敲一个字重渲染 2723 行组件,且刷新丢失未发送的问题
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`/` 主对话页输入框,聊天场景中最高频的交互路径。
|
||||
- 用户现象:在输入框中打字时界面卡顿,长会话下尤其明显;此外刷新页面或误触返回会丢失已经写了一半的问题,没有任何恢复入口。
|
||||
- 根因:`draft`(输入框文本)是 `Home()` 的顶层 `useState`,而 `Home()` 单个函数体就有 2723 行、56 个 `useState`、18 个 `useEffect`、47 个内部函数、向子组件传下 62 个函数 prop,且全文件 `useCallback` 与 `useMemo` 各为 0,React Compiler 也未启用。因此每次击键都会重渲染整个组件、重建全部 47 个内部函数、给所有子组件换一批新的函数引用。草稿仅存在于 React state,没有任何持久化。
|
||||
- 修复:新增 `frontend/src/lib/composer-draft.ts` 作为模块级草稿 store,新增 `frontend/src/components/chat-composer.tsx` 通过 `useSyncExternalStore` 订阅它。`Home()` 中 `draft` state 移除,`setDraft` / `setDraftTheme` / `setDraftEntrypoint` 保留为同名函数(前者转发到 store,后两者写 ref),十余处既有调用点逐字未改。选用外部 store 而非 `useImperativeHandle`:输入框在生时校正面板与引导表单打开时会真的卸载,而 `selectSession`、`openRectificationCase` 恰好在这些窗口里调用 `setDraft("")`,ref 句柄此时为 null 会让清空静默失效、旧文本在重新挂载后复活。草稿以 `jyotisha.composer-draft` 写入 sessionStorage(沿用 `jyotisha.pending-consultation` 命名约定),发送成功即删除键,读取时校验类型、拒绝未来时间戳、丢弃超过 24 小时或超出 500 字上限的值,`sessionStorage` 抛错(隐私模式、配额)时降级为纯内存,绝不向打字路径抛出异常。输入框保持受控,`isComposing` 中文输入法守卫原样保留。
|
||||
- 验证:`tsc --noEmit`、`eslint`(未新增任何 disable 注释)、`npx next build` 均通过;读取 `page.tsx` 的 24 个既有测试文件 268 条断言**无需修改任何一条即全部通过**;新增 `frontend/tests/composer-isolation-contract.test.ts` 5 条。逐项确认发送后清空、停止后恢复原问题、推荐问题填入、咨询恢复回填、发送按钮空态禁用五个行为均未变。未做的验证:未在真实浏览器中实测中文输入法,也未用 React Profiler 量化重渲染减少量——重渲染的消除是结构性的(击键不再改变 `Home()` 中任何 state),但没有实测数字。
|
||||
- 防复发:高频输入状态必须下沉到实际使用它的叶子组件,不得放在大型容器组件的顶层;跨组件读写的输入状态优先用外部 store 而非 ref 句柄,因为句柄在组件卸载期间不可达。
|
||||
- 相关记录:BUG-249 无前序同类记录
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-250 | 首屏强制下载动画引擎与完整 Markdown 管线,多出 73 KB
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`/` 主对话页首屏 JavaScript 体积,影响所有用户尤其是移动端与弱网首次访问。
|
||||
- 用户现象:打开对话页时需要先下载并解析动画引擎和完整 Markdown 渲染管线,而这两者在用户发出第一条消息、收到第一条回答之前都用不上,首屏可交互时间被拖长。
|
||||
- 根因:`chat-message-row.tsx` 静态导入 `gsap`、`@gsap/react` 与 `thinking-orbs`,`chat-message-content.tsx` 静态导入 `react-markdown` 与 `remark-gfm`,两者又都被 `page.tsx` 静态导入。全代码库此前只有 1 处 `next/dynamic`,其余全部静态。
|
||||
- 修复:Markdown 抽到 `chat-markdown-view.tsx`,经 `import()` 加模块级缓存加载——刻意不用 `React.lazy` + `Suspense`,因为 lazy 的 promise 落定后仍需一次重渲染,可能露出一帧 fallback;模块级缓存则保证解析完成后每次渲染都同步出 Markdown,整个生命周期只切换一次。加载前的兜底**仅在文本不含任何 Markdown 语义字符时**渲染纯段落(此时输出与 CommonMark + GFM 逐字一致),含语法则渲染空而非原文,确保用户永远不会看到裸露的 `**bold**`。GSAP 改为 `import("gsap")`,且只在模块已解析时才执行入场动画——若在首屏后补放会先绘制可见再拉回 `autoAlpha: 0` 淡入,那是比不动画更糟的闪烁;`prefers-reduced-motion` 守卫保留并强化(该类用户现在完全不下载 gsap)。移除 `@gsap/react`(它会静态导入 gsap,留着就白拆了),`useGSAP` 替换为等价的 isomorphic layout effect,`gsap.matchMedia()` / `motion.revert()` 结构不变。thinking-orbs 用 `next/dynamic` + `ssr: false`,占位符预留精确的 20×20 盒子避免抖动。三个 chunk 在首屏绘制后经 `requestIdleCallback`(Safari 回退 `setTimeout(300)`)预取。
|
||||
- 验证:`next build` 前后实测。Next 16.2.10 的 Turbopack 已不再打印 First Load JS 列,故改为从预渲染的 `.next/server/app/index.html` 汇总其引用的全部 `/_next/static/**.js` 并按 gzip -9 计算,两次口径一致:549.5 KB → 476.3 KB gzip(**−73.2 KB,−13.3%**),raw 1819.4 KB → 1601.4 KB(−12.0%)。两次构建的 `page.tsx` sha256 相同,19 个首屏 chunk 中 17 个逐字节相同,三个新 lazy chunk 合计 74,006 字节 gzip,占降幅的 98.7%,可归因。以最小化产物特征串(gsap 的 `GreenSock`、markdown 的 `micromark`、orbs 的 `ribbon`)确认三者已不在任何首屏脚本中。`tsc`、`eslint` 清洁;新增 `frontend/tests/chat-bundle-splitting-contract.test.ts` 5 条(含对"绝不渲染裸 Markdown"规则的真实单元断言,覆盖 `**`、`##`、表格、反引号、裸 URL 等);相关既有测试 38 条全绿。未做的验证:无法在真实浏览器中目视确认无闪烁,无闪烁的结论来自代码路径与合同测试而非实际渲染。
|
||||
- 防复发:仅在首次交互后才需要的重依赖不得静态导入进首屏组件树;延迟加载必须同时给出不产生布局抖动、不闪烁、不泄漏源码语法的兜底,做不到就不要延迟。
|
||||
- 相关记录:BUG-250 无前序同类记录
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-251 | 查看报告与充值走整页跳转,返回后对话状态全部丢失
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`/` 主对话页跳往 `/reports` 与会员充值页的 5 个入口。
|
||||
- 用户现象:从对话中点开报告列表或余额、充值入口时整页刷新,返回对话后需要重新加载全部会话与账户数据;点击充值时刚输入的问题也一并丢失。
|
||||
- 根因:`page.tsx` 有 11 处 `window.location` 跳转、`useRouter` 使用为 0,文件从未引入 `next/navigation`。每一次跳转都丢弃整棵 React 树、已加载的 JS 与全部客户端状态,再从头启动应用。
|
||||
- 修复:逐处判断后改软导航 5 处(`/reports` 侧栏入口、会员入口、余额按钮、以及发送前余额守卫与 `/api/consult` 返回 402 两处充值跳转),改为 `router.push`。其中发送前的余额守卫在清空输入框**之前**执行,软导航因此顺带保住了用户刚输入的问题。**刻意保留硬跳转 6 处**:5 处 `/login` 认证重定向(401 时客户端持有服务端已拒绝的会话,整页加载是可靠的清除方式;`signOut()` 后尤其如此,会话已在服务端销毁,软跳转会让整棵树带着已登出用户的数据继续存活),与 1 处启动失败页的重试按钮(失败数据全部来自挂载时 `useEffect` 内的客户端 `fetch`,`router.refresh()` 只重取服务端组件、不会重新挂载客户端树,改了按钮反而会静默失效)。此判断与本分支 `membership/page.tsx` 保留认证硬跳转的处理一致。
|
||||
- 验证:`tsc`、`eslint` 清洁;`next build` 通过;新增 `frontend/tests/chat-navigation-a11y-contract.test.ts` 13 条,同时锁定"哪些是软导航"与"认证重定向是**有意**保持硬跳转",未来有人机械改写会被测试拦下。修改既有测试 1 个:`personal-report-entry.test.ts` 原断言 `window.location.assign("/reports")`,随代码更新为 `router.push` 并**追加** `assert.doesNotMatch` 反向断言,强于原状。全部非数据库套件 199 个文件 1592 条断言全绿。未做的验证:未在运行中的应用里实测软导航的实际体感。
|
||||
- 防复发:应用内同源路由必须走 `router.push`;认证重定向与启动失败恢复例外,且例外必须在合同测试中显式声明为有意,不能只是"还没改"。
|
||||
- 相关记录:BUG-251 无前序同类记录
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-252 | 回答完成对屏幕阅读器完全无提示,且开始提示很可能也未被念出
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`/` 主对话页依赖屏幕阅读器的用户的全部问答流程。
|
||||
- 用户现象:提问后只能听到"Jyotisha 正在回答"(且这一条很可能也没被念出),此后没有任何提示——不知道回答何时结束、是否可以开始阅读、内容在哪里。
|
||||
- 根因:其一,`page.tsx` 只有开始态的 live region,完成态没有任何播报出口。其二(排查中发现的更严重问题),该 region 位于 `.message-list` 内部,而后者带 `aria-busy={isLoading}`;`aria-busy="true"` 的语义正是让辅助技术暂缓呈现该子树的变化,因此连"正在回答"很可能都从未被念出。其三,该 region 会随会话首条消息重新挂载,而挂载时即带内容的 live region 本身就不可靠。
|
||||
- 修复:新增 `frontend/src/lib/chat-reply-announcement.ts`,把回答生命周期归为六个阶段并为每个阶段指定唯一播报出口,避免重复播报:`generating` 与 `completed` 走新的状态区域,`recovering` / `stopped` 交给既有 sonner(已确认其容器带 `aria-live="polite" aria-relevant="additions text"`,BUG-216 接入的 44 条提示本就会被念出),`failed` 交给既有 `role="alert"`。完成播报含序号(「第 N 条回答已显示在对话区末尾」),因为连续两次内容完全相同的播报常被屏幕阅读器抑制。状态区域移出 `aria-busy` 子树,改挂在 `main.chat-app` 下并跨分支常驻。**流式文本本身绝不进入 live region**——逐字更新会让屏幕阅读器持续重复播报,完全不可用;合同测试断言播报模块不引用任何流式文本变量。`.message-list` 的 `aria-busy` 保留不动,它同时在抑制 `agent-activity-status` 那个按阶段变化的 `role="status"` 标签,去掉会造成串读。启动失败页从 `<main>` 上的 `aria-live="assertive"` 改为内层 `role="alert"`,同等紧急度但不再覆盖 main 地标,全文件不再出现 `aria-live="assertive"`。
|
||||
- 验证:`tsc`、`eslint` 清洁;`next build` 通过;新增合同测试 13 条(与 BUG-251 同一文件);全部非数据库套件 199 个文件 1592 条断言全绿。同时确认 BUG-217 新增的"跳到最新"控件键盘可达:真实 `<button>`、无 `tabIndex`、标签与 `aria-label` 一致、`min-h-11 min-w-11`、`pointer-events-auto`。未做的验证:**无浏览器与屏幕阅读器,全部无障碍行为均为依据 ARIA 规范推理并以源码合同测试固定,没有任何一条经过实听**。特别未经证实的有:NVDA / JAWS / VoiceOver 是否真的念出完成播报、序号是否足以避开重复文本抑制、`aria-busy` 在各家实现中抑制的程度。
|
||||
- 待跟进:「跳到最新」按钮在跳转成功后立即卸载,焦点会掉回 `<body>`,下一次 Tab 从文档顶部重新开始。修复需要给滚动容器一个可编程聚焦目标,会牵动 BUG-217 刚落地的合同测试,另开一轮处理。
|
||||
- 防复发:`aria-live` 区域不得放在任何可能带 `aria-busy="true"` 的子树内,也不得随内容分支重新挂载;流式内容只播报状态迁移,绝不播报增量文本;每个事件必须有且只有一个播报出口,静默必须是显式声明的选择而非遗漏。
|
||||
- 相关记录:BUG-216、BUG-217
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-253 | 27 个 BUG 编号被两条不同记录共用,交叉引用体系失效
|
||||
|
||||
- 状态:resolved(编号唯一性已修复;文档更深层的错位问题见待跟进)
|
||||
- 首次发现:2026-08-17
|
||||
- 最近更新:2026-08-17
|
||||
- 影响面:`docs/BUG_HISTORY.md` 全文的交叉引用体系,以及 `AGENTS.md` 第 7 节的可执行性。
|
||||
- 用户现象:无终端用户可见现象。对维护者与 AI agent 而言,`相关记录:BUG-209` 这类引用无法确定指向哪一条记录,文档最有价值的部分(247 条记录中有 224 条填写了 `相关记录`)因此不可靠。
|
||||
- 根因:`BUG-018` 至 `BUG-043` 连续 26 个编号加上 `BUG-209`,共 27 个编号各被两条不同记录占用。成因是并行分支各自追加记录、合并时两侧都保留。该失效模式仍在持续:仅在准备本批提交的一小时内,`staging` 就两次抢占了本地待提交记录的编号(先 `BUG-214` 后 `BUG-215`),迫使本地记录两轮顺延。
|
||||
- 修复:保留每对中较早的记录编号,为后加入的一条分配 `BUG-221`~`BUG-247`。逐条审计了 78 行指向受影响编号的引用:其中 10 行凭"合并保留了两侧副本、两份仅编号不同"直接判定,13 行凭语义判定(如 `复发自:BUG-019` 出现在「收到具体经历后仍重复泛问」中,按保留编号读是「原问题交接租约过期」全然无关,按顺延编号读是「自然语言真实事件被静默丢弃」同属一条提取链),14 行属自引用的全球地点集群,5 行属合并后的模板追问流;36 行确认本就正确未动。另在文档头部 `使用流程` 增补一条:追加记录前必须先检索当前最大编号。`AGENTS.md` 第 7 节把「必须完整读取并搜索」改为「必须用报错原文、接口路径、状态码、模块名和用户操作强制检索,并完整读完命中的记录」,并明写「检索不是可选步骤」——该文件已达 3690 行约 150 KB,通读一次约耗 5 万 token,原措辞在实践中不可执行;六条执行要求逐条未改。同步更新 `tests/test_bug_history_workflow.py` 的断言以匹配新措辞并追加「检索不是可选步骤」一条,保持约束强度。
|
||||
- 验证:重复编号 0;记录总数 247 条不变;编号 001–247 无空缺;无悬空引用;无冲突标记;`docs/BUG_HISTORY.md` 仅因新增的头部说明 +1 行,`AGENTS.md` 行数不变;逐行归类确认改动仅落在 27 个标题行、38 个 `相关记录` 行、8 个 `复发自` 行,`状态`/`根因`/`修复`/`验证`/`防复发` 等正文字段零改动。`tests/test_bug_history_workflow.py` 两条断言通过。
|
||||
- 待跟进:审计中发现一个更早、更严重的缺陷——约 3617–3853 行区间内**记录正文与标题整体错位一位**,部分标题下只剩 3 行结尾字段而没有 `状态`/`根因`(247 条记录只有 228 条填了 `首次发现`);且 `BUG-221`~`BUG-234` 本质是 `BUG-020`~`BUG-035` 的重复记录,编号已唯一但内容仍冗余。两者都需要移动正文内容,属内容编辑,未在本轮处理。另有 7 行属"混合编号"(此前有人部分修正过),证据不足以判定,已就地加 `(编号存疑:…)` 标注而未改数字。
|
||||
- 防复发:BUG 编号必须唯一;追加记录前先检索当前最大编号;出现重复时保留较早记录的编号并同步修正指向它的 `相关记录` 与 `复发自`。强制性文档约束的措辞必须与文档实际体量相称,否则会被普遍忽略而失去约束力。
|
||||
- 相关记录:BUG-253 无前序同类记录
|
||||
- 修复版本:本地未提交候选
|
||||
@@ -14,6 +14,38 @@
|
||||
--color-border: #d8d6cf;
|
||||
--color-input: #b8b5ad;
|
||||
--color-ring: #85432f;
|
||||
--color-action: var(--color-action);
|
||||
--color-action-soft: var(--color-action-soft);
|
||||
--color-action-hover: var(--color-action-hover);
|
||||
--color-action-on-dark: var(--color-action-on-dark);
|
||||
--color-focus: var(--color-focus);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-ink-strong: var(--color-ink-strong);
|
||||
--color-ink-secondary: var(--color-ink-secondary);
|
||||
--color-ink-tertiary: var(--color-ink-tertiary);
|
||||
--color-canvas: var(--color-canvas);
|
||||
--color-canvas-soft: var(--color-canvas-soft);
|
||||
--color-canvas-muted: var(--color-canvas-muted);
|
||||
--color-canvas-strong: var(--color-canvas-strong);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-sidebar-solid: var(--color-sidebar-solid);
|
||||
--color-selected: var(--color-selected);
|
||||
--color-surface-dark: var(--color-surface-dark);
|
||||
--color-surface-dark-raised: var(--color-surface-dark-raised);
|
||||
--color-surface-dark-soft: var(--color-surface-dark-soft);
|
||||
--color-on-dark: var(--color-on-dark);
|
||||
--color-border-strong: var(--color-border-strong);
|
||||
--color-danger: var(--color-danger);
|
||||
--color-danger-muted: var(--color-danger-muted);
|
||||
--color-success: var(--color-success);
|
||||
--color-success-muted: var(--color-success-muted);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-scrim: var(--color-scrim);
|
||||
--color-frosted: var(--color-frosted);
|
||||
--color-light-hover: var(--color-light-hover);
|
||||
--color-dark-hover: var(--color-dark-hover);
|
||||
--color-dark-muted: var(--color-dark-muted);
|
||||
--color-dark-border: var(--color-dark-border);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
+81
-61
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowDown, ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
@@ -31,9 +32,9 @@ import {
|
||||
LocationSearchCombobox,
|
||||
type ResolvedBirthLocation,
|
||||
} from "@/components/location-search-combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChatComposer } from "@/components/chat-composer";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
|
||||
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
|
||||
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
|
||||
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement";
|
||||
import {
|
||||
requestBirthTimeAssessment,
|
||||
type JourneyClientResponse,
|
||||
@@ -189,6 +191,11 @@ type ChatSession = {
|
||||
};
|
||||
|
||||
type RequestError = { sessionId: string; message: string };
|
||||
type ReplyOutcome = {
|
||||
readonly sessionId: string;
|
||||
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
|
||||
readonly replyOrdinal: number;
|
||||
};
|
||||
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView };
|
||||
type BirthPlace = {
|
||||
label: string;
|
||||
@@ -833,7 +840,7 @@ function OnboardingChatMessage({ role, text, streaming = false, length = text.le
|
||||
streaming ? (
|
||||
<>
|
||||
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={protectedVisibleText} /></div>
|
||||
<span className="sr-only" aria-live="polite">{length >= text.length ? text : ""}</span>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{length >= text.length ? text : ""}</span>
|
||||
</>
|
||||
) : <ChatMessageContent text={protectedVisibleText} />
|
||||
) : <p>{protectedVisibleText}</p>}
|
||||
@@ -993,6 +1000,7 @@ async function patchSessionModel(sessionId: string, modelId: string, signal?: Ab
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
@@ -1019,14 +1027,14 @@ export default function Home() {
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
|
||||
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
|
||||
const draftTheme = useRef<Theme | null>(null);
|
||||
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
|
||||
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
|
||||
const [cancellationPending, setCancellationPending] = useState(false);
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||||
createBirthTimeConsultationConsentState,
|
||||
@@ -1110,6 +1118,11 @@ export default function Home() {
|
||||
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
|
||||
? streamingReply.activity
|
||||
: undefined;
|
||||
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
|
||||
const replyPhase: ChatReplyPhase = isLoading
|
||||
? consultationPhase === "recovering" ? "recovering" : "generating"
|
||||
: activeReplyOutcome?.phase ?? "idle";
|
||||
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
|
||||
const accountId = account?.user.id;
|
||||
const rectificationCardAction = resolveRectificationEntryAction(
|
||||
rectificationEntrySummary ?? {
|
||||
@@ -1270,6 +1283,18 @@ export default function Home() {
|
||||
&& !conversationAnchor.anchored
|
||||
&& Boolean(activeSession?.messages.length);
|
||||
|
||||
function setDraft(value: string) {
|
||||
setComposerDraft(value);
|
||||
}
|
||||
|
||||
function setDraftTheme(theme: Theme | null) {
|
||||
draftTheme.current = theme;
|
||||
}
|
||||
|
||||
function setDraftEntrypoint(entrypoint: ConsultationEntrypoint | null) {
|
||||
draftEntrypoint.current = entrypoint;
|
||||
}
|
||||
|
||||
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
|
||||
if (pendingConsultation.current) return;
|
||||
const lastMessage = session.messages.at(-1);
|
||||
@@ -1597,7 +1622,7 @@ export default function Home() {
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setRequestError(null);
|
||||
setComposerNotice("回答已恢复。");
|
||||
setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void refreshAccount();
|
||||
return;
|
||||
}
|
||||
@@ -2208,7 +2233,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
async function saveOnboardingName() {
|
||||
const name = draft.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
if (!name || !account || profileSaving) return;
|
||||
const nextProfile = { ...profileDraft, name };
|
||||
setProfileSaving(true);
|
||||
@@ -2649,6 +2674,7 @@ export default function Home() {
|
||||
const pending = pendingConsultation.current;
|
||||
if (!pending || pending.cancelled) return;
|
||||
|
||||
setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 });
|
||||
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
|
||||
if (pending.phase !== "undo" && !isPreview) {
|
||||
stoppedRequestAwaitingSettlement.current = pending.requestId;
|
||||
@@ -2711,7 +2737,7 @@ export default function Home() {
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setComposerNotice("回答已恢复。");
|
||||
setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void refreshAccount();
|
||||
return;
|
||||
}
|
||||
@@ -2841,7 +2867,7 @@ export default function Home() {
|
||||
: initialConsultationRoute;
|
||||
|
||||
if (account.credits <= 0 && !account.activeSubscription) {
|
||||
window.location.assign(membershipHref("insufficient-credits"));
|
||||
router.push(membershipHref("insufficient-credits"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2865,6 +2891,7 @@ export default function Home() {
|
||||
const previousOnboardingState = onboardingJustCompleted;
|
||||
cancellationFeedbackRequest.current = null;
|
||||
setRequestError(null);
|
||||
setReplyOutcome(null);
|
||||
setComposerNotice("");
|
||||
consultationStatusMissingCount.current = 0;
|
||||
setPendingSessionId(sessionId);
|
||||
@@ -2997,7 +3024,7 @@ export default function Home() {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
|
||||
if (response.status === 401) window.location.assign("/login");
|
||||
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
|
||||
if (response.status === 402) router.push(membershipHref("insufficient-credits"));
|
||||
throw new ConsultationResponseError(
|
||||
response.status,
|
||||
payloadMessage(errorPayload, "服务暂时不可用"),
|
||||
@@ -3092,6 +3119,11 @@ export default function Home() {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => completedSession);
|
||||
setReplyOutcome({
|
||||
sessionId,
|
||||
phase: "completed",
|
||||
replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length,
|
||||
});
|
||||
completeConsultationInterface(requestId);
|
||||
void refreshAccount();
|
||||
return true;
|
||||
@@ -3101,6 +3133,7 @@ export default function Home() {
|
||||
const partialReply = latestPartialReply;
|
||||
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
|
||||
setRequestError({ sessionId, message: caught.message });
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(caught.message);
|
||||
completeConsultationInterface(requestId);
|
||||
return false;
|
||||
@@ -3144,7 +3177,7 @@ export default function Home() {
|
||||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||||
return;
|
||||
}
|
||||
void send(draft, draftTheme ?? undefined, draftEntrypoint);
|
||||
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -3165,8 +3198,8 @@ export default function Home() {
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="app-loading app-loading-error" aria-live="assertive">
|
||||
<div className="app-loading-content">
|
||||
<main className="app-loading app-loading-error">
|
||||
<div className="app-loading-content" role="alert">
|
||||
<strong>暂时无法进入 Jyotisha</strong>
|
||||
<span>{accountError}</span>
|
||||
<div className="app-loading-actions">
|
||||
@@ -3200,6 +3233,7 @@ export default function Home() {
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
|
||||
<main className="chat-app">
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
<AppSidebar
|
||||
sessions={sidebarSessions}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
@@ -3235,10 +3269,10 @@ export default function Home() {
|
||||
}}
|
||||
onAccountMenuOpenChange={setAccountMenuOpen}
|
||||
onNewChat={() => void startNewChat()}
|
||||
onOpenReports={() => window.location.assign("/reports")}
|
||||
onOpenReports={() => router.push("/reports")}
|
||||
onSelectSession={selectSession}
|
||||
onOpenProfile={() => openAccountDialog("profile")}
|
||||
onOpenRedeem={() => window.location.assign(membershipHref("account-menu"))}
|
||||
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
|
||||
onOpenLogout={() => openAccountDialog("logout")}
|
||||
/>
|
||||
{pendingSessionDeletion ? (
|
||||
@@ -3264,7 +3298,7 @@ export default function Home() {
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<button className="credit-button" type="button" onClick={() => router.push(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
<span>{account ? account.credits : "—"}</span>
|
||||
</button>
|
||||
@@ -3445,7 +3479,6 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
<span className="sr-only" aria-live="polite">{isLoading ? "Jyotisha 正在回答" : ""}</span>
|
||||
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
|
||||
<ChatMessageRow key={message.renderKey} message={message} />
|
||||
))}
|
||||
@@ -3497,48 +3530,35 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<Textarea
|
||||
ref={composerInput}
|
||||
aria-label={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
placeholder={!account
|
||||
? "正在读取账户…"
|
||||
: !profileComplete
|
||||
? onboardingStep === "name"
|
||||
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
|
||||
: "请先完成上方资料"
|
||||
: account.credits === 0 && !account.activeSubscription
|
||||
? "余额不足,可前往会员页充值点数或开通会员"
|
||||
: "例如:未来半年是否适合换工作?"}
|
||||
rows={1}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
disabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void stopResponse()}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<ChatComposer
|
||||
inputRef={composerInput}
|
||||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
placeholder={!account
|
||||
? "正在读取账户…"
|
||||
: !profileComplete
|
||||
? onboardingStep === "name"
|
||||
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
|
||||
: "请先完成上方资料"
|
||||
: account.credits === 0 && !account.activeSubscription
|
||||
? "余额不足,可前往会员页充值点数或开通会员"
|
||||
: "例如:未来半年是否适合换工作?"}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
|
||||
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
stopVisible={isLoading}
|
||||
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
onSubmit={submit}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
onStop={() => void stopResponse()}
|
||||
/>
|
||||
<div className="composer-footer">
|
||||
<ModelSelector
|
||||
models={modelCatalog?.models ?? []}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ThinkingOrb, type OrbState } from "thinking-orbs";
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { OrbState } from "thinking-orbs";
|
||||
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
|
||||
const labels = {
|
||||
working: "正在处理任务…",
|
||||
@@ -9,6 +14,17 @@ const labels = {
|
||||
shaping: "正在生成结果…",
|
||||
} as const satisfies Record<OrbState, string>;
|
||||
|
||||
const importThinkingOrb = () => import("thinking-orbs");
|
||||
|
||||
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
|
||||
loading: () => (
|
||||
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
prefetchOnIdle(importThinkingOrb);
|
||||
|
||||
export type AgentActivityState = OrbState;
|
||||
|
||||
export function AgentActivityStatus({
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
export function prefetchOnIdle(load: () => Promise<unknown>) {
|
||||
if (typeof window === "undefined") return;
|
||||
const request = () => void load();
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
window.requestIdleCallback(request, { timeout: 2_000 });
|
||||
return;
|
||||
}
|
||||
window.setTimeout(request, 300);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
composerDraftSnapshot,
|
||||
serverComposerDraftSnapshot,
|
||||
subscribeComposerDraft,
|
||||
} from "@/lib/composer-draft";
|
||||
|
||||
export function useComposerDraft(): string {
|
||||
return useSyncExternalStore(
|
||||
subscribeComposerDraft,
|
||||
composerDraftSnapshot,
|
||||
serverComposerDraftSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
type ChatComposerProps = {
|
||||
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
|
||||
readonly inputLabel: string;
|
||||
readonly placeholder: string;
|
||||
readonly maxLength: number;
|
||||
readonly inputDisabled: boolean;
|
||||
readonly submitLabel: string;
|
||||
readonly submitBlocked: boolean;
|
||||
readonly stopVisible: boolean;
|
||||
readonly stopLabel: string;
|
||||
readonly stopTitle: string;
|
||||
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
readonly onStop: () => void;
|
||||
};
|
||||
|
||||
export function ChatComposer({
|
||||
inputRef,
|
||||
inputLabel,
|
||||
placeholder,
|
||||
maxLength,
|
||||
inputDisabled,
|
||||
submitLabel,
|
||||
submitBlocked,
|
||||
stopVisible,
|
||||
stopLabel,
|
||||
stopTitle,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onStop,
|
||||
}: ChatComposerProps) {
|
||||
const draft = useComposerDraft();
|
||||
|
||||
return (
|
||||
<form className="composer" onSubmit={onSubmit}>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
aria-label={inputLabel}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
maxLength={maxLength}
|
||||
disabled={inputDisabled}
|
||||
value={draft}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{stopVisible ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label={stopLabel}
|
||||
title={stopTitle}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const markdownComponents: Components = {
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a {...props} href={href} rel="noreferrer" target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="markdown-table">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export function renderChatMarkdown(text: string) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,50 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
"use client";
|
||||
|
||||
const markdownComponents: Components = {
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a {...props} href={href} rel="noreferrer" target="_blank">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="markdown-table">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { plainParagraphs } from "@/components/chat-message-paragraphs";
|
||||
|
||||
type MarkdownRenderer = (text: string) => ReactNode;
|
||||
|
||||
let markdownRenderer: MarkdownRenderer | undefined;
|
||||
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
|
||||
|
||||
function loadMarkdownRenderer() {
|
||||
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
|
||||
markdownRenderer = module.renderChatMarkdown;
|
||||
return module.renderChatMarkdown;
|
||||
});
|
||||
return markdownRendererRequest;
|
||||
}
|
||||
|
||||
prefetchOnIdle(loadMarkdownRenderer);
|
||||
|
||||
function useMarkdownRenderer() {
|
||||
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => markdownRenderer);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderer) return;
|
||||
let active = true;
|
||||
void loadMarkdownRenderer().then((loaded) => {
|
||||
if (active) setRenderer(() => loaded);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [renderer]);
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
export function ChatMessageContent({ text }: { text: string }) {
|
||||
const renderMarkdown = useMarkdownRenderer();
|
||||
|
||||
return (
|
||||
<div className="message-markdown">
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
{renderMarkdown
|
||||
? renderMarkdown(text)
|
||||
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
|
||||
<p key={index}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const markdownSyntax = /[*_#`~|<>[\]\\!&=+\r-]|:\/\/|www\.|@|\t|^[ ]|[ ]$|^\d+[.)]/m;
|
||||
|
||||
export function plainParagraphs(text: string): readonly string[] | null {
|
||||
if (markdownSyntax.test(text)) return null;
|
||||
return text.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import gsapModule from "gsap";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { AgentActivityStatus } from "@/components/agent-activity-status";
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
|
||||
const gsap = (gsapModule as typeof gsapModule & { gsap?: typeof gsapModule }).gsap ?? gsapModule;
|
||||
type GsapCore = typeof import("gsap")["gsap"];
|
||||
|
||||
gsap.registerPlugin(useGSAP);
|
||||
const useEntryEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
let gsapCore: GsapCore | undefined;
|
||||
let gsapRequest: Promise<GsapCore> | undefined;
|
||||
|
||||
function loadGsap() {
|
||||
gsapRequest ??= import("gsap").then((module) => {
|
||||
const core = module.gsap ?? module.default;
|
||||
gsapCore = (core as GsapCore & { gsap?: GsapCore }).gsap ?? core;
|
||||
return gsapCore;
|
||||
});
|
||||
return gsapRequest;
|
||||
}
|
||||
|
||||
function motionPreferred() {
|
||||
return typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
if (motionPreferred()) prefetchOnIdle(loadGsap);
|
||||
|
||||
export function AgentAvatar() {
|
||||
return <span className="agent-avatar" aria-hidden="true" />;
|
||||
@@ -40,11 +59,17 @@ export function ChatMessageRow({
|
||||
const activityLabel = message.activity?.label
|
||||
?? (message.state === "thinking" ? "正在处理…" : undefined);
|
||||
|
||||
useGSAP(() => {
|
||||
if (!messageRow.current) return;
|
||||
useEntryEffect(() => {
|
||||
const row = messageRow.current;
|
||||
if (!row) return;
|
||||
if (!gsapCore) {
|
||||
if (motionPreferred()) void loadGsap();
|
||||
return;
|
||||
}
|
||||
const gsap = gsapCore;
|
||||
const motion = gsap.matchMedia();
|
||||
motion.add("(prefers-reduced-motion: no-preference)", () => {
|
||||
gsap.fromTo(messageRow.current, {
|
||||
gsap.fromTo(row, {
|
||||
autoAlpha: 0,
|
||||
y: message.role === "user" ? 8 : 12,
|
||||
}, {
|
||||
@@ -56,7 +81,7 @@ export function ChatMessageRow({
|
||||
});
|
||||
});
|
||||
return () => motion.revert();
|
||||
}, { scope: messageRow });
|
||||
}, [message.role]);
|
||||
|
||||
return (
|
||||
<article
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type ChatReplyPhase =
|
||||
| "idle"
|
||||
| "generating"
|
||||
| "recovering"
|
||||
| "completed"
|
||||
| "stopped"
|
||||
| "failed";
|
||||
|
||||
export type ChatReplyAnnouncer =
|
||||
| "none"
|
||||
| "reply_status_region"
|
||||
| "chat_notice_toast"
|
||||
| "conversation_alert";
|
||||
|
||||
const announcers = {
|
||||
idle: "none",
|
||||
generating: "reply_status_region",
|
||||
recovering: "chat_notice_toast",
|
||||
completed: "reply_status_region",
|
||||
stopped: "chat_notice_toast",
|
||||
failed: "conversation_alert",
|
||||
} as const satisfies Record<ChatReplyPhase, ChatReplyAnnouncer>;
|
||||
|
||||
export function chatReplyAnnouncer(phase: ChatReplyPhase): ChatReplyAnnouncer {
|
||||
return announcers[phase];
|
||||
}
|
||||
|
||||
export function chatReplyAnnouncement(phase: ChatReplyPhase, replyOrdinal: number): string {
|
||||
if (chatReplyAnnouncer(phase) !== "reply_status_region") return "";
|
||||
if (phase === "generating") return "Jyotisha 正在回答,完成后会提示你阅读。";
|
||||
return `Jyotisha 已回答完毕,第 ${replyOrdinal} 条回答已显示在对话区末尾,可以开始阅读。`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export const composerDraftStorageKey = "jyotisha.composer-draft";
|
||||
|
||||
const composerDraftLimit = 500;
|
||||
const composerDraftMaxAgeMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let draft = "";
|
||||
let restored = false;
|
||||
|
||||
function draftStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
// Private-mode browsers without session storage keep the draft in memory only.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredDraft() {
|
||||
const storage = draftStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.removeItem(composerDraftStorageKey);
|
||||
} catch {
|
||||
// A rejected write never blocks the composer.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredComposerDraft(): string {
|
||||
const storage = draftStorage();
|
||||
if (!storage) return "";
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = storage.getItem(composerDraftStorageKey);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (!stored) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Record<string, unknown>;
|
||||
const text = parsed.text;
|
||||
const savedAt = parsed.savedAt;
|
||||
if (typeof text !== "string"
|
||||
|| typeof savedAt !== "number"
|
||||
|| !Number.isFinite(savedAt)
|
||||
|| savedAt > Date.now()
|
||||
|| Date.now() - savedAt > composerDraftMaxAgeMs) {
|
||||
clearStoredDraft();
|
||||
return "";
|
||||
}
|
||||
return text.slice(0, composerDraftLimit);
|
||||
} catch {
|
||||
clearStoredDraft();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredComposerDraft(value: string) {
|
||||
if (!value) {
|
||||
clearStoredDraft();
|
||||
return;
|
||||
}
|
||||
const storage = draftStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(composerDraftStorageKey, JSON.stringify({
|
||||
text: value.slice(0, composerDraftLimit),
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch {
|
||||
// A full or blocked storage never blocks typing.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreComposerDraft() {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
draft = readStoredComposerDraft();
|
||||
}
|
||||
|
||||
export function composerDraftSnapshot(): string {
|
||||
restoreComposerDraft();
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function serverComposerDraftSnapshot(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
restoreComposerDraft();
|
||||
if (draft === value) return;
|
||||
draft = value;
|
||||
writeStoredComposerDraft(value);
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
export function subscribeComposerDraft(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { plainParagraphs } from "../src/components/chat-message-paragraphs.ts";
|
||||
|
||||
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const contentSource = readProjectFile("src/components/chat-message-content.tsx");
|
||||
const markdownViewSource = readProjectFile("src/components/chat-markdown-view.tsx");
|
||||
const messageRowSource = readProjectFile("src/components/chat-message-row.tsx");
|
||||
const activitySource = readProjectFile("src/components/agent-activity-status.tsx");
|
||||
const prefetchSource = readProjectFile("src/components/chat-chunk-prefetch.ts");
|
||||
|
||||
test("the markdown pipeline is split out of the initial chat bundle", () => {
|
||||
// Given: the chat surface statically imports the message content component.
|
||||
assert.doesNotMatch(contentSource, /^import .*"react-markdown"/m);
|
||||
assert.doesNotMatch(contentSource, /^import .*"remark-gfm"/m);
|
||||
|
||||
// Then: the renderer only arrives through a dynamic import.
|
||||
assert.match(contentSource, /import\("@\/components\/chat-markdown-view"\)/);
|
||||
assert.match(contentSource, /markdownRenderer = module\.renderChatMarkdown/);
|
||||
assert.match(markdownViewSource, /^import ReactMarkdown, \{ type Components \} from "react-markdown";$/m);
|
||||
assert.match(markdownViewSource, /^import remarkGfm from "remark-gfm";$/m);
|
||||
assert.match(markdownViewSource, /remarkPlugins=\{\[remarkGfm\]\}/);
|
||||
assert.match(markdownViewSource, /skipHtml/);
|
||||
});
|
||||
|
||||
test("the pre-markdown fallback renders prose but never raw markdown", () => {
|
||||
// Given: a reply that carries markdown syntax.
|
||||
assert.equal(plainParagraphs("**重点**在这里"), null);
|
||||
assert.equal(plainParagraphs("## 阶段判断"), null);
|
||||
assert.equal(plainParagraphs("- 第一点"), null);
|
||||
assert.equal(plainParagraphs("1. 第一点"), null);
|
||||
assert.equal(plainParagraphs("| 宫位 | 行星 |"), null);
|
||||
assert.equal(plainParagraphs("`Vimshottari`"), null);
|
||||
assert.equal(plainParagraphs("参见 https://example.com"), null);
|
||||
assert.equal(plainParagraphs("> 引用"), null);
|
||||
assert.equal(plainParagraphs("[链接](/x)"), null);
|
||||
assert.equal(plainParagraphs("行尾软换行 \n下一行"), null);
|
||||
|
||||
// Then: only plain prose takes the fallback path, split exactly like CommonMark.
|
||||
assert.deepEqual(plainParagraphs("第一段。\n\n第二段。"), ["第一段。", "第二段。"]);
|
||||
assert.deepEqual(plainParagraphs("一行\n下一行"), ["一行\n下一行"]);
|
||||
assert.deepEqual(plainParagraphs("只有一段。"), ["只有一段。"]);
|
||||
|
||||
// And: one stable wrapper serves both paths, so no styling or caret is lost on upgrade.
|
||||
assert.equal(contentSource.match(/className="message-markdown"/g)?.length, 1);
|
||||
assert.match(contentSource, /<p key=\{index\}>\{paragraph\}<\/p>/);
|
||||
assert.match(contentSource, /plainParagraphs\(text\) \?\? \[\]/);
|
||||
});
|
||||
|
||||
test("gsap loads on demand while keeping the reduced-motion gate", () => {
|
||||
// Given: the animation engine is no longer part of the static import graph.
|
||||
assert.doesNotMatch(messageRowSource, /^import .*from "gsap"/m);
|
||||
assert.doesNotMatch(messageRowSource, /@gsap\/react/);
|
||||
assert.match(messageRowSource, /import\("gsap"\)/);
|
||||
|
||||
// Then: reduced-motion users never request it, and the tween contract is unchanged.
|
||||
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
|
||||
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
|
||||
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
|
||||
assert.match(messageRowSource, /duration: 0\.18/);
|
||||
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
|
||||
|
||||
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
|
||||
assert.match(messageRowSource, /if \(!gsapCore\) \{/);
|
||||
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
|
||||
});
|
||||
|
||||
test("the thinking orb loads on demand behind a same-size placeholder", () => {
|
||||
// Given: only the state type is imported at build time.
|
||||
assert.doesNotMatch(activitySource, /^import \{ ThinkingOrb/m);
|
||||
assert.match(activitySource, /^import type \{ OrbState \} from "thinking-orbs";$/m);
|
||||
assert.match(activitySource, /dynamic\(async \(\) => \(await importThinkingOrb\(\)\)\.ThinkingOrb/);
|
||||
assert.match(activitySource, /ssr: false/);
|
||||
|
||||
// Then: the placeholder reserves the exact 20px orb box so the row cannot shift.
|
||||
assert.match(activitySource, /loading: \(\) => \(/);
|
||||
assert.match(activitySource, /height: 20, width: 20/);
|
||||
assert.match(activitySource, /flex: "0 0 auto"/);
|
||||
|
||||
// And: the live region and the decorative marking survive.
|
||||
assert.match(activitySource, /role="status"/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
});
|
||||
|
||||
test("deferred chat chunks are requested during idle time after first paint", () => {
|
||||
assert.match(prefetchSource, /if \(typeof window === "undefined"\) return;/);
|
||||
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
|
||||
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
|
||||
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
|
||||
assert.match(activitySource, /prefetchOnIdle\(importThinkingOrb\)/);
|
||||
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
chatReplyAnnouncement,
|
||||
chatReplyAnnouncer,
|
||||
type ChatReplyPhase,
|
||||
} from "../src/lib/chat-reply-announcement.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
|
||||
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
|
||||
const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.ts", import.meta.url), "utf8");
|
||||
|
||||
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("in-app destinations navigate client-side so the chat survives the round trip", () => {
|
||||
// Given: the chat page now owns a router.
|
||||
assert.match(pageSource, /import \{ useRouter \} from "next\/navigation"/);
|
||||
assert.match(pageSource, /const router = useRouter\(\)/);
|
||||
|
||||
// Then: every same-app destination the user comes back from is a soft push.
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
|
||||
assert.match(pageSource, /onOpenRedeem=\{\(\) => router\.push\(membershipHref\("account-menu"\)\)\}/);
|
||||
assert.match(pageSource, /onClick=\{\(\) => router\.push\(membershipHref\("credits"\)\)\}/);
|
||||
assert.match(pageSource, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
|
||||
assert.match(pageSource, /if \(response\.status === 402\) router\.push\(membershipHref\("insufficient-credits"\)\)/);
|
||||
|
||||
// And: none of them fall back to a document load.
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/);
|
||||
});
|
||||
|
||||
test("both insufficient-credit paths are soft so the typed question is not thrown away", () => {
|
||||
const creditGuard = sourceBetween(
|
||||
pageSource,
|
||||
"if (account.credits <= 0 && !account.activeSubscription) {",
|
||||
"const [year, month, day]",
|
||||
);
|
||||
const sendSource = pageSource.slice(pageSource.indexOf(" async function send("));
|
||||
|
||||
// Given: the pre-flight guard runs before send() clears the composer draft.
|
||||
assert.match(creditGuard, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
|
||||
assert.doesNotMatch(creditGuard, /window\.location/);
|
||||
assert.ok(sendSource.indexOf('router.push(membershipHref("insufficient-credits"))')
|
||||
< sendSource.indexOf(' setDraft("");'));
|
||||
assert.equal(pageSource.match(/router\.push\(membershipHref\("insufficient-credits"\)\)/g)?.length, 2);
|
||||
});
|
||||
|
||||
test("auth redirects stay hard document loads so stale session state cannot survive", () => {
|
||||
// Given: a 401 means the client is holding a session the server has rejected.
|
||||
// Then: every login redirect is a deliberate full page load, not router.push.
|
||||
assert.match(pageSource, /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/);
|
||||
assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4);
|
||||
assert.equal(pageSource.match(/window\.location\.replace\("\/login"\)/g)?.length, 1);
|
||||
assert.doesNotMatch(pageSource, /router\.push\("\/login"\)/);
|
||||
assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/);
|
||||
|
||||
// And: the login redirect after sign-out drops the whole React tree with the session.
|
||||
const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion");
|
||||
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
|
||||
|
||||
// And: the sibling membership surface keeps the same precedent.
|
||||
assert.match(membershipSource, /import \{ useSearchParams \} from "next\/navigation"/);
|
||||
assert.equal(membershipSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3);
|
||||
assert.doesNotMatch(membershipSource, /router\.(push|replace)\("\/login"\)/);
|
||||
});
|
||||
|
||||
test("bootstrap failure retries with a hard reload because refresh cannot rerun client bootstrap", () => {
|
||||
// Given: the account, catalog and sessions are fetched from a mount-once client effect,
|
||||
// which router.refresh() would not re-run because it never remounts the client tree.
|
||||
assert.match(pageSource, /onClick=\{\(\) => window\.location\.reload\(\)\}>重试<\/button>/);
|
||||
assert.doesNotMatch(pageSource, /router\.refresh\(\)/);
|
||||
assert.match(pageSource, /void loadCloudData\(\);\n\s*return \(\) => \{[\s\S]*?\n \}, \[\]\);/);
|
||||
});
|
||||
|
||||
test("the streaming reply announces state transitions, never streamed deltas", () => {
|
||||
// Given: one page-level status region outside every aria-busy subtree.
|
||||
assert.match(
|
||||
pageSource,
|
||||
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
|
||||
);
|
||||
|
||||
// Then: the region is fed a phase-derived string, not the streaming text.
|
||||
assert.match(pageSource, /const replyAnnouncement = chatReplyAnnouncement\(replyPhase, activeReplyOutcome\?\.replyOrdinal \?\? 0\)/);
|
||||
assert.doesNotMatch(announcementSource, /activeStreamingText|streamingReply|answer\.delta/);
|
||||
assert.doesNotMatch(pageSource, /aria-live=[^>]*\{activeStreamingText/);
|
||||
assert.doesNotMatch(pageSource, /\{activeStreamingText\}<\/span>/);
|
||||
|
||||
// And: the old start-only region that lived inside the busy message list is gone.
|
||||
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
|
||||
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*\{chatMessageViews\(/);
|
||||
});
|
||||
|
||||
test("the reply phase covers start, completion and every terminal state", () => {
|
||||
const phaseDerivation = sourceBetween(pageSource, "const replyPhase: ChatReplyPhase = isLoading", "const accountId =");
|
||||
|
||||
assert.match(phaseDerivation, /consultationPhase === "recovering" \? "recovering" : "generating"/);
|
||||
assert.match(phaseDerivation, /activeReplyOutcome\?\.phase \?\? "idle"/);
|
||||
assert.match(pageSource, /const activeReplyOutcome = replyOutcome && replyOutcome\.sessionId === activeSession\?\.id \? replyOutcome : null/);
|
||||
|
||||
// Given: each terminal transition records its outcome from an event handler, not an effect.
|
||||
assert.match(pageSource, /setReplyOutcome\(\{\n\s*sessionId,\n\s*phase: "completed",\n\s*replyOrdinal: completedSession\.messages\.filter\(\(message\) => message\.role === "assistant"\)\.length,\n\s*\}\)/);
|
||||
assert.match(pageSource, /setReplyOutcome\(\{ sessionId, phase: "failed", replyOrdinal: 0 \}\)/);
|
||||
assert.match(pageSource, /setReplyOutcome\(\{ sessionId: pending\.sessionId, phase: "stopped", replyOrdinal: 0 \}\)/);
|
||||
|
||||
// And: a new question clears the previous outcome so a stale reply is never re-announced.
|
||||
assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*setComposerNotice\(""\)/);
|
||||
});
|
||||
|
||||
test("completion is announced with where to find the reply, and start says one is coming", () => {
|
||||
assert.equal(chatReplyAnnouncement("generating", 0), "Jyotisha 正在回答,完成后会提示你阅读。");
|
||||
assert.equal(
|
||||
chatReplyAnnouncement("completed", 3),
|
||||
"Jyotisha 已回答完毕,第 3 条回答已显示在对话区末尾,可以开始阅读。",
|
||||
);
|
||||
|
||||
// Given: two consecutive replies would otherwise repeat identical text and stay unspoken.
|
||||
assert.notEqual(chatReplyAnnouncement("completed", 3), chatReplyAnnouncement("completed", 4));
|
||||
|
||||
// And: every announcement is simplified Chinese in the existing product voice.
|
||||
for (const phase of ["generating", "completed"] as const) {
|
||||
const text = chatReplyAnnouncement(phase, 1);
|
||||
assert.match(text, /^Jyotisha /);
|
||||
assert.doesNotMatch(text.replace("Jyotisha", ""), /[A-Za-z]/);
|
||||
}
|
||||
});
|
||||
|
||||
test("toast-owned and alert-owned events are deliberately silent in the status region", () => {
|
||||
// Given: sonner mounts its own polite live region for every chat notice.
|
||||
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
|
||||
|
||||
// Then: stop, recovery and disconnect stay with the toast instead of double-announcing.
|
||||
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
|
||||
assert.equal(chatReplyAnnouncer("recovering"), "chat_notice_toast");
|
||||
assert.equal(chatReplyAnnouncement("stopped", 0), "");
|
||||
assert.equal(chatReplyAnnouncement("recovering", 0), "");
|
||||
|
||||
// And: a failed reply belongs to the assertive conversation alert, not the polite region.
|
||||
assert.equal(chatReplyAnnouncer("failed"), "conversation_alert");
|
||||
assert.equal(chatReplyAnnouncement("failed", 0), "");
|
||||
assert.match(pageSource, /<p className="error-message" role="alert">\{activeError\}<\/p>/);
|
||||
|
||||
// And: only the two live-region phases ever produce spoken text.
|
||||
const phases: readonly ChatReplyPhase[] = ["idle", "generating", "recovering", "completed", "stopped", "failed"];
|
||||
const spoken = phases.filter((phase) => chatReplyAnnouncement(phase, 1) !== "");
|
||||
assert.deepEqual(spoken, ["generating", "completed"]);
|
||||
});
|
||||
|
||||
test("the recovered background reply tells the reader where it landed", () => {
|
||||
assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2);
|
||||
assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/);
|
||||
});
|
||||
|
||||
test("assertive announcement is reserved for genuine errors", () => {
|
||||
// Given: the bootstrap failure screen is the only assertive surface on the page.
|
||||
assert.match(pageSource, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
|
||||
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
|
||||
|
||||
// And: the still-loading screen stays polite and marks itself busy.
|
||||
assert.match(pageSource, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
|
||||
});
|
||||
|
||||
test("the onboarding typewriter announces the finished line once instead of each character", () => {
|
||||
const onboardingMessage = sourceBetween(pageSource, "function OnboardingChatMessage(", "function isProfileComplete(");
|
||||
|
||||
// Given: the visibly typed text is hidden from assistive technology.
|
||||
assert.match(onboardingMessage, /className=\{`onboarding-stream[^`]*`\} aria-hidden="true"/);
|
||||
|
||||
// Then: only the completed line reaches the polite region, as one atomic unit.
|
||||
assert.match(
|
||||
onboardingMessage,
|
||||
/<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{length >= text\.length \? text : ""\}<\/span>/,
|
||||
);
|
||||
assert.doesNotMatch(onboardingMessage, /aria-live="polite">\{visibleText\}|aria-live="polite">\{protectedVisibleText\}/);
|
||||
assert.doesNotMatch(onboardingMessage, /aria-busy/);
|
||||
});
|
||||
|
||||
test("aria-busy marks the updating message list without swallowing the status region", () => {
|
||||
// Given: the activity status inside a streamed row is itself a role="status" region.
|
||||
const activitySource = readFileSync(
|
||||
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(activitySource, /role="status"/);
|
||||
|
||||
// Then: aria-busy on the message list suppresses that per-phase chatter while streaming,
|
||||
// and the reply status region sits outside it so completion is still spoken.
|
||||
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>/);
|
||||
const messageList = sourceBetween(pageSource, '<div className="message-list" aria-busy={isLoading}>', "</div>");
|
||||
assert.doesNotMatch(messageList, /aria-live|role="status"/);
|
||||
});
|
||||
|
||||
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
|
||||
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</div>\n )}");
|
||||
const buttonTag = sourceBetween(jumpControl, "<button", ">");
|
||||
|
||||
// Given: a real button in document order, never removed from the tab sequence.
|
||||
assert.match(jumpControl, /<button\b/);
|
||||
assert.match(buttonTag, /type="button"/);
|
||||
assert.doesNotMatch(buttonTag, /tabIndex/);
|
||||
assert.doesNotMatch(buttonTag, /aria-hidden/);
|
||||
assert.doesNotMatch(buttonTag, /disabled/);
|
||||
|
||||
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
|
||||
assert.match(jumpControl, /aria-label="跳到最新"/);
|
||||
assert.match(jumpControl, /\n\s*跳到最新\n/);
|
||||
assert.match(jumpControl, /focus-visible:ring-3/);
|
||||
assert.match(jumpControl, /min-h-11/);
|
||||
assert.match(jumpControl, /min-w-11/);
|
||||
|
||||
// And: the icon is decorative, and pointer-events never gate keyboard activation.
|
||||
assert.match(jumpControl, /<ArrowDown aria-hidden="true"/);
|
||||
assert.match(jumpControl, /pointer-events-auto/);
|
||||
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8");
|
||||
|
||||
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("keystrokes never re-render the chat page root", () => {
|
||||
// Given: the composer text used to live in page-level React state.
|
||||
assert.doesNotMatch(pageSource, /const \[draft, setDraft\] = useState/);
|
||||
assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/);
|
||||
assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/);
|
||||
|
||||
// When: the page keeps only the hidden routing values it reads at submit time.
|
||||
assert.match(pageSource, /const draftTheme = useRef<Theme \| null>\(null\)/);
|
||||
assert.match(pageSource, /const draftEntrypoint = useRef<ConsultationEntrypoint \| null>\(null\)/);
|
||||
|
||||
// Then: every writer the page still exposes updates a ref or the external store.
|
||||
assert.match(pageSource, /function setDraft\(value: string\) \{\n setComposerDraft\(value\);\n \}/);
|
||||
assert.match(pageSource, /function setDraftTheme\(theme: Theme \| null\) \{\n draftTheme\.current = theme;\n \}/);
|
||||
assert.match(pageSource, /function setDraftEntrypoint\(entrypoint: ConsultationEntrypoint \| null\) \{\n draftEntrypoint\.current = entrypoint;\n \}/);
|
||||
assert.match(pageSource, /import \{ composerDraftSnapshot, setComposerDraft \} from "@\/lib\/composer-draft"/);
|
||||
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
|
||||
});
|
||||
|
||||
test("the textarea lives in an isolated composer that owns the draft subscription", () => {
|
||||
// Given: the page renders the composer without handing it the draft text.
|
||||
assert.match(pageSource, /import \{ ChatComposer \} from "@\/components\/chat-composer"/);
|
||||
const composerElement = sourceBetween(pageSource, "<ChatComposer", "/>");
|
||||
assert.doesNotMatch(composerElement, /\bdraft=/);
|
||||
assert.doesNotMatch(pageSource, /<Textarea/);
|
||||
assert.doesNotMatch(pageSource, /value=\{draft\}/);
|
||||
|
||||
// When: the composer resolves the draft itself.
|
||||
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
|
||||
assert.match(composerSource, /const draft = useComposerDraft\(\)/);
|
||||
|
||||
// Then: only the composer subtree reads the value that changes on every keystroke.
|
||||
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
|
||||
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
|
||||
});
|
||||
|
||||
test("the composer keeps its Chinese input, focus and accessibility contract", () => {
|
||||
// Given: Enter submits only outside an IME composition.
|
||||
const keyHandler = sourceBetween(pageSource, "function handleComposerKeyDown", "\n\n if (!hydrated");
|
||||
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
|
||||
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
|
||||
assert.match(pageSource, /onKeyDown=\{handleComposerKeyDown\}/);
|
||||
|
||||
// When: the page still focuses the same textarea element through its own ref.
|
||||
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
||||
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
||||
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
||||
assert.match(pageSource, /composerInput\.current\?\.focus\(\)/);
|
||||
|
||||
// Then: the form shell, labels, hit targets and the stop control are unchanged.
|
||||
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
|
||||
assert.match(composerSource, /aria-label=\{inputLabel\}/);
|
||||
assert.match(composerSource, /rows=\{1\}/);
|
||||
assert.match(composerSource, /maxLength=\{maxLength\}/);
|
||||
assert.match(composerSource, /className="composer-stop"/);
|
||||
assert.match(composerSource, /aria-label=\{stopLabel\}/);
|
||||
assert.match(composerSource, /title=\{stopTitle\}/);
|
||||
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
|
||||
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
|
||||
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
|
||||
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
|
||||
});
|
||||
|
||||
test("every external draft writer keeps working through the page-owned setters", () => {
|
||||
// Given: editing clears the hidden routing that a suggested question attached.
|
||||
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
|
||||
|
||||
// When: each existing write path is inspected.
|
||||
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function chooseConversationSuggestion");
|
||||
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
|
||||
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
|
||||
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
|
||||
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
|
||||
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (process.env.NODE_ENV === \"development\" && uiPreview.current)");
|
||||
const sendRestore = sourceBetween(pageSource, "if (activeSessionIdRef.current === sessionId) {", "setRequestError({");
|
||||
|
||||
// Then: suggestions fill, session switches clear, stop restores and send clears.
|
||||
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
|
||||
assert.match(startNewChat, /setDraft\(""\)/);
|
||||
assert.match(selectSession, /setDraft\(""\)/);
|
||||
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
|
||||
assert.match(saveOnboardingName, /setDraft\(""\)/);
|
||||
assert.match(stopRestore, /setDraft\(pending\.question\);\n\s*setDraftTheme\(pending\.theme\);\n\s*setDraftEntrypoint\(pending\.entrypoint\);/);
|
||||
assert.match(sendClear, /setDraft\(""\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);/);
|
||||
assert.match(sendRestore, /setDraft\(originalQuestion\);\n\s*setDraftTheme\(theme\);\n\s*setDraftEntrypoint\(entrypoint\);/);
|
||||
assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/);
|
||||
|
||||
// And: opening a rectification case still empties the composer it replaces.
|
||||
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage");
|
||||
assert.match(openRectification, /setDraft\(""\)/);
|
||||
});
|
||||
|
||||
test("the draft survives a reload without resurrecting a sent question", () => {
|
||||
// Given: the draft key follows the existing session-scoped storage convention.
|
||||
assert.match(pageSource, /const pendingConsultationStorageKey = "jyotisha\.pending-consultation"/);
|
||||
assert.match(draftStoreSource, /export const composerDraftStorageKey = "jyotisha\.composer-draft"/);
|
||||
assert.match(draftStoreSource, /return window\.sessionStorage;/);
|
||||
|
||||
// When: the store restores exactly once and every write mirrors the new value.
|
||||
assert.match(draftStoreSource, /function restoreComposerDraft\(\) \{\n if \(restored\) return;\n restored = true;\n draft = readStoredComposerDraft\(\);\n\}/);
|
||||
assert.match(draftStoreSource, /export function composerDraftSnapshot\(\): string \{\n restoreComposerDraft\(\);\n return draft;\n\}/);
|
||||
assert.match(draftStoreSource, /export function setComposerDraft\(value: string\) \{\n restoreComposerDraft\(\);/);
|
||||
assert.match(draftStoreSource, /writeStoredComposerDraft\(value\);\n for \(const listener of \[\.\.\.listeners\]\) listener\(\);/);
|
||||
|
||||
// Then: clearing after a send removes the key instead of persisting an empty draft.
|
||||
assert.match(draftStoreSource, /function writeStoredComposerDraft\(value: string\) \{\n if \(!value\) \{\n clearStoredDraft\(\);\n return;\n \}/);
|
||||
|
||||
// And: a corrupt, foreign, oversized or stale value is discarded, never surfaced.
|
||||
const readStored = sourceBetween(draftStoreSource, "export function readStoredComposerDraft()", "function writeStoredComposerDraft");
|
||||
assert.match(readStored, /typeof text !== "string"/);
|
||||
assert.match(readStored, /typeof savedAt !== "number"/);
|
||||
assert.match(readStored, /!Number\.isFinite\(savedAt\)/);
|
||||
assert.match(readStored, /savedAt > Date\.now\(\)/);
|
||||
assert.match(readStored, /Date\.now\(\) - savedAt > composerDraftMaxAgeMs/);
|
||||
assert.match(readStored, /clearStoredDraft\(\);\n return "";\n \}/);
|
||||
assert.match(readStored, /catch \{\n clearStoredDraft\(\);\n return "";\n \}/);
|
||||
assert.match(readStored, /text\.slice\(0, composerDraftLimit\)/);
|
||||
|
||||
// And: an unavailable storage or a server render degrades to an empty composer.
|
||||
assert.match(draftStoreSource, /if \(typeof window === "undefined"\) return null;/);
|
||||
assert.match(draftStoreSource, /export function serverComposerDraftSnapshot\(\): string \{\n return "";\n\}/);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
|
||||
|
||||
/**
|
||||
* Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom
|
||||
* property is invisible to the compiler, so `text-ink` compiles to nothing at
|
||||
* all while still looking correct in the JSX. Text assertions on class names
|
||||
* cannot catch that, so this file cross-references the two sides instead.
|
||||
*/
|
||||
function themeDeclarations() {
|
||||
const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/);
|
||||
assert.ok(block, "globals.css must declare a @theme block");
|
||||
return new Map(
|
||||
[...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]),
|
||||
);
|
||||
}
|
||||
|
||||
function rootDeclarations() {
|
||||
const entries = new Map<string, string>();
|
||||
for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) {
|
||||
for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
|
||||
if (!entries.has(name)) entries.set(name, value.trim());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function sourceFiles(path: string): string[] {
|
||||
const directory = new URL(path, root);
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const child = join(directory.pathname, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
|
||||
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// Utility prefixes that resolve their argument through the `--color-*` namespace.
|
||||
const colorPrefixes = [
|
||||
"accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s",
|
||||
"border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from",
|
||||
"inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke",
|
||||
"text", "text-shadow", "to", "via",
|
||||
];
|
||||
|
||||
/** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */
|
||||
function colorTokensUsedAsUtilities(palette: Map<string, string>) {
|
||||
const used = new Map<string, Set<string>>();
|
||||
for (const file of sourceFiles("src")) {
|
||||
const label = file.slice(file.indexOf("/src/") + 1);
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
|
||||
for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) {
|
||||
// Strip variants (`hover:`), the important marker and any opacity modifier.
|
||||
const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0];
|
||||
for (const prefix of colorPrefixes) {
|
||||
if (!utility.startsWith(`${prefix}-`)) continue;
|
||||
const token = `--color-${utility.slice(prefix.length + 1)}`;
|
||||
if (!palette.has(token)) continue;
|
||||
if (!used.has(token)) used.set(token, new Set());
|
||||
used.get(token)!.add(`${label}:${index + 1} (${utility})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
test("every color token used as a Tailwind utility is exposed through @theme", () => {
|
||||
const theme = themeDeclarations();
|
||||
const palette = rootDeclarations();
|
||||
const dead: string[] = [];
|
||||
for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) {
|
||||
if (theme.has(token)) continue;
|
||||
dead.push(`${token} -> ${[...callSites].sort().join(", ")}`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
dead,
|
||||
[],
|
||||
`these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("the whole :root colour palette is reachable from the utility namespace", () => {
|
||||
const theme = themeDeclarations();
|
||||
const missing = [...rootDeclarations().keys()]
|
||||
.filter((token) => token.startsWith("--color-") && !theme.has(token))
|
||||
.sort();
|
||||
assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`);
|
||||
});
|
||||
|
||||
test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => {
|
||||
const theme = themeDeclarations();
|
||||
const aliases: Record<string, string> = {
|
||||
"--color-background": "var(--color-canvas)",
|
||||
"--color-foreground": "var(--color-ink)",
|
||||
"--color-primary": "var(--color-action)",
|
||||
"--color-primary-foreground": "var(--color-on-dark)",
|
||||
"--color-secondary": "var(--color-canvas-muted)",
|
||||
"--color-secondary-foreground": "var(--color-ink)",
|
||||
"--color-muted": "var(--color-canvas-muted)",
|
||||
"--color-muted-foreground": "var(--color-ink-secondary)",
|
||||
"--color-destructive": "var(--color-danger)",
|
||||
};
|
||||
for (const [name, value] of Object.entries(aliases)) {
|
||||
assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("@theme resolves palette aliases inline so the :root values stay authoritative", () => {
|
||||
// `inline` makes a utility emit the declared value rather than a reference to
|
||||
// the theme variable, which is what lets `--color-background` forward to
|
||||
// `--color-canvas` instead of shadowing it.
|
||||
assert.match(css, /@theme inline \{/);
|
||||
// Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat
|
||||
// layered ones, so the literal values below must stay outside any layer.
|
||||
assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette");
|
||||
assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m);
|
||||
});
|
||||
|
||||
test("@theme literals do not drift from the :root token they duplicate", () => {
|
||||
const palette = rootDeclarations();
|
||||
for (const [name, value] of themeDeclarations()) {
|
||||
if (!name.startsWith("--color-") || value.startsWith("var(")) continue;
|
||||
const token = palette.get(name);
|
||||
if (token === undefined) continue;
|
||||
assert.equal(value, token, `@theme ${name} must match the :root token ${token}`);
|
||||
}
|
||||
});
|
||||
@@ -192,7 +192,8 @@ test("global report copy uses the canonical profile, preserves accepted/confirme
|
||||
test("entry is global in the sidebar and absent from the active session header", () => {
|
||||
assert.match(sidebarSource, /我的报告/);
|
||||
assert.match(sidebarSource, /onOpenReports/);
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => window\.location\.assign\("\/reports"\)\}/);
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
|
||||
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
|
||||
assert.match(
|
||||
globalStyles,
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_agents_requires_read_before_and_update_after_bug_work() -> None:
|
||||
text = AGENTS.read_text(encoding="utf-8")
|
||||
|
||||
assert "docs/BUG_HISTORY.md" in text
|
||||
assert "开始诊断前必须完整读取并搜索" in text
|
||||
assert "开始诊断前必须用报错原文、接口路径、状态码、模块名和用户操作强制检索" in text
|
||||
assert "检索不是可选步骤" in text
|
||||
assert "必须在同一变更中更新" in text
|
||||
assert "严禁写入" in text
|
||||
|
||||
Reference in New Issue
Block a user