Compare commits

..
Author SHA1 Message Date
leeferandmultica-agent bf234570c0 HEL-880: preserve inbox pagination gaps until reach-outs are delivered
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 22:28:54 +08:00
leeferandmultica-agent 1ae243f286 HEL-875: 接上真实主动留言,保全视觉总监原始补丁
Apply the exact eight-file HEL-875 patch from the visual director on 31970c4. Independently reran 49 frontend tests, typecheck/lint/build and original Python requirement checks. Candidate only; intake found pagination overflow dropping older reach-outs after >250 new records, requiring author repair before approval or deployment.

Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 22:08:58 +08:00
12 changed files with 698 additions and 266 deletions
+62
View File
@@ -0,0 +1,62 @@
{
"baseline_version": "1.11",
"read_commit": "31970c4dd3851aae67a922b03264743d504b8e97",
"task": "HEL-875 接线:沿已认可界面把真实主动留言(R4 reach-out,chat_message proactive=1)接进普通入口——打开页面期间接收、去重、未读提示与清除、不抢断阅读与回复;只改视觉总监负责的前端,不重定视觉方向,不改后端",
"requirement_ids": [
"CG-008",
"CG-011",
"CG-018",
"CG-031",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013",
"UX-001",
"UX-002",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "自统一母线 origin/hel-870-assembly @ 31970c4 起的前端补丁(仅 web/):1) 接口核实:后端没有 /api/sync;现有只读 GET /api/messages(按行 id 倒序分页、limit+1 探测、next_before,主动行带 proactive:true,见 src/cybergril/api/app.py:399-414 与 life/public.py:26-34)已足够,未改后端、未造假服务。2) web/src/adapters/inbox.ts:打开页面期间每 20 秒(标签页隐藏时停读,回到页面或网络恢复时立即读一次;失败安静退避至最长 2 分钟)读取比已持有行更新的记录,长时间离开会有界回翻;只接收她的主动行(proactive、companion、sent),你自己的轮次及她的回复按 turn_id 已在页面上,绝不重复插入;重复读取、重连、epoch 变化返回同一 id 不再插入。未读规则:进入交谈且页面可见时,已进入页面记录的主动留言都算已读(单句在舞台呈现;多句从此刻留言卡或「回她」进入时打开完整记录并定位第一句;直接点导航「交谈」时舞台只呈现最新一句,较早一句在记录里);交谈中排队、尚未呈现的不算已读;标记为本浏览器 localStorage cg.real.seen(最新已读 id),不写服务器;本浏览器首次加载时既有历史一律视为已读,不把旧历史标成刚来的新消息。3) web/src/app/RealApp.tsx:此刻页沿用已认可的留言卡(letter)——有未读时显示「她留给你的 · 还没看」与她的原话(多句时显示「N 句还没看」),无未读时「最近说过的话」用既有 .letter.read 状态;导航「交谈」沿用既有未读微光点(.nav-item.unread .badge);日子/设置页沿用演示已认可的主动留言卡(.reach),换成真实原话、去掉「模拟」,只有「回她 / 稍后再看」,绝不自行打开交流;一句未读回她时在舞台呈现,多句时打开完整记录并定位第一句未读。4) web/src/chat/ChatPanel.tsx:交谈中到达的新留言先排队,在她正在回答、你暂停或回看、完整记录/全文打开、鼠标停在台词上、当前最后一句尚未停留满时都不插入;条件满足后作为新一轮按既有新轮呈现动效进入;被你的阅读卡住时状态行只用既有文字样式说明「她刚又说了一句 · 继续后呈现 / 读完这段呈现」;草稿不受影响;离开交谈时排队留言并入记录并保持未读。5) 测试与证据:web/tests/inbox.test.ts(隔离 HTTP 合同 fixture,7 项);web/tests/hel875_mock.mjs + hel875_shots.mjs(隔离持久消息 mock + 无头 Edge,25 项浏览器检查、两主题电脑/手机截图与三段帧序列)。普通入口与 ?demo=1 完全分开:演示的「模拟主动消息」未改、未搬入真实入口,演示入口不请求 /api/messages。未新增 CSS、颜色、动效或组件(构建 CSS 产物哈希与改动前一致 index-DdDYzR5S.css)。",
"acceptance": [
"页面打开时收到服务器新主动留言:此刻页留言卡换成原话并显示未读,导航「交谈」亮未读点;日子/设置页出现主动留言卡;从不自行打开交流(浏览器检查 2–3、9)",
"关闭重开:完整记录、行 id 唯一不重复;未读跨重开保留;本浏览器首次加载既有历史不算新消息(浏览器检查 1、7;inbox.test unread 3 项)",
"重复读取、epoch 递增、503 失败后重连:只保留一份、画面无报错提示(浏览器检查 2;inbox.test 第 1 项)",
"不抢断:她回答中到达的留言等回答结束且最后一句停留满后才作为新一轮进入;暂停时停在你的那一句并以文字说明,继续后进入;草稿保持不变(浏览器检查 4–5,帧序列 m2-admit)",
"读过按明确规则清未读:进入交谈且页面可见时清除(排队未呈现的除外);稍后再看只收起卡片、未读点保留;多句未读时打开记录并定位第一句(浏览器检查 3、8、9)",
"只做网页内文字:未申请麦克风、摄像头或通知权限(浏览器检查计数为 0),无外部推送",
"工程:web tsc 0 错;eslint src 0 错(1 条既有警告在 ChatPanel 全文按钮焦点归还处,非本卡引入);vitest 6 文件 49 项全过(原 42 + 新 7);vite build 通过"
],
"deferred": [
"真机手机与 Safari 未测;手机为 390×844 等效视口,不冒充真机(UX-001)",
"未读标记只存本浏览器;同一账号换浏览器或清除站点数据后,既有留言按首次加载规则视为已读;多标签页之间不实时同步已读(下一次读取时各自按自己的标记)。若需要跨设备已读,须后端增加已读字段,此为最小后端需求,交总管决定,本卡未改后端",
"轮询为 20 秒;若要即时到达须后端推送通道(SSE/WebSocket),属后端合同变更,本卡未做",
"她在你等待回复期间生成的主动留言,服务器行 id 可能落在你那一轮的用户行与回复行之间;本页面先呈现回复再呈现留言,重开后按服务器 id 顺序显示,记录内容一致、顺序可能与本次所见不同",
"日子页电脑 1440×900 下,既有 .reach 卡与既有「她此刻」人物窗说明相距很近(未重叠);位置沿用既有样式,如需调整属新视觉,先出样图",
"R5 语音连续对话、语音开启时主动开口;R6 天气/素材包/备份;R7 有条件视觉观察——均未接,界面诚实文案不变",
"用户整体沉浸体验判断与验收由白栖知决定;本卡为功能接线,工程通过不代表项目完成"
],
"requirement_changes": [],
"verification": [
"实际读取提交 31970c4dd3851aae67a922b03264743d504b8e97(gitea 归档下载,origin/hel-870-assembly 分支头 API 核对一致);已读 AGENTS.md、docs/requirements/README.md、current.md v1.11、runbook-hel870.md §5/§7、handoff-HEL-870.json 及后端 /api/messages 与 reach_out 实现",
"web:tsc --noEmit 0;eslint src 0 错;vitest run 49/49;vite build 成功(依赖按 package-lock 由本机既有 npm 缓存离线铺设,176 个包,与此前 npm ci 一致,无新增依赖、无下载)",
"浏览器:node tests/hel875_mock.mjs 8875 + node tests/hel875_shots.mjs <msedge.exe> <out> 8875,25/25 通过;截图覆盖月光/雾光 × 电脑 1440×900 / 手机 390×844",
"需求结构检查:本运行环境无 Python 与 git,check_requirements.py 由逐行移植的 Node 版本执行,比较目标为 gitea 只读取得的 origin/main 与 origin/agent/agent/de8874a3803b,各含/不含本交接;结果见交付评论。Python 原脚本须由总管/总工在有 Python 的环境复跑"
]
}
-51
View File
@@ -1,51 +0,0 @@
{
"baseline_version": "1.11",
"read_commit": "31970c4dd3851aae67a922b03264743d504b8e97",
"task": "HEL-876:核清 .12 已有语音底座真实状态并把 R5 语音接入工程材料备齐;同步只读核对 R6 接缝;零真实模型/嵌入/合成/识别请求,不改现网",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-022",
"CG-023",
"CG-028",
"CG-029",
"CG-030",
"CG-031",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "docs/voice-r5-readiness-HEL876.md(带时间戳的 .12 只读核查:7880/10095/8090 存活证据、8081=voice worker 的身份修正、9880 TTS 于 2026-10-24 22:17 优雅停机未运行、8010 MuseTalk 单元已不存在、8188 ComfyUI 占 26.6GB 显存致空闲仅 3.9GB<所需 5.6GB、音色表 11 支与当前 rain、两个 venv 版本、.11 网关与 open-meteo 探活;R5 worker 复用 app.py:488 同一 chat 链的接缝与协议;R6 城市天气/素材/备份四处接缝定位;三栏结论与零成本工程清单)+ scripts/verify_hel876_voice_seam.py 离线零模型协议检查(六项全过:epoch 同步、完整轮含 receipt、replay 零重复调用、received 确认、barge-in 新轮取消旧行=cancelled、409 重同步)。src/ 与 web/ 零改动,无新产品 PR。",
"acceptance": [
"端口/进程/健康证据全部为本轮 2026-10-26 13:22-13:38 UTC 实测,未照抄 8090/8081 旧解释:8081 实为 livekit-agents worker(pid 451883),MuseTalk 8010 单元已不存在,TTS 9880 停机(tts.log 优雅停机于 10-24 22:17)",
"凭据与 token 未读取未输出;.env 仅列白名单字段名;证书/防火墙/系统环境未改;未启停任何服务;未访问 4080;零真实 LLM 对话/嵌入/合成/识别请求",
"语音 worker 复用同一条业务链的结论落到文件/行为级:chat 链六项协议性质(epoch/串行/取消/replay/receipt/断开清理)以 fake provider 离线证明,全部通过;明确该证明不等于端到端真实通话通过",
"R5 材料备齐:目录/端口/worker 方案、复用依赖与版本、启动顺序与 TTS 重启命令、音色候选清单(无用户决定则不替定,D1 附试听/克隆真实入口及 TTS 停机期间不可试听的如实说明)、D3 麦克风用户动作、GPU 时段协调项",
"R6 只读核对只定位不实现:城市(ManagePanel.tsx:182 占位、后端无代码)、天气(无代码、open-meteo 出网已证)、素材 manifest(仅演示态 :178/:183、后端无代码)、备份边界(companion.db+vec_public+mem0_public_history.db+嵌入账本,复制单 .db 不算完整;Persona.fingerprint 可作同一位她校验)",
"三栏结论如实:已证实/未验证/确实缺资源(GPU 3.9GB<5.6GB 为唯一硬缺口);零成本工程清单六项;文档独立分支提交、候选 PR 不合并"
],
"deferred": [
"R5 真实验收:连续 3 轮、开口打断 ~1s 停声、结束即停麦、无残音、移动端麦克风——TTS 恢复并接好 worker 后在 R5 施工卡执行",
"TTS 重启:需 GPU 空闲≥6GB(ComfyUI 释放协调),由总管与白栖知安排时段,本卡未执行任何启停",
"D1 音色最终决定与 D3 用户设备信任操作:交总管集中沟通;D2 天气源采纳、D4 视觉观察仍按原依赖待用户决策",
"R6 城市/天气/素材/备份的实现(本卡仅定位接缝):建议总管按 §7 零成本清单连续派工",
"统一候选独立审核部署(8360)与语音产品代码增量(/api/voice/session、HTTPS 口、前端语音按钮):后续卡"
],
"requirement_changes": [],
"verification": [
"SSH 只读核查 2026-10-26 13:22-13:38 UTC(ss/ps/systemctl/docker/nvidia-smi/curl/日志尾部/源码阅读),未做任何写操作",
"scripts/verify_hel876_voice_seam.py 于 3.11 venv(uv 离线缓存安装,零下载)+ requirements.lock 全过六项;ruff 0 错",
"check_requirements.py 双目标(origin/main 与 origin/hel-870-assembly)含本 handoff 退出码 0",
"未验证并如实声明:本轮端到端语音可用性(TTS 停机)、真机移动端麦克风、TTS 恢复后延迟"
]
}
+62
View File
@@ -0,0 +1,62 @@
{
"baseline_version": "1.11",
"read_commit": "1ae243f286b14789289e0bdca77eec818074c879",
"task": "HEL-880 返工(父卡 HEL-875):补齐长时间离开页面后的主动留言续读——有界分页读取未覆盖旧游标时不再把游标推进到最新行,保留缺口并在后续读取中续读,所有主动留言按 id 顺序且仅一次进入;只改前端逻辑与测试/交接,不改界面、CSS、后端、已读政策,不增加推送",
"requirement_ids": [
"CG-008",
"CG-011",
"CG-018",
"CG-031",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013",
"UX-001",
"UX-002",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "自候选 hel-875-proactive-wiring @ 1ae243f 起的前端补丁(仅 web/ 与本交接):1) 缺陷:web/src/adapters/inbox.ts 的 fetchAfter 每次最多读 5 页×50 条,未读到旧游标就把本次见到的最新 id 作为 newest 返回,RealApp 随即推进游标,缺口内较早的主动留言永远不再读取(总管 fixture:页面持有 id=1,id=2 为主动留言,普通记录至 id=301,arrivals 为空)。另:after=0 时只读首页即停,超过 50 条同样漏读。2) 修复:游标改为 InboxCursor { through, gap }——through 以下全部已读并已交出;有界读取停在中途时 gap 记住下一次从哪一行(before)往下续读、已读到哪一行(top),以及途中读到的她的主动留言(held,只留主动行,内存有界)。下一次调用先沿 gap 往下续读,碰到 through 或记录开头即一次性按 id 顺序交出 held,through 推进到 top;若这个缺口是之前留下的,同一次调用再读一次最新页取新到的记录。读取失败或中止时整体抛出,不改调用方游标,下次从同一位置重来;不以扩大上限掩盖。较新的留言只等待尚欠的读取(每次 250 条,缺口未补齐时 RealApp 1 秒后续读,而非 20 秒),不会因最旧记录永久卡住。3) RealApp.tsx:游标存 InboxCursor,成功才提交;缺口存在时 1 秒后续读,失败退避与隐藏时停读不变。去重、排序(arrivals 不变,交出顺序随 through 单调递增)、交谈中排队不抢断、草稿、未读规则 cg.real.seen、演示入口隔离均未改。ChatPanel、CSS、后端、服务、档案均未改;构建 CSS 产物仍为 index-DdDYzR5S.css。4) 测试:web/tests/inbox.test.ts 改用新游标并新增 6 项(总管原 fixture 精确复现、1200 条散布 13 句主动留言、after=0 超过 50 条、分页第 3 页失败后恢复、追读期间又有新消息、重复读取/epoch 变化/页面已持有不重复);web/tests/hel875_mock.mjs 增 /__bulk/<n>,hel875_shots.mjs 增 8b 长时间离开步骤(2 项)。",
"acceptance": [
"总管 fixture(持有 id=1;id=2 主动留言;普通记录至 301):首次读取不交出、游标保留缺口 before=52/top=301;第二次读取交出 id=2,游标推进至 301(inbox.test「HEL-875 review fixture」)",
"超过 250 条:1200 条中散布的 13 句主动留言全部按 id 顺序各进入一次,追平后再读不新增(inbox.test)",
"after=0 且超过 50 条:读过首页继续续读,两句主动留言全部进入(inbox.test)",
"分页中途失败:调用抛出、页面游标与失败前完全一致;恢复后补齐全部留言(inbox.test)",
"分页途中又有新消息:先交出缺口内较早的,再交出新到的,各一次(inbox.test)",
"重复读取同一游标结果一致、epoch 变化不影响、页面已持有的不再加入(inbox.test)",
"无头 Edge:长时间离开(中间 320 条普通记录)回到页面后此刻留言卡显示「2 句还没看」,记录里各一句、较早者在前并定位到较早一句(浏览器检查 8b);原 25 项检查全部仍通过",
"工程:tsc 0 错;eslint src 0 错(1 条既有警告,非本卡);vitest 6 文件 55/55(原 49 + 新 6);vite build 通过"
],
"deferred": [
"原版 Python 需求检查未在本运行时执行(无 Python、无 git);须总管/总工在有 Python 的环境对 origin/hel-870-assembly、origin/main 与原产品目标 agent/agent/de8874a3803b 含/不含本交接复跑",
"真机手机与 Safari 未测;手机为 390×844 等效视口(UX-001)",
"缺口追读期间较新的主动留言要等缺口补齐才出现(每秒续读 250 条;例如离开期间 1 万条约需 40 秒);若需即时到达须后端推送或按 id 正向分页(after 参数),属后端合同变更,本卡未做",
"页面若在缺口补齐前关闭或刷新,重开时按完整记录加载,不丢失;但 held 不跨页面保存",
"HEL-875 既有承接项不变:未读标记只存本浏览器、20 秒轮询、等待回复期间的留言顺序说明、日子页 .reach 位置等,见 handoff-HEL-875.json",
"R5 语音、R6 天气/素材/备份、R7 有条件视觉观察仍由主卡 HEL-797 承接;用户整体沉浸体验验收由白栖知决定"
],
"requirement_changes": [],
"verification": [
"实际读取提交 1ae243f286b14789289e0bdca77eec818074c879(gitea 归档下载 zip SHA256 E55C5E95B7DF575D56E305961E29A85B46CCBE963818271754208641C1FCE87C);已读 AGENTS.md、docs/requirements/README.md、current.md v1.11、coverage.md、changes.md、sources/integration-plan-v1.md、handoff-HEL-875.json 及后端 GET /api/messages(api/app.py:id < before 倒序、limit+1 探测、next_before 为页内最旧 id)",
"web:tsc --noEmit 0;eslint src 0 错;vitest run 55/55;vite build 成功(node_modules 自 HEL-875 同一 package-lock 树离线复制,无新增依赖、无下载)",
"浏览器:node tests/hel875_mock.mjs 8880 + node tests/hel875_shots.mjs <msedge.exe> <out> 8880,27/27 通过(含新增 8b 两项)",
"需求结构检查:仅用 HEL-875 留下的 Node 移植版作辅助参考(基准文件经 gitea 只读取得)——origin/main、agent/agent/de8874a3803b、hel-870-assembly@31970c4(本候选实际基点)各含/不含本交接均通过;对 hel-870-assembly 现头 e978e878 失败,原因是该分支在候选之后合入 HEL-874、追加了来源 comment-evidence-full-experience-20261026.json,候选尚未包含,与本补丁无关,合并前需先同步该分支。不代表原 Python 脚本通过"
]
}
-79
View File
@@ -1,79 +0,0 @@
# HEL-876 语音底座核验与 R5/R6 施工材料(2026-10-26)
> 性质:**只读核查 + 离线协议检查**。零真实 LLM 请求(仅 GET /v1/models 与 GET /health 类探活)、零嵌入、零 TTS 合成与 ASR 识别请求、未采集麦克风/摄像头、未下载/安装、未启停或修改 .11/.12 任何服务与配置、未访问 4080、未动三账与任何档案。服务器凭据与 token 一律未读取输出。
> 基准:v1.11;统一候选 `origin/hel-870-assembly` @ `31970c4dd3851aae67a922b03264743d504b8e97`;.12 核查时间 **2026-10-26 13:22–13:38 UTC**;离线检查时间 2026-10-26 13:5x UTC。
> 主卡语境:白栖知原话 `01a1780e-6954-73ad-91da-3a5d81831a07`——工程取舍由团队承担,先交连贯体验。本卡即 R5 前的工程核清。
## 1. .12 实测端口真相(修正旧解释,全部本轮带时间戳实测)
| 端口 | 实测身份(2026-10-26) | 健康 | 与旧说法的差异 |
| --- | --- | --- | --- |
| 7880/7881 | LiveKit SFU(docker `voice-livekit`,host 网络,up 10 天) | HTTP 200 | 与旧一致 |
| 10095 | FunASR 2pass 流式 ASR(docker `voice-funasr`,funasr-runtime-sdk-online-cpu-0.1.13,up 10 天) | HTTP GET → **426 Upgrade Required**(WS-only 存活证据) | 与旧一致 |
| 8081 | **voice-agent 的 livekit-agents worker**(`/opt/voice-agent/agent` `agent.py start`,pid 451883,自 2026-10-18 22:32 运行) | HTTP 200 | **修正**:不是"历史口型试验件" |
| 8090 | `/opt/voice-agent/web` `server.py`(HTTPS 自签通话页+音色管理+token 签发,pid 2136834,2026-10-25 21:49 启动) | `curl -sk` 200 text/html | 与旧一致(音色管理页即此端口) |
| 9880 | CosyVoice3 流式 TTS(`/opt/voice-agent/cosyvoice/server.py`) | **未运行**:`curl /health` 连接拒绝 | **TTS 已停**(见下) |
| 8010 | MuseTalk 口型试验件 | **systemd 单元已不存在**(could not be found) | 旧报告"运行中"过期 |
| 8188 | **ComfyUI**(`/media/lucian/Downloads/ComfyUI`,2026-10-24 20:26 起,非本项目服务) | 监听中 | 新事实,旧方案未记载 |
| 8300/8310/8320/8340/8350 | CyberGril S1/R1/R2/R3(guarded) 各实例 | 全部运行(与 runbook-hel870 §8 一致) | 无变化 |
| 8360 | 空闲 | — | 统一候选建议端口可用 |
**TTS 停机细节**(tts.log):最后一次真实合成 2026-10-24 22:17:54(首包 2.585s,正常),随后收到**优雅停机**("Shutting down…Finished server process",非崩溃、非 OOM 报错)。同窗口 agent.log 记录了一次完整真实通话(2026-10-24 22:16–22:18 UTC 14:16–14:18:用户语音进来 → 字幕 → TTS 回复 → 用户文字打断 → 回复 → 用户挂断 CLIENT_INITIATED → job 干净退出)。这证明**该底座在 10-24 当天端到端真实可用过**;本轮未复验端到端(TTS 停机,不冒充已验证)。
## 2. 资源与版本(复用清单)
- **GPU:RTX 5090 D 32GB,已用 28.2GB(ComfyUI 26.6GB + gnome-remote-desktop 1GB),空闲仅 3.9GB**。CosyVoice 常驻需 ~5.6GB → **TTS 当前无法重启**,这是唯一的硬资源缺口。ComfyUI 属服务器桌面用户进程,本队无权也不应擅停;需总管与白栖知协调语音测试时段(或等 ComfyUI 空闲释放显存)。内存 94GB(可用 69GB)、根盘 916GB(用 16%)充足。
- agent venv(可直接复用):livekit-agents 1.8.8、livekit-plugins-silero 1.8.8、livekit-plugins-openai 1.8.8、livekit 1.1.20、websockets 17.2、httpx 0.28.1。
- cosyvoice venv:torch 2.8.0+cu128(Blackwell/sm_120 必需)、transformers 4.51.3 + huggingface_hub 0.30.2 + diffusers 0.29.0(README 记载三者必须锁这套组合)、fastapi 0.143.2。**重启 TTS 命令**(README 运维节,本卡未执行):`cd /opt/voice-agent/cosyvoice && fuser -k 9880/tcp; (nohup venv/bin/python server.py > tts.log 2>&1 &); curl localhost:9880/health`。
- 模型权重在位:`/opt/models/CosyVoice3-0.5B-2512`(9.1GB)、`/opt/models/funasr`(ASR 四件套)。
- `.env` 白名单字段名(值未读取):`LLM_BASE_URL/LLM_MODEL/LLM_API_KEY/TTS_VOICE/LIVEKIT_KEYS/LIVEKIT_URL/FUNASR_WS/COSYVOICE_URL`。
- .11:8731 网关探活 200:`halogen-qwen3.8-flash-next`(262K 上下文)——worker 的 LLM 由 CyberGril API 承担后,worker 不再直连它。
- **open-meteo 出网实测 200(2.28s)**——R6 天气可行性今日已证(integration-plan D2 的先测条件满足)。
## 3. 业务链复用核验(防"有声但无记忆的第二位她")
统一候选唯一对话入口 `src/cybergril/api/app.py:488 chat()` 已含全部业务语义:epoch 409 同步(:491)、`chat_lock` 轮次串行 + 不同 turn 自动取消活动轮(:505-509)、完成轮幂等 replay(:510-522)、user/companion 双行落 `chat_message`、system = `persona.prompt()`+身份关系+mood 提示词+dyad 提示+记忆/语义通道+生活分享候选(:539-552)、SSE `chat.delta/chat.final/chat.error`、回复接收确认 receipt(`sharing.prepare` :641;`/api/chat/received` :455)、完成即 `interrupt_for_chat()+dyad.ritual()`(:647-649)、断开/取消 → user 行标 `cancelled`(:668-674)。
**结论:语音 worker 不自带任何人格/记忆/LLM。** 推荐接缝(最小新代码):
1. 新适配器 `cybergril_llm.py`(livekit `llm.LLM` 子类,参照 `stt_funasr.py`/`tts_cosyvoice.py` 的同款写法):`chat()` = 先 `GET /api/health` 取 epoch → `POST /api/chat {text, turn_id(uuid), epoch}` → 把 SSE `chat.delta` 逐帧转为 ChatChunk。409 → 重读 epoch 重试一次——与前端 `web/src/adapters/real.ts` `RealChatAdapter` **同一协议、零分叉**。
2. worker 组装照抄 `/opt/voice-agent/agent/agent.py`:`FunASRSTT` + `CyberGrilLLM` + `CosyVoiceTTS(default_voice=…)` + silero VAD;**新 `agent_name`(如 `cybergril-voice-agent`)**,避免与 8081 的通用 worker 争抢派发。字幕上报、persona_state.json 读取等通用助手逻辑全部去掉。
3. 打断:VAD → AgentSession 默认 allow_interruptions 截断 TTS;用户新话轮即新 turn_id,服务端 chat 链自动取消旧轮(离线已证,见下)。
4. 结束停麦:浏览器挂断 → participant disconnect → job 退出(agent.log 10-24 实测此路径);麦克风由页面 stop track 释放。
5. 失败:`chat.error` 的 φ 文案("她这会儿没接上话,稍后再试试")由 worker 原句念出,不新造话术;receipt 在 TTS 播完后由 worker 调 `/api/chat/received` 回执。
6. 音色:worker 读独立 voice_state(如 `voice_cybergril.json`),不动通用页的 `voice_state.json`。
**离线协议检查(零模型)已跑通**:`scripts/verify_hel876_voice_seam.py`(3.11 venv + requirements.lock,fake provider)全部通过——① health epoch + `capabilities.voice=false` 诚实上报;② 完整轮 delta 流 + `chat.final` 带 receipt(message_id/turn_id/epoch/token/text_hash/expires_at);③ 完成轮 replay 只回存档 final、**模型调用数不变**;④ receipt 经 `/api/chat/received` 确认;⑤ barge-in 新轮取消活动轮:旧行=cancelled、无幽灵 companion 行、下一轮正常;⑥ 旧 epoch 409+新 epoch 重同步。**这不等于真实通话通过**:连续 3 轮、~1s 停声、结束即停麦无残音、打断后无旧音频,仍属 R5 真实验收(integration-plan R5 验收四条)。
## 4. R5 施工卡备齐材料
- **目录/端口**:`/opt/voice-agent-cybergril/`(独立目录:`cybergril_llm.py`、`worker.py`、`.env`、`restart_cybergril.sh` 仿 `restart_agent.sh`);复用 7880/10095/9880 与两个 venv,**8090 与通用 worker 原样不动**。统一 CyberGril 实例建议 8360(已核空闲);语音页需 HTTPS(麦克风 secure context):给统一实例加第二 HTTPS 端口(如 8361,uvicorn ssl,自签证书按 8090 已验证流程)或由统一 API 新增 `POST /api/voice/session`(建房+派发+token,照抄 `web/server.py` 同名逻辑,密钥不进浏览器)——推荐后者+前端语音按钮,属 R5 卡的产品代码增量。
- **启动顺序**(R5 卡执行时):统一实例(8360) → TTS(9880,前置 GPU≥6GB 空闲) → cybergril worker(`restart_cybergril.sh`) → 浏览器 HTTPS 入口。
- **音色(D1,无白栖知决定,不替用户定)**:现有中文女声候选 6 支——`default`(官方女声兜底)、`female_soft`(沉稳知性)、`xiaoyun`(俏皮)、`rain`、`jiaojiao`(后两支参考音频为亲密向内容,风格强烈)、`bailu`(阳光开朗;当前 persona_state 恰有用户自存的"晚晚/白鹿"角色)。`male_warm` 实为英文男声,不适合。**试听与克隆真实入口**:`https://192.168.200.12:8090` 音色管理面板(录音 5–20s 或上传 ≤15MB WAV,参考文本留空自动 FunASR 转写;零样本克隆的"像不像"取决于中文参考音频质量)——**当前因 TTS 停机暂不可试听**,恢复后即可用。若 6 支都不满意,录一段 5–20s 中文参考音频克隆是关键路径。
- **麦克风可信访问(D3)用户动作**(由总管集中转达):每台设备首次打开 `https://192.168.200.12:<语音端口>` → 浏览器"高级→继续访问"信任自签证书 → 允许麦克风。不建内网 CA、不买公网证书、不改防火墙(8090 已验证此路可行)。
- **GPU 协调**:TTS 重启前置 = ComfyVoice 需 ~5.6GB 而空闲仅 3.9GB;需与服务器桌面用户(ComfyUI)协调时段,属总管对白栖知的沟通项,本队不擅停他人进程。
## 5. R6 接缝只读核对(只定位,不实现)
- **城市设定**:前端占位 `web/src/manage/ManagePanel.tsx:182`(真实模式全禁用+诚实文案+候选城市列表);后端**无任何**城市存储/端点。缺口:settings 持久化 + `GET /api/env`(时段/天气)。
- **天气客户端**:后端无代码;顶栏诚实显示"天气尚未接通"(`web/src/app/RealApp.tsx:52`)。open-meteo 出网已证(§2),D2 推荐路线可直落。
- **素材 manifest/导入**:仅演示态 JSON 只读检查(`ManagePanel.tsx:178` `importMaterial`);真实模式列 5 项"待检查"(:183);后端无 manifest/appearance_id/导入代码。缺口:manifest v1 schema + 结构/时长/命名校验 + 仅换 `appearance_id`(CG-006:身份卡/八字/记忆不变)。**CG-030 首轮素材由小队制作**,接入点即此,不推给用户。
- **完整备份边界(不能"复制单个 .db"冒充)**:数据面 = `companion.db`(SQLite WAL 单文件,`memory/store.py`)**加上** 语义层目录 `vector_root/vec_public`(qdrant on-disk)与 `history_root/mem0_public_history.db`(`memory/semantic.py:156-185`、`config/semantic.example.yaml`),另有外部权威嵌入账本 sqlite。完整导出须含 DB(WAL 需 checkpoint 或含 -wal/-shm)+ 语义目录 + 设定 + 素材引用;恢复校验"同一位她"复用 `Persona.fingerprint`(`life/persona.py:42`,pillars_hash)。当前统一候选语义=stub/off,备份产品化可先行支持 DB+目录结构,mem0 激活后同一清单自动覆盖。
## 6. 三栏结论
| 已证实(本轮带证据) | 未验证(不冒充) | 确实缺资源 |
| --- | --- | --- |
| LiveKit/FunASR/8090/8081 worker 存活与健康协议;TTS 优雅停机于 10-24 22:17;底座 10-16 与 10-24 两次真实端到端史(日志在机);.11 网关探活;open-meteo 出网;11 支音色与当前选中 rain;两个 venv 精确版本;8360 空闲;chat 链六项协议性质(离线零模型证明);R6 四处接缝与缺口精确定位 | **本轮端到端语音可用性**(TTS 停机,未连麦);连续 3 轮/打断 ~1s 停声/结束停麦无残音(R5 真实验收);TTS 重启后首包是否仍 1.7–2.5s;8361 HTTPS 口实际签发;移动端 Safari/Chrome 麦克风流(未实测) | **GPU 显存**:空闲 3.9GB < CosyVoice ~5.6GB,TTS 无法重启,需协调 ComfyUI 释放(用户桌面进程,不擅停);其余(CPU/内存/盘/出网/权重)均不缺 |
## 7. 现在零成本可做的工程(零真实请求)
1. `cybergril_llm.py` worker 适配器 + worker 骨架(离线 fake provider 可全测,参照本卡脚本)。
2. 统一 API `POST /api/voice/session` 端点 + 测试(建房/派发/token,照抄 web/server.py)。
3. 统一实例 HTTPS 第二端口(自签同 8090 流程)与前端语音按钮接线(视觉总监按已认可语言设计呈现)。
4. R6:open-meteo 客户端 + 城市存储 + `GET /api/env`(出网已证)。
5. R6:素材 manifest v1 schema + 离线校验器 + 测试;备份导出/恢复产品化(先覆盖 DB+目录,mem0 边界如实标注)。
## 8. 交付边界
本卡未改任何产品代码(`src/`、`web/` 零改动),无新产品 PR;新增仅为本文档、`scripts/verify_hel876_voice_seam.py` 离线检查脚本与 `docs/requirements/handoff-HEL-876.json`,独立分支提交,候选 PR 不合并。真实语音验收(连续 3 轮、打断、停麦、无残音)与 R6 实现属后续卡;D1(音色)与 D3(麦克风)用户动作、GPU 时段协调由总管统一与白栖知沟通。
-121
View File
@@ -1,121 +0,0 @@
"""HEL-876 offline protocol check for the R5 voice-worker seam (zero model / zero embed / zero TTS / zero ASR).
Proves, against the unified-candidate API with a fake LLM provider, that a voice
worker reusing POST /api/chat gets the exact contract the text adapter already
uses: epoch resync (409), serialized turns, mid-stream cancel (client disconnect
-> row marked cancelled, next turn clean), idempotent replay of a completed turn
(no second model call), and the receive-confirmation receipt round trip.
Run: PYTHONPATH=src .venv311/bin/python scripts/verify_hel876_voice_seam.py
"""
from __future__ import annotations
import json
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
from fastapi.testclient import TestClient # noqa: E402
from test_r2 import R2LLM # noqa: E402
from cybergril.api.app import create_app # noqa: E402
from cybergril.life.runtime import LifeConfig # noqa: E402
from cybergril.think.router import build_router # noqa: E402
from cybergril.think.scenes import Tier # noqa: E402
CREATE = {"name": "协议检查", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
T1, T2, T3, T4 = (str(uuid.uuid4()) for _ in range(4))
def sse_frames(response) -> list[dict]:
frames = []
for line in response.iter_lines():
if line.startswith("data: "):
frames.append(json.loads(line[6:]))
return frames
def main() -> int:
provider = R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
tmp = Path(tempfile.mkdtemp(prefix="hel876-seam-"))
app = create_app(db_path=tmp / "seam.db", router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="stub")
ok = []
with TestClient(app) as client:
# 1. epoch discovery (worker startup: GET /api/health)
health = client.get("/api/health").json()
epoch = health["epoch"]
assert health["capabilities"]["voice"] is False, "voice must stay honestly false"
ok.append(f"health epoch={epoch}, voice=false")
# 2. companion exists (worker does NOT create a second her)
client.post("/api/companion", json=CREATE)
assert client.get("/api/companion").status_code == 200
# 3. full turn: deltas then final with receipt
r = client.post("/api/chat", json={"text": "你好呀", "turn_id": T1, "epoch": epoch})
frames = sse_frames(r)
deltas = [f["delta"] for f in frames if f["t"] == "chat.delta"]
final = [f for f in frames if f["t"] == "chat.final"]
assert deltas and "".join(deltas) == final[0]["utterance"]["text"]
assert final[0]["receipt"], "final must carry the receive-confirmation receipt"
receipt = final[0]["receipt"]
ok.append(f"turn v-1 streamed {len(deltas)} deltas + final(receipt keys={sorted(receipt)})")
calls_after_turn = len(provider.calls)
# 4. replay of the completed turn: no second model call
r2 = client.post("/api/chat", json={"text": "你好呀", "turn_id": T1, "epoch": epoch})
frames2 = sse_frames(r2)
assert [f["t"] for f in frames2] == ["chat.final"], frames2
assert len(provider.calls) == calls_after_turn, "replay must not call the model"
ok.append("replay of v-1 returns saved final only, model calls unchanged")
# 5. receive confirmation (worker acks the receipt after TTS playback)
ack = client.post("/api/chat/received", json={k: receipt[k] for k in
("message_id", "turn_id", "epoch", "token", "text_hash")})
assert ack.status_code == 200 and ack.json() == {"accepted": True}
ok.append("receipt acknowledged via /api/chat/received")
# 6. mid-stream interruption: a new turn (barge-in) cancels the active one
provider.mode = "slow" # reply hangs after first delta
def slow_turn() -> None:
with client.stream("POST", "/api/chat",
json={"text": "说一半我打断你", "turn_id": T2, "epoch": epoch}) as s:
for _ in s.iter_lines():
pass
slow = threading.Thread(target=slow_turn, daemon=True)
slow.start()
time.sleep(1.0) # first delta arrives, turn v-2 is active and hanging
r3 = client.post("/api/chat", json={"text": "还在吗", "turn_id": T3, "epoch": epoch})
assert [f["t"] for f in sse_frames(r3)][-1] == "chat.final"
slow.join(timeout=10)
provider.mode = ""
messages = client.get("/api/messages").json()["messages"]
v2 = [m for m in messages if m["turn_id"] == T2]
v3 = [m for m in messages if m["turn_id"] == T3]
assert v2 and all(m["status"] == "cancelled" for m in v2), v2
assert not [m for m in v2 if m["role"] == "companion"], "no phantom companion row for aborted turn"
assert any(m["status"] == "sent" and m["role"] == "companion" for m in v3)
ok.append("barge-in new turn cancels active v-2 (row=cancelled, no companion row); v-3 completes")
# 8. epoch mismatch -> 409 with fresh epoch (worker resyncs like real.ts)
stale = client.post("/api/chat", json={"text": "旧纪元", "turn_id": T4, "epoch": epoch + 999})
assert stale.status_code == 409 and "epoch" in stale.json()
ok.append("stale epoch rejected with 409 + current epoch for resync")
print("HEL-876 voice-seam offline protocol check: ALL PASS")
for line in ok:
print(" ✓", line)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+73
View File
@@ -0,0 +1,73 @@
import type { Message } from '../domain/model';
import { api, toMessage, within, type MessagePage } from './real';
// ── Her own messages arriving while the page is open (R4 reach-out, CG-011). ──
// The server keeps every reach-out as a chat_message row with proactive=1 and serves it from GET /api/messages with
// ascending ids; there is no push channel, so an open page reads the newest rows now and then. Only text inside the
// page: no microphone, notification or push permission is ever asked for.
// Server ids are positive integers; local, not yet confirmed messages carry random ids and never count.
export const serverId = (m: Message) => /^[1-9]\d{0,18}$/u.test(m.id) ? Number(m.id) : 0;
export const newestId = (messages: Message[]) => messages.reduce((top, m) => Math.max(top, serverId(m)), 0);
const newestProactive = (messages: Message[]) => messages.reduce((top, m) => m.proactive ? Math.max(top, serverId(m)) : top, 0);
// Where reading stands. Every row up to `through` has been read and handed over. The server only pages backwards from
// the newest row, so after a long absence one bounded read may stop short: then `gap` remembers it. Rows from `before`
// up to `top` are read, and her reach-outs among them are `held`; rows between `through` and `before` are not read yet.
export interface InboxCursor { through: number; gap: { before: number; top: number; held: Message[] } | null }
export const startAt = (through: number): InboxCursor => ({ through, gap: null });
// Her reach-outs newer than the cursor, oldest first, and where the next read starts. At most `pages` pages per call.
// A read that stops short hands over nothing yet: the next call goes on down from where it stopped (before looking for
// anything newer), and when it meets `through` everything held is handed over at once, in id order, exactly once. So an
// older reach-out is never skipped, and newer ones wait only for the reads still owed, never for good. A failed or
// aborted read throws and leaves the cursor it was given untouched; the same call simply runs again.
export async function fetchAfter(cursor: InboxCursor, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; cursor: InboxCursor }> {
const rows: Message[] = [];
let through = cursor.through, gap = cursor.gap && { ...cursor.gap, held: [...cursor.gap.held] }, carried = !!gap;
for (let i = 0; i < pages; i++) {
const page: MessagePage = await api(`messages?limit=50${gap ? `&before=${gap.before}` : ''}`, { signal: within(signal, 10000) });
const list = Array.isArray(page.messages) ? page.messages.filter(m => Number.isSafeInteger(m?.id) && m.id > 0 && typeof m.text === 'string') : [];
const fresh = list.filter(m => m.id > through), opened = !gap;
gap ??= { before: 0, top: list.reduce((top, m) => Math.max(top, m.id), through), held: [] };
gap.held.unshift(...fresh.filter(m => m.proactive === true).map(toMessage));
// Not yet down to what was read before, and more of the record below: keep the gap for the next page or call.
if (fresh.length === list.length && page.next_before && Number.isSafeInteger(page.next_before)) { gap.before = page.next_before; continue; }
rows.push(...gap.held); through = gap.top; gap = null;
// A gap left by an earlier call is closed; look once for anything newer. Otherwise the newest rows were read.
if (opened || !carried) break;
carried = false;
}
return { rows, cursor: { through, gap } };
}
// What may be added from a poll: her own reach-out rows the page does not hold yet. Your own turns (and her replies to
// them) are already on the page under their turn ids and come in through the chat stream, so they are never added
// twice; a repeated read, a reconnect or a restarted server epoch returns the same ids and adds nothing.
export function arrivals(known: Message[], rows: Message[]): Message[] {
const ids = new Set(known.map(m => m.id)), turns = new Set(known.flatMap(m => m.turnId ? [`${m.role}:${m.turnId}`] : []));
const out: Message[] = [];
for (const m of [...rows].sort((a, b) => serverId(a) - serverId(b))) {
if (!m.proactive || m.role !== 'companion' || m.status !== 'sent' || !m.text.trim() || ids.has(m.id) || (m.turnId && turns.has(`companion:${m.turnId}`))) continue;
ids.add(m.id); out.push(m);
}
return out;
}
// ── Unread. One rule: a reach-out that is in the page's record is read once you are in the conversation with the
// page visible (one plays on the stage; several open the record at the first). One still queued is not. The mark is
// the newest seen id, kept in this browser only; it never changes the server. ──
const SEEN = 'cg.real.seen';
export function storedSeen(): number | null {
try { const raw = localStorage.getItem(SEEN); if (raw === null || !/^\d{1,19}$/u.test(raw)) return null; const v = Number(raw); return Number.isSafeInteger(v) ? v : null; } catch { return null; }
}
export function storeSeen(id: number) { try { localStorage.setItem(SEEN, String(id)); } catch { /* The mark stays in memory for this visit. */ } }
// First visit from this browser: everything already in the record is history, not "just arrived".
export function initialSeen(messages: Message[]): number {
const stored = storedSeen();
if (stored !== null) return stored;
const seen = newestProactive(messages);
storeSeen(seen);
return seen;
}
export const unreadOf = (messages: Message[], seen: number) => messages.filter(m => m.proactive && serverId(m) > seen);
export const seenThrough = newestProactive;
+8 -5
View File
@@ -121,7 +121,7 @@ export interface PersonaView {
export const loadPersona = (signal: AbortSignal) => api<PersonaView>('persona', { signal: within(signal, 10000) });
// Creation-time generation is bounded server-side (one model call, 30 s cap); give the explicit retry room for it.
export const retryPersona = (signal: AbortSignal) => api<PersonaView>('persona/retry', { method: 'POST', signal: within(signal, 90000) });
function within(signal: AbortSignal, ms: number) {
export function within(signal: AbortSignal, ms: number) {
const c = new AbortController(), stop = () => c.abort(), timer = setTimeout(stop, ms);
signal.addEventListener('abort', () => { clearTimeout(timer); stop(); }, { once: true });
return c.signal;
@@ -144,15 +144,18 @@ export async function loadReal(): Promise<DemoState | null> {
try { settings.reduced = JSON.parse(localStorage.getItem('cg.real.display') || '{}').reduced === true; } catch { /* Invalid local preference does not affect server data. */ }
return { version: 1, identity, settings, events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() };
}
export interface ServerMessage { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status']; lifeIds?: number[]; proactive?: boolean }
export interface MessagePage { messages: ServerMessage[]; next_before: number | null; epoch?: number }
// lifeIds arrive already trimmed by the server to records she has told; older servers omit them.
// proactive arrives only on her own persisted reach-out rows (R4); older servers omit it.
export const toMessage = (m: ServerMessage): Message => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: ints(m.lifeIds) ? m.lifeIds.map(String) : [], turnId: m.turn_id, status: m.status, ...(m.proactive === true ? { proactive: true } : {}) });
export async function loadMessages(): Promise<Message[]> {
// Cursor pages are bounded by the server. All pages restore in deterministic id order.
const messages: Message[] = [];
let before: number | null = null;
do {
const page: { messages: { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status']; lifeIds?: number[]; proactive?: boolean }[]; next_before: number | null } = await api(`messages?limit=200${before ? `&before=${before}` : ''}`);
// lifeIds arrive already trimmed by the server to records she has told; older servers omit them.
// proactive arrives only on her own persisted reach-out rows (R4); older servers omit it.
messages.unshift(...page.messages.map(m => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: ints(m.lifeIds) ? m.lifeIds.map(String) : [], turnId: m.turn_id, status: m.status, ...(m.proactive === true ? { proactive: true } : {}) })));
const page: MessagePage = await api(`messages?limit=200${before ? `&before=${before}` : ''}`);
messages.unshift(...page.messages.map(toMessage));
before = page.next_before;
} while (before);
return messages;
+58 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, lifeStatus, loadReal, memoryNote, type Health } from '../adapters/real';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, unreadOf } from '../adapters/inbox';
import type { DemoState, Message, Theme } from '../domain/model';
import { Presenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
@@ -16,9 +17,14 @@ export function RealApp() {
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const [herFocus, setHerFocus] = useState(0);
const latest = useRef(state); latest.current = state;
// ── Her reach-outs while the page is open (inbox.ts). `cursor` is how far this page has read the server record;
// `incoming` holds what arrived during a conversation until the stage lets it in; `seen` is the unread mark. ──
const cursor = useRef(startAt(0)), [incoming, setIncoming] = useState<Message[]>([]), held = useRef(incoming); held.current = incoming;
const [seen, setSeen] = useState(0), [fresh, setFresh] = useState<Message | null>(null), [visible, setVisible] = useState(!document.hidden);
const areaNow = useRef(area); areaNow.current = area;
const initialize = useCallback(async () => {
setError(''); setReady(false);
try { setHealth(await api<Health>('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); }
try { setHealth(await api<Health>('health')); const next = await loadReal(); cursor.current = startAt(next ? newestId(next.messages) : 0); setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); }
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
}, []);
useEffect(() => { void initialize(); }, [initialize]);
@@ -28,6 +34,51 @@ export function RealApp() {
useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
// Read the newest rows every 20 s while the page is shown, at once on return or reconnect. A failed read is quiet
// (the next one simply tries again, slower); nothing on screen changes until a new row actually exists. A read that
// stopped short of what was read before goes on after a second, until it has caught up.
const live = !!state && !creating && ready;
useEffect(() => {
if (!live) return;
let timer: ReturnType<typeof setTimeout> | undefined, running = false, failures = 0, stopped = false, controller = new AbortController();
const poll = async () => {
if (running || stopped || document.hidden) return;
running = true; clearTimeout(timer); controller = new AbortController();
try {
const { rows, cursor: next } = await fetchAfter(cursor.current, controller.signal);
if (stopped) return;
cursor.current = next; failures = 0;
const add = latest.current ? arrivals([...latest.current.messages, ...held.current], rows) : [];
if (add.length && latest.current) {
if (areaNow.current === 'talk') setIncoming(list => [...list, ...add]);
else { changeMessages([...latest.current.messages, ...add]); setFresh(add[add.length - 1]); }
}
} catch { failures++; }
finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : cursor.current.gap ? 1000 : 20000); }
};
const wake = () => { setVisible(!document.hidden); if (!document.hidden) void poll(); };
document.addEventListener('visibilitychange', wake); addEventListener('online', wake);
timer = setTimeout(() => void poll(), 20000);
return () => { stopped = true; controller.abort(); clearTimeout(timer); document.removeEventListener('visibilitychange', wake); removeEventListener('online', wake); };
}, [live, changeMessages]);
// Leaving the conversation before the stage let them in: they join the record and stay unread.
useEffect(() => { if (area !== 'talk' && incoming.length && latest.current) { const ids = new Set(latest.current.messages.map(m => m.id)); changeMessages([...latest.current.messages, ...incoming.filter(m => !ids.has(m.id))]); setIncoming([]); } }, [area, incoming, changeMessages]);
const admitted = useCallback((ids: string[]) => setIncoming(list => list.filter(m => !ids.includes(m.id))), []);
// The one unread rule: on the conversation with the page visible, what the page's record holds has been seen
// (a message still queued in `incoming` is not in it yet).
useEffect(() => {
if (area !== 'talk' || !visible || !state) return;
const top = seenThrough(state.messages);
if (top > seen) { setSeen(top); storeSeen(top); }
setFresh(null);
}, [area, visible, state, seen]);
const unread = useMemo(() => state ? unreadOf(state.messages, seen) : [], [state, seen]);
// On 此刻 the letter already carries her words; the card speaks up only over 日子 and 设置. It keeps its words while
// it fades out.
const card = !!fresh && (area === 'days' || area === 'settings'), shownFresh = useRef<Message | null>(null); if (fresh) shownFresh.current = fresh;
useEffect(() => { if (fresh && !unread.some(m => m.id === fresh.id)) setFresh(null); }, [fresh, unread]);
// Reply to her: one new message plays on the stage; several open the record at the first one you have not seen.
const openUnread = () => { setFresh(null); const first = unread[0]; leaveSettingsThen(() => { setArea('talk'); setFocusLife(''); setLog(unread.length > 1); setFocusIds(unread.length > 1 && first ? [first.id] : []); }); };
const ignoreVoice = useCallback(() => {}, []);
// Status words follow the server, re-read when you open a page that shows them; a failed read keeps the last one.
const life = lifeStatus(health);
@@ -53,10 +104,12 @@ export function RealApp() {
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
{!creating && state && <>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · {life.label} <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">{memoryNote(health, life)}</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife={focusLife} focusIds={focusIds} onReceipt={acknowledged}/>}
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · {life.label} <Icon name="next"/></button>{unread.length ? <button className="letter glass" onClick={openUnread}><span className="lh"><b>她留给你的</b><span>{unread.length > 1 ? `${unread.length} 句还没看` : '还没看'}</span></span><p>{unread[unread.length - 1].text}</p></button> : lastReply && <button className="letter glass read" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">{memoryNote(health, life)}</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife={focusLife} focusIds={focusIds} onReceipt={acknowledged} incoming={incoming} onAdmit={admitted}/>}
<LifePanel real state={state} open={area === 'days'} now={life.now} life={life} ackAt={ackAt} onClose={() => navigate('now')} onChat={openTold} onSaid={openSaid} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/>
<ManagePanel real state={state} open={area === 'settings'} now={life.now} life={life} focus={herFocus} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
{/* Same card as the approved demo reach-out, now with her real words; it never opens the conversation by itself. */}
<aside className={`reach glass ${card ? 'show' : ''}`} aria-hidden={!card} ref={el => { el?.toggleAttribute('inert', !card); }}>{shownFresh.current && <><div className="rh"><b>{state.identity.name}</b>主动说的</div><p>{shownFresh.current.text}</p><div className="acts"><Button primary onClick={openUnread}>回她</Button><Button onClick={() => setFresh(null)}>稍后再看</Button></div></>}</aside>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className={`nav-item ${id === 'talk' && (unread.length || incoming.length) ? 'unread' : ''}`} aria-current={area === id ? 'page' : undefined} aria-label={id === 'talk' && unread.length ? `${label},她有 ${unread.length} 句新留言` : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}<i className="badge"/></button>)}</nav></>}
</>}</>;
}
+17 -5
View File
@@ -15,11 +15,14 @@ const START: Anchor = { mi: 0, offset: 0 };
const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; };
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
const NO_IDS: string[] = [];
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt }: {
const NO_IDS: string[] = [], NO_MESSAGES: Message[] = [];
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt, incoming = NO_MESSAGES, onAdmit }: {
real?: boolean;
// Real only: the server accepted a complete-receipt confirmation (the life it may unlock is decided later).
onReceipt?: () => void;
// Real only: her own messages that reached the open page while you are here, not yet on the stage.
incoming?: Message[];
onAdmit?: (ids: string[]) => void;
state: DemoState;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
@@ -72,6 +75,11 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
if (cursor > headIndex) head.current = anchor;
const held = useRef(new Set<string>()), clock = useRef(new ReadingClock());
const reading = useRef({ pages, cursor, flow }); reading.current = { pages, cursor, flow };
// Her own message that arrives while you are here waits its turn: never while she is answering you, a sheet is
// open, you paused or went back, or the line on screen has not had its time. Then it comes in as a new turn.
const waiting = useRef({ count: 0, busy: false, sheet: false }), admit = useRef(() => {});
waiting.current = { count: incoming.length, busy, sheet: log || full };
admit.current = () => { const add = incoming.filter(m => !live.current.some(k => k.id === m.id)); onAdmit?.(incoming.map(m => m.id)); if (add.length) change([...live.current, ...add]); };
function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
@@ -119,8 +127,10 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
let raf = 0;
const c = clock.current, away = () => c.pause();
const tick = (now: number) => {
const { pages: list, cursor: at, flow: on } = reading.current;
if (c.tick(now, list[at], on && !held.current.size && !document.hidden) && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); }
const { pages: list, cursor: at, flow: on } = reading.current, w = waiting.current;
const running = on && !held.current.size && !document.hidden, done = c.tick(now, list[at], running);
if (done && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); }
else if (w.count && !w.busy && !w.sheet && running && at >= list.length - 1 && (done || !list.length)) { w.count = 0; admit.current(); }
if (meter.current) meter.current.style.transform = `scaleX(${c.progress})`;
raf = requestAnimationFrame(tick);
};
@@ -214,7 +224,9 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
}
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); };
const lastUser = [...view.messages].reverse().find(m => m.role === 'user');
const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : !pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型');
// Only says why her new words wait when it is your own reading that holds them; otherwise they simply come in.
const queued = incoming.length && !busy ? (!flow ? `${name}刚又说了一句 · 继续后呈现` : !atEnd ? `${name}刚又说了一句 · 读完这段呈现` : '') : '';
const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : queued || (!pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型'));
const unread = pages.length - 1 - cursor, single = pages.length <= 1 && !busy, fullMessage = page?.message;
const textarea = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]);
+65
View File
@@ -0,0 +1,65 @@
// HEL-875 isolated contract mock for her reach-outs on an open page. Serves web/dist and the persisted-message
// contract of the HEL-870 backend: GET /api/messages pages newest-first by row id (limit+1 probe, rows oldest-first,
// next_before) and marks only her reach-out rows proactive:true (api/app.py, life/public.py). Synthetic data only:
// no model, no embeddings, no network.
// Usage: node tests/hel875_mock.mjs <port>. Control: POST-free GETs /__reach?text=, /__epoch, /__down/<n>, /__bulk/<n>, /__log.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const dist = fileURLToPath(new URL('../dist/', import.meta.url));
const port = Number(process.argv[2] || 8875);
const T0 = Date.UTC(2026, 9, 25, 12, 0);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
let epoch = 3, down = 0;
const log = [], rows = [];
const add = row => { const id = rows.length + 1; rows.push({ id, ts: T0 + id * 60e3, status: 'sent', lifeIds: [], ...row }); return id; };
// History: two turns and one reach-out she left long before this browser first opened the page.
add({ role: 'user', text: '下雨天的时候,你会想听什么?', turn_id: '00000000-0000-4000-8000-000000000001' });
add({ role: 'companion', text: '会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累?', turn_id: '00000000-0000-4000-8000-000000000001' });
add({ role: 'companion', text: '昨晚的月亮很亮,想起你说过喜欢夜里散步。', turn_id: 'reach-old', proactive: true });
add({ role: 'user', text: '今天加班到九点,好累。', turn_id: '00000000-0000-4000-8000-000000000002' });
add({ role: 'companion', text: '那先别急着做别的,喝口热水,慢慢跟我说。', turn_id: '00000000-0000-4000-8000-000000000002' });
const dto = r => ({ id: r.id, ts: r.ts, role: r.role, text: r.text, turn_id: r.turn_id, status: r.status, lifeIds: r.lifeIds, ...(r.proactive ? { proactive: true } : {}) });
const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x'), p = url.pathname;
if (!p.startsWith('/__')) log.push(`${req.method} ${p}${url.search}`);
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/__rows') return send(res, 200, rows.map(dto));
if (p === '/__reach') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '窗外的雨停了,想跟你说一声。', turn_id: `reach-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); }
// HEL-880: a long absence elsewhere — n plain turns (no reach-outs) land at once.
if (p.startsWith('/__bulk/')) { const n = Number(p.slice(8)); for (let i = 0; i < n; i++) add({ role: i % 2 ? 'companion' : 'user', text: `别处的第${i + 1}句`, turn_id: `bulk-${rows.length + 1}` }); return send(res, 200, { top: rows.length }); }
if (p === '/__epoch') { epoch++; return send(res, 200, { epoch }); }
if (p.startsWith('/__down/')) { down = Number(p.slice(8)); return send(res, 200, { down }); }
if (p === '/api/health') return send(res, 200, { ok: true, version: 'R4-assembly', epoch, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true, mood: true, reach_out: true, semantic: false, semantic_backend: null }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, life: false, voice: false, vision: false, weather: false, materials: false, backup: false, memory: true, semantic_memory: null } });
if (p === '/api/companion') return send(res, 200, identity);
if (p === '/api/messages') {
if (down > 0) { down--; return send(res, 503, { detail: 'Service Unavailable' }); }
const limit = Math.min(200, Number(url.searchParams.get('limit') || 200)), before = Number(url.searchParams.get('before') || Number.MAX_SAFE_INTEGER);
const desc = rows.filter(r => r.id < before).sort((a, b) => b.id - a.id).slice(0, limit + 1), more = desc.length > limit, page = desc.slice(0, limit);
return send(res, 200, { messages: [...page].reverse().map(dto), next_before: more ? page[page.length - 1].id : null, epoch });
}
if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
if (p === '/api/life') return send(res, 200, { events: [], next_before: null, epoch });
if (p === '/api/memories') return send(res, 200, { memories: [], next_before: null, epoch });
if (p === '/api/chat' && req.method === 'POST') {
let body = ''; for await (const chunk of req) body += chunk;
const { text: said, turn_id } = JSON.parse(body), reply = '我在。今天的雨停了没有?要是还在下,就慢点回家。';
add({ role: 'user', text: said, turn_id, status: 'pending' });
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
// A slow stream (about 4 s), so a reach-out can land in the middle of her answer.
for (const part of reply.match(/.{1,4}/gu)) { res.write(`data: ${JSON.stringify({ t: 'chat.delta', epoch, turn_id, delta: part })}\n\n`); await sleep(330); }
add({ role: 'companion', text: reply, turn_id }); rows.forEach(r => { if (r.turn_id === turn_id) r.status = 'sent'; });
res.end(`data: ${JSON.stringify({ t: 'chat.final', epoch, turn_id, utterance: { text: reply, speech_tags: [] }, receipt: null })}\n\n`);
return;
}
if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
const file = normalize(join(dist, p === '/' ? 'index.html' : p));
if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
}).listen(port, '127.0.0.1', () => console.log(`hel875 mock on ${port}`));
+166
View File
@@ -0,0 +1,166 @@
// HEL-875 browser check: drives headless Edge over CDP against tests/hel875_mock.mjs (reach-outs on an open page).
// Usage: node tests/hel875_shots.mjs <edge.exe> <outDir> [port]. Prints one PASS/FAIL line per check, exits 1 on any FAIL.
// A read is triggered the way a returning tab triggers it (visibilitychange), instead of waiting the 20 s interval.
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const [edge, out, portArg] = process.argv.slice(2);
const port = Number(portArg || 8875), base = `http://127.0.0.1:${port}`, cdpPort = 9475;
mkdirSync(out, { recursive: true });
const profile = join(tmpdir(), `hel875-edge-${Date.now()}`);
const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', 'about:blank'], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let seq = 0; const waiting = new Map();
ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
const cdp = (method, params = {}) => new Promise((ok, ko) => { const id = ++seq; waiting.set(id, { ok, ko }); ws.send(JSON.stringify({ id, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
const ctl = async path => (await fetch(`${base}${path}`)).json();
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.click(); return true; })()`);
const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].map(e => e.innerText).join('\\n')`);
const exists = sel => js(`!!document.querySelector(${JSON.stringify(sel)})`);
const said = () => js(`document.querySelector('.conversation .sr')?.textContent || ''`);
const status = () => js(`document.querySelector('.talk-status')?.innerText || ''`);
const read = async () => { await js(`document.dispatchEvent(new Event('visibilitychange')), true`); await sleep(700); };
const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
async function boot(theme, { fresh = false } = {}) {
await cdp('Page.navigate', { url: `${base}/` }); await sleep(500);
await js(`${fresh ? 'localStorage.clear();' : ''} localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`);
await cdp('Page.navigate', { url: `${base}/` }); await sleep(1800);
}
// Frames for the motion evidence: every ~80 ms for `ms`.
async function frames(name, ms) { const list = []; const end = Date.now() + ms; while (Date.now() < end) { const { data } = await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 }); list.push(data); } list.forEach((d, i) => writeFileSync(join(out, `${name}-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64'))); return list.length; }
await cdp('Page.enable'); await cdp('Runtime.enable');
// Count any request for microphone, camera or notifications.
await cdp('Page.addScriptToEvaluateOnNewDocument', { source: `window.__asked = 0; try { const md = navigator.mediaDevices; if (md) md.getUserMedia = () => { window.__asked++; return Promise.reject(new Error('blocked')); }; } catch {} try { if (window.Notification) Notification.requestPermission = () => { window.__asked++; return Promise.resolve('denied'); }; } catch {}` });
// ── 1. First visit: the old reach-out in the record is history, not new. ──
await view('desktop'); await boot('moon', { fresh: true }); await ctl('/__log');
check('first visit marks existing history as seen', await js(`localStorage.getItem('cg.real.seen')`) === '3' && !(await exists('.nav-item.unread')) && (await text('.home .letter')).includes('最近说过的话'), await js(`localStorage.getItem('cg.real.seen')`));
await shot('01-moon-desktop-home-history');
// ── 2. A reach-out lands while the page is open on the home view. ──
await ctl('/__reach?text=' + encodeURIComponent('窗外的雨停了,想跟你说一声。'));
await js(`document.dispatchEvent(new Event('visibilitychange')), true`);
await frames('m1-reach-in', 1100);
check('on 此刻 the letter takes her real words with the nav mark; no second card', (await text('.home .letter')).includes('窗外的雨停了') && (await text('.home .letter')).includes('还没看') && await exists('.nav-item.unread') && !(await exists('.reach.show')));
check('it does not open the conversation by itself', await js(`!document.body.classList.contains('talk')`));
await shot('02-moon-desktop-home-letter');
// Repeated reads, a restarted epoch and a failed read add nothing and show nothing.
await read(); await read(); await ctl('/__epoch'); await read(); await ctl('/__down/1'); await read(); await read();
check('repeat/epoch/reconnect reads keep one copy and no error on screen', !(await exists('.toast.show')) && (await text('.home')).split('窗外的雨停了').length === 2);
// ── 3. Over 日子 the card speaks up; reply to her: the stage presents it; then it counts as seen. ──
await click('.nav-item', '日子'); await sleep(1300);
check('over 日子 the reach-out card shows her real words', (await text('.reach.show')).includes('窗外的雨停了'));
await shot('02b-moon-desktop-days-reach');
await click('.reach .btn-primary', '回她'); await sleep(1500);
check('回她 opens the conversation at her message', (await said()).includes('窗外的雨停了'), await said());
check('seen once shown on the stage: mark cleared, stored id advanced', !(await exists('.nav-item.unread')) && await js(`localStorage.getItem('cg.real.seen')`) === '6');
await shot('03-moon-desktop-talk-reach');
await click('.log-btn'); await sleep(900);
check('record holds it once, tagged as hers', (await text('.log-panel .scroll')).split('窗外的雨停了').length === 2 && (await text('.log-panel')).includes('她主动说的'));
await click('.log-panel .btn-round'); await sleep(600);
// ── 4. A reach-out during her answer waits; your draft stays. ──
await js(`(() => { const t = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, '我刚到家'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
await js(`document.querySelector('.send').click(), true`); await sleep(1200);
await js(`(() => { const t = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, '还没写完的草稿'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
await ctl('/__reach?text=' + encodeURIComponent('刚才楼下有人在弹吉他,弹得很慢。'));
await read();
const during = await said();
check('while she is answering, the new reach-out does not cut in', !during.includes('弹吉他'),`${during} | ${await status()}`);
await shot('04-moon-desktop-during-reply');
// Wait for the stream to end and her last line to have its time; then it comes in on its own.
// Frames are kept around the moment it enters: the last ones before and about 1.2 s after.
const film = []; let arrived = false;
for (let i = 0; i < 160 && !arrived; i++) { film.push((await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 })).data); arrived = (await said()).includes('弹吉他'); }
for (const end = Date.now() + 1200; Date.now() < end;) film.push((await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 })).data);
film.slice(-16).forEach((d, i) => writeFileSync(join(out, `m2-admit-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64')));
check('after her answer has had its time, the reach-out comes in as a new turn', arrived);
check('draft untouched', await js(`document.querySelector('textarea').value`) === '还没写完的草稿');
await shot('05-moon-desktop-admitted');
// ── 5. Paused reading holds it, with a quiet reason; resuming lets it in. ──
await click('.reader-bar .flow'); await sleep(400);
await ctl('/__reach?text=' + encodeURIComponent('今天的晚霞是粉色的。')); await read(); await sleep(1500);
check('paused: stays on your line and says why', !(await said()).includes('晚霞') && (await status()).includes('继续后呈现'), await status());
await shot('06-moon-desktop-paused-held');
await click('.reader-bar .flow'); arrived = false; for (let i = 0; i < 30 && !arrived; i++) { await sleep(500); arrived = (await said()).includes('晚霞'); }
check('resume lets it in', arrived);
// ── 6. Leaving the conversation with one held: it joins the record as unread. ──
await click('.reader-bar .flow'); await sleep(300);
await ctl('/__reach?text=' + encodeURIComponent('我把书签夹在第三十页了。')); await read(); await sleep(600);
await click('.nav-item', '此刻'); await sleep(1200);
check('held one becomes unread on leaving', await exists('.nav-item.unread') && (await text('.home .letter')).includes('书签'));
await shot('07-moon-desktop-home-unread');
// ── 7. Reopen: same record, no duplicates, unread still unread. ──
const before = await ctl('/__rows');
await cdp('Page.reload'); await sleep(2000);
const ids = await js(`(async () => (await (await fetch('/api/messages?limit=200')).json()).messages.map(m => m.id))()`);
check('reopen: full record, unique ids', ids.length === before.length && new Set(ids).size === ids.length, `${ids.length} rows`);
check('reopen: unread survives a reload', await exists('.nav-item.unread') && (await text('.home .letter')).includes('书签'));
// ── 8. Two unread: 回她/letter opens the record at the first one. ──
await ctl('/__reach?text=' + encodeURIComponent('还有,明天可能降温。')); await read();
check('two unread counted on the letter', (await text('.home .letter')).includes('2 句还没看'));
await click('.home .letter'); await sleep(1300);
check('several unread open the record at the first', await js(`!!document.querySelector('.log-panel:not([hidden]) .message.hl')`) && (await text('.log-panel .message.hl')).includes('书签'));
await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900);
check('all seen after opening the record', !(await exists('.nav-item.unread')));
// ── 8b. HEL-880: back after a long absence — more rows than one bounded read (5 × 50). The older reach-out is not
// skipped; both arrive once, in order, after the follow-up reads. ──
await ctl('/__reach?text=' + encodeURIComponent('你走之后我把灯调暗了一点。')); await ctl('/__bulk/320');
await ctl('/__reach?text=' + encodeURIComponent('回来了就好。')); await ctl('/__log');
await read(); await sleep(2500);
const caught = await text('.home .letter'), reads = (await ctl('/__log')).filter(l => l.includes('/api/messages')).length;
check('long absence: the older reach-out is not skipped; both counted once', caught.includes('2 句还没看') && caught.includes('回来了就好'), `${caught.replace(/\n/g, ' ')} | ${reads} reads`);
await click('.home .letter'); await sleep(1300);
const absent = await text('.log-panel .scroll');
check('long absence: record holds each once, older one first and opened at', absent.split('灯调暗').length === 2 && absent.split('回来了就好').length === 2 && absent.indexOf('灯调暗') < absent.indexOf('回来了就好') && (await text('.log-panel .message.hl')).includes('灯调暗'));
await shot('08b-moon-desktop-long-absence');
await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900);
check('no microphone, camera or notification request', await js('window.__asked') === 0);
// ── 9. Evidence: both themes, desktop and phone, with a fresh reach-out on home and in the days pane. ──
for (const theme of ['moon', 'fog']) for (const v of ['desktop', 'phone']) {
await view(v); await boot(theme);
await click('.nav-item', '日子'); await sleep(1300);
await ctl('/__reach?text=' + encodeURIComponent('雨停了,窗台上还有一小洼水。')); await read(); await sleep(500);
await shot(`10-${theme}-${v}-days-reach`);
await click('.reach button', '稍后再看'); await sleep(700);
check(`${theme}/${v}: 稍后再看 hides the card, the mark stays`, !(await exists('.reach.show')) && await exists('.nav-item.unread'));
await click('.nav-item', '此刻'); await sleep(1200);
await shot(`11-${theme}-${v}-home-unread`);
await click('.nav-item', '交谈'); await sleep(1600);
await shot(`12-${theme}-${v}-talk`);
await click('.nav-item', '此刻'); await sleep(800);
}
await view('phone'); await boot('fog');
await click('.nav-item', '日子'); await sleep(1200);
await ctl('/__reach?text=' + encodeURIComponent('我在窗边等你。')); await read(); await sleep(400);
await frames('m3-reach-paned-phone', 900);
await shot('13-fog-phone-days-reach');
// ── 10. The demo entry stays separate: no real message reads. ──
await ctl('/__log');
await cdp('Page.navigate', { url: `${base}/?demo=1` }); await sleep(2500);
check('demo entry makes no real message requests', !(await ctl('/__log')).some(l => l.includes('/api/messages')));
ws.close(); browser.kill();
try { rmSync(profile, { recursive: true, force: true }); } catch { /* Edge may still hold the profile. */ }
const failed = results.filter(r => !r).length;
console.log(`${results.length - failed}/${results.length} passed`);
process.exit(failed ? 1 : 0);
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, storedSeen, unreadOf, type InboxCursor } from '../src/adapters/inbox';
import { loadMessages, type ServerMessage } from '../src/adapters/real';
import type { Message } from '../src/domain/model';
// An isolated copy of the persisted-message contract (api/app.py GET /api/messages + life/public.py message()):
// newest first by id, limit+1 probe, rows returned oldest-first, next_before = oldest id on the page when more exist.
// No model, no embeddings, no network.
class Archive {
rows: ServerMessage[] = []; epoch = 1; calls: string[] = []; down = 0;
add(row: Partial<ServerMessage> & { role: ServerMessage['role'] }) { const id = this.rows.length + 1; this.rows.push({ id, text: `第${id}条`, turn_id: `t${id}`, status: 'sent', lifeIds: [], ...row } as ServerMessage); return id; }
reach(text = '窗外的雨停了,想跟你说一声。') { return this.add({ role: 'companion', text, proactive: true, turn_id: `reach-${this.rows.length + 1}` }); }
fetch = vi.fn(async (url: string) => {
this.calls.push(url);
if (this.down) { this.down--; throw new TypeError('Failed to fetch'); }
const u = new URL(url, 'http://x'), limit = Number(u.searchParams.get('limit') || 200), before = Number(u.searchParams.get('before') || Number.MAX_SAFE_INTEGER);
const desc = this.rows.filter(r => r.id < before).sort((a, b) => b.id - a.id).slice(0, limit + 1), more = desc.length > limit, page = desc.slice(0, limit);
return new Response(JSON.stringify({ messages: [...page].reverse(), next_before: more ? page[page.length - 1].id : null, epoch: this.epoch }));
});
}
function storage() { const data = new Map<string, string>(); vi.stubGlobal('localStorage', { getItem: (k: string) => data.has(k) ? data.get(k)! : null, setItem: (k: string, v: string) => void data.set(k, v) }); return data; }
const signal = () => new AbortController().signal;
// One poll the way RealApp runs it: read after the cursor, commit it only when the read succeeded, keep only what the
// page does not hold.
type Page = { messages: Message[]; cursor: InboxCursor };
async function poll(page: Page) {
const { rows, cursor } = await fetchAfter(page.cursor, signal());
page.cursor = cursor;
const add = arrivals(page.messages, rows); page.messages = [...page.messages, ...add];
return add;
}
// Polls again while a gap is open, as RealApp does a second later; returns everything that arrived, in order.
async function catchUp(page: Page, most = 50) {
const all: Message[] = [];
for (let i = 0; i < most; i++) { all.push(...await poll(page)); if (!page.cursor.gap) return all; }
throw new Error('never caught up');
}
const opened = (messages: Message[]): Page => ({ messages, cursor: startAt(newestId(messages)) });
afterEach(() => vi.unstubAllGlobals());
describe('reach-outs reaching an open page', () => {
it('adds a new reach-out once; repeated reads, a restarted epoch and a reconnect add nothing more', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user', turn_id: 'a' }); server.add({ role: 'companion', turn_id: 'a' });
const page = opened(await loadMessages());
expect(await poll(page)).toEqual([]);
const id = server.reach();
const first = await poll(page);
expect(first.map(m => [m.id, m.proactive, m.role])).toEqual([[String(id), true, 'companion']]);
expect(await poll(page)).toEqual([]);
server.epoch = 2; expect(await poll(page)).toEqual([]);
server.down = 1; await expect(poll(page)).rejects.toThrow(); expect(await poll(page)).toEqual([]);
expect(page.messages.filter(m => m.id === String(id))).toHaveLength(1);
// Reopening reads the same record in the same order: nothing lost, nothing doubled.
expect((await loadMessages()).map(m => m.id)).toEqual(page.messages.map(m => m.id));
});
it('never re-adds your own turn or her reply to it, and ignores rows still being written', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
// The page holds your message and her streamed reply under local ids; the server has them under row ids.
const page = opened([{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }]);
server.add({ role: 'user', turn_id: 'turn-x', text: '在吗' }); server.add({ role: 'companion', turn_id: 'turn-x', text: '在' });
server.add({ role: 'user', turn_id: 'turn-y', status: 'pending' });
server.add({ role: 'companion', proactive: true, turn_id: 'turn-x' });
expect(await poll(page)).toEqual([]);
expect(page.cursor).toEqual(startAt(4));
});
it('pages back after a long absence and keeps the order of what arrived', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach('一')]; for (let i = 0; i < 70; i++) server.add({ role: 'companion' }); ids.push(server.reach('二'));
const added = await poll(page);
expect(added.map(m => m.text)).toEqual(['一', '二']);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(server.calls.filter(c => c.includes('limit=50'))).toHaveLength(2);
});
it('reads from an empty record without paging the whole history', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
const page = opened([]);
server.reach();
expect(await poll(page)).toHaveLength(1);
expect(server.calls).toHaveLength(1);
});
});
// HEL-880: a sleeping tab can come back to more rows than one bounded read covers (5 pages × 50).
describe('catching up after a long absence', () => {
it('keeps the oldest reach-out when more than five pages arrived (HEL-875 review fixture)', async () => {
// Exactly the reviewer's case: the page holds id 1; id 2 is her reach-out; plain rows follow up to id 301.
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
server.reach('最早的那句'); while (server.rows.length < 301) server.add({ role: 'companion' });
const first = await fetchAfter(page.cursor, signal());
expect(first.rows).toEqual([]);
expect(first.cursor).toMatchObject({ through: 1, gap: { before: 52, top: 301 } });
const second = await fetchAfter(first.cursor, signal());
expect(arrivals(page.messages, [...first.rows, ...second.rows]).map(m => m.id)).toEqual(['2']);
expect(second.cursor).toEqual(startAt(301));
});
it('more than 250 rows with reach-outs spread through them: each arrives once, in order, nothing skipped', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages()), ids: number[] = [];
for (let i = 0; i < 1200; i++) { if (i % 97 === 3) ids.push(server.reach(`第${i}句`)); else server.add({ role: i % 2 ? 'companion' : 'user' }); }
const added = await catchUp(page);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(page.cursor).toEqual(startAt(server.rows.length));
expect(await poll(page)).toEqual([]);
expect(page.messages.filter(m => m.proactive)).toHaveLength(ids.length);
});
it('an empty page (cursor 0) facing more than 50 rows reads past the first page', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
const page = opened([]);
const ids = [server.reach('一')]; for (let i = 0; i < 120; i++) server.add({ role: 'user' }); ids.push(server.reach('二'));
expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String));
expect(server.calls).toHaveLength(3);
});
it('a read that fails mid-way commits nothing; the next one resumes from the same place', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach('一')]; for (let i = 0; i < 400; i++) server.add({ role: 'user' }); ids.push(server.reach('二'));
expect(await poll(page)).toEqual([]);
const kept = structuredClone(page.cursor);
// Two pages succeed, the third fails: the call throws and the page keeps the cursor it had.
let left = 2; const ok = server.fetch.getMockImplementation()!;
server.fetch.mockImplementation(async (url: string) => { if (left-- <= 0) throw new TypeError('Failed to fetch'); return ok(url); });
await expect(poll(page)).rejects.toThrow();
expect(page.cursor).toEqual(kept);
server.fetch.mockImplementation(ok);
expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String));
});
it('rows that arrive while it is catching up are read too, after the older ones, once', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach('久别前')]; for (let i = 0; i < 600; i++) server.add({ role: 'companion' });
expect(await poll(page)).toEqual([]);
ids.push(server.reach('追读时新来的')); for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach('最新的'));
const added = await catchUp(page);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(page.cursor).toEqual(startAt(server.rows.length));
});
it('repeated reads and a restarted epoch during catch-up add nothing twice', async () => {
const server = new Archive(); vi.stubGlobal('fetch', server.fetch);
server.add({ role: 'user' });
const page = opened(await loadMessages());
const ids = [server.reach()]; for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach());
const first = await fetchAfter(page.cursor, signal());
// The same cursor read twice (a retried poll) gives the same answer; the epoch changing does not matter.
server.epoch = 2;
const again = await fetchAfter(page.cursor, signal());
expect(again.cursor).toEqual(first.cursor);
page.cursor = first.cursor;
const added = await catchUp(page);
expect(arrivals(added, added)).toEqual([]);
expect(added.map(m => m.id)).toEqual(ids.map(String));
expect(await poll(page)).toEqual([]);
// A reach-out the page already holds (it came in through a reload) is not added again.
const record = await loadMessages(), page2: Page = { messages: [record[0], record.find(m => m.id === String(ids[1]))!], cursor: startAt(1) };
expect((await catchUp(page2)).map(m => m.id)).toEqual([String(ids[0])]);
});
});
describe('unread', () => {
it('first visit from this browser counts the old record as seen; later arrivals stay unread until seen', () => {
const data = storage();
const history: Message[] = [{ id: '3', role: 'companion', text: '旧的', lifeIds: [], proactive: true }, { id: '4', role: 'user', text: '嗯', lifeIds: [] }];
const seen = initialSeen(history);
expect(seen).toBe(3); expect(unreadOf(history, seen)).toEqual([]); expect(data.get('cg.real.seen')).toBe('3');
const later = [...history, { id: '9', role: 'companion' as const, text: '新的', lifeIds: [], proactive: true }];
expect(unreadOf(later, initialSeen(later)).map(m => m.id)).toEqual(['9']);
storeSeen(seenThrough(later)); expect(unreadOf(later, storedSeen()!)).toEqual([]);
});
it('an empty record on first visit leaves a zero mark, so her first reach-out is unread', () => {
storage();
expect(initialSeen([])).toBe(0);
expect(unreadOf([{ id: '1', role: 'companion', text: '你好', lifeIds: [], proactive: true }], initialSeen([]))).toHaveLength(1);
});
it('a damaged or unavailable mark falls back to treating the record as history', () => {
vi.stubGlobal('localStorage', { getItem: () => 'abc', setItem: () => {} });
expect(initialSeen([{ id: '5', role: 'companion', text: 'x', lifeIds: [], proactive: true }])).toBe(5);
vi.stubGlobal('localStorage', { getItem: () => { throw new Error('denied'); }, setItem: () => { throw new Error('denied'); } });
expect(initialSeen([{ id: '6', role: 'companion', text: 'x', lifeIds: [], proactive: true }])).toBe(6);
});
});