From 20a54d176c69cc79b717d0a5342c9a5dda711896 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 28 Sep 2026 09:36:21 +0800 Subject: [PATCH] docs(consult): BUG-1074/1075 records, changelog, device checklist, progress and BLOCKED for first-frame and pacing (T4) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe --- BLOCKED.md | 7 ++ CHANGELOG.md | 7 ++ docs/BUG_HISTORY.md | 36 ++++++ ...consult-first-frame-and-pacing-20260928.md | 104 ++++++++++++++++++ docs/tasks/README.md | 2 +- ...consult-first-frame-and-pacing-20260928.md | 2 +- ...consult-first-frame-and-pacing-20260928.md | 17 +++ frontend/tests/consultation-recovery.test.ts | 6 +- 8 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 docs/tasks/PROGRESS-consult-first-frame-and-pacing-20260928.md create mode 100644 docs/testing/consult-first-frame-and-pacing-20260928.md diff --git a/BLOCKED.md b/BLOCKED.md index 2e5eefb5..dc0760ad 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,12 @@ # BLOCKED +## TASK-consult-first-frame-and-pacing:真实 PostgreSQL 寒暄结算测试、真机与真实耗时待验(2026-09-28) + +- 本机无 Docker:BUG-976 那组真实 PostgreSQL 寒暄结算测试(`database-local-business.test.ts` 等 24 条 DB / 部署套件)未跑,失败清单与开工基线逐条同名。本单没有改 `complete_consultation_free` / `cancel_consultation_credit` 的调用点与参数,寒暄流 `streamSmalltalkResponse` 也未改,只是被外层流逐字节中继;假模型层补了「流内完成、免费结算调用一次」的断言(`consult-first-frame-and-pacing-20260928.test.tsx` ②)。以门禁 DB job 为准。 +- 无登录态、无模型凭据:首帧、寒暄闪一下、逐字写出、停止定格只在纯函数与源码断言层验证,真机清单 `docs/testing/consult-first-frame-and-pacing-20260928.md` 7 条由产品走。 +- 分类耗时与首字节耗时的真实分位数无法从本机取得(staging 观测日志需登录后台):部署后用 `classification.` 与新增的 `first_byte` 阶段的 `durationMs` 复核。 +- 未部署。 + ## TASK-consult-gender-optional:真实 PostgreSQL 测试、登录态真机与模型输出待验(2026-09-27) - ~~本机无 Docker:`npm run test:db` 未跑。本单新增 `frontend/tests/database-profile-gender.test.ts`(两条,本机 docker unavailable 跳过),覆盖新列、CHECK、RLS、授权与真实路由读写;BUG-1062 的补授权也由其中的账户 PATCH 用例在真实库上证实。结果以门禁 DB job 为准(run 号待推送后补记)。替代证据:迁移源文本合同(`profile-gender-20260927.test.ts`)与 service_role 授权静态合同(`profile-service-role-grants-20260927.test.ts`)已跑通。~~ → 已解除:门禁 run 2977 真实 PostgreSQL 通过(`gender migration is additive…`、`service_role can select every profiles column…`),全量 4204 / 0 fail / 0 skip。 diff --git a/CHANGELOG.md b/CHANGELOG.md index f882492c..43a1b0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-28 — 普通对话发出即有反馈,回答按打字节奏写出(待验收) + +- 按下发送的那一帧,回答行就带着步骤栏,第一行是「收到,正在看你的问题…」;服务端也先开流、首字节就是这句,读会话、预留点数、判断是不是寒暄这些步骤都在流里做。以前这一两秒整行空白(BUG-1074)。发「你好」这类寒暄会先闪一下这一行,服务端判定是寒暄后收掉,只留一句回复。 +- 回答正文按打字速度出现:每帧最多 4 个字,流结束后剩下的字也按同样速度在 1.5 秒内写完,不再一整段瞬间出现(BUG-1075)。点停止、出错、网络断开时立刻全部显示。扣点与保存不等这段动画。生时校正面的回答暂不改(它的流尾仍一次显示)。 +- 开流后才发现的拒绝(点数不足、对话写满、资料不完整等)改在流里用同一句话告诉你,处理方式与以前相同(点数不足仍打开充值)。 +- 服务端「满 160 字或出现标题才放出」的过程话防线不动;排盘工具调用、扣点、寒暄分类都不变。Skill 版本不 bump(Skill 文本未改)。不改数据库。 + ## 2026-09-27 — 普通对话回答问的那句话:不揣测动机、追问轮直接答、首轮只说一遍(待验收) - 回答不再替你说你「其实想问什么」「要的是什么」「是否需要什么」,也不替父母、伴侣说他们的心思;反差只写盘上的结构(哪颗星、哪个宫、哪段大运),问的是别人时写那个人在你盘上对应的宫位与代表星。是非题第一句就答「是」「不是」或「看情况」,再给依据。删掉了让模型先改写问题再回答的那条规则(BUG-1070)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dcec6644..4963012b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14431,3 +14431,39 @@ - 相关记录:BUG-1070、BUG-1071、BUG-1072;`84b293fb`。 - 复发自:无 - 修复版本:staging `9d01757f`(2026-09-28 部署核对:`/api/health` `deployment.gitCommit` = 9d01757f,未登录 `/api/account` = 401;Claude 验收门禁见 PROGRESS;真机 2026-09-28 产品用 deepseek-v4-flash 走清单第 1~4 步通过:首轮三节无开场行动、无重复;「是不是」首句「不是。」约 120 字无标题;「那我爸呢」短答;「完整看一下我的事业」回到开场 + 三节含行动节。第 5~8 步未走) + +## BUG-1074 | 普通对话发出后到服务端分类完成之前整行空白,一两秒没有任何反馈 + +- 状态:fixed_pending_acceptance +- 首次发现 / 最近更新:2026-09-28 / 2026-09-28 +- 影响面:`frontend/src/components/chat-message-row.tsx`(`awaitingClassification` 静默)、`frontend/src/components/consultation-run-timeline.tsx`(`QUEUED_TIMELINE_ROW`)、`frontend/src/app/api/consult/route.ts`(开流顺序)、`frontend/src/lib/stream-first-response.ts`(新)、`frontend/src/lib/consultation-agent-events.ts`(`activity` phase `received`、`run.failed` 的 `request_rejected`)、`frontend/src/hooks/use-consultation-run.ts`(事件而非响应头判寒暄与拒绝)。 +- 现象:产品 2026-09-28 staging 真机(`deepseek-v4-flash`):按下发送后一两秒内没有「思考中」也没有步骤栏,之后才一起出现。 +- 触发条件:每一轮普通对话(agentic 运行时),无论最终是咨询还是寒暄。 +- 根因:两层叠加。R1 客户端:BUG-976 寒暄快路(`539d4dae`)为了让「你好」的回复不带步骤栏,让消息行在服务端首事件到达前 `quiet`(不渲染时间线与活动面板),把 BUG-475 为「首事件前无反馈」加的「正在处理…」排队行挡掉了。R2 服务端:`route.ts` 在建 Response 之前依次做会话读取、`prepareConsultationRoute`(资料 / 区间 / 时区)、`reserve_consultation_usage`、`append_consultation_question`,再用会话模型跑一次 `classifyConsultationTurn`(3 秒 fail-open),分类完才 `streamAgentResponse` / `streamSmalltalkResponse` 建流,浏览器此前收不到任何字节。校正面 BUG-1047 已改「先建流、首帧确定性进度句」,普通对话没有同步。不是回归:两条各自按当时目的是对的,叠加后才成空白。 +- 修复(产品 2026-09-28 拍板 D1、D2): + - D1 客户端:`quiet` 只由 `responseKind === "smalltalk"` 决定;排队行文案改为「收到,正在看你的问题…」(`CONSULTATION_RECEIVED_LABEL`),从发送那一帧起可见;服务端判定寒暄后客户端收到 `run.completed` 的 `responseKind: "smalltalk"` 即转 `quiet`。 + - D2 服务端:鉴权、请求体、会话与会话模型仍在开流前答 HTTP 状态;其余整段搬进 `runTurn`,agentic 运行时由 `streamFirstResponse` 先返回 NDJSON 流、首帧同步写 `activity`(phase `received`,同一句文案),`runTurn` 的 Response 原样中继:NDJSON 逐字节透传(咨询与寒暄流都不改),JSON 拒绝转成一条 `run.failed` `request_rejected`(带原 `status`、按 recovery → message → error 取的句子、原 `code` 作 `reason`)。客户端删响应头判寒暄,`request_rejected` 按原状态语义处理(402 开充值、4xx 回滚、`session_full` / `session_not_consultation` 沿用)。legacy 运行时(`CONSULTATION_AGENTIC_RUNTIME=legacy` / canary 非成员)保持原顺序,因其 text/plain 回复不能走 NDJSON。 + - 观测:`consultation-stream-first-v1` 记 `first_byte` 阶段(请求进入到首帧写出的毫秒),不含原文。 +- 验证:`frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx`:① 分类挂起时首帧已写出(`onFirstFrame` 毫秒数);② 真实 `streamSmalltalkResponse` 在流内完成,免费结算调用 1 次,事件为 activity → answer.delta → run.completed(smalltalk),无响应头;③ 402 / 409(session_full) / 503(recovery) 三种 JSON → `request_rejected` 事件字段逐一对照,schema 只允许 `request_rejected` 带 `status` / `reason` 且必须带 `status`;④ 咨询 NDJSON 首帧之后逐字节一致;断连传给内层 reader;`run` 抛错 → 通用 `run.failed` + 日志回调;route 源码:开流前 / 流内两张清单、`shouldUseAgenticRuntime` 分流、`first_byte`;客户端源码:无响应头、事件判寒暄、`request_rejected` 的 402 / 状态 / code 处理。`consultation-smalltalk-ui.test.tsx`、`chat-stream-settle-contract.test.ts`、`consultation-smalltalk.test.ts` 三处断言按三栏改。真实 PostgreSQL 的寒暄结算测试本机无 Docker 未跑(结算 RPC 与调用点未改,见 `BLOCKED.md`)。 +- 防复发:会产生模型或数据库等待的对话请求必须先建流、首字节是确定性进度句(同 BUG-1047 防复发);消息行的静默只能由服务端明确的回复类型触发,不得由「还没收到事件」触发;新的开流前失败必须能表达为 `request_rejected`(带 status),不得让它变成静默空回复。 +- 相关记录:BUG-475、BUG-976、BUG-977、BUG-1047、BUG-1053;`TASK-consult-first-frame-and-pacing-20260928`。 +- 复发自:无(BUG-475 的排队行仍在,只是被 BUG-976 的静默挡住)。 +- 修复版本:codex/consult-first-frame-and-pacing-20260928(待验收推 staging)。 + +## BUG-1075 | 普通对话正文一下全出来:短回答在流结束时一帧显示,首轮前 160 字一坨 + +- 状态:fixed_pending_acceptance +- 首次发现 / 最近更新:2026-09-28 / 2026-09-28 +- 影响面:`frontend/src/lib/stream-frame-buffer.ts`(放字节奏与 `settle`)、`frontend/src/hooks/use-consultation-run.ts`(结算与显示层分离)、`frontend/src/lib/chat-message-view.ts`(`settlingChatMessageView`)、`frontend/src/components/chat-message-row.tsx`(写出期间时间线不 live)。 +- 现象:产品 2026-09-28 staging 真机:回答没有流式输出的动画,整段一下出现。 +- 触发条件:追问轮(正文 ≤ 200 字、无标题)几乎必现;首轮开场前 160 字成一坨、之后一块一块跳。 +- 根因:R3 服务端按步截留(BUG-1053 取答边界,`ANSWER_RELEASE_CHARS = 160`):一步里的正文出现标题或满 160 字才放出,不到就扣到该步结束整段放,追问轮正文因此在流尾才到。R4 客户端 `stream-frame-buffer.ts`:正常每帧放 `max(2, 积压/12)` 字,一坨 160 字约 12 帧吐完;`settle()` 把剩余文字一帧全放,`use-consultation-run.ts` 读完流立即 `settle()`。R3 + R4 = 整段一帧出现。160 字阈值是过程话防线,产品拍板不动(D4)。 +- 修复(产品 2026-09-28 拍板 D3): + - 每帧上限 `STREAM_RELEASE_MAX_CHARS = 4`(≈ 240 字/秒),积压超过 `STREAM_RELEASE_CATCHUP_CHARS = 600` 才回到按 1/12 追赶(重连、后台标签页)。 + - `settle({ paced: true })` 按节奏写完剩余(每帧 `max(4, 剩余/90)`,即 1.5 s 内),最后一帧才 `settled: true`,返回 Promise;无参 `settle()` 保持旧行为(一帧全放),用于停止、失败、截断,也是校正面 `rectification-chat-turn-run.ts` 两处调用的契约(它在 settle 后同步合并快照,本单不动;执行时首版把默认改成按节奏,校正面 5 条测试立刻失败,改回 opt-in);页面隐藏照旧一帧全放;写出期间 `dispose()` 延到写完。 + - 显示层与数据层分开:流结束后 hook 先 `setStreamingReply` 打上 `settling: requestId`,随后照常入库、`persistSession`、结算、`completeConsultationInterface`(它不再清掉正在写出的回复);每个写出帧只在回复仍属于本请求时更新(函数式更新,新一轮发送会接管显示),最后一帧清空。`latestAssistantView` / `chatMessageViews` 在 `loading` 为假、释放文本是已存回复的严格前缀时,用同一个 `renderKey` 渲染前缀(`settling: true`,时间线不 live);不是前缀(被替换的回复)就直接显示全文。滚动跟随不新写:仍是 `useConversationScrollAnchor` 观察内容增长。 +- 验证:`stream-frame-buffer.test.ts`:160 字一坨 40 帧写完;600 以上仍 12 帧追赶;剩余 300 字的 settle 在 90 帧内、单调、最后一帧 settled;immediate 一帧;dispose 延后。`consult-first-frame-and-pacing-20260928.test.tsx`:前缀覆盖的 view / 行渲染(无 spinner、无「正在分析」)、非前缀 / loading / 全文不覆盖;hook 源码:完成走节奏、停止 / 截断 / 失败立即、`completeConsultationInterface` 不清 settling、函数式更新只认本请求。既有「settle 同步放完」断言按三栏改。 +- 防复发:普通对话完成路径必须走 `settle({ paced: true })`(源码断言只允许一处);任何流尾一次性到达的正文都要经同一个帧缓冲写出;显示层的延迟不得阻塞入库与结算;校正面若要同样的写出节奏,先改它 settle 后的同步快照合并,另立单。 +- 相关记录:BUG-473、BUG-1053、BUG-1074;`TASK-consult-first-frame-and-pacing-20260928`。 +- 复发自:无 +- 修复版本:codex/consult-first-frame-and-pacing-20260928(待验收推 staging)。 diff --git a/docs/tasks/PROGRESS-consult-first-frame-and-pacing-20260928.md b/docs/tasks/PROGRESS-consult-first-frame-and-pacing-20260928.md new file mode 100644 index 00000000..857421f8 --- /dev/null +++ b/docs/tasks/PROGRESS-consult-first-frame-and-pacing-20260928.md @@ -0,0 +1,104 @@ +# PROGRESS · 普通对话发出即有反馈,正文按打字节奏(2026-09-28) + +任务书:`TASK-consult-first-frame-and-pacing-20260928.md`(产品已确认 D1、D2、D3;D4 160 字阈值不动;D5 工具 / 扣点 / 分类模型不动)。执行方:Claude fork 子代理(直接执行模式)。 + +## 基线 + +- `origin/staging` = `584ad05c`(2026-09-28);分支 `codex/consult-first-frame-and-pacing-20260928`,工作树 `.worktrees/consult-first-frame-and-pacing-20260928`。 +- 开工实测(Node 22.14,`npm test`):4197 条 / pass 4143 / fail 24 / skipped 30 / cancelled 0;24 条失败均为无 Docker 的 DB / 部署套件(清单存 scratchpad `baseline2-fails.txt`)。 +- 首屏 rootMainFiles gzip(9) 基线:130,933 B(`PROGRESS-consult-answer-the-question-20260927` 口径,4 个文件)。 +- 分类耗时的真实分位数:无法从本机取得(staging 观测日志需登录后台)。代码上限是 `SMALLTALK_TIMEOUT_MS = 3_000`(fail-open);在 R2 的开流前序列里它前面还有会话读取(1 次查询)、`prepareConsultationRoute`(资料 / 档案 / 区间,2~3 次查询)、`reserve_consultation_usage`(1 次 RPC + 1 次计价查询)、`append_consultation_question`(1 次 RPC)。部署后用 `classification.` 与本单新增的 `first_byte` 阶段 `durationMs` 复核。 + +## 做了什么 + +### T1 客户端首帧(BUG-1074,提交 `86d99115`) +- `chat-message-row.tsx`:删 `awaitingClassification`,`quiet` 只由 `responseKind === "smalltalk"` 决定。 +- `consultation-run-timeline.tsx`:`QUEUED_TIMELINE_ROW.label` 改 `CONSULTATION_RECEIVED_LABEL`(「收到,正在看你的问题…」,新增在 `consultation-activity-labels.ts`,服务端首帧共用)。 +- `DESIGN.md` §9、`docs/VOICE.md` 新段。 + +### T2 服务端先建流(BUG-1074,提交 `0bc66590`,补修 `891e9c62`) +- 新 `frontend/src/lib/stream-first-response.ts`:`streamFirstResponse({ requestId, run, onFirstFrame, onUnhandled })` 先返回 NDJSON Response,`start()` 里同步写首帧 `activity`(phase `received`),再 `await run()`:内层 NDJSON Response 逐字节透传,内层 JSON 拒绝转成一条 `run.failed` `request_rejected`(`consultationRejectionEvent(status, payload)`:句子按 recovery → message → error 取,与客户端 `payloadMessage` 同一优先级;原 `code` 作 `reason`),`run` 抛错则写通用 `run.failed` 并回调日志;外层 `cancel()` 把断连传给内层 reader(内层 `continueAfterDisconnect` 照旧结算)。 +- `route.ts`:`const runTurn = async (): Promise => { … }` 包住从 `prepareConsultationRoute` 到最后 `catch` 的整段(**正文缩进未动**,保持 diff 最小、源码断言不变);`shouldUseAgenticRuntime(user)` 为假走原顺序(legacy 的 text/plain 回复不能走 NDJSON),为真 `return streamFirstResponse({...})`;`onFirstFrame` 记 `consultation-stream-first-v1` / `first_byte`(毫秒,不含原文)。寒暄分支、`streamSmalltalkResponse`、免费结算 RPC 与参数一行未改(任务书写「改成可写 controller 的函数」,实际用逐字节中继达到同一效果、少改一处结算代码;见偏离)。 +- `consultation-agent-events.ts`:`publicActivityPhaseSchema` 加 `received`;`run.failed` 的 `code` 加 `request_rejected`,可选 `status`(400–599)与 `reason`(≤ 80),union 级 `superRefine`:`status` / `reason` 只允许 `request_rejected`,且 `request_rejected` 必须带 `status`。 +- `use-consultation-run.ts`:删 `x-jyotish-response-kind` 响应头读取(只认 `run.completed.responseKind`);`request_rejected` 事件与 HTTP 非 2xx 走同一个 `rejectRun(status, message, code)`(401 跳登录、402 开充值、抛 `ConsultationResponseError`),后续按 `status` / `code` 的回滚、`session_full`、`session_not_consultation`、`request_conflict` 处理不变;首帧 `activity(received)` 不带已完成轨迹。`chat-message-row.tsx` 的 phase → 状态映射加 `received: "working"`。 + +#### 开流前 / 流内对照表(agentic 运行时) + +| 失败 | 以前 | 现在 | 客户端 | +| --- | --- | --- | --- | +| Supabase 未配置 | JSON 503 | **仍开流前** JSON 503 | 不变 | +| 未登录 | JSON 401 | **仍开流前** 401 | 跳 `/login` | +| 请求体不合法 | JSON 400 | **仍开流前** 400 | 不变 | +| 会话读取失败 / 不存在 / 是校正会话 / 模型已变化 | JSON 503 / 404 / 409(`session_not_consultation`) / 409 | **仍开流前** | 不变(`session_not_consultation` 仍切到校正会话) | +| 会话模型不可用;旧版校正入口;提示词提取 | JSON 503 / 409 / 400 | **仍开流前** | 不变 | +| 计费配置不可用(`pricing_configuration_unavailable`) | JSON 503 | 流内 `request_rejected` status 503 reason 同 | 同句、同状态 | +| 咨询计划不可用(上限 / 模式不一致) | JSON 409 | 流内 status 409 | 409 → 回滚问题到输入框 | +| 主体 / 资料失败(`consultationSubjectFailureResponse`、`ConsultationProfileTruthError`) | JSON 4xx/503 | 流内,status 原值 | 不变 | +| 预留失败 / 模型不可用 | JSON 503 / 409 | 流内 | 不变 | +| 点数不足 | JSON 402 | 流内 status 402 | 开充值 + 回滚 | +| 保存问题失败 / 对话已写满 | JSON 503 / 409(`session_full`) | 流内,`reason: "session_full"` | 「开新对话」提示不变 | +| 生成失败(最后的 catch) | JSON 503(recovery 句) | 流内,句子取 recovery | 不变 | +| 寒暄 | NDJSON + 响应头 | 流内 NDJSON 透传,无响应头 | 只认 `run.completed.responseKind` | + +### T3 客户端节奏(BUG-1075,提交 `3f8b8172`,补修 `891e9c62`) +- `stream-frame-buffer.ts`:`STREAM_RELEASE_MAX_CHARS = 4`(普通积压每帧上限)、`STREAM_RELEASE_CATCHUP_CHARS = 600`(超过才按 1/12 追赶)、`STREAM_SETTLE_MAX_FRAMES = 90`;`settle({ paced: true })` 按 `max(4, 剩余/90)` 每帧写完剩余、最后一帧 `settled: true`、返回 Promise;无参 `settle()` 保持一帧全放;页面隐藏一帧全放;写出期间 `dispose()` 延到写完;`reset()` 结束写出。 +- `use-consultation-run.ts`:流正常结束 → `settleFrames(false)`:先给 streaming reply 打 `settling: requestId`,`frames.settle({ paced: true })`;停止 / 截断 / 失败 → 无参 settle 立即全放。数据层(入库、`persistSession`、结算、`completeConsultationInterface`)不等动画;`completeConsultationInterface` 不再清掉正在写出的回复(函数式更新);每个写出帧只在 `current.settling === requestId` 时更新,最后一帧清空;新一轮发送覆盖 streaming reply 后旧帧自然落空。 +- `chat-message-view.ts`:`settlingChatMessageView(last, loading, streamingText)`,`latestAssistantView` / `chatMessageViews` 在 `loading` 为假、释放文本是已存回复严格前缀时用同一 `renderKey` 渲染前缀(`settling: true`);`chat-message-row.tsx` 写出期间时间线不 live。`home-types.ts` `StreamingReply.settling`。 +- 滚动跟随不新写:`useConversationScrollAnchor` 观察内容增长,写出期间与流式期间同一路径。 + +### T4 记录(本提交) +- `docs/BUG_HISTORY.md`:BUG-1074、BUG-1075(`fixed_pending_acceptance`)。 +- `CHANGELOG.md` 一条;`docs/testing/consult-first-frame-and-pacing-20260928.md` 7 条;`BLOCKED.md` 新段;`docs/tasks/README.md` 行状态「已实现待验收」。 + +## 测试 + +### 新增 +- `frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx`(13 条):T3 前缀覆盖 view / 行渲染、非前缀 / loading / 全文不覆盖、hook 源码(完成走节奏、停止 / 截断 / 失败立即、`completeConsultationInterface` 不清 settling、只一处 `settle({ paced: true })`);T2 ① 分类挂起首帧已出(`onFirstFrame` 40 ms)② 真实 `streamSmalltalkResponse` 流内完成、免费结算 1 次、无响应头 ③ 402 / 409 / 503 三种 JSON → `request_rejected` 字段逐一对照 + schema 约束 ④ NDJSON 首帧后逐字节一致、断连传内层、`run` 抛错通用失败;route 源码开流前 / 流内两张清单、分流、`first_byte`;客户端源码事件判寒暄、`rejectRun` 两处调用、计数不变。 +- `stream-frame-buffer.test.ts` +3:160 字 40 帧;600 以上仍 12 帧追赶;剩余 300 字 paced settle ≤ 90 帧、单调、最后一帧 settled、无参 settle 一帧;dispose 延后。 + +### 改既有断言(原值 / 新值 / 原因,注释也写在测试里) + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `chat-stream-settle-contract.test.ts` | live 时间线含「正在处理…」 | 含「收到,正在看你的问题…」、不含旧句 | D1 / BUG-1074 | +| `consultation-smalltalk-ui.test.tsx` | 分类回来前不渲染时间线、不出现「正在处理 / 正在分析」 | 渲染排队行(唯一一行、无 think / write 步);只有 smalltalk 行静默 | D1 / BUG-1074 | +| `consultation-smalltalk.test.ts` | `/const quiet = smalltalk \|\| awaitingClassification/` | `/const quiet = smalltalk;/` 且无 `awaitingClassification` | D1 / BUG-1074 | +| `consultation-agentic-runtime.test.ts` | `writerToSend` 的 phase 手写四值联合 | `PublicActivityPhase`(多 `received`) | D2 / BUG-1074 | +| `chat-navigation-a11y-contract.test.ts` | `/if \(response\.status === 402\) openAccountDialog(...)/` | `/if \(status === 402\) openAccountDialog(...)/`(`rejectRun` 里) | D2;调用点计数 3 / 2 不变 | +| `consultation-recovery.test.ts` | `/!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/` | `rejectRun(status, message, code): never` 抛 `ConsultationResponseError(status, …)`,`!response.ok` 调 `rejectRun(response.status, …)` | D2 / BUG-1074:HTTP 体与流内事件共用一处 | +| `stream-frame-buffer.test.ts` | `settle()` 同步放完 | `settle({ paced: true })` 在 90 帧内写完、最后一帧 settled;无参 settle 仍一帧 | D3 / BUG-1075 | + +`chat-stream-layout.test.ts` 的 `/正在处理…/` 仍成立(活动面板兜底文案未改,那是无时间线行的 fallback)。 + +## 门禁数字(完工实测,Node 22.14,本工作树,顺序执行) + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(128 条既有 warning 未动) | +| `npm test` | 4212 条(基线 4197,+15)/ pass 4158 / fail 24 / skipped 30 / cancelled 0;退出码 1 = 那 24 条 | +| 失败清单比对 | 24 条失败按名称与基线逐条一致(`diff` 为空,均为无 Docker 的 DB / 部署套件)。全部测试名对比(基线取自 `9d01757f` 同代码的验收全量 TAP):新增 17 个名字;消失 2 个 = 本单按三栏改名的两条(`classification wait does not invent a thinking step before any activity` → `the classification wait shows the queued row from the send frame; only a smalltalk reply is quiet`;`many events collapse into one flush per frame and settle releases everything synchronously` → `… and a paced settle writes the rest out within the budget`),原名在测试注释的原值里 | +| `next build --webpack` | exit 0;`/`、`/chart`、`/ephemeris`、`/people` 均 ○ Static | +| 首屏 rootMainFiles gzip(9) | 130,950 B(4 个文件),基线 130,933 B,+17 B(+0.013%,±2% 内);量于 `891e9c62`,其后只改了测试与文档 | +| `page.tsx` | 未动 | +| `python3 -m pytest tests/test_repo_privacy_markers.py -q` | 通过(写了实测数字的文档推送前必跑) | + +## 提交 + +| SHA | 内容 | +| --- | --- | +| `86d99115` | T1 客户端首帧 | +| `3f8b8172` | T3 放字节奏与 paced settle(首版默认 paced) | +| `0bc66590` | T2 先建流、事件、客户端 | +| `891e9c62` | T2/T3 补修:paced 改 opt-in、`rejectRun` 合并两条拒绝路径(全量跑出 7 条新失败后改) | +| (本提交) | T4 记录 + `consultation-recovery.test.ts` 三栏(第二次全量跑出的最后 1 条源码断言) | + +## 偏离与说明 + +1. **寒暄流没改成「写 controller 的函数」**(任务书 T2 第 1 条):外层流把内层 `streamSmalltalkResponse` 的 Response 逐字节中继,效果相同(先免费结算再发 answer.delta + run.completed),且不动 BUG-976 那组结算代码;本机无 Docker 跑不了它的真实 PG 测试,少改一处更稳。假模型层的断言补在新测试 ②。 +2. **paced settle 改成 opt-in**:首版把 `settle()` 默认改成按节奏,全量跑出 7 条新失败——校正面 `rectification-chat-turn-run.ts` 两处 `settle()` 之后同步合并快照(`rectification-dup-question`、`rectification-opening-plain`、`rectification-activity-receipt` 5 条),以及 Node 无 `window.requestAnimationFrame` 的未捕获异常。改成 `settle({ paced: true })` 只在普通对话完成路径用,校正面契约不动;校正面要同样节奏另立单(BUG-1075 防复发写明)。另 2 条是 `chat-navigation-a11y-contract` 数 `window.location.assign("/login")` / `openAccountDialog("billing", …)` 的调用点,首版在事件分支复制了两句,改成 `rejectRun` 共用后计数回到 3 / 2。 +3. **legacy 运行时不先建流**:`CONSULTATION_AGENTIC_RUNTIME=legacy` / canary 非成员仍是原顺序(text/plain 回复无法走 NDJSON)。staging / 生产 compose 都是 `enabled`。 +4. **`route.ts` 的 `runTurn` 正文没有重新缩进**:包一层箭头函数但保留原缩进,避免上千行的纯缩进 diff 打破十几个按源码文本断言的合同测试;lint 无缩进规则报错。 +5. 首帧的 `activity(received)` 在时间线 reducer 里不加行(返回原状态),客户端从发送那一帧起已经显示同一句排队行;这样那一帧到达时字不变、行不跳。 +6. `next build` 默认 Turbopack 对软链 `node_modules` 报错(同前几单),用 `--webpack` 量 `/` 与 gzip。 +7. 真机、真实耗时、真实 PG 结算测试:见 `BLOCKED.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1d08f2ea..10c3cab3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -245,7 +245,7 @@ | `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` | | `TASK-cost-accounting-gaps-20260927.md` | — | **成本记账补漏与缓存分价**:七处模型调用不入账(标题 / 摘要 / 校正意图 / 采用旁白 / 校正开场 / 生时引导 / 每日星语);缓存命中按全价算、单价表无缓存列;多事件账本 `metadata.cache` 被覆盖;失败运行无成本记录;测算页不显示 token 分位数;**后台模型表单没有单价输入框,单价只能是 0**。只加「记」不加「扣」,不动售价与会员参数 | 待领取 | BUG-1064~1069 预留;部署后由产品按 `docs/testing/cost-accounting-20260927.md` 采真实成本 | | `TASK-consult-answer-the-question-20260927.md` | `PROGRESS-consult-answer-the-question-20260927.md` | **普通对话回答问的那句话**:反差只写盘上、删「按我的理解答」、禁写动机句;追问轮 ≤ 200 字直接回答不重开四标题骨架(服务端按「本会话已有解读」切指令,不用正则);清单是核对项不是段落大纲;步骤栏「先抓住你真正在问的事」改「先回答你问的这件事」 | 已实现待验收(Claude 子代理直接执行) | 分支 `codex/consult-answer-the-question-20260927`(未推送);基线 `2ce3c216`;BUG-1070~1073;Skill 不 bump;真机清单 `docs/testing/consult-answer-the-question-20260927.md` | -| `TASK-consult-first-frame-and-pacing-20260928.md` | — | **普通对话发出即有反馈、正文按打字节奏放出**:客户端分类前静默(BUG-976 副作用)+ 服务端分类后才开流 → 发出后一两秒空白;服务端 160 字截留 + 客户端 settle 一帧全放 → 追问轮正文一下全出。D1 首帧「收到,正在看你的问题…」、D2 先建流、D3 每帧上限 + 限时收尾、D4 160 字阈值不动 | 待产品确认 D1–D3 | 基线 `56ca8b26`;BUG 从 1074 起 | +| `TASK-consult-first-frame-and-pacing-20260928.md` | `PROGRESS-consult-first-frame-and-pacing-20260928.md` | **普通对话发出即有反馈、正文按打字节奏放出**:客户端分类前静默(BUG-976 副作用)+ 服务端分类后才开流 → 发出后一两秒空白;服务端 160 字截留 + 客户端 settle 一帧全放 → 追问轮正文一下全出。D1 首帧「收到,正在看你的问题…」、D2 先建流、D3 每帧上限 + 限时收尾、D4 160 字阈值不动 | 已实现待验收 | 分支 `codex/consult-first-frame-and-pacing-20260928`(未推送);进度 `PROGRESS-consult-first-frame-and-pacing-20260928.md`;BUG-1074 / BUG-1075 | | `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `
` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — | | `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md`、`PROGRESS-cend-rectification-20260916.md`、`PROGRESS-cend-chart-eph-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4–R8 全部已实现并验收合入** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编。R7:区间常驻条与盘面折叠收敛。**任务书 E9 也不准确**——对话区顶部早有常驻条 `RectificationTimeline` 且窄屏可见,真正只在盘面标题行的是**代表分钟**;因此没另造第二条,在既有条上补齐代表分钟与已答题数(与盘面同一次 `workingRectificationTime()` 调用)。折叠块实际是 **8 个**不是 5 个。**触发让步顺序第 5 条**:收窄进度未做——服务端无该字段,且 `candidate_range` 会放宽(BUG-572),前端相减会把一次放宽报成收窄,已写进 `BLOCKED.md`。顺带修掉一个**静默失效的旧断言**(`slice(indexOf(A), indexOf(B))` 在 B 改名后变成几乎整份文件,四条 `doesNotMatch` 假通过)| | — (产品口头拍板,无任务书) | `PROGRESS-chart-glyphs-20260918.md` | **星盘改用行星符号,度数进行星表(非 Bug,无 BUG 号)**:北印星盘(星盘页 / 校正右栏 / 我的报告三处共用 `VedicChartSvg`)宫内不再画「水 19°」「罗逆 29°」这类文本——一宫三颗星就叠三行、字号压到 8。改成九个彩色符号(☉☽☿♀♂♃♄☊☋),每行最多 3 个、逆行改符号下同色横线、第 1 宫加 `As`、盘下两行图例。颜色只认星不表吉凶,罗计共用中性灰,`--color-planet-*` 九个 token 在 `:root` 与两个深色块各一份;颜色挂 `is-*` 修饰类而非行内 `style`(报告标记合同禁 `style=`)。符号字体是 vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),`--font-planet-glyphs`,♀♂ 带 U+FE0E 防 iOS 画成表情。星盘页行星表四列扩到八列(行星 / 黄道经度 / 速度 / 宫位 / 星宿 / 宿主 / pada / 状态),度数精确到分,首行是上升(`vedic.ascendant` 新字段)。数据来源:`toNorthIndianChart()` 加结构化 `occupantGlyphs`;校正与报告两条路线的模型只有引擎/golden 显示文本,按首字解析并有测试锁住。**星宿未译中文**——仓库无对照表,印度 27 宿与中国二十八宿不同源,不自造 | 已实现,待验收 | `codex/chart-glyphs-20260918`:tsc 0 / lint 0 error(118 warning 不变)/ npm test 3471→3484 条、36 红与基线 `84b293fb` 逐条相同、0 新红 / `○ /` Static / 首屏 JS gzip 1,450,774→1,455,017(+0.29%),CSS gzip +2.23%(构成见 PROGRESS §2)。真机走查欠(iOS 符号呈现、深浅两主题配色、375px 横滚、打印) | diff --git a/docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md b/docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md index 4d797fa5..c676eea9 100644 --- a/docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md +++ b/docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md @@ -31,7 +31,7 @@ - 网络与模型:分类耗时有埋点(`classification.` 的 `durationMs`),执行方开工时从 staging 观测日志取一周分位数写进 PROGRESS;即便分类只要 300 ms,R1 也会让这 300 ms 是空白。 - `createVisibleTextTransformer`(`stream-text-response.ts`)只处理隐藏块,不按句截留。 -## 决策记录(产品 2026-09-28,待确认后执行;Claude 建议如下) +## 决策记录(产品 2026-09-28 已确认 D1–D3,Claude 直接执行) - D1 **发出那一帧就有步骤栏**:去掉 `awaitingClassification` 的静默,每轮从发送起显示时间线,首行是确定性文案「收到,正在看你的问题…」(改 `QUEUED_TIMELINE_ROW` 的 label;文案对照 VOICE)。寒暄轮:服务端一旦判定寒暄,客户端收到 `responseKind: "smalltalk"` 即按现有逻辑转 `quiet`,步骤栏消失、只剩一句回复。代价是寒暄前会闪约一秒「收到…」,Claude 认为可接受;若产品不接受,退而求其次是 D1':首行不进时间线,只在头衔旁放一个 `InlineSpinner`(DESIGN §9 已有的唯一 live 标记),寒暄回来一样消失。 - D2 **服务端先建流**(同 BUG-1047 D3 的做法):`route.ts` 在鉴权与请求体校验之后就返回流;会话读取、准备、预留扣点、分类、以及最终的 `streamAgentResponse` / 寒暄流全部在 `start()` 里跑,首帧推一条确定性 `activity` 事件(label 同 D1 文案)。分类为寒暄时,在同一条流里写寒暄回复与免费结算(现在 `streamSmalltalkResponse` 自建 Response,需改成可写入外层 controller 的形式)。`x-jyotish-response-kind` 响应头在先建流后无法再设,客户端改为只认 `run.completed` 事件里的 `responseKind`(`use-consultation-run.ts` 第 838 / 924 行已有事件路径,删头部路径)。错误路径(预留失败、会话不存在等原本返回 4xx/5xx JSON 的分支)改为流内 `run.failed` 事件,文案不变;客户端对这些 code 的处理保持一致,写进测试。 diff --git a/docs/testing/consult-first-frame-and-pacing-20260928.md b/docs/testing/consult-first-frame-and-pacing-20260928.md new file mode 100644 index 00000000..062d00bf --- /dev/null +++ b/docs/testing/consult-first-frame-and-pacing-20260928.md @@ -0,0 +1,17 @@ +# 真机清单 · 普通对话发出即有反馈、正文按打字节奏(2026-09-28) + +对应 `docs/tasks/TASK-consult-first-frame-and-pacing-20260928.md`(BUG-1074、BUG-1075)。本机没有登录态与模型凭据,以下只能在 staging 部署后由产品在手机或桌面浏览器上照做。问句用虚构句式,不要贴真实问答原文到任何记录里。 + +前置:`https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于本单合入 staging 的 SHA;用一个已有出生分钟的星盘;模型优先 `deepseek-v4-flash`(真机反馈来自它)。 + +| # | 操作 | 期望 | 结果 | +| --- | --- | --- | --- | +| 1 | 新会话,输入「我和父母的关系怎么看」,按下发送的同时盯着回答行 | 发送那一帧就出现步骤栏,第一行「收到,正在看你的问题…」带行内小圈;没有一两秒的空白。之后按顺序换成「正在读取印度占星分析规则…」「正在计算本命盘…」等 | | +| 2 | 同一会话发「你好」 | 先出现「收到,正在看你的问题…」约一秒,随后步骤栏收掉,只剩一句寒暄回复;不扣点 | | +| 3 | 看第 1 步的正文出现方式 | 从第一坨开始按打字速度出现(每秒约两三百字),不是一块一块跳;开场(无标题那段)也是逐字写出 | | +| 4 | 接着问「我妈是不是不太在意我」(追问轮,100~200 字短答) | 回答逐字写出,大约半秒到一秒写完;不是一帧整段出现 | | +| 5 | 再问一次任意问题,在正文开始出现后点「停止」 | 立刻定格在已收到的文字上,没有继续写出的动画 | | +| 6 | 把点数用到不足(或用一个点数为 0 的测试账号)再发一句 | 步骤栏先出现「收到…」,随后提示「请先兑换咨询点数后再继续。」并打开充值;问题回到输入框,不扣点 | | +| 7 | 桌面浏览器切到别的标签页发一句,几秒后切回 | 切回时正文已经全部显示(页面隐藏时一帧全放),没有错乱或重复 | | + +任一条不符,按 BUG-1074 / BUG-1075 追加记录,附操作步骤与回答的**结构描述**(有没有排队行、正文是逐字还是整段、大约字数),不贴回答原文。 diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index e3facbae..9fba6dff 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -55,7 +55,11 @@ test("durable partial stop cancels before preserving content and recovers comple }); test("explicit consultation HTTP failures unlock instead of entering recovery", () => { - assert.match(sendSource, /!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/); + // 原值: /!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/ + // 新值: 非 2xx 走 rejectRun(response.status, …),它抛 ConsultationResponseError(status, …);流内 request_rejected 事件走同一函数 + // 原因: TASK-consult-first-frame-and-pacing-20260928 D2 / BUG-1074:开流后拒绝改走事件,两条路径共用一处状态语义 + assert.match(sendSource, /const rejectRun = \(status: number, message: string, code\?: string\): never => \{[\s\S]*throw new ConsultationResponseError\(status, message, code\);/); + assert.match(sendSource, /!response\.ok[\s\S]*rejectRun\(response\.status/); const explicitFailure = sendSource.slice( sendSource.indexOf("caught instanceof ConsultationResponseError"), sendSource.indexOf("if (!cancelled && ownsInterface && pendingConsultation.current)"),