Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7279e68f | ||
|
|
8dc84f88e9 | ||
|
|
18aef2a459 | ||
|
|
9254c17316 | ||
|
|
b0dcb17e1a | ||
|
|
9756db3204 | ||
|
|
11fd0e2dc2 | ||
|
|
f92a86f5d8 | ||
|
|
fb7ea846ff | ||
|
|
46ac0a7a20 | ||
|
|
fc905fe6bd | ||
|
|
3228b1e0d7 | ||
|
|
b4dadd82b3 | ||
|
|
7c73d392b9 | ||
|
|
78a5cfd8d7 | ||
|
|
7f0a739213 | ||
|
|
7f66015ac8 | ||
|
|
8afdabc7d1 | ||
|
|
48252b1dcd | ||
|
|
01ff919859 | ||
|
|
c61d9c95c9 | ||
|
|
761a2331f5 | ||
|
|
f259bd34e1 | ||
|
|
b95ce798b4 | ||
|
|
aba2aebeb0 | ||
|
|
a324fd57b7 | ||
|
|
23415fbcd0 |
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
web/node_modules
|
||||
.venv311/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# CyberGril 语音会话端点脱敏配置示例(HEL-877,R5 后台施工)
|
||||
#
|
||||
# 复制为 config/voice.yaml(或用 CYBERGRIL_VOICE_CONFIG 指定路径)后由总管
|
||||
# 按部署值填写;api_key/api_secret 只写服务器本地文件,不进仓库、不进浏览器、
|
||||
# 不进任何日志。环境变量 CYBERGRIL_VOICE_* 可覆盖同名字段。
|
||||
#
|
||||
# 没有这份文件 = 本实例未配置语音:POST /api/voice/session 如实 503 拒绝,
|
||||
# /api/health 的 capabilities.voice 保持 false,不伪造已接通。
|
||||
|
||||
# LiveKit SFU(.12 已有 docker voice-livekit;浏览器接入地址)
|
||||
# 注意:7880 是纯 ws 且被通用 worker/8090 共享,不得改 TLS。页面走 HTTPS 时
|
||||
# 浏览器连 ws:// 属混合内容会被拦——R5 方案是独立的 TLS 桥(拟议,未部署):
|
||||
# deploy/voice/wss_bridge.example.py,livekit_url 用 wss://192.168.200.12:8363
|
||||
# (详见 docs/rework/hel879/unified-review/r5-test-package.md §3)。
|
||||
livekit_url: ws://192.168.200.12:7880
|
||||
# 服务端 Twirp 调用地址(本机回环即可)
|
||||
livekit_internal: http://127.0.0.1:7880
|
||||
# LiveKit API 凭据(与 .12 /opt/voice-agent/.env 中 LIVEKIT_KEYS 同源;此处只放占位符)
|
||||
api_key: <LIVEKIT_API_KEY_PLACEHOLDER>
|
||||
api_secret: <LIVEKIT_API_SECRET_PLACEHOLDER>
|
||||
|
||||
# 专属 worker 的 agent_name:与通用 cascade-voice-agent 区分派发,互不争抢
|
||||
agent_name: cybergril-voice-agent
|
||||
|
||||
# CosyVoice TTS 健康探测(TTS 不在就诚实拒绝开通话,不假装能说话)
|
||||
tts_health_url: http://127.0.0.1:9880/health
|
||||
|
||||
# ---- 自定义音色(HEL-899)----
|
||||
# .12 语音面板(8090,https 自签):音色列表/zero-shot 登记的唯一写入者,
|
||||
# CyberGril 统一 API 服务端代理调用;浏览器永不直连,凭据不外发。
|
||||
panel_url: https://127.0.0.1:8090
|
||||
# CosyVoice 合成入口(试听现合成;/health 的热加载名单用于通话开启时校验)
|
||||
tts_url: http://127.0.0.1:9880
|
||||
# 共享 voices.json 只读回看(补全 ASR 自动转写的参考文本,供备份清单);不配则留空
|
||||
registry_file: /opt/voice-agent/cosyvoice/voices.json
|
||||
# CyberGril 专属 worker 的音色状态文件(worker 会话入口读一次;通话中改选最迟下一通话生效)
|
||||
worker_state_file: /opt/voice-agent-cybergril/voice_state_cybergril.json
|
||||
|
||||
# 浏览器接入 token 有效期(秒):只够加入房间,短时限
|
||||
token_ttl_seconds: 300
|
||||
# 会话登记有效期(秒):房间早空/挂断后登记自然过期
|
||||
session_ttl_seconds: 7200
|
||||
@@ -20,6 +20,10 @@ mem0 同进程导入的准确缺项与不下载解决路径、空账初始化、
|
||||
|
||||
## 2. 安装命令(发布卡执行)
|
||||
|
||||
> **已被取代(HEL-879,2026-10-27)**:本节安装命令缺 `--offline`/`--no-index`
|
||||
> 强制项。唯一照抄入口改为 `deploy/hel895-release-candidate.md` §2。本文件其余
|
||||
> 内容作为历史交付证据保留原样。
|
||||
|
||||
```bash
|
||||
# 全新目录,逐条照抄;任何一步失败即停
|
||||
# 2.1 服务账号与目录(独立可写数据路径,权限边界明确)
|
||||
@@ -63,6 +67,11 @@ Mem0 提取都会在发送前被硬闸拒绝(`budget_*_exhausted`)——这
|
||||
|
||||
## 4. 真实语义激活前置(B 部分批准后;准确缺项与不下载路径)
|
||||
|
||||
> **已被取代(HEL-879,2026-10-27)**:本节 4.1/4.2 按索引拉 `mem0ai==2.2.1`
|
||||
> 同名 wheel,不能保证固定源码身份 2c81af3;固定 SDK 激活的唯一照抄入口改为
|
||||
> `deploy/hel895-release-candidate.md` §4(固定 tarball 哈希 → 固定源码构建轮 →
|
||||
> cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import)。以下保留为历史证据。
|
||||
|
||||
**当前事实(HEL-882 离线核实,tools/check_release_offline_hel882.py R6):**
|
||||
固定安装 hel857-venv 是 Python 3.10.12,产品发布 venv 是 3.11.16,两者
|
||||
site-packages 不能混用;本机 uv/pip 缓存里没有 mem0ai/fastembed/qdrant-client
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# HEL-895 · 统一发布候选(离线核验过,未执行部署)
|
||||
|
||||
> **HEL-879 发布工具收口(2026-10-27,隔离候选分支 `hel-879-assembly`)**:按总管
|
||||
> 对 HEL-896 的独立复核完成四项有限收口——①归档一致性:checkpoint 返回值必查、
|
||||
> 源/快照行数不一致或 checkpoint busy 一律 CLI 非 0 且不形成可交付包,verify 拒绝
|
||||
> 记录了 `rows_match=False` 的旧包(§5);②目录内真实 SQLite(history_root 的
|
||||
> `mem0_public_history.db` 等)停服口径走 checkpoint+逐表核对,在线模式只允许显式
|
||||
> 单库 backup API、含 SQLite 的目录直接拒绝;③提前封账:`tools/seal_budget_hel879.py`
|
||||
> 显式封账(真实用量保留、跨重启/归档拒绝,负例 S6);④安装入口唯一化:本文件 §2/§4
|
||||
> 为唯一照抄入口(全 offline/no-index;mem0ai 只认固定源码轮),`deploy/hel874-release.md`
|
||||
> §2/§4 已标注被取代。产物清单与精确提交见 `docs/rework/hel879/sdk-audit/` 与
|
||||
> `docs/rework/hel879/release-artifacts.json`。**不要用旧父提交(04eff61/152479b)
|
||||
> 的说明冒充本候选 head:以交付评论与 handoff JSON 记录的精确提交为准。**
|
||||
|
||||
> **HEL-896 修订(2026-10-27,正常追加 PR#11)**:按总管独立复审补齐——①浏览器
|
||||
> 「真实服务 7/7」中 B3 原为已证实误报,测试已修为精确交互+严格断言(9 项检查,
|
||||
> 含权限 API 调用计数与非回环出站守卫,证据 `docs/rework/hel896/browser/`);
|
||||
@@ -16,6 +28,16 @@ HEL-881 已审核的主动留言前端(`bf234570`)装成一个统一候选
|
||||
|
||||
## 0. 精确 head 与整合方式
|
||||
|
||||
> **HEL-879 返修勘误(2026-10-27)**:本节以下原文记录的是 **HEL-895/896 历史
|
||||
> 候选**(分支 `hel-870-assembly`,PR #11 未合并),其 299 pytest / 55 vitest 等
|
||||
> 验证数字属于那个历史 head,**不是当前发布候选**。当前统一发布候选是分支
|
||||
> `hel-879-assembly`(在 7f0a739 之后正常追加返修提交,不强推、不并入 PR #11),
|
||||
> 其精确 head、pytest/vitest/build 全量数字与证据以**交付评论与
|
||||
> `docs/requirements/handoff-HEL-879.json` 记录为准**;本文件的安装/检查入口
|
||||
> (§2/§4/§5/§6)继续适用于当前候选,产物清单见
|
||||
> `docs/rework/hel879/sdk-audit/` 与 `docs/rework/hel879/release-artifacts.json`
|
||||
> (`rework_head` 字段)。历史叙述保留原样,不冒充、不改写。
|
||||
|
||||
- **候选 head**:见交付评论(分支 `hel-870-assembly`,PR #11 的正常追加提交,
|
||||
未强推、未合并)。第一父为 HEL-894 通过复审的 `152479b3503c3d1ab27681170e6bfd592c54380a`,
|
||||
合并入 HEL-881 已审核的 `bf234570c0b6595a72f8dbc79e42c63685e08e01`
|
||||
@@ -51,13 +73,36 @@ HEL-881 已审核的主动留言前端(`bf234570`)装成一个统一候选
|
||||
| 预算执行包 | `docs/budget-package-hel895.md`(含 §6 HEL-896 分账修订) | A/B/C 一次批准核定稿,默认 0 |
|
||||
| HEL-896 工具 | `tools/verify_budget_split_hel896.py`、`tools/audit_sdk_offline_hel896.py`、`tools/release_snapshot_hel896.py`、修订版 `web/tests/hel895_real.mjs` | 分账切换验证、SDK 余缺只读审计、一致性快照/回退验证、严格浏览器检查 |
|
||||
|
||||
## 2. 安装命令(发布卡执行)
|
||||
## 2. 安装命令(发布卡执行;本节是唯一照抄入口——HEL-879 收口)
|
||||
|
||||
与 `deploy/hel874-release.md` §2 完全相同(服务账号 → 全新空目录 clone 本 head →
|
||||
`uv venv --python 3.11` + `uv pip install -r requirements.lock` → `npm ci &&
|
||||
npm run build` → 安装 service → `curl /api/health`),此处不重复抄写;唯一差别是
|
||||
`git checkout <本文件 §0 的精确 head>`。发布前必过检查同样按 `deploy/hel874-release.md`
|
||||
§6 执行,另加本卡两个新工具(真实 HTTP 分页合同、真实服务浏览器)在目标机重跑。
|
||||
```bash
|
||||
# 全新目录,逐条照抄;任何一步失败即停。全部安装强制 offline/no-index:
|
||||
# 缓存命中即装,缓存缺失即停并留证;联网补齐只发生在获批的发布窗口内,
|
||||
# 逐项 sha256 留证(seq48–51 勘误教训:不写 offline 的安装发生过网络获取)。
|
||||
# 2.1 服务账号与目录(独立可写数据路径,权限边界明确)
|
||||
useradd --system --home-dir /opt/cybergril-hel874 --shell /usr/sbin/nologin cybergril || true
|
||||
mkdir -p /opt/cybergril-hel874/data # data 必须为空:新账从零初始化
|
||||
chown -R cybergril:cybergril /opt/cybergril-hel874
|
||||
|
||||
# 2.2 代码 checkout:全新空目录 clone(空目录里没有仓库,不能直接 git fetch)
|
||||
git clone ssh://git@192.168.200.36:222/leefer/CyberGril.git /opt/cybergril-hel874/current
|
||||
git -C /opt/cybergril-hel874/current checkout <交付评论/handoff JSON 记录的精确head>
|
||||
|
||||
# 2.3 依赖与前端(uv/npm 一律 offline;缓存缺失即停)
|
||||
cd /opt/cybergril-hel874/current
|
||||
uv venv --python 3.11 .venv311
|
||||
uv pip install --python .venv311/bin/python --offline -r requirements.lock
|
||||
(cd web && npm ci --offline && npm run build)
|
||||
|
||||
# 2.4 服务单元(单元内已带 PYTHONPATH=<current>/src:requirements.lock 只装依赖,
|
||||
# 产品代码经 PYTHONPATH 进程内可 import)
|
||||
cp deploy/cybergril-hel874-8360.service.example /etc/systemd/system/cybergril-hel874.service
|
||||
systemctl daemon-reload && systemctl enable --now cybergril-hel874
|
||||
curl -s http://127.0.0.1:8360/api/health | python3 -m json.tool
|
||||
```
|
||||
|
||||
发布前必过检查按 §6 与 `deploy/hel874-release.md` §6 的清单执行(该节仍有效),
|
||||
另加本卡工具(真实 HTTP 分页合同、真实服务浏览器)在目标机重跑。
|
||||
|
||||
## 3. 健康能力矩阵(/api/health 预期)
|
||||
|
||||
@@ -68,38 +113,70 @@ weather/materials/backup=false。**主动留言前端已并入本候选:生活
|
||||
真实 reach-out 后,打开的页面每 20 秒读一次新行、回来立即读,未读有标记;该行为
|
||||
已在本 head 用真实服务验证(§0)。**
|
||||
|
||||
## 4. 固定 SDK(3.11)准确缺项与不下载路径
|
||||
## 4. 固定 SDK(3.11)激活——唯一照抄入口(HEL-879 收口)
|
||||
|
||||
**HEL-896 只读审计修订(2026-10-27,`tools/audit_sdk_offline_hel896.py`,证据
|
||||
`docs/rework/hel896/sdk-audit/`)**:此前「只缺 mem0ai/qdrant-client/fastembed 三个
|
||||
cp311 wheel 及纯 Python 传递依赖」的概括**不准确**,勘误如下:
|
||||
**本节取代 `deploy/hel874-release.md` §4(历史文件,已标注被取代,不得再照抄)。**
|
||||
旧 4.1/4.2 按 `deploy/mem0-stack.lock` 的 `mem0ai==2.2.1` 向索引拉同名 wheel——
|
||||
**索引同名 wheel 不能证明固定源码身份 2c81af3**,这条路已废除。入口只有一条:
|
||||
|
||||
- 三个顶层包固定版本(mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1)的
|
||||
WHEEL 均为 `py3-none-any` 纯 Python,本身无 cp311 专用轮一说;**mem0ai 的安装来源
|
||||
是本地固定源码**(hel857-src,`deploy/mem0-stack.lock` 源码提交 2c81af3、tarball
|
||||
SHA256 `9dcef930…dda23` 已在本机 hel857-data 实测一致)——从索引下载同名 2.2.1
|
||||
wheel 不证明与固定源码相同,发布必须用固定 tarball/源码离线构建安装。
|
||||
- 按 3.11 Linux markers + extras 从 3.10 固定栈安装元数据求闭包共 **55 项,产品
|
||||
3.11 venv 缺 41 项**(28 纯 Python + 13 原生扩展:grpcio/numpy/onnxruntime/
|
||||
tokenizers/sqlalchemy/protobuf/greenlet/pillow 等)。这是**依赖闭包清单**,不是
|
||||
已完成的 cp311 解析或下载结果。
|
||||
- 当前本机缓存对 41 项缺项**立即可复用**的只有 3 项(greenlet 3.5.6 cp311、
|
||||
pillow 12.3.0 cp311、truststore 0.10.5 py3-none-any,uv wheels-v6/v7 缓存实测);
|
||||
其余 37 项 + mem0ai 固定源码离线构建所需 build backend(hatchling 未在缓存)由
|
||||
发布卡获授权后按 `deploy/hel874-release.md` §4 拉取,逐项 sha256 留证,下载清单
|
||||
保持 `deploy/mem0-stack.lock` 与固定源码身份;缺项不阻塞其余发布工作。
|
||||
- **fastembed 模型缓存实测**:hel857-data/fastembed-cache 含真实模型
|
||||
`Qdrant/bge-small-zh-v1.5` 与 `Qdrant/bm25`(约 92MB);发布机落位=实例数据目录
|
||||
内该缓存的拷贝,真实 mem0 模式的 cache_dir 指向它;本卡零下载、不推理。
|
||||
- **seq48–51 pip 事件勘误(缓存取证)**:HEL-895 运行中去掉 `--offline` 安装的
|
||||
pip 26.2.2 在 uv 缓存的 wheel 条目创建于 2026-10-27 02:10:04(该运行窗口内,
|
||||
来源 files.pythonhosted.org,且为窗口内唯一新增缓存条目)——该步发生了网络
|
||||
获取,前卡「零下载」对此步不成立;requirements.lock 装机同期未产生新缓存条目
|
||||
(全部命中既有缓存)。字节级完整下载与条件请求无法由缓存取证区分,按保守口径
|
||||
记为下载。**后续一切安装强制 `--offline`/`--no-index`。**
|
||||
**固定 tarball 哈希 → 固定源码构建轮 → 目标 cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import。**
|
||||
|
||||
- **第 0 步 · 产物清单核对**:`docs/rework/hel879/sdk-audit/sdk-audit.json`(本候选
|
||||
精确提交上重跑)与 `docs/rework/hel879/release-artifacts.json` 记录闭包 55 项、
|
||||
缺 41 项、缓存可复用 3 项与逐项哈希;发布卡先核对清单与目标提交一致。
|
||||
事实口径(HEL-896 审计核定,HEL-879 重跑仍成立):三个顶层固定版本
|
||||
(mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1)的 wheel 均为
|
||||
`py3-none-any` 纯 Python;闭包按 3.11 Linux markers+extras 共 **55 项,产品
|
||||
3.11 venv 缺 41 项**(28 纯 Python + 13 原生:grpcio/numpy/onnxruntime/
|
||||
tokenizers/sqlalchemy/protobuf 等),这是闭包清单不是已完成的 cp311 下载结果。
|
||||
seq48–51 pip 事件勘误保留:不写 offline 的安装发生过网络获取(uv 缓存取证),
|
||||
故本入口全程 `--offline`/`--no-index`。
|
||||
- **第 1 步 · 固定 tarball 哈希核对**(零下载):
|
||||
```bash
|
||||
sha256sum <hel857-data>/mem0-2c81af3.tar.gz
|
||||
# 必须等于 deploy/mem0-stack.lock 记录的
|
||||
# 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23
|
||||
```
|
||||
不一致即停;不得改用索引 `mem0ai==2.2.1` wheel 冒充固定源(audit 已证明两者
|
||||
身份不可互证)。
|
||||
- **第 2 步 · 固定源码构建轮(HEL-879 返修:已完成可离线构建)**:此前此处写
|
||||
「hatchling 当前不在任何本机缓存、待补」——经总管只读核查与本卡复核**勘误**:
|
||||
uv wheels-v6/wheels-v7 均有 hatchling **1.32.4** 缓存(`release-artifacts.json`
|
||||
`build_backend` 节逐路径留证)。固定源 tarball 已在 3.11.16 + `uv build --wheel
|
||||
--offline` 下**离线构建出轮**(零下载、零安装、不混 3.10 site-packages):
|
||||
`mem0ai-2.2.1-py3-none-any.whl`,
|
||||
sha256 `698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db`。
|
||||
发布卡在目标机重跑同一命令并核对该哈希:
|
||||
```bash
|
||||
tar xzf <hel857-data>/mem0-2c81af3.tar.gz && cd mem0-2c81af3*/
|
||||
uv build --wheel --offline --python 3.11 -o wheels/
|
||||
sha256sum wheels/mem0ai-2.2.1-py3-none-any.whl
|
||||
```
|
||||
- **第 3 步 · 目标 cp311 依赖准备**:41 项缺项中**缓存今天即可复用 3 项**
|
||||
(greenlet 3.5.6 cp311、pillow 12.3.0 cp311、truststore 0.10.5 py3-none-any,
|
||||
uv wheels-v6/v7 缓存实测,HEL-879 重跑核实仍命中);其余 37 项
|
||||
**由总管获批后发布窗口拉取**(`python3 -m pip download --only-binary :all:
|
||||
--implementation cp --python-version 3.11 --platform manylinux2014_x86_64
|
||||
--abi cp311 -r <缺项清单> -d wheels/`),逐项 sha256 留证;本卡零下载、零联网。
|
||||
某固定版本若无 cp311 wheel,按 lock 注释如实上报缺项,不换版本号、不混 sdist。
|
||||
- **第 4 步 · 仅本地轮安装**(--no-index 全程,目录里只有上两步产物):
|
||||
```bash
|
||||
uv pip install --python .venv311/bin/python --no-index \
|
||||
--find-links <本地wheels目录> mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 <41缺项>
|
||||
```
|
||||
`deploy/mem0-stack.lock` 的 mem0ai 行只作版本记录;安装来源必须是第 2 步的
|
||||
固定源码轮(同版本号不同来源不互认)。
|
||||
- **第 5 步 · 同进程 co-import 与模型落位**:
|
||||
```bash
|
||||
.venv311/bin/python -c "import cybergril.api, mem0, qdrant_client, fastembed; print('co-import ok')"
|
||||
```
|
||||
fastembed 模型缓存(hel857-data/fastembed-cache,Qdrant/bge-small-zh-v1.5 +
|
||||
bm25 约 92MB,实测存在)拷入实例数据目录,真实 mem0 模式 cache_dir 指向它。
|
||||
|
||||
产品 venv 目前 `import mem0/qdrant_client/fastembed` 均缺、产品代码导入不受影响;
|
||||
在 wheel 落位且 co-import 通过前,不得以 stub/FTS 冒充真实 Mem0 体验就绪。
|
||||
真缺的下载(37 项运行闭包;hatchling 已勘误为缓存命中并完成离线构建,不在缺单)
|
||||
仍待主卡授权,本卡不为补命令联网。
|
||||
|
||||
## 5. 停用与保留数据回退
|
||||
|
||||
@@ -108,26 +185,44 @@ cp311 wheel 及纯 Python 传递依赖」的概括**不准确**,勘误如下
|
||||
`systemctl stop` → 数据目录原样保留 → 切回上一 release/上一提交(R4 迁移全部
|
||||
增量新表,旧代码可安全打开同一数据库);彻底撤销留数据档不删。
|
||||
|
||||
**HEL-896 归档/回退修订(2026-10-27)**:此前「SQLite WAL 单文件可整体复制/归档」
|
||||
的口径**不安全**——在线库只复制主 `.db` 会丢 WAL 里已提交的资料(总管复现件连
|
||||
`companion` 表都不在;本卡 `tools/release_snapshot_hel896.py` 自测同样复现
|
||||
`no such table: companion`,证据 `docs/rework/hel896/release-snapshot/selftest.json`)。
|
||||
发布归档一律二选一:**停服务 + `PRAGMA wal_checkpoint(TRUNCATE)` 后整目录归档**
|
||||
(推荐),或**在线用 SQLite backup API 逐库一致性快照**。照抄命令:
|
||||
**HEL-896 归档/回退修订 + HEL-879 收口(2026-10-27,v3 返修)**:在线库只复制主
|
||||
`.db` 会丢 WAL 里已提交的资料(总管复现件连 `companion` 表都不在)。发布归档一律
|
||||
二选一:**停服务 + `PRAGMA wal_checkpoint(TRUNCATE)` 后整目录归档**(推荐),或
|
||||
**在线用 SQLite backup API 对显式单库做一致性快照**。工具已按收口语义强制执行
|
||||
(v3;v2 的两处已证缺陷由总管独立复核提出并修复):
|
||||
|
||||
- checkpoint 返回 busy≠0 或源/快照行数不一致 → CLI 非 0、不产生可交付归档
|
||||
(「先停服」是前提,不是忽略失败结果的理由);
|
||||
- 目录内真实 SQLite(history_root 的 `mem0_public_history.db`、向量目录内库等,
|
||||
按文件头魔数递归识别)在停服口径一并 checkpoint+逐表核对;
|
||||
- 在线模式只接受显式 `--db`(backup API);传入含 SQLite 的 `--file` 目录直接拒绝;
|
||||
- `verify` 要求**三个计数直接相等**(manifest 源行数 = manifest 快照行数 = 恢复
|
||||
实测;`rows_match` 只是额外约束——v2 的 `rows_match is True or …` 允许记录值
|
||||
绕过源计数比较,源 2/快照 1 仍 ok,已废除);回退演练按源路径映射**实际恢复**
|
||||
到归档旁的持久独立目标树、打开恢复库核对行数与哈希,输出 rollback_plan;
|
||||
- 归档侧目标名无冲突映射:不同目录的同名 `ledger.db` 不再互相覆盖(v2 后写覆盖
|
||||
前写仍先交成功包);同一源重复传入直接拒绝。
|
||||
|
||||
照抄命令:
|
||||
|
||||
```bash
|
||||
# 覆盖范围:产品库(CYBERGRIL_DB)、语义向量目录 vector_root、推导史 history_root、
|
||||
# 权威嵌入账 ledger_path(按部署实例 config/semantic.yaml 实际路径逐项代入)
|
||||
# 停服口径(推荐;目录内 SQLite 一并一致性处理):
|
||||
.venv311/bin/python tools/release_snapshot_hel896.py snapshot \
|
||||
--name <release-或阶段名> --db <产品库> [--db <权威账>] \
|
||||
--file <vector_root> --file <history_root> --out /opt/<实例>/archives/
|
||||
# 在线兜底口径:仅显式单库(backup API),不得传含 SQLite 的目录:
|
||||
.venv311/bin/python tools/release_snapshot_hel896.py snapshot --name <名> \
|
||||
--db <产品库> --out /opt/<实例>/archives/ --online
|
||||
.venv311/bin/python tools/release_snapshot_hel896.py verify --archive <产物>.tar.gz
|
||||
```
|
||||
|
||||
产物含逐文件 SHA256 清单、**代码精确提交**(快照时刻 `git rev-parse HEAD`)、
|
||||
逐表行数核对与回退演练结果;不删档,旧归档永久保留。回退=停服务 → 解包快照到
|
||||
新数据目录 → 指回上一 release/上一提交 → 起 service → `verify` 通过后交付。
|
||||
本节是**发布归档/回退要求**,不冒充 R6(CG-009 每日自动备份/手动完整导出)完成。
|
||||
新数据目录(按 rollback_plan 的源路径映射)→ 指回上一 release/上一提交 → 起
|
||||
service → `verify` 通过后交付。本节是**发布归档/回退要求**,不冒充 R6(CG-009
|
||||
每日自动备份/手动完整导出)完成。
|
||||
|
||||
## 6. 本候选不做的事
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# CyberGril 语义记忆层固定栈清单(HEL-882;B 部分批准后由发布卡拉取)
|
||||
# 固定源:mem0ai 2.2.1(源码提交 2c81af3bbe27d9bccb09c772d583df9e9be72ee5,
|
||||
# tarball SHA256 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23)。
|
||||
# 用于 deploy/hel874-release.md §4:在具备内网源权限的环境按 3.11 平台拉全
|
||||
# wheel 后离线并入发布 venv;本卡不执行下载。版本来自 hel857-venv 实测安装
|
||||
# HEL-879 唯一安装入口:deploy/hel895-release-candidate.md §4(固定 tarball 哈希 →
|
||||
# 固定源码构建轮 → cp311 依赖准备 → 仅本地轮安装 → 同进程 co-import)。
|
||||
# 本文件的 mem0ai==2.2.1 行只作版本记录——不得按此行向索引拉同名 wheel 冒充固定源
|
||||
# (旧 deploy/hel874-release.md §4 已被取代)。若某包无 3.11 wheel,发布卡须如实
|
||||
# 上报缺项,不得换版本号冒充固定安装。版本来自 hel857-venv 实测安装
|
||||
#(Python 3.10.12 侧 mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1),
|
||||
# 3.11 侧需同版本 wheel;若某包无 3.11 wheel,发布卡须如实上报缺项,不得
|
||||
# 换版本号冒充固定安装。
|
||||
# 3.11 侧需同版本 wheel。
|
||||
mem0ai==2.2.1
|
||||
qdrant-client==1.20.0
|
||||
fastembed==0.9.1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# CyberGril 专属语音 worker 运维脚本草案(HEL-877,本卡未执行)。
|
||||
# 用法: bash /opt/voice-agent-cybergril/restart_cybergril.sh [stop|status]
|
||||
# 边界: 只管本 worker(python -m cybergril.voice.worker),绝不匹配通用
|
||||
# worker 的 `agent.py start`,不动 /opt/voice-agent 与 8090/8081。
|
||||
set -u
|
||||
BASE=/opt/voice-agent-cybergril
|
||||
cd "$BASE"
|
||||
VENV=/opt/voice-agent/agent/venv # 复用已装 livekit-agents 1.8.8 的 venv
|
||||
PAT='cybergril.voice.worker start' # 精确匹配本 worker 命令行
|
||||
|
||||
for p in $(pgrep -f "$PAT"); do kill "$p" 2>/dev/null; done
|
||||
sleep 2
|
||||
for p in $(pgrep -f "$PAT"); do kill -9 "$p" 2>/dev/null; done
|
||||
|
||||
if [ "${1:-}" = "stop" ]; then echo "cybergril voice worker stopped"; exit 0; fi
|
||||
if [ "${1:-}" = "status" ]; then
|
||||
pgrep -af "$PAT" || echo "not running"
|
||||
curl -s --max-time 3 "http://127.0.0.1:${CYBERGRIL_WORKER_HEALTH_PORT:-8082}/" \
|
||||
&& echo " <- health" || echo "health port not answering"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -a; source "$BASE/.env"; set +a
|
||||
PYTHONPATH="$BASE" nohup "$VENV/bin/python" -m cybergril.voice.worker start > worker.log 2>&1 &
|
||||
echo "cybergril voice worker starting (pid $!)"
|
||||
sleep 18
|
||||
grep -m1 "registered worker" worker.log >/dev/null && echo "OK: worker registered" \
|
||||
|| { echo "FAIL:"; tail -5 worker.log; exit 1; }
|
||||
@@ -0,0 +1,105 @@
|
||||
"""统一 CyberGril 实例单进程双入口启动器(HEL-877 部署草案,本卡未执行).
|
||||
|
||||
HTTP 8360 与 HTTPS 8361(语音页需要 secure context 才能拿麦克风)必须共用
|
||||
同一个 API 进程、同一份 app.state(epoch / chat_lock / store / 语音会话登记)。
|
||||
|
||||
HEL-879 收口(总管复核第 1 项):uvicorn 两个 Server 默认各自进入一次 FastAPI
|
||||
lifespan,导致 epoch 1→2、Store/life/chat_lock 全部被第二次初始化替换——同一个
|
||||
app 对象并不保证只初始化一次。本启动器改为:
|
||||
|
||||
- 应用生命周期由本启动器**独占管理且只进入一次**(`app.router.lifespan_context`);
|
||||
- 两个 uvicorn Server 以 `lifespan="off"` 只负责监听,共享同一份状态;
|
||||
- 任一监听退出(失败/取消/信号)先把另一个也停掉,**再**退出生命周期:
|
||||
绝不出现一边还在接请求、一边已被另一个监听提前关库;循环与资源只关闭一次;
|
||||
- `create_app` 的 lifespan 自带重入门卫:任何二次进入直接报错(fail-closed),
|
||||
防止未来改回 auto 双 Server 时再次静默替换状态。
|
||||
|
||||
自签证书签发命令与回滚步骤见 docs/runbook-hel877-voice.md。
|
||||
|
||||
用法(部署卡执行,先 `cd` 到仓库根并激活 3.11 venv):
|
||||
PYTHONPATH=src python deploy/voice/serve_dual_8360_8361.py \
|
||||
--http-port 8360 --https-port 8361 \
|
||||
--cert /opt/cybergril-voice-certs/cert.pem --key /opt/cybergril-voice-certs/key.pem
|
||||
|
||||
本地离线冒烟(不涉及任何现网/证书变更):
|
||||
PYTHONPATH=src python deploy/voice/serve_dual_8360_8361.py \
|
||||
--http-port 18360 --https-port 18361 --cert <本地测试证书> --key <本地测试密钥>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
from cybergril.api.app import create_app
|
||||
|
||||
|
||||
async def serve(http_port: int, https_port: int, cert: str, key: str, app=None) -> None:
|
||||
# ONE app object, ONE lifespan owner: both listeners share state, locks,
|
||||
# epoch and the store. The uvicorn servers never run the app lifespan
|
||||
# themselves (lifespan="off") — the context manager below is its only
|
||||
# owner, so initialization and shutdown each happen exactly once.
|
||||
if app is None:
|
||||
app = create_app()
|
||||
http_server = uvicorn.Server(
|
||||
uvicorn.Config(app, host="0.0.0.0", port=http_port, log_level="info",
|
||||
lifespan="off")
|
||||
)
|
||||
https_server = uvicorn.Server(
|
||||
uvicorn.Config(app, host="0.0.0.0", port=https_port, ssl_certfile=cert,
|
||||
ssl_keyfile=key, log_level="info", lifespan="off")
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
servers = [asyncio.create_task(http_server.serve()),
|
||||
asyncio.create_task(https_server.serve())]
|
||||
try:
|
||||
done, pending = await asyncio.wait(servers,
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
except asyncio.CancelledError:
|
||||
# The launcher itself was cancelled: stop both listeners first
|
||||
# (graceful should_exit, hard cancel only as a last resort), await
|
||||
# their full exit, only then let the lifespan close (and re-raise).
|
||||
# No listener is ever left running without its state.
|
||||
for server in (http_server, https_server):
|
||||
server.should_exit = True
|
||||
for task in servers:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
||||
for task in servers:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
raise
|
||||
# Any listener ending stops the other FIRST; only after both listeners
|
||||
# have fully exited does the lifespan context above close the loop's
|
||||
# resources and the store — never the other way around.
|
||||
for server in (http_server, https_server):
|
||||
server.should_exit = True
|
||||
for task in pending:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
for task in done:
|
||||
task.result() # surface the first listener's real error, after cleanup
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--http-port", type=int, default=8360)
|
||||
parser.add_argument("--https-port", type=int, default=8361)
|
||||
parser.add_argument("--cert", required=True)
|
||||
parser.add_argument("--key", required=True)
|
||||
parser.add_argument("--db", default=os.environ.get("CYBERGRIL_DB"))
|
||||
args = parser.parse_args()
|
||||
if args.db:
|
||||
os.environ["CYBERGRIL_DB"] = args.db
|
||||
asyncio.run(serve(args.http_port, args.https_port, args.cert, args.key))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
# CyberGril 语音 worker 脱敏环境变量示例(HEL-877)
|
||||
#
|
||||
# 部署位置 /opt/voice-agent-cybergril/.env(权限 600,不进仓库)。
|
||||
# 复用 /opt/voice-agent/agent/venv(livekit-agents 1.8.8)与 .12 现有
|
||||
# LiveKit(7880)/FunASR(10095)/CosyVoice(9880);不新装任何依赖。
|
||||
|
||||
# 统一 CyberGril 实例(HTTP 8360 / HTTPS 8361 同进程双入口)
|
||||
CYBERGRIL_API_BASE=http://127.0.0.1:8360
|
||||
# 专属派发名(与 config/voice.yaml 的 agent_name 一致)
|
||||
CYBERGRIL_AGENT_NAME=cybergril-voice-agent
|
||||
# worker 注册 LiveKit 所需凭据(与通用 worker restart 脚本同源;只放占位符)
|
||||
LIVEKIT_URL=ws://127.0.0.1:7880
|
||||
LIVEKIT_API_KEY=<LIVEKIT_API_KEY_PLACEHOLDER>
|
||||
LIVEKIT_API_SECRET=<LIVEKIT_API_SECRET_PLACEHOLDER>
|
||||
# 专属健康端口(livekit-agents worker 内建 GET / 健康检查;8081 是通用 worker)
|
||||
CYBERGRIL_WORKER_HEALTH_PORT=8082
|
||||
# CyberGril 自己的音色状态文件(不读通用助手 voice_state.json)
|
||||
CYBERGRIL_VOICE_STATE=/opt/voice-agent-cybergril/voice_state_cybergril.json
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CyberGril R5 · WSS TLS bridge (PROPOSAL — 拟议材料,未在任何服务器执行).
|
||||
|
||||
背景(docs/rework/hel879/unified-review/r5-facts-20261027.md E2/E4):.12 的
|
||||
LiveKit 7880 是纯 HTTP/WS 且通用 worker/8090 演示都依赖它保持明文,不能改
|
||||
TLS;HTTPS 页面直连 ws:// 又是混合内容。本脚本在**新端口**终结 TLS 并把字节
|
||||
流透明转发给本机 7880,使浏览器可用 ``wss://192.168.200.12:<port>`` 接入
|
||||
LiveKit 信令,零新依赖(asyncio + ssl 标准库),不触碰既有服务。
|
||||
|
||||
- 监听(拟议):0.0.0.0:8363,证书 = 与 8361 同一张自签证书
|
||||
(SAN=IP:192.168.200.12,存 /opt/cybergril-voice-certs/,600)。
|
||||
- 上游:127.0.0.1:7880(仅回环,不暴露第二明文入口)。
|
||||
- voice.yaml:livekit_url 改 wss://192.168.200.12:8363;
|
||||
livekit_internal 保持 http://127.0.0.1:7880。
|
||||
- 实现为透明 TCP TLS 终结(不解析 WebSocket/HTTP),LiveKit 信令是 7880 上的
|
||||
HTTP/WebSocket,字节级转发即协议无关;WebRTC 媒体面(UDP/7881)不经本桥,
|
||||
由 WebRTC 自带 DTLS/SRTP 加密。
|
||||
- 每设备分别打开 https://192.168.200.12:8361 与 :8363,验证证书例外和实际
|
||||
连接;不推断例外缓存范围。禁止用 --ignore-certificate-errors 冒充验收。
|
||||
|
||||
部署(获批后)示例:
|
||||
python3 wss_bridge.example.py --listen 0.0.0.0:8363 \
|
||||
--upstream 127.0.0.1:7880 \
|
||||
--cert /opt/cybergril-voice-certs/cybergril-voice.pem \
|
||||
--key /opt/cybergril-voice-certs/cybergril-voice.key
|
||||
|
||||
回滚:停本进程即可(独立进程,不影响 LiveKit/通用 worker/8090)。
|
||||
|
||||
本地冒烟(零网络出站):见
|
||||
docs/rework/hel879/unified-review/r5-facts-20261027.md 与
|
||||
docs/rework/hel879/unified-review/wss-bridge-smoke.log(一次性本地证书 +
|
||||
dummy 上游回环验证字节穿透与 TLS 握手)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
|
||||
async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(65536)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (ConnectionError, asyncio.CancelledError, ssl.SSLError):
|
||||
pass
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
writer.close()
|
||||
|
||||
|
||||
async def _handle(
|
||||
downstream: tuple[asyncio.StreamReader, asyncio.StreamWriter],
|
||||
upstream_host: str,
|
||||
upstream_port: int,
|
||||
) -> None:
|
||||
down_reader, down_writer = downstream
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.open_connection(upstream_host, upstream_port)
|
||||
except (ConnectionError, OSError):
|
||||
down_writer.close()
|
||||
return
|
||||
await asyncio.gather(
|
||||
_pipe(down_reader, up_writer),
|
||||
_pipe(up_reader, down_writer),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
async def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="TLS->plain TCP bridge (CyberGril R5 WSS proposal)")
|
||||
parser.add_argument("--listen", default="0.0.0.0:8363", help="host:port to terminate TLS on")
|
||||
parser.add_argument("--upstream", default="127.0.0.1:7880", help="plain upstream host:port")
|
||||
parser.add_argument("--cert", required=True, help="TLS certificate PEM path")
|
||||
parser.add_argument("--key", required=True, help="TLS private key path")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
listen_host, _, listen_port = args.listen.rpartition(":")
|
||||
up_host, _, up_port = args.upstream.rpartition(":")
|
||||
if not listen_host or not up_host:
|
||||
parser.error("listen/upstream must be host:port")
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(args.cert, args.key)
|
||||
|
||||
async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
await _handle((reader, writer), up_host, int(up_port))
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handler, listen_host or "0.0.0.0", int(listen_port), ssl=ctx
|
||||
)
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets or [])
|
||||
print(f"wss-bridge(PROPOSAL) tls {addrs} -> {up_host}:{up_port}", file=sys.stderr)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"base": "bf234570c0b6595a72f8dbc79e42c63685e08e01",
|
||||
"patch_sha256": "02a7b3d2c03aee79c9fc7c8a470c7053cd7b8aa410699ec51b255c333acdeda4",
|
||||
"source_zip_sha256": "4596f2cc03210074be1fc9fe23dcdd607f5d240b16f142b67986c1886ce6e9be",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/requirements/handoff-HEL-878.json",
|
||||
"sha256": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516",
|
||||
"bytes": 9439
|
||||
},
|
||||
{
|
||||
"path": "web/README.md",
|
||||
"sha256": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726",
|
||||
"bytes": 3113
|
||||
},
|
||||
{
|
||||
"path": "web/VENDOR.md",
|
||||
"sha256": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6",
|
||||
"bytes": 893
|
||||
},
|
||||
{
|
||||
"path": "web/public/vendor/livekit-client-2.22.4.umd.min.js",
|
||||
"sha256": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626",
|
||||
"bytes": 598796
|
||||
},
|
||||
{
|
||||
"path": "web/src/app/RealApp.tsx",
|
||||
"sha256": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e",
|
||||
"bytes": 14382
|
||||
},
|
||||
{
|
||||
"path": "web/src/chat/ChatPanel.tsx",
|
||||
"sha256": "419c2f4243d7a147ea8ebcf377d74c97dc558049a3bf76dee39bba0746d2c9e6",
|
||||
"bytes": 34936
|
||||
},
|
||||
{
|
||||
"path": "web/src/styles/components.css",
|
||||
"sha256": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d",
|
||||
"bytes": 89243
|
||||
},
|
||||
{
|
||||
"path": "web/src/voice/livekit.ts",
|
||||
"sha256": "c42475d37f7eef7abe0903b66656aedc5379bc9de3dcba11c1b88450a470ab04",
|
||||
"bytes": 6859
|
||||
},
|
||||
{
|
||||
"path": "web/src/voice/session.ts",
|
||||
"sha256": "128123ab75e788699150321ca1341081450c34b09918b0b825c542dbc60b6e07",
|
||||
"bytes": 18058
|
||||
},
|
||||
{
|
||||
"path": "web/tests/fake-livekit.js",
|
||||
"sha256": "d16e794308073e967566f3ebda16e72d0f5eb0132b657338baa6a29115b02508",
|
||||
"bytes": 4177
|
||||
},
|
||||
{
|
||||
"path": "web/tests/hel878_mock.mjs",
|
||||
"sha256": "d7c9d79308979f1f3cd7172f56f52b4cfb35de1fcbae2876fa7d66ea4e2c3071",
|
||||
"bytes": 8855
|
||||
},
|
||||
{
|
||||
"path": "web/tests/hel878_shots.mjs",
|
||||
"sha256": "ad664b26b66990e59ca118b0afe1e6dfea8a64351bb9bbb7a666f332b2f15760",
|
||||
"bytes": 22874
|
||||
},
|
||||
{
|
||||
"path": "web/tests/voice.test.ts",
|
||||
"sha256": "f6bc899b3202465ec68b276ed21ccbbc5ba6e7edda014e2d7ea3ff63e5683a73",
|
||||
"bytes": 20829
|
||||
}
|
||||
],
|
||||
"unchanged_archive_files_match_base": true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
HEL-878 源码保全与补查(总管,2026-10-27 北京时间)
|
||||
|
||||
原作者提交评论:01a1797c-188d-79df-9be1-df6bd47effc6。读取需求 v1.11/60项;前端基点 bf234570c0b6595a72f8dbc79e42c63685e08e01,后台契约 aba2aeb,实际总装基点 04eff611e7d85e539d43717711172441420ac853,main 3a986726d8593e0ed2274f79db6cc5d807d2ebf0。
|
||||
|
||||
13 个改动文件逐字节匹配作者 MANIFEST;补丁 SHA-256 匹配,git apply --reverse --check 通过;源码包其余文件与 bf234570 内容一致(仅允许未改文件 CRLF/LF 差异),包内 MANIFEST 与单独附件一致。原样提交 b95ce798b4a9455d941d247a46865b3ac202bf2c,不改作者业务代码或视觉。
|
||||
|
||||
补齐前端分支缺少的历史用户原话文件 comment-evidence-full-experience-20261026.json 及 source-manifest,逐字节来自总装 04eff611;无需求变更。Python 原版检查对 origin/main、origin/hel-870-assembly、origin/agent/2/6a08a4c18f23 各带/不带 handoff-HEL-878.json 共6次通过。
|
||||
|
||||
使用既有 node_modules(未安装下载)复跑 typecheck、lint、81项单测、build 全通过;lint 0错/1条既有警告。作者45项浏览器检查本轮未重跑,仍为作者证据。真实语音、真实麦克风、WebRTC、ASR/TTS/LLM请求均未执行;未部署、未合并、未改现有服务或绑定。
|
||||
|
||||
独立补查发现必须返修的问题:
|
||||
|
||||
1. 第一次会话正常开启;模拟掉线,服务端 DELETE s1 延迟返回。
|
||||
2. 用户立即开启第二次会话,worker已加入,界面 listening,controller.active=true。
|
||||
3. 放行第一次的 DELETE(含 settle 补偿请求)。旧 fail() 在 await teardown(live) 之后继续写 OFF,没有重新核对代次,覆盖新会话的界面。
|
||||
4. 实测:controller.active=true,但最后 onView.phase=off。旧失败提示也会覆盖新会话,界面与麦克风会话状态不一致。
|
||||
|
||||
定位 web/src/voice/session.ts 的 start() 内 fail()。附件 review878_late_cleanup.test.ts 为独立负例,放到 web/tests/ 后用 ./node_modules/.bin/vitest run tests/review878_late_cleanup.test.ts 可复现;本轮实际执行失败:Expected listening / Received off,原始日志见 review878-race.log。这里只做有限复现,未代替原作者修改;交原作者修复异步清理后的状态提交边界并补有意义回归。
|
||||
|
||||
关联 CG-010/018、VIS-013、UX-005/006、GOV-001–006、DEL-001。原81项通过不涵盖这个负例,不能称离线验收通过。修正后交 HEL-879 统一审核;CG-011语音主动开口仍为空操作、增量字幕仍缺、远端断网清理/租约与API重启实情、真实SDK回调与播放回执、真机三轮/打断/停止/HTTPS均继续承接,不因协议现状或mock通过降低完整要求。R6/R7继续主卡HEL-797承接。
|
||||
@@ -113,16 +113,24 @@ HEL-857 的 60 次实验授权);B/C 需白栖知明确批准。
|
||||
`docs/budget-plan-hel874.md` §1 体验链 1–7 逐步执行:创建/诠释/习惯 4、对话 16、
|
||||
生活 8、判定/成长/gist 20、主动措辞 3,其余额度备用至 55;每步在四道闸下原子
|
||||
预占(类别日顶→日 token 池→一次性总量;S1 验证叠加取最紧)。
|
||||
2. **A 结束即封账**,两种到达方式同义:额度用满(`budget_total_requests_exhausted:55/55`
|
||||
如实拒绝、不补发)或体验链走完。封账动作:`systemctl stop` 8360 实例 →
|
||||
`tools/release_snapshot_hel896.py` 做一致性快照(停服+checkpoint/backup API)→
|
||||
归档(数据目录 + 哈希清单)交交付评论。封账跨重启、跨归档不失效(S2 验证),
|
||||
2. **A 结束即封账**,两种到达方式都有可执行动作(HEL-879 收口):额度用满时
|
||||
`budget_total_requests_exhausted:55/55` 如实拒绝、不补发;**体验链提前走完
|
||||
(如 51/55)时运行 `tools/seal_budget_hel879.py --db <团队实例库>` 显式封账**
|
||||
——真实用量 51 原样写入并保留(不伪造 55、不清零),此后该库任何类别任何
|
||||
余量的 `acquire` 都拒绝 `budget_sealed:51/55`,跨重启、跨 backup API 归档/
|
||||
快照恢复同样拒绝(S6 负例:51/55 提前收尾、误重启、归档恢复均拒绝;重复
|
||||
seal 幂等不覆盖原标记;无解除封账代码路径)。封账动作顺序:`systemctl stop`
|
||||
8360 实例 → `seal_budget_hel879.py` → `tools/release_snapshot_hel896.py`
|
||||
一致性快照(封账标记随库一同归档)→ 归档(数据目录 + 哈希清单)交交付评论。
|
||||
`budget_total` 永不手工清零。
|
||||
3. **旧测试数据不会转成用户伴侣**:测试档案、聊天、生活数据只存在于团队实例库与
|
||||
归档;用户实例是全新空库,由用户自己创建档案;产品无任何「测试库导入用户库」
|
||||
路径,发布卡明令禁止把 8360 数据目录复制/合并进用户实例数据目录。
|
||||
4. 后续任何团队复检 = 新批准 + 新实例/新库(或在 8360 实例上以新批准抬
|
||||
`total_requests`),绝不续在用户账上。
|
||||
4. 后续任何团队复检 = **新批准 + 全新隔离实例/新库**,绝不续在用户账上,也
|
||||
**绝不在已封账的 8360 库上抬 `total_requests` 重开**(HEL-879 返修勘误:本节
|
||||
旧版括注「或在 8360 实例上以新批准抬 total_requests」与「无解除封账路径」
|
||||
自相矛盾——封账标记随库持久,抬 cap 不能也不得绕过它;该括注废除)。旧
|
||||
8360 库及其封账标记原样归档留存,作为真实用量的证据链。
|
||||
|
||||
### 6.3 B/C 的开始与日常
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"version": "1.11",
|
||||
"version": "1.12",
|
||||
"approval_comment_id": "01a1734c-1159-7a44-a81f-3105f1b8d98d",
|
||||
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
|
||||
"scope": "完整要求;v1.11在v1.10的59项上新增成熟方案优先复用执行约束,原产品与验收边界保留;不代表已选定底座、授权迁移或功能通过。",
|
||||
@@ -118,10 +118,11 @@
|
||||
"category": "产品",
|
||||
"topic": "连续语音与文字",
|
||||
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
|
||||
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
|
||||
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。",
|
||||
"source_comment_ids": [
|
||||
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
|
||||
"01a15750-e061-733b-9d80-38459d306b97"
|
||||
"01a15750-e061-733b-9d80-38459d306b97",
|
||||
"01a17de1-2892-78be-a422-eb6a11ffebfd"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -108,6 +108,16 @@
|
||||
"GOV-006"
|
||||
],
|
||||
"impact": "原59项保持;不预设保留自研底座,也不指定任何最终开源项目;未授权产品迁移、依赖安装、现网部署或改变资料/隐私边界。"
|
||||
},
|
||||
{
|
||||
"version": "1.12",
|
||||
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
|
||||
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
|
||||
"summary": "CG-010音色边界更新:以可自定义音色为准(网页上传/录制参考音频→试听→应用),直接复用已装TTS零样本克隆;不以仅选预设替代,参考包试听方案不采用;本轮通话测试授权总管自定音色、最长2小时/3次通话。",
|
||||
"affected_ids": [
|
||||
"CG-010"
|
||||
],
|
||||
"impact": "只改音色入口与测试授权;换声音只换发声、不重建伴侣、不改底色/名字/关系/聊天/记忆的既有边界不变;交付与阶段通过不等于用户已验收音色;不新增付费服务、不改变部署与资料边界。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,3 +39,13 @@ v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入
|
||||
> 记住我的原则,能借鉴成熟方案的就直接借鉴,从零创造的难度太大需要的是一个专业的团队,不适用于我这个不专业的人加上没有决策性的AI的组合;另外我之前让你看的我跟另外智能体的交流内容希望你提取值得借鉴的地方
|
||||
|
||||
旧记录未明确规定通用内核必须优先复用成熟实现;现新增GOV-006,减少从零研发和长期自行维护,不因沉没工作量而默认保留自研内核。原59项不删减。交流材料提炼为候选机制,具体技术栈、底座、情绪映射及迁移范围未获批;不将本次文档记录当功能通过或现网施工授权。
|
||||
|
||||
## v1.12:音色入口定为可自定义音色
|
||||
|
||||
来源评论:`01a17de1-2892-78be-a422-eb6a11ffebfd`(2026-10-27 15:01:10 UTC,回应总管阻塞通报 `01a17d45-f14e-7b62-9850-646e040c7318`)。白栖知原话:
|
||||
|
||||
> .12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行
|
||||
|
||||
旧边界(CG-010/D1 路线):音色按"先在现有中文女声里挑、都不满意再做零样本克隆"的条件路线推进,本轮以参考包编号试听为交付形式。新要求:**音色入口以可自定义音色为准**——在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,参考包试听方案不采用。另明确:本轮通话测试授权总管自定音色、最长2小时/3次通话(操作授权,记 HEL-879 测试执行,不进需求边界)。
|
||||
|
||||
影响范围:仅 CG-010 音色入口与测试授权;换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天/记忆的既有边界不变;CG-010 其余条文与原59项不动。工程交付或测试通过不等于用户已验收音色,也不得把"用户已授权自定义音色"改写成"用户已选定某支音色"。原话记录见 `sources/comment-evidence-custom-voice-20261027.json`(施工员2号按平台原话逐字核对录入)。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseline_version": "1.11",
|
||||
"baseline_version": "1.12",
|
||||
"items": [
|
||||
{
|
||||
"requirement_id": "CG-001",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# CyberGril 完整当前需求 · v1.11
|
||||
# CyberGril 完整当前需求 · v1.12
|
||||
|
||||
完整要求;版本1.10在v1.9(交流静图认可)之上,把白栖知已体验的对坐动态小样 v2 整体效果(演示层面)记入 VIS-011 与 CG-004 边界;不证明其余页面、真实功能或完整产品已验收。
|
||||
|
||||
v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界保持。
|
||||
|
||||
v1.12按白栖知2026-10-27原话(`01a17de1-2892-78be-a422-eb6a11ffebfd`)把CG-010音色边界更新为可自定义音色:网页上传/录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS零样本克隆,不以仅选预设替代;先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话。该项交付不等于用户已验收音色。
|
||||
|
||||
## 阅读与效力
|
||||
|
||||
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
|
||||
@@ -84,9 +86,9 @@ v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界
|
||||
|
||||
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
|
||||
|
||||
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
|
||||
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。
|
||||
|
||||
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
|
||||
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`、`01a17de1-2892-78be-a422-eb6a11ffebfd`。
|
||||
|
||||
## CG-011 · 主动联系范围
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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 恢复后延迟"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"baseline_version": "1.11",
|
||||
"read_commit": "31970c4dd3851aae67a922b03264743d504b8e97",
|
||||
"task": "HEL-877:把已核清的语音底座接到统一 CyberGril 业务链(获批 R5 后台施工);新增专属 livekit worker(agent_name=cybergril-voice-agent,复用 .12 FunASR/CosyVoice/silero VAD,不 fork 不另搭平台)+ 统一实例 POST /api/voice/session 最小协议(建房/专属派发/短时限 token 服务端处理,密钥不进浏览器)+ 与文字前端同协议的 livekit LLM 适配器(POST /api/chat,SSE/epoch 409 有界重同步/turn_id 幂等/打断即断流)+ SDK 真实播放完成才确认的分享回执;零真实请求自测交可审核代码",
|
||||
"requirement_ids": [
|
||||
"CG-006",
|
||||
"CG-010",
|
||||
"CG-011",
|
||||
"CG-013",
|
||||
"CG-014",
|
||||
"CG-015",
|
||||
"CG-016",
|
||||
"CG-018",
|
||||
"CG-019",
|
||||
"CG-020",
|
||||
"CG-021",
|
||||
"AM-001",
|
||||
"GOV-001",
|
||||
"GOV-002",
|
||||
"GOV-003",
|
||||
"GOV-004",
|
||||
"GOV-005",
|
||||
"GOV-006",
|
||||
"DEL-001"
|
||||
],
|
||||
"deliverable": "src/cybergril/voice/ 独立模块:session.py+router.py(POST /api/voice/session、DELETE 结束删房释放麦克风/job、GET /api/voice/status 诚实开关;缺配置/无档案/TTS 不可用如实拒绝;app.py 仅最小注册且 capabilities.voice 不伪造)、livekit_wire.py(零新依赖 HS256 token+Twirp 派发/删房,与已装 SDK 同线上格式)、chat_api_client.py(与 real.ts 同协议:health epoch、SSE 分片鲁棒解析、409 一次重试、固定 turn_id、打断 finally 即断流)、llm_chat_api.py(livekit llm.LLM 适配器,只发当前用户话轮,服务端组装全部人格/记忆/关系;φ 原句经 ChatChunk 念出无回执)、receipt.py(should_ack 只有 played_to_end 且未打断未失败且带完整 receipt 且 epoch 一致才确认;TurnLedger 绑定 speech handle↔流;确认恰好一次)、stt_funasr.py/tts_cosyvoice.py(.12 已验证适配器原样复用带出处)、worker.py(agent_name=cybergril-voice-agent、独立 voice_state_cybergril.json 音色、无第二人格、cg.subtitle 字幕数据通道、CG-011 主动开口空操作接缝、专属健康端口 8082);config/voice.example.yaml 与 deploy/voice/{worker.env.example,restart_cybergril.sh.example,serve_dual_8360_8361.py} 部署草案(HTTP 8360+HTTPS 8361 同进程双入口共用同一状态/锁,runbook 含启动顺序/回滚/GPU 前置);docs/runbook-hel877-voice.md 契约+部署文档;tests/test_voice_session.py(9)+test_voice_chat_adapter.py(16)、scripts/verify_hel877_voice_chain.py、scripts/gen_hel877_sdk_check.py",
|
||||
"acceptance": [
|
||||
"语音轮次走统一链被证明:worker 适配器只调 POST /api/chat,文字/语音互相打断由服务端同一 chat_lock 轮次门处理(本地 socket 实测:语音打断文字轮旧行=cancelled 无幽灵行、打断取消即刻断 HTTP 流旧行 fail-closed、新轮干净)",
|
||||
"完成轮重放不新增模型调用;409 旧 epoch 一次有界重同步后成功;SSE 多字节/CRLF/多行 data 分片边界解析正确;turn_id 固定使 SDK 内部重试幂等",
|
||||
"分享 receipt 仅在 SDK 真实播放完成后确认:should_ack 对被打断/播放失败/未播完/φ 轮/残缺回执/旧 epoch 全负例拒绝(测试覆盖),确认恰好一次,服务端仍独立 fail-closed 校验",
|
||||
"POST /api/voice/session 服务端建房+专属派发(agent_name=cybergril-voice-agent,protobuf CreateDispatch 与已装 SDK 同格式)+短时限 token(TTL≤300s,与 livekit.api.AccessToken 声明集全等,.12 真 SDK 比对通过);房间隔离(两 session 不同房、token 各自房间);DELETE 删房结束;缺配置/无档案/TTS 停机诚实拒绝且 capabilities.voice 保持 false",
|
||||
"worker 不持有第二人格/记忆、不直连 LLM、不读通用助手 persona_state/voice_state(独立 voice_state_cybergril.json,D1 未拍板回落 default);CG-011 语音主动开口留明确空操作接缝并写清边界(仅已开启会话、同业务链、可打断、不新回执通道)",
|
||||
"真 SDK 兼容在 .12 agent venv(livekit-agents 1.8.8)stdin-only 零写入零网络验证 8 项全过(真 LLM/LLMStream 子类、真 ChatContext、真流机制 ChatChunk+final+receipt、φ ChatChunk、token 声明集与 SDK AccessToken 全等、server token 同形、SpeechHandle/事件 API 存在、打断取消立即关流)",
|
||||
"双入口同进程:本地 18360/18361 冒烟单 pid 双端口同 epoch 两端诚实拒答;HTTP 与 HTTPS 共用同一 app 对象/状态/锁,无第二实例写同一 DB",
|
||||
"部署草案齐全(/opt/voice-agent-cybergril、健康端口 8082、启动顺序含 TTS GPU≥6GB 前置、回滚、禁抄历史 fuser 命令的明确警告);本卡未执行任何部署/启停/下载安装,未动 web/、三账、档案、4080、现网服务"
|
||||
],
|
||||
"deferred": [
|
||||
"R5 真机验收:连续 3 轮、开口打断 ~1s 停声、结束停麦无残音、移动端 Safari/Chrome 麦克风——TTS 恢复(GPU 需 ≥5.6GB,现 3.9GB,ComfyUI 不停)并接线后在后续卡执行;未经真实验收不得称 R5 完成",
|
||||
"视觉总监接线:语音按钮/字幕(cg.subtitle)/失败/打断/挂断按 runbook §3 契约;本卡未改 web/",
|
||||
"总工独立审核与部署(runbook §4):.12 上对真 LiveKit server 的 dispatch/delete、HTTPS 证书签发与实机信任(每台手机高级→继续)待实测,不承诺每台手机点继续必然可用",
|
||||
"语音内主动开口(CG-011 接缝)与 D1 音色最终决定、D3 用户设备动作、GPU 时段:总管集中与白栖知沟通",
|
||||
"R6 天气/素材/完整备份承接不变;视觉观察默认关闭不变"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"PYTHONPATH=src .venv311/bin/python -m pytest tests -q:281 passed(总工 HEL-874 返工后全量 256 + 本卡 25,含语音会话 9 与适配器/回执 16,全 fake HTTP/SSE/本地 127.0.0.1 socket,零真实模型/嵌入/合成/识别)",
|
||||
"scripts/verify_hel877_voice_chain.py 一键全链 8 项全过(真实 app + fake LiveKit/TTS + 本地 socket:拒答序/专属派发/短时限 token/房间隔离/删房结束/多轮回执/重放零调用/409 重同步/打断 fail-closed/播放完成回执一次/capabilities.voice=false)",
|
||||
"scripts/gen_hel877_sdk_check.py 生成脚本经 SSH stdin 在 .12 /opt/voice-agent/agent/venv/bin/python(livekit-agents 1.8.8、livekit 1.1.20)执行:8 项 ALL PASS(零写入、零网络、未触任何服务)",
|
||||
"双入口本地冒烟:一次性自签证书(本机 openssl 生成)+ deploy/voice/serve_dual_8360_8361.py 于 18360/18361,单 pid、同 epoch、/api/voice/status 诚实、未配置 POST 503;测后进程与端口已释放",
|
||||
"ruff check src tests scripts deploy:本卡代码 0 错(唯一遗留 SIM115 属既有 deploy/hel835_guard.py,非本卡);check_requirements.py 双目标(origin/hel-870-assembly 现tip e978e87 与 origin/main)含本 handoff 通过",
|
||||
"读取位置记录:开工时 origin/hel-870-assembly=31970c4 与卡文一致;施工中总工推入 HEL-874(e978e87),本卡已完整 merge 保其成果不覆盖;origin/hel-876-voice-readiness=23415fb(总管读取点)已并入",
|
||||
"未验证如实声明:端到端语音(TTS 停机未连麦)、真 LiveKit server 的 dispatch/delete 实测、worker 运行时行为(字幕/VAD/回声抑制仅源码级对齐 .12 已验证实现与 SDK 静态契约)、移动端实机"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"baseline_version": "1.11",
|
||||
"read_commit": "bf234570c0b6595a72f8dbc79e42c63685e08e01",
|
||||
"task": "HEL-878(R5 前端接线,视觉总监本人设计并编码):在已认可的交谈界面上由用户主动开启/结束连续语音;按 HEL-877 后台契约(aba2aeb docs/runbook-hel877-voice.md §3)接 GET /api/voice/status、POST/DELETE /api/voice/session 与 cg.subtitle 字幕,复用 .12 :8090 既有 livekit-client 2.22.4 浏览器客户端;文字与语音同一位她、同一聊天记录,轮次从 /api/messages 回读不串台;所有退出路径立即停麦并清理;零真实模型/嵌入/合成/识别/采集/部署",
|
||||
"requirement_ids": [
|
||||
"CG-006", "CG-010", "CG-011", "CG-018", "CG-020",
|
||||
"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 @ bf23457 起的补丁(仅 web/ 与本交接):1) web/src/voice/session.ts——VoiceController 生命周期(代次计数:任何迟到的 token/房间/麦克风/房间事件只清理不显示);顺序为诚实状态(只 GET status,不建房不采麦)→ 加载客户端 → 采麦(拒绝则服务端什么都不开)→ POST session(结束时已在途的请求等其返回并 DELETE,不留孤儿房)→ 加入并发布麦克风 → 等 worker 加入(20 s 未加入如实失败);状态取 worker 的 lk.agent.state,缺属性时按字幕保守推断;interim 为草稿、user_final 取消在途文字回复并触发回读、assistant_final 触发回读、error(φ) 只显示「未记入聊天」不入记录;文字盖过她的语音时静音残余音频直到她回到聆听;结束顺序为停麦→关房间与音频→DELETE;断网时本地立即停采,远端 DELETE 记入本浏览器 cg.voice.owed,恢复网络/再次进入/再次开启时补发(404 视为已清);页面卸载用 keepalive DELETE;mergeTurns/recentRows 只合并非主动留言的服务器行(主动留言仍走原 inbox 轮询),按 turn_id 与已有文字轮去重,pending 行回读至落定。2) web/src/voice/livekit.ts——同源懒加载 /vendor/livekit-client-2.22.4.umd.min.js(来源 .12:8090/static,SHA-256 见 web/VENDOR.md,未改 package.json/锁文件),回声消除采集、她的音频集中在隐藏容器(结束即暂停/清空/移除)、自动播放受阻时提供「点一下听她的声音」、加入可被结束即时中止。3) ChatPanel.tsx——真实模式麦克风按钮按状态诚实标注(不可用时淡化并说明原因),确认框说明麦克风、去向、同一份记忆、如何打断与停止、未经真机验收;沿用原演示 voice-row 位置与视觉语言做成玻璃单行(连接/等待为呼吸、聆听用你的颜色、她说话用她的光色,文字始终写明状态);文字流在无 final 时先查服务器行:被新轮取消即按已取消处理,不报连接中断、不回填草稿;离开交谈/Esc/结束交谈/导航卸载面板即结束语音;在途语音轮期间她的主动留言照原规则排队。4) RealApp.tsx——语音进行中顶栏写「语音(未经真机验收)」。5) components.css 新增 .voice-row.real 等样式(含手机)。6) 测试:web/tests/voice.test.ts 26 项;web/tests/hel878_mock.mjs(HEL-877 路由契约 mock)+ fake-livekit.js(脚本化替身,浏览器假音频设备)+ hel878_shots.mjs 浏览器检查 45 项与截图;web/VENDOR.md、README 语音段落。",
|
||||
"acceptance": [
|
||||
"到达交谈页只发一次 GET /api/voice/status:不建房、不申请麦克风、不加载 LiveKit 客户端;configured=false / tts_ok=false / 无路由各有诚实说明,按钮淡化且不弹确认;voice=false 不被当作已接通(浏览器 1–2、单测 honest switch)",
|
||||
"用户确认后:一个会话、一个麦克风、发布到该房间;等待 worker 时显示等待而非已接通;聆听/思考/说话随 lk.agent.state 切换;字幕草稿、φ 原句(标未记入聊天且不入记录)均按契约(浏览器 4–6)",
|
||||
"语音轮只从 /api/messages 进入舞台与完整记录,各一次,与文字同一段记录(浏览器 5);前端从不 POST /api/chat 或 /api/chat/received(单测)",
|
||||
"双通道:文字盖过她说话时残余音频静音、回到聆听再恢复;语音打断在途文字回复时该轮按已取消处理,无“连接中断/重试”、不回填草稿,记录显示已取消(浏览器 7–8)",
|
||||
"结束语音/导航离开/Esc/结束交谈/房间掉线/麦克风被撤销/服务端拒绝/重复开关后:本地麦克风 track 全部 ended、她的音频与容器移除、服务端房间删除;迟到事件不复活旧会话(浏览器 10–14,单测 lifecycle 15 项)",
|
||||
"断网:本地立即停麦停音频,远端房间如实记为欠删(不冒充已删);恢复网络后补发 DELETE 并清除记录;页面卸载 keepalive DELETE 到达(浏览器 14);欠删跨页面保留、404 视为已清(单测)",
|
||||
"电脑 1440×900 与手机 390×844(2x)、月光/雾光两主题截图;手机语音行不出屏(浏览器 15)",
|
||||
"真实 livekit-client 2.22.4 构建中,适配器用到的全部名称存在(RoomEvent 七项、Track.Source.Microphone、ParticipantKind.AGENT、createLocalAudioTrack、Room 方法/属性);仅 API 面核对,未连接",
|
||||
"演示入口零语音请求;工程:tsc 0 错,eslint 0 错(1 条既有警告),vitest 7 文件 81/81,vite build 通过"
|
||||
],
|
||||
"deferred": [
|
||||
"真实通话全部未测(R5 真机验收交 HEL-879 统一审核):连续 3 轮、开口打断约 1 s 停声、结束停麦无残音、回声、首包延迟、移动端 Safari/Chrome 麦克风与自动播放、自签 HTTPS 信任;浏览器检查用的是脚本化 LiveKit 替身与 Chromium 假音频设备,不是 WebRTC 通话",
|
||||
"lk.agent.state 属性名按 livekit-agents 1.x 惯例接入,并在缺失时用字幕推断;worker 实际是否发布该属性须真机确认",
|
||||
"断网时服务端房间的即时删除在物理上做不到:依赖 worker 在浏览器参与者离开后结束任务与会话登记过期(runbook §3.4/3.5,后台行为未在本卡实测),本页恢复网络或下次进入时补发 DELETE",
|
||||
"CG-011 语音内主动开口:worker._maybe_open_proactive 仍是空操作,前端未虚构;后台最小需求见 runbook §3.6(会话内取已落库 proactive 行 session.say 原文、可打断、无新回执通道),呈现节奏待总管/视觉总监另定;目前语音开启时她的主动留言仍以文字进入并在她思考/说话时排队",
|
||||
"她说话时没有逐字字幕(契约只在播放完成后发 assistant_final);舞台在落库后呈现全文。若要边说边显示需后台发增量字幕,属契约变更",
|
||||
"D1 音色、D3 设备信任、测试额度由总管与白栖知沟通;本卡未接触任何服务、凭据或部署",
|
||||
"原版 Python 需求检查未在本运行时执行(无 Python、无 git),须总管/总工对实际目标分支与 origin/main 含/不含本交接复跑;需与 HEL-882 最新母线同步后再审",
|
||||
"R6 天气/素材/备份、R7 视觉观察、CG-028/029 口型与真实视频仍由主卡 HEL-797 承接;整体沉浸体验由白栖知验收"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"读取:前端 bf234570(总管输入包 frontend/,HEL-875/880 原作者原稿);后台契约 aba2aeb(输入包 voice-backend/:runbook-hel877-voice.md、voice/router.py、session.py、worker.py、handoff-HEL-877.json、requirements v1.11 含 comment-evidence-full-experience-20261026.json);已读 AGENTS.md、README/current(CG-006/010/011/018/020、UX-005/006、VIS-013)/coverage/changes、integration-plan-v1 R5 与 D1/D3、voice-r5-readiness-HEL876.md",
|
||||
"web:tsc --noEmit 0;eslint src 0 错;vitest run 7 文件 81/81(原 55 + 语音 26,含变异检查:去掉迟到授权回收或代次判定各使对应用例失败);vite build 通过(node_modules 沿用 HEL-875 同一锁文件离线树,无新增依赖、无下载)",
|
||||
"浏览器:node tests/hel878_mock.mjs 8878 + node tests/hel878_shots.mjs <msedge.exe> <out> 8878,45/45(无头 Edge,--use-fake-device-for-media-stream;脚本化 LiveKit 替身;本机回环);中断前一轮为 39/43,失败的 4 项(语音打断文字误报、断网远端清理、卸载检查受其连带)经本轮修复后通过",
|
||||
"唯一外部读取:一次只读 GET .12:8090 页面及其 /static/livekit-client.umd.min.js 以复用既有客户端;无模型、嵌入、合成、识别请求,无真实麦克风/摄像头,无部署或服务改动",
|
||||
"需求结构检查:本运行时无 Python/git,未执行原版脚本,不声称通过;仅用 HEL-875 留下的 Node 移植版辅助:以 bf23457 为基含本交接通过(60 项);以后台 aba2aeb 的需求文件为基失败「历史来源必须保留」,原因是该线已追加 comment-evidence-full-experience-20261026.json 而前端原稿尚未包含,与本补丁无关,合入母线前须先同步该需求目录"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"baseline_version": "1.11",
|
||||
"read_commit": "b95ce798b4a9455d941d247a46865b3ac202bf2c",
|
||||
"task": "HEL-897(HEL-878 有限返修,视觉总监本人编码):修正重开语音被旧掉线结果覆盖——旧会话 fail() 在 await teardown 之后提交 OFF,覆盖已 listening 的新会话;并检查同一失败/结束/重开路径中 await 前后的代次与所有权。不重新设计,不改视觉",
|
||||
"requirement_ids": [
|
||||
"CG-010", "CG-018",
|
||||
"VIS-013",
|
||||
"UX-005", "UX-006",
|
||||
"GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006",
|
||||
"DEL-001"
|
||||
],
|
||||
"deliverable": "自 HEL-878 原稿(总管保全提交 b95ce798,13 文件哈希逐一核对一致)起的补丁,只动 web/src/voice/session.ts 与测试:1) start() 内 fail():代次与 live 释放后先发起 teardown(麦克风同步先停、房间关闭与 DELETE 已发出),立即在本会话仍拥有页面时提交 OFF 与失败提示,再等待清理完成;清理迟到返回只释放旧会话自己的麦克风/房间/服务端房间,之后不再写界面。2) start() 先同步取得新代次与 live,再等待被替换会话的清理(原先 await end() 之后才取代次:两次开启交错时,较早一次醒来会抢走代次、把已接通的新会话孤立在外,其麦克风与房间不再被释放);被替换后醒来即退出;settle 补发在取得代次之后。3) web/tests/voice.test.ts 新增「a late end of an older call」6 项(掉线/麦克风被撤销各一,worker 未加入超时,正常结束慢 DELETE,交错重开,迟到麦克风授权);Server 假件增加按会话挂起 DELETE。4) web/tests/review878_late_cleanup.test.ts:总管独立负例原样纳入。5) hel878_mock.mjs 增加 /__hold-delete 控制;hel878_shots.mjs 增加第 12 段后 HEL-897 浏览器复现 4 项。",
|
||||
"acceptance": [
|
||||
"总管负例 review878_late_cleanup.test.ts:修前失败(Expected listening / Received off,与总管日志一致),修后通过;测试未改",
|
||||
"掉线与麦克风被撤销后,旧 DELETE 与 settle 补发都挂起期间重开:新会话 listening、active=true、无旧提示;放行后界面不再变化;旧麦克风已停、旧房间已关,新麦克风/房间未被停;服务端只剩新房间;再结束后全部清空(单测各 1 项)",
|
||||
"worker 未加入超时失败且 DELETE 慢、正常结束 DELETE 慢后重开、两次开启交错(前一次仍在等旧房间归还)、迟到麦克风授权:只有最新会话存活,界面与 active 一致(单测 4 项;交错一项修前失败,暴露同路径的第二处代次漏洞)",
|
||||
"修前对照:同一测试集在原 session.ts 上 5 项失败(总管负例 + 新增 4 项),修后 33/33",
|
||||
"浏览器(无头 Edge,脚本化 LiveKit 替身与假音频设备):掉线后旧 DELETE 挂起,提示与停麦立即生效;新会话 listening;放行旧 DELETE 后新会话仍 listening、无旧提示、1 路活麦克风、她的音频在、服务端只剩新房间;结束后全部清理。修前构建 45/49(实测 phase=off 而新麦克风仍活),修后 49/49,原 45 项(结束/导航/Esc/结束交谈/断网/卸载/快速开关等退出路径)全部保持通过",
|
||||
"工程:tsc 0 错;eslint 0 错(1 条既有警告);vitest 8 文件 88/88;vite build 通过"
|
||||
],
|
||||
"deferred": [
|
||||
"CG-011 语音内主动开口、她说话时的增量字幕、断网时远端即时清理/租约与 API 重启实情、真实 SDK 回调与播放回执、真机连续三轮/打断/停麦/HTTPS/Safari 全部未测,仍交 HEL-879 统一审核;本卡单测与浏览器均为替身与假设备,不是 WebRTC 通话",
|
||||
"原版 Python 需求检查未在本运行时执行(无 Python、无 git),须总管对实际目标分支复跑(含/不含 handoff-HEL-878.json 与本交接);未用 Node 移植版冒充通过",
|
||||
"总管在前端分支从 04eff611 补齐的历史原话文件与 source-manifest 以总管保全分支为准;本补丁不触碰需求目录中除本交接以外的任何文件",
|
||||
"D1 音色、D3 设备信任由总管与白栖知沟通;R6/R7、CG-028/029 仍由主卡 HEL-797 承接;整体沉浸体验由白栖知验收"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"读取:总管 HEL-897 卡与附件(HEL-878-总管复核.md、preservation-878.json、review878-race.log、review878_late_cleanup.test.ts);本人 HEL-878 原稿树(逐文件 SHA-256 与 preservation-878.json 13 项一致后才施工,原稿另行备份);需求与 integration-plan R5/D1/D3 沿用 HEL-878 已读内容,本卡无需求变更",
|
||||
"web:tsc --noEmit 0;eslint src 0 错 1 既有警告;vitest run 8 文件 88/88(原 81 + 本卡 6 + 总管负例 1);vite build 通过;node_modules 沿用 HEL-878 离线树,未下载、未安装",
|
||||
"修前/修后:同一测试集与浏览器脚本分别在原 session.ts 与修正版上运行,日志随交付包",
|
||||
"无模型、嵌入、合成、识别请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台与 HEL-896 发布包"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"baseline_version": "1.11",
|
||||
"read_commit": "f92a86f5d8f839338311cdee08179049de34eb20",
|
||||
"task": "HEL-898(HEL-879 接线卡,视觉总监本人编码):在已认可的交谈界面接上她的增量字幕(cg.subtitle 契约 v2)与语音内主动开口呈现,处理打断/文字接管/错误/挂断收尾;不另做聊天页、不改主题与整体动效;f92a86f 统一复核后的有限返修三项(worker 离场诚实收场、已退休 speech 不复活、短语音回复播放中可见)及回放基础设施",
|
||||
"requirement_ids": [
|
||||
"CG-004", "CG-006", "CG-010", "CG-011", "CG-018", "CG-020",
|
||||
"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": "自总管输入包 3228b1e(INPUT-MANIFEST 逐文件 SHA-256)起的前端补丁,只动 web/src/voice/session.ts、web/src/chat/ChatPanel.tsx 与测试:1) session.ts 严格解析 v2 信封(v===2、正整数 seq、kind 白名单、角色与种类对应、她的事件必带 speech_id、assistant_final 必带 turn_id、proactive_* 必带正整数 message_id;不合格整条丢弃且不推进序号);每次通话(控制器代次)独立 seq 高水位,seq<=已应用即丢;按 speech 生命周期只接受当前打开的一轮,已关闭/被取代的 speech 不再显示,旧 speech 的高 seq 事件不能关闭新轮。VoiceView 新增 her(speech/text/state open|final|done/turnId/messageId/proactive),与用户 ASR 草稿 heard 分槽。2) turn_start 开轮,assistant_interim 整句替换,assistant_final 原子替换为落库文本并请页面回读 /api/messages,turn_end 关轮(无 final 的关轮按未成为正式回复即清),turn_cancel/error(φ)/user_final/文字接管 hush/结束/掉线/卸载/重开即时清;φ 原句仍在语音行以“未记入聊天”显示。3) proactive_start 以落库原文、同源 message_id 显示在她的位置,proactive_end 保留至记录或下一轮接替,proactive_cancel 即清;不发任何请求、不标已读、不开麦、不重 TTS、不进入正式记录。4) ChatPanel:她的语音行进入与文字回复相同的舞台槽位(当前一两句、自动续读、暂停/回看、全文),生长中沿用文字流的“最新一句先压住”规则,逐分片不重启动画;落库行到来时同一 turn 同一位置接替,行键不变不重放;被撤回的语音行以 instant 方式立即离场(不淡出);减少动态设置沿用既有逻辑。你用语音开始新一轮时与发送文字一致恢复自动呈现(原先暂停状态下她的回答不会进来)。5) 测试:voice.test.ts 改为 v2 帧并新增 11 项(录制样例三场景回放、信封负例、乱序/重放/迟到/双 handle、φ、主动开口及打断、文字接管/挂断/重开、舞台行键接替);fake-livekit.js 未带信封的帧自动套 v2;hel878_mock.mjs 增 /__proactive;hel878_shots.mjs 的她的 final 与 φ 改为 v2 生命周期;新增 hel898_shots.mjs(两主题×电脑/手机)。【f92a86f 有限返修,首轮读取 3228b1e;本轮输入包 166 文件 SHA-256 全符】1) livekit.ts 监听 RoomEvent.ParticipantDisconnected(vendor 2.22.4 原名 participantDisconnected,emit(participant, reason)),只在本房间她的 worker(按身份记住的 agent 或 isAgent)离开、且房内没有其他 agent 时报 agentLeft;房间关闭中不报。session.ts 的 RoomEvents 新增 agentLeft:仅当前代次且 agent 已接入时走既有 fail(清字幕/草稿、停麦、清播放、关房、DELETE 本次服务房,失败入欠账重试),中文提示 WORDS.agentLeft;普通参与者离场、旧房间迟到离场、用户主动挂断都不触发。2) 已关闭 speech 由 64 项窗口数组改为每次通话一个 Set,随通话代次释放;seq 严格门不变。3) 语音回复开放态改为 live=speaking:reading.pagesOf 对其最后一块只显示已成句部分(。!?!?… 及其后的收尾引号括号),未成句部分仍压住;文字流 live=generating 的规则不变。已显示的语音行之后只会在同键下增长(短句与下一句合并时):LineStage.grow 原位追加新字的既有墨迹,不重建行、不重放;减少动态时直接设文本。ChatPanel 签名对语音行计入文本,全文页“还在说…”也适用于 speaking;ReadingClock 对同键增长只延长停留时间。没有新布局或样式。4) 测试:hel879_frames 的 DOCUMENTED GAP 改为拒绝断言,并新增每通话独立用例;voice.test 新增短回复/长句/文字流不变用例;新增 hel898_agent_left.test.ts(真实 livekitTransport 适配器 + 脚本化 SDK 事件名,另有控制器负例);fake-livekit.js 增 ParticipantDisconnected/leave/lateLeave/guest;hel878_mock.mjs 增 /__pid、/__reset,监听失败即退出;hel879_unified_shots.mjs 修回放基础设施(端口空闲与 pid 确认、多段录制按当前通话单调重编号并写 seq-renumbering.json、新通话重置、每主题 /__reset 隔离、负例原样发送并断言拒绝),加三项正反例和 CDP screencast→WebM 录屏",
|
||||
"acceptance": [
|
||||
"假 LiveKit 有序回放 normal-turn.jsonl:用户草稿→用户终稿→turn_start→两次增量(整句替换)→final(带 turn_id)→turn_end;她的字在舞台出现时 /__rows 尚无该回复;落库后记录中只出现一次(页面只回读,不写)",
|
||||
"interrupt.jsonl 与负例:turn_cancel 后她的未完成行立即离场;被取消轮之后的高 seq 增量/final/重复 turn_start 均不显示;整段录制重放不改变界面;双 handle 时旧 speech 的 turn_end/turn_cancel 不关闭新轮;无 final 的 turn_end 视为未成为正式回复并清除",
|
||||
"φ:error 原句在语音行显示并标“未记入聊天”,她的增量行立即清除,随后 turn_cancel(error_phi) 不抹掉 φ 原句;不落正式记录",
|
||||
"proactive.jsonl:原文一字不差显示在她的位置并保留落库行身份(id/turn),可被你的语音打断(proactive_cancel 即清);全程页面无任何非 GET 请求(无回执、无已读、无消息写入),不调用 onSpoke/onRecord",
|
||||
"文字接管、结束语音、掉线、新通话:她的行即时清;旧房间迟到帧被代次挡住;新通话序号从 1 重新开始并正常显示",
|
||||
"两主题(月光/雾光)电脑 1440×900 与手机 390×844 截图与连续帧:沿用已认可位置与组件,无新增组件或样式;减少动态设置下她的字照常出现、无入场动画",
|
||||
"工程:tsc 0 错;eslint 0 错 1 条既有警告(ChatPanel fullButton ref,HEL-897 起即有);vitest 8 文件 99/99;vite build 通过;浏览器 hel898 61/61,HEL-878 回归 49/49;修前对照:新 voice.test.ts 在原 session.ts/ChatPanel 上 12 项失败",
|
||||
"f92a86f 返修-1:worker 离场(房间仍开)时通话即时结束:她的行清除,提示“她那边的语音中断了……”,麦克风轨 ended,播放容器移除,本次 session DELETE 恰好一次;普通参与者离场不挂断;旧房间迟到离场不影响新通话;用户主动挂断只有一次 DELETE、没有离场提示;DELETE 失败入欠账并重试(单测)",
|
||||
"f92a86f 返修-2:adversarial-gates 66 次挤出后,speech_A 以新 seq 重开/增量/终稿都被拒(单测与浏览器原样探针),之后新 speech 正常进入;新通话从 seq 1 开始,不受旧通话已退休 id 影响",
|
||||
"f92a86f 返修-3:worker-normal 回放中“在听呢,”“…那件事”未成句时舞台不显示她的半句;第三个增量成句后、final 之前即显示整句,并在播放期间保持;final 与落库行接替为同一 DOM 行(不重建、不重放);短句与下一句合并时同节点原位增长、首字墨迹不动;长句只显示已完成的行、不整段铺满;文字流仍压住最新块",
|
||||
"f92a86f 返修工程:tsc 0;eslint 0 错 1 条既有警告;vitest 10 文件 118/118;build 通过;修前对照 vitest 7 项失败;统一实际 worker 帧浏览器回放(moon/fog × 电脑/手机 + 减少动态)修后 53/53、修前 30/53(失败都落在三项及其连带);hel898_shots 61/61、hel878_shots 49/49;8 段 WebM 录屏取自真实 screencast 帧"
|
||||
],
|
||||
"deferred": [
|
||||
"真实通话未测:真实 LiveKit/WebRTC、真实 worker 与 SDK 回调时序、真机/Safari/iOS、可信 HTTPS+WSS、R5 连续三轮/1 秒打断/停麦无残音/有限预算,均仍由 HEL-797 主卡承接;本卡全部为假 LiveKit、假音频设备与 Chromium 无头 Edge",
|
||||
"CG-011 语音内主动开口仅是“会话开时念一条已落库旧留言”的最小接缝(后台默认关,本卡不启用);会话中持续主动联系、激活节奏、未读联动策略未完成,不得记为 CG-011 完成",
|
||||
"后台并行小修(建房延迟后的清理唤醒、主动 say 的 start 发得过晚、无完整 receipt 错误发 final)由原总工在 HEL-879 处理;本卡按 v2 正确语义实现,最终后台 commit 出来后需按其统一复核(特别是 proactive_start 晚于音频开始时的呈现)",
|
||||
"已知边界:seq 按“一次 worker job 一个序列”实现;若同一房间内 worker 重连且序号归零,前端会按契约丢弃其帧直到超过旧高水位——需后台确认 job 与房间一一对应",
|
||||
"原版 Python 需求检查(scripts/check_requirements.py --base-ref origin/main,含本交接)未在本运行时执行(无 Python、无 git),须总管对实际目标分支复跑",
|
||||
"D1 音色、cap=0、R6/R7、整体沉浸体验由主卡与白栖知决定;工程自测不是用户体验验收",
|
||||
"f92a86f 返修:真实 SDK 下 worker 离场事件的时序(含网络重连期间 remoteParticipants 被清空但不发离场事件)只按 vendor 源码核对,未在真实房间验证",
|
||||
"回放基础设施把多段录制重编号(原始帧保留在 fixtures,映射见 seq-renumbering.json),证明的是页面在“同一通话单调 seq”下的呈现与门,不证明 worker 跨 job 的 seq 行为"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"读取:HEL-898 卡(启动交接与原说明)、输入包 3228b1e:docs/voice-subtitle-contract-v2.md、docs/rework/hel879/voice-seam-interfaces.md、subtitle-samples/*.jsonl、src/cybergril/voice/seam.py 与 worker.py(事件发出点)、AGENTS.md、docs/requirements/README.md/current.md(v1.11)/coverage.md/changes.md、sources/integration-plan-v1.md R5",
|
||||
"工具:沿用本机既有离线工具链(HEL-834 留存 node v22.11.0;HEL-897 留存 web/node_modules,package-lock 哈希与本包一致,目录联接使用);未下载、未安装",
|
||||
"web:tsc --noEmit 0;eslint src 0 错 1 既有警告;vitest run 8 文件 99/99(原 88 + 新增);vite build 通过;日志随交付包",
|
||||
"浏览器:node tests/hel878_mock.mjs + node tests/hel898_shots.mjs(无头 Edge、脚本化 LiveKit 替身、Chromium 假音频设备)61/61;node tests/hel878_shots.mjs 回归 49/49;截图与连续帧随交付包",
|
||||
"无模型、嵌入、ASR、TTS 请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台、总装分支或 PR#11",
|
||||
"f92a86f 返修读取:卡内三项说明、HEL-898-统一复核返修输入-f92a86f.zip(166 文件 SHA-256 全符)、web/public/vendor/livekit-client-2.22.4.umd.min.js 中 RoomEvent.ParticipantDisconnected 的定义与 emit(participant, reason)、断开清理路径不发该事件;本包缺 docs/rework/hel879/subtitle-samples,测试运行时沿用 3228b1e 输入包同名文件(不入补丁)",
|
||||
"f92a86f 返修浏览器:node tests/hel879_unified_shots.mjs <msedge> <out> 8879 9499(自带 mock,先确认端口空闲与 pid);hel898_shots/hel878_shots 由本运行自起 mock(核对 pid 后按 PID 停止)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"baseline_version": "1.12",
|
||||
"read_commit": "8dc84f88e9d5e961e17b246f24c23eaf526b012e",
|
||||
"task": "HEL-899:把现有 CosyVoice 参考音频零样本克隆能力接入 CyberGril 统一 API(网页上传/录制参考音频→登记 cg- 专属 ID→试听现合成→应用到同一位她的后续通话);直接复用 .12 语音面板 8090 的 zero-shot 注册与 CosyVoice 热加载、9880 /tts 合成,不训练新模型、不重造克隆引擎、不自建第二写者;本卡只做后台/协议与零真实调用自测,前端按合同接视觉总监,真实试听放统一审核",
|
||||
"requirement_ids": [
|
||||
"CG-006",
|
||||
"CG-009",
|
||||
"CG-010",
|
||||
"CG-013",
|
||||
"CG-014",
|
||||
"CG-015",
|
||||
"CG-016",
|
||||
"CG-018",
|
||||
"CG-019",
|
||||
"CG-020",
|
||||
"CG-021",
|
||||
"AM-001",
|
||||
"GOV-001",
|
||||
"GOV-002",
|
||||
"GOV-003",
|
||||
"GOV-004",
|
||||
"GOV-005",
|
||||
"GOV-006",
|
||||
"DEL-001"
|
||||
],
|
||||
"deliverable": "src/cybergril/voice/custom.py(CustomVoiceService:8090 面板代理=共享 voices.json 唯一写者、cg-+8hex 服务端生成 ID 不撞既有 11 支、WAV 头服务端复验 RIFF/WAVE≤15MB≥2s、显式 allow_asr 才走 FunASR 转写、试听先验存在拦住 CosyVoice 未知音色静默回落 default、原子落盘+失败整体回滚、选择/元数据/资源流水持久在实例资料空间重启读回、备份清单 manifest);router.py 五个新端点(GET/POST /api/voice/voices、POST /api/voice/preview、GET/POST /api/voice/selection)+ /api/voice/session 响应新增 voice=本次通话捕获音色(选择失效诚实回落 default;未选择不代写 worker 文件不替 D1 定音色);session.py 会话记录 voice 字段+/health 名单读回;worker 语义不变(入口读一次→通话中改选最迟下一通话生效、绝不半句换人);契约文档 docs/voice-custom-contract-HEL899.md(前端可直接实现的请求/响应/错误/录音格式/部署与回滚);config/voice.example.yaml 增补(panel_url/tts_url/registry_file/worker_state_file,env 可覆盖);零新依赖(登记端点原始字节体+查询串,特意不引 multipart 库);tests/test_voice_custom.py 18 项 + scripts/verify_hel899_custom_voice.py 12/12;治理:v1.12(CG-010 边界更新,来源评论 01a17de1-2892-78be-a422-eb6a11ffebfd 逐字录入 sources/comment-evidence-custom-voice-20261027.json + manifest/baseline/coverage/changes 同步)",
|
||||
"acceptance": [
|
||||
"完整最小合同成立:列出可用声音(builtin 11 支+custom 标记、选择/试听状态、备份清单字段)、登记参考音频(服务端生成 cg- 专属 ID,不可能撞名覆盖既有 11 支,撞名重试不覆盖)、试听生成(每次现合成 WAV 24k/mono、无缓存不拿旧音频冒充)、读取/应用专属音色(换声音只换发声)全部经统一 API,浏览器不直连 8090/9880,凭据不下发",
|
||||
"当前 session/chat 业务不分叉:worker/统一聊天链零改动;通话开启捕获有效音色并写入会话记录与 worker 状态文件,通话中改选不影响本通话、最迟下一通话生效(测试断言会话 voice 定住)、不打断或误认原轮次/receipt(receipt 链路未动)",
|
||||
"原子与失败诚实:登记失败(面板 400/5xx/断连/谎报成功)不留下半条可选项、无元数据残留;应用失败整体回滚(选择状态与 worker 文件都回旧值);服务不可用一律 503 中文文案;ASR 仅在 allow_asr=true 显式准许且参考文字为空时发生(面板 FunASR,非 LLM),每次记 register_asr/preview_tts 流水可核算",
|
||||
"试听不写聊天/记忆/生活/回执(四表计数零变化测试断言);自定义音色未试听不得应用(409),内置 D1 候选不强制;未知音色 404 且 TTS 零调用(前置拦住 CosyVoice 静默回落 default);未知选择通话开启诚实回落 default 不冒充",
|
||||
"持久化与身份不变:选择/元数据/试听凭据重启读回(重建服务同批文件测试);换声音不改 companion/bazi_core/chat_message(逐字节对比测试);通用助手 voice_state/persona_state/既有音色零写入零删除(桩端点计数零);无人经 API 选择时不代写 worker 文件(D1 域不替用户定)",
|
||||
"零真实调用:全链 fake 面板/TTS/LiveKit 传输,零真实 LLM/嵌入/ASR/TTS、零模型下载/安装、零共享音色实写、零部署、零现网触碰、未访问 4080、未动 HEL-879 现场"
|
||||
],
|
||||
"deferred": [
|
||||
"真实 CosyVoice 试听/真实 FunASR 转写/克隆相似度:统一审核卡在获批环境实测,复用合法剩余额度,不另开账绕 HEL-879 上限(最长2小时/3次通话授权属总管测试执行,不在本卡)",
|
||||
"前端入口(上传/录音/试听/应用 UI 与入口呈现):视觉总监按 docs/voice-custom-contract-HEL899.md §2 契约接线;本卡未动 web/",
|
||||
"自定义音色删除与完整备份/恢复(CG-009/R6):本卡最小合同不含删除(失败/多余登记不影响使用),备份清单字段已就位,恢复=按清单重新登记(面板无导出接口)",
|
||||
"D1 最终音色决定仍归白栖知/总管;本轮测试音色已授权总管自定",
|
||||
"R5 整体真机验收与 R1–R7 完整要求继续承接;自定义音色交付不等于 R5 完成或整产品完成"
|
||||
],
|
||||
"requirement_changes": [
|
||||
{
|
||||
"summary": "v1.12:CG-010 音色边界更新为可自定义音色(网页上传/录制参考音频→试听→应用,直接复用已装TTS零样本克隆,不以仅选预设替代,参考包试听方案不采用);影响仅 CG-010 音色入口与测试授权,换声音只换发声等既有边界不变",
|
||||
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
|
||||
"record": "sources/comment-evidence-custom-voice-20261027.json + changes.json/changes.md v1.12 + baseline.json/coverage.json/current.md 同步"
|
||||
}
|
||||
],
|
||||
"verification": [
|
||||
"PYTHONPATH=src .venv311/bin/python -m pytest tests -q:370 通过(基线 8dc84f8 全量 352 + 本卡 18:tests/test_voice_custom.py,全 fake 面板/TTS/LiveKit httpx.MockTransport + 临时文件 + 隔离 registry,零网络零真实模型调用)",
|
||||
"scripts/verify_hel899_custom_voice.py:12/12 全过(真实 app+router+service,fake 传输:列表→无准许拒ASR→显式登记→未试听409→试听现合成→应用→会话捕获→通话中改选定住本通话→下一通话生效→capabilities.voice=false→重启读回选择/元数据/试听凭据)",
|
||||
"ruff check src tests scripts deploy:本卡代码 0 错(既有 deploy/hel835_guard.py 的 SIM115 遗留不变,非本卡)",
|
||||
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 8dc84f8(实际基准):含本 handoff 通过(v1.12 升版+新来源+变更记录合法,历史来源与变更未改写)",
|
||||
".12 只读取证(SSH 零写入零服务触碰):/opt/voice-agent/web/server.py 实读核对 upload 校验(名字规则/15MB/WAV magic/32000字节/空参考文本→FunASR 转写/失败删 wav/voices.json 原子写)、/opt/voice-agent/cosyvoice/server.py 实读核对 mtime 热加载与 /tts 未知音色静默回落 default、voices.json 11 支音色清单——契约按实际源码写,不按记忆",
|
||||
"未验证如实声明:真实 CosyVoice 合成与 FunASR 转写未调用(TTS 9880 停机中且本卡零真实调用);浏览器端录音上传未实现(前端合同已交);.12 未部署、未改任何现网配置/端口/数据"
|
||||
]
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
"comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
|
||||
"mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
|
||||
"other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
|
||||
"comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff"
|
||||
"comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
|
||||
"comment-evidence-custom-voice-20261027.json": "ed72bd78218e1c0b4ca644bf933d6bdb2e3aa8cd2c0333f3410f6b60845bd3cb"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
|
||||
"author_type": "member",
|
||||
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
|
||||
"content": ".12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行",
|
||||
"created_at": "2026-10-27T15:01:10Z",
|
||||
"parent_id": "01a17d45-f14e-7b62-9850-646e040c7318"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ok": true,
|
||||
"checks": 9,
|
||||
"fail_count": 0,
|
||||
"netguard": {
|
||||
"guard_loaded": true,
|
||||
"nonloopback_connect_attempts": 0,
|
||||
"nonloopback_dns_lookups": 0,
|
||||
"rejected": []
|
||||
},
|
||||
"service_epoch_isolation": "CYBERGRIL_MODELS loopback, caps 0, socket guard rejecting non-loopback"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 350 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
@@ -0,0 +1,39 @@
|
||||
# HEL-879 · D1 参考试听包(零合成,已交付)
|
||||
|
||||
> 交付时间 2026-10-27。性质:把服务器既有 11 支音色的 zero-shot 参考录音整理成
|
||||
> 可离线播放的 HTML 试听页 + 音频包,作为主卡 D1 拍板材料。**零 TTS 合成、零
|
||||
> 克隆、零共享音色切换、零下载安装**;对 .12 只做 SSH 只读复制,未改服务器任何
|
||||
> 文件与状态。
|
||||
|
||||
## 包内容(随本卡评论附件交付,不入仓库大媒体)
|
||||
|
||||
`HEL-879-D1-参考试听包.zip`(4.4MB,sha256
|
||||
`e20764f9f98cbfac5ef966227540a75c5061cf16d151e5d0c9d86b620e3972f9`):
|
||||
|
||||
- `index.html` —— 离线试听页(自包含,无外部依赖;解压后直接用浏览器打开),
|
||||
逐支列出音色 ID、时长/采样率、参考录音原文、播放器、文件哈希前缀;
|
||||
页首明确注明「参考录音 ≠ 实际合成效果」。
|
||||
- `manifest.json` —— 机器可读清单:11 支音色的 id / 文件 / prompt_text /
|
||||
服务器源路径 / sha256 / 字节数 / 秒数 / 采样率 / 声道(本目录留有同内容副本)。
|
||||
- `voice_samples/*.wav` —— 11 段参考录音原文件。
|
||||
|
||||
## 来源与取回方式(可复核)
|
||||
|
||||
- 音色登记:`192.168.200.12:/opt/voice-agent/cosyvoice/voices.json`(实读,
|
||||
11 个 ID:bailu、bajie、default、female_soft、haimianbaobao、jiaojiao、
|
||||
male_warm、nezha、rain、sunwukong、xiaoyun;每项 `prompt_wav` + `prompt_text`)。
|
||||
- 音频文件:`prompt_wav` 所指 wav(`voice_samples/<id>.wav` 10 支 +
|
||||
default 用 `CosyVoice/asset/zero_shot_prompt.wav`),`scp` 只读取回。
|
||||
- 完整性:每文件本地 sha256 与服务器 `sha256sum` 输出逐一相符(见
|
||||
manifest.json;default 为 IEEE float32/24kHz/3.5s,bailu 为 44.1kHz 立体声
|
||||
10s,其余 16k/32k 单声道 2.4–16.8s)。
|
||||
|
||||
## 边界(如实)
|
||||
|
||||
- 参考录音是各音色**来源说话人的真实录音**(zero-shot 克隆依据),不是
|
||||
CosyVoice 实际合成产物;实际合成效果与参考录音不同,需在获批测试窗口内
|
||||
真实合成复核(计入 TTS 预算)。
|
||||
- 参考录音原文为各来源录音本身的内容,按原样保留,仅作试听对照;其中数支
|
||||
为影视/角色向来源,是否适合「她」由白栖知试听后判断,工程不代选、不预筛。
|
||||
- 通用演示当前 `voice_state.json` = rain 是共享演示状态,不是本用户的选择依据。
|
||||
- 克隆新音色需写共享 `voices.json` 并触发共享 TTS 热加载,**需单独授权**,本包未做。
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"package": "HEL-879 D1 参考试听包(零合成)",
|
||||
"generated": "2026-10-27",
|
||||
"source_server": "192.168.200.12 (SSH 只读取回,未改服务器任何文件)",
|
||||
"source_registry": "/opt/voice-agent/cosyvoice/voices.json",
|
||||
"count": 11,
|
||||
"notes": [
|
||||
"参考录音 = 该音色 zero-shot 克隆的来源录音(音色来源说话人的真实录音),不是 CosyVoice 实际合成产物;实际合成效果与参考录音不同,需在获批测试窗口内真实合成复核(计入 TTS 预算)。",
|
||||
"通用演示当前使用 rain,是共享演示状态,不是本用户的选择依据。",
|
||||
"本包只读取得,未切换/修改任何共享音色状态;克隆需另行单独授权。"
|
||||
],
|
||||
"voices": [
|
||||
{
|
||||
"id": "default",
|
||||
"file": "voice_samples/zero_shot_prompt.wav",
|
||||
"prompt_text": "希望你以后能够做的比我还好呦。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/CosyVoice/asset/zero_shot_prompt.wav",
|
||||
"sha256": "c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6",
|
||||
"bytes": 334138,
|
||||
"seconds": 3.5,
|
||||
"sample_rate": 24000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "sunwukong",
|
||||
"file": "voice_samples/sunwukong.wav",
|
||||
"prompt_text": "俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖怪,哪里逃吃?俺老孙吧。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/sunwukong.wav",
|
||||
"sha256": "a0cf44a8ab659ebddb8bc4faffe88f717d14d2c66c09bc5ea657212826806e68",
|
||||
"bytes": 429746,
|
||||
"seconds": 13.4,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "nezha",
|
||||
"file": "voice_samples/nezha.wav",
|
||||
"prompt_text": "天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑,哈哈逆天改命。我吹喇叭滴答滴答,滴滴答。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/nezha.wav",
|
||||
"sha256": "9911a73e3f54c235dedb05c18f29356f41ba3b1e1b58811c4f766897f0fe1d10",
|
||||
"bytes": 305196,
|
||||
"seconds": 9.5,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "bajie",
|
||||
"file": "voice_samples/bajie.wav",
|
||||
"prompt_text": "师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云块,要不先去画个圆。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/bajie.wav",
|
||||
"sha256": "1f9c84ce8c10145ace5f615a428a08e2482f322363f037245878068aa95ed98a",
|
||||
"bytes": 424320,
|
||||
"seconds": 13.3,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "haimianbaobao",
|
||||
"file": "voice_samples/haimianbaobao.wav",
|
||||
"prompt_text": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/haimianbaobao.wav",
|
||||
"sha256": "c60912dfccd76390baa921d4c99b0120f528837608964290e96cad1221da9c9d",
|
||||
"bytes": 228726,
|
||||
"seconds": 7.1,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "female_soft",
|
||||
"file": "voice_samples/female_soft.wav",
|
||||
"prompt_text": "这点我说一下,就是因为那个就是摩根豪泽尔这本儿金钱心理学。其实在我们这行业里也算一本很有名名的书了。就我之前也读过。然后你说到这点啊,让我想起来他这本书里还挺。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/female_soft.wav",
|
||||
"sha256": "4a65d17099239711601cb17e28b7aa7b6149acd153b92fd0366c23902bd4f687",
|
||||
"bytes": 341192,
|
||||
"seconds": 10.7,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "male_warm",
|
||||
"file": "voice_samples/male_warm.wav",
|
||||
"prompt_text": "um a while we built eight hype looe test track adjacent to space acts just for a student competition。 um to encourage intiative ideas in transport and actually ends up being the the biggest um vacuum chamber in the world after the launch。 haveringcollider。",
|
||||
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/male_warm.wav",
|
||||
"sha256": "2c4109b2d685e1923ed66433eb08c92047a1f67510629a27edf49af4e5c606dd",
|
||||
"bytes": 539032,
|
||||
"seconds": 16.8,
|
||||
"sample_rate": 16000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "xiaoyun",
|
||||
"file": "voice_samples/xiaoyun.wav",
|
||||
"prompt_text": "盯着你看怎么啦,难得看你这么有意思的样子嘛",
|
||||
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/xiaoyun.wav",
|
||||
"sha256": "6ad3ca7a6f1d9ddd10c5647338200fc9913f7adc71d8495a61b6b8e18eadc87e",
|
||||
"bytes": 297004,
|
||||
"seconds": 4.6,
|
||||
"sample_rate": 32000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "rain",
|
||||
"file": "voice_samples/rain.wav",
|
||||
"prompt_text": "你是不是早就想睡我了呀",
|
||||
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/rain.wav",
|
||||
"sha256": "37db24611b999f2f2328fa19c5c6e26117b5ec5d3d2037068af2fc12c6ad25ba",
|
||||
"bytes": 152364,
|
||||
"seconds": 2.4,
|
||||
"sample_rate": 32000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "jiaojiao",
|
||||
"file": "voice_samples/jiaojiao.wav",
|
||||
"prompt_text": "怎么还有没射的啊,是我不够努力吗",
|
||||
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/jiaojiao.wav",
|
||||
"sha256": "6db2b2de2d59e22bb0badbb1a6325d68885eda409ad63f5941e5ccad6486fc9f",
|
||||
"bytes": 301484,
|
||||
"seconds": 4.7,
|
||||
"sample_rate": 32000,
|
||||
"channels": 1
|
||||
},
|
||||
{
|
||||
"id": "bailu",
|
||||
"file": "voice_samples/bailu.wav",
|
||||
"prompt_text": "烈火军校啊,招摇啊这种类型,我都是那种比较……呃……豪爽,比较开朗的那种类型,阳光大女孩儿,那种感觉",
|
||||
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/bailu.wav",
|
||||
"sha256": "3fbc0aff09118a9bc10bae87a837e6a3de6a30268c6ee01c727b56c49aecd26d",
|
||||
"bytes": 1769550,
|
||||
"seconds": 10.0,
|
||||
"sample_rate": 44100,
|
||||
"channels": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"head": "HEL-894 close ownership under cancellation",
|
||||
"real_llm_requests": 0,
|
||||
"real_embeddings": 0,
|
||||
"boundary": "deterministic 512 vectors, non-loopback forbidden",
|
||||
"protocol": "drain-phase cancel re-arms; finish-phase cancel keeps markers, refuses ticks, lands the layer-owned close exactly once; lock waiters never touch the owner's state",
|
||||
"checks": [
|
||||
{
|
||||
"name": "F1 cancelled caller does not return before its close lands",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "F1 markers stay: closing set, not yet closed, client untouched",
|
||||
"ok": true,
|
||||
"detail": "{\"closing\": true, \"closed\": false, \"client_usable\": true}"
|
||||
},
|
||||
{
|
||||
"name": "F1 tick during closing claims nothing and sends nothing",
|
||||
"ok": true,
|
||||
"detail": "{\"gateway_arrived\": false, \"gateway_posts\": 0, \"llm_used\": 0, \"batch\": {\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}}"
|
||||
},
|
||||
{
|
||||
"name": "F1 layer-owned close lands exactly once, no live workers",
|
||||
"ok": true,
|
||||
"detail": "{\"closed\": true, \"client_usable\": false, \"live_workers\": 0, \"close_calls\": 1, \"closing_still_set\": true, \"batch\": {\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}}"
|
||||
},
|
||||
{
|
||||
"name": "F1 no late error",
|
||||
"ok": true,
|
||||
"detail": "None"
|
||||
},
|
||||
{
|
||||
"name": "F1 reopened batch intact and unbilled",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null, \"row_done\": 0, \"llm_used\": 0}"
|
||||
},
|
||||
{
|
||||
"name": "F2 cancelled caller does not return before its close lands",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "F2 markers stay: closing set, not yet closed, client untouched",
|
||||
"ok": true,
|
||||
"detail": "{\"closing\": true, \"closed\": false, \"client_usable\": true}"
|
||||
},
|
||||
{
|
||||
"name": "F2 tick during closing claims nothing and sends nothing",
|
||||
"ok": true,
|
||||
"detail": "{\"gateway_arrived\": false, \"gateway_posts\": 0, \"llm_used\": 0, \"batch\": {\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}}"
|
||||
},
|
||||
{
|
||||
"name": "F2 layer-owned close lands exactly once, no live workers",
|
||||
"ok": true,
|
||||
"detail": "{\"closed\": true, \"client_usable\": false, \"live_workers\": 0, \"close_calls\": 1, \"closing_still_set\": true, \"batch\": {\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}}"
|
||||
},
|
||||
{
|
||||
"name": "F2 no late error",
|
||||
"ok": true,
|
||||
"detail": "None"
|
||||
},
|
||||
{
|
||||
"name": "F2 reopened batch intact and unbilled",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null, \"row_done\": 0, \"llm_used\": 0}"
|
||||
},
|
||||
{
|
||||
"name": "W owner close still pending, waiter cancel keeps the signal",
|
||||
"ok": true,
|
||||
"detail": "{\"active_close_pending\": true, \"signal_after_waiter_cancel\": true}"
|
||||
},
|
||||
{
|
||||
"name": "W new batch refused during the owner's close",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}"
|
||||
},
|
||||
{
|
||||
"name": "W original batch still actively owned while blocked",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"pending\", \"reason\": null, \"owner\": \"a6173b4a79a84dbd6378281c17b23127\", \"attempts\": 1, \"entry_ids\": null}"
|
||||
},
|
||||
{
|
||||
"name": "W original worker completes, one close, new batch untouched",
|
||||
"ok": true,
|
||||
"detail": "{\"original_batch\": {\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"494b27cf-6e99-4e9a-ad17-bc4c9d2f42ec\\\"]\"}, \"new_batch_still\": {\"state\": \"queued\", \"reason\": null, \"owner\": null, \"attempts\": 0, \"entry_ids\": null}, \"row_done\": 1, \"gateway_posts_after_release\": 1, \"client_closed\": true}"
|
||||
}
|
||||
],
|
||||
"finish_cancel_sdk_only": {
|
||||
"caller_done_after_cancel": false,
|
||||
"after_cancellation": {
|
||||
"closing": true,
|
||||
"closed": false,
|
||||
"client_usable": true
|
||||
},
|
||||
"tick_during_closing": {
|
||||
"gateway_arrived": false,
|
||||
"gateway_posts": 0,
|
||||
"llm_used": 0,
|
||||
"batch": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
}
|
||||
},
|
||||
"finish_landed": {
|
||||
"closed": true,
|
||||
"client_usable": false,
|
||||
"live_workers": 0,
|
||||
"close_calls": 1,
|
||||
"closing_still_set": true,
|
||||
"batch": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
}
|
||||
},
|
||||
"late_worker_error": null,
|
||||
"persisted": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null,
|
||||
"row_done": 0,
|
||||
"llm_used": 0
|
||||
}
|
||||
},
|
||||
"finish_cancel_then_store_close": {
|
||||
"caller_done_after_cancel": false,
|
||||
"after_cancellation": {
|
||||
"closing": true,
|
||||
"closed": false,
|
||||
"client_usable": true
|
||||
},
|
||||
"tick_during_closing": {
|
||||
"gateway_arrived": false,
|
||||
"gateway_posts": 0,
|
||||
"llm_used": 0,
|
||||
"batch": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
}
|
||||
},
|
||||
"finish_landed": {
|
||||
"closed": true,
|
||||
"client_usable": false,
|
||||
"live_workers": 0,
|
||||
"close_calls": 1,
|
||||
"closing_still_set": true,
|
||||
"batch": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
}
|
||||
},
|
||||
"late_worker_error": null,
|
||||
"persisted": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null,
|
||||
"row_done": 0,
|
||||
"llm_used": 0
|
||||
}
|
||||
},
|
||||
"waiter_cancel": {
|
||||
"active_close_pending": true,
|
||||
"signal_after_waiter_cancel": true,
|
||||
"new_batch_during_closing": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
},
|
||||
"original_batch_while_blocked": {
|
||||
"state": "pending",
|
||||
"reason": null,
|
||||
"owner": "a6173b4a79a84dbd6378281c17b23127",
|
||||
"attempts": 1,
|
||||
"entry_ids": null
|
||||
},
|
||||
"after_release": {
|
||||
"original_batch": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"494b27cf-6e99-4e9a-ad17-bc4c9d2f42ec\"]"
|
||||
},
|
||||
"new_batch_still": {
|
||||
"state": "queued",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
},
|
||||
"row_done": 1,
|
||||
"gateway_posts_after_release": 1,
|
||||
"client_closed": true
|
||||
}
|
||||
},
|
||||
"failures": []
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"head": "HEL-886 inflight lifecycle",
|
||||
"real_llm_requests": 0,
|
||||
"real_embeddings": 0,
|
||||
"boundary": "deterministic 512 vectors, non-loopback forbidden",
|
||||
"checks": [
|
||||
{
|
||||
"name": "L1_cancel_single_send_and_full_record",
|
||||
"ok": true,
|
||||
"detail": "arrived=1 concurrent=1 http_total=1 points=1 batch_live={'state': 'pending', 'owner': '9a6afb62871305fe6c7a056acf0b8797'} batch_concurrent=pending final={'state': 'done', 'reason': None, 'entry_ids': '[\"9838ab55-15ab-4872-b7cd-1ba91faea2d7\"]'} row_done=1"
|
||||
},
|
||||
{
|
||||
"name": "L2_lease_single_send_and_full_record",
|
||||
"ok": true,
|
||||
"detail": "arrived=1 concurrent=1 http_total=1 points=1 batch_live={'state': 'pending', 'owner': '8f42725681bcfda7043797c128eb0750'} batch_concurrent=pending final={'state': 'done', 'reason': None, 'entry_ids': '[\"e71ba931-650f-40a3-b571-5945829f6f7c\"]'} row_done=1"
|
||||
},
|
||||
{
|
||||
"name": "L3_shutdown_drains_live_worker_before_close",
|
||||
"ok": true,
|
||||
"detail": "arrived=1 waited=0.25s row_done_at=2000000 batch={'state': 'done', 'finished_at': 2000000}"
|
||||
},
|
||||
{
|
||||
"name": "L4a_all_done_finalizes_without_new_budget",
|
||||
"ok": true,
|
||||
"detail": "before={'state': 'done', 'entry_ids': '[\"80c8cb6b-e437-478c-a3fb-46d386ac9a07\"]'} after={'state': 'done', 'reason': None, 'entry_ids': '[\"80c8cb6b-e437-478c-a3fb-46d386ac9a07\"]'} http=1 llm_used=1"
|
||||
},
|
||||
{
|
||||
"name": "L4b_partial_keeps_the_hard_gate",
|
||||
"ok": true,
|
||||
"detail": "after={'state': 'queued', 'reason': 'semantic_llm_budget_exhausted'} http=0"
|
||||
},
|
||||
{
|
||||
"name": "L5_dead_owner_lease_still_recovers",
|
||||
"ok": true,
|
||||
"detail": "recovered={'state': 'failed', 'reason': 'lease_expired'} after={'state': 'done'} http=2"
|
||||
}
|
||||
],
|
||||
"gateway_requests_total": 7,
|
||||
"failures": []
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"started": "2026-10-26T19:35:46Z",
|
||||
"embedding_boundary": "deterministic 512-dimensional vectors installed before TextEmbedding construction; no model loaded; zero real embeddings",
|
||||
"external_network": "socket.connect rejects all non-loopback hosts",
|
||||
"install": {
|
||||
"python": "3.10.12",
|
||||
"mem0ai": "2.2.1",
|
||||
"qdrant-client": "1.20.0",
|
||||
"fastembed": "0.9.1",
|
||||
"mem0_path": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-venv/lib/python3.10/site-packages/mem0",
|
||||
"module_sha256": {
|
||||
"memory/main.py": "f329ca4a17af1fbefb32ac10d4cc7ae7ddbdb7435a87eb74c30915cff98b8b7c",
|
||||
"memory/storage.py": "5f5ecdadd2b260fd14f36a0f338336eb760275465dd1056a99a47e1ef07702bd",
|
||||
"embeddings/fastembed.py": "3ec2ae87df5d0bebedb8b5471366c3412975847458499f66097f7b8c9ac8c9b8",
|
||||
"llms/openai.py": "37e6d3c22b55df0731740f4e0868b95bd480ce44363227fe53461db328c5f67e",
|
||||
"vector_stores/qdrant.py": "da570579d5cad69e6f123fdfe32b725235a25604e70047666bec763ba6e2bf43"
|
||||
},
|
||||
"source_tarball_sha256": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23"
|
||||
},
|
||||
"adapter_has_llm_guard": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "C1_construct",
|
||||
"ok": true,
|
||||
"detail": "client_path=sdk-vector-store-client"
|
||||
},
|
||||
{
|
||||
"name": "C2_add_anchor_payload",
|
||||
"ok": true,
|
||||
"detail": "created=True payload_keys=['created_at', 'data', 'hash', 'input_sha256', 'src_group', 'text_lemmatized', 'turn_ids', 'turn_roles']"
|
||||
},
|
||||
{
|
||||
"name": "C2_add_user_scope",
|
||||
"ok": true,
|
||||
"detail": "user_id present in stored payload"
|
||||
},
|
||||
{
|
||||
"name": "C2_add_ledger_shape",
|
||||
"ok": true,
|
||||
"detail": "embed_items=3 llm_calls=1"
|
||||
},
|
||||
{
|
||||
"name": "C3_extraction_input_set",
|
||||
"ok": true,
|
||||
"detail": "old_memory=True new_row=True history_carry=True"
|
||||
},
|
||||
{
|
||||
"name": "C3b_unshared_canary_never_outbound",
|
||||
"ok": true,
|
||||
"detail": "queued_public_batches=2 leaked=False anchor_taint=False"
|
||||
},
|
||||
{
|
||||
"name": "C4_search_filters",
|
||||
"ok": true,
|
||||
"detail": "hits=3"
|
||||
},
|
||||
{
|
||||
"name": "C4_search_single_debit",
|
||||
"ok": true,
|
||||
"detail": "embed_delta=1 (pre-fix=2)"
|
||||
},
|
||||
{
|
||||
"name": "C6_concurrency_cap",
|
||||
"ok": true,
|
||||
"detail": "outcomes=['ok', 'refused'] used=3 cap=3"
|
||||
},
|
||||
{
|
||||
"name": "C7_reopen_after_close",
|
||||
"ok": true,
|
||||
"detail": "hits_after_reopen=3"
|
||||
},
|
||||
{
|
||||
"name": "C5_embed_zero_refuses",
|
||||
"ok": true,
|
||||
"detail": "err=semantic_embed_budget_not_approved llm_sent=0"
|
||||
},
|
||||
{
|
||||
"name": "C5_llm_zero_refuses",
|
||||
"ok": true,
|
||||
"detail": "err=semantic_llm_budget_not_approved llm_sent=0"
|
||||
}
|
||||
],
|
||||
"embed_rows_after_first_add": [
|
||||
{
|
||||
"kind": "authority_prefix",
|
||||
"items": 0,
|
||||
"note": "authority:synthetic-verify-prefix"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:batch:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:add"
|
||||
}
|
||||
],
|
||||
"final_embed_rows": [
|
||||
{
|
||||
"kind": "authority_prefix",
|
||||
"items": 0,
|
||||
"note": "authority:synthetic-verify-prefix"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:batch:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:batch:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:batch:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:add"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
},
|
||||
{
|
||||
"kind": "dense",
|
||||
"items": 1,
|
||||
"note": "product:search"
|
||||
}
|
||||
],
|
||||
"final_llm_used": 3,
|
||||
"finished": "2026-10-26T19:35:47Z",
|
||||
"gateway_requests_total": 3,
|
||||
"failures": [],
|
||||
"temp_dir": "/tmp/multica-task-29494715/hel874-real-sdk-8dkofioe"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"head": "HEL-885 rework",
|
||||
"real_llm_requests": 0,
|
||||
"real_embeddings": 0,
|
||||
"boundary": "deterministic 512 vectors, non-loopback forbidden",
|
||||
"checks": [
|
||||
{
|
||||
"name": "S0_positive_caps_without_authority_fail_closed",
|
||||
"ok": true,
|
||||
"detail": "missing=ledger_authority_required empty=ledger_authority_required"
|
||||
},
|
||||
{
|
||||
"name": "S1_p1_first_tick_partial",
|
||||
"ok": true,
|
||||
"detail": "state=queued reason=LLM extraction failed: semantic_llm_budget_exhausted http=1 completed=[2] points=1 per_row_anchor=True"
|
||||
},
|
||||
{
|
||||
"name": "S2_p1_restart_sends_only_missing_row",
|
||||
"ok": true,
|
||||
"detail": "state=done http=1 user_sent=True completed=[1, 2] entry_ids=[\"07401cdd-4964-4c74-aab5-516ab09cf5e4\", \"4d76b52d-186c-4b21-8bfa-e81341213589\"]"
|
||||
},
|
||||
{
|
||||
"name": "S3a_lease_expired_batch_recovers",
|
||||
"ok": true,
|
||||
"detail": "recovered={'state': 'failed', 'reason': 'lease_expired'} state_after=done http=2"
|
||||
},
|
||||
{
|
||||
"name": "S3b_concurrent_ticks_single_send",
|
||||
"ok": true,
|
||||
"detail": "http=2 states=['done']"
|
||||
},
|
||||
{
|
||||
"name": "S4a_aged_unknown_llm_reservation_kept",
|
||||
"ok": true,
|
||||
"detail": "second_allowed=False used_after_restart=1"
|
||||
},
|
||||
{
|
||||
"name": "S4b_aged_unknown_embed_reservation_kept",
|
||||
"ok": true,
|
||||
"detail": "second_allowed=False used=1 after_release=0"
|
||||
}
|
||||
],
|
||||
"gateway_requests_total": 6,
|
||||
"failures": []
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"head": "HEL-889 shutdown ownership & queued-cancel",
|
||||
"real_llm_requests": 0,
|
||||
"real_embeddings": 0,
|
||||
"boundary": "deterministic 512 vectors, non-loopback forbidden",
|
||||
"checks": [
|
||||
{
|
||||
"name": "Q1 queued snapshot shows phantom-liveness",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "Q1 cancel releases registry and requeues",
|
||||
"ok": true,
|
||||
"detail": "{\"registered_workers\": 0, \"batch\": {\"state\": \"queued\", \"reason\": \"queued_worker_cancelled\", \"owner\": null, \"attempts\": 0, \"entry_ids\": null}, \"row_done\": 0, \"stored_entries\": 0}"
|
||||
},
|
||||
{
|
||||
"name": "Q1 in-process recovery completes without restart",
|
||||
"ok": true,
|
||||
"detail": "{\"batch\": {\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"stub-1\\\"]\"}, \"registered_workers\": 0, \"stored_entries\": 1, \"row_done\": 1}"
|
||||
},
|
||||
{
|
||||
"name": "S1 aclose does NOT return while the worker is alive",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "S1 worker completes and flushes before store close",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"19039615-6f32-48e6-979b-715d2cd56fcb\\\"]\"}"
|
||||
},
|
||||
{
|
||||
"name": "S1 no late-worker error after honest drain",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "S1 durable reopen: done + row_done=1 + llm_used=1",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"19039615-6f32-48e6-979b-715d2cd56fcb\\\"]\", \"row_done\": 1, \"llm_used\": 1, \"gateway_posts\": 1}"
|
||||
},
|
||||
{
|
||||
"name": "S2 shutdown between rows requeues with first row proven",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"queued\", \"reason\": \"shutdown_between_rows\", \"owner\": null, \"attempts\": 0, \"entry_ids\": null, \"row_done\": 1, \"gateway_posts_during_shutdown\": 1}"
|
||||
},
|
||||
{
|
||||
"name": "S2 resume sends ONLY the missing row and completes",
|
||||
"ok": true,
|
||||
"detail": "{\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"f26d374a-858e-4dde-b2de-ae8af7057021\\\", \\\"258ab9a5-d523-45b0-ada1-d58fbda50ea7\\\"]\", \"row_done\": 2, \"gateway_posts_resume\": 2}"
|
||||
},
|
||||
{
|
||||
"name": "S3 cancelled aclose closes nothing and re-arms",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "S3 worker finishes done and a later aclose closes for real",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "S4 concurrent aclose drains once and closes once",
|
||||
"ok": true,
|
||||
"detail": "{\"batch\": {\"state\": \"done\", \"reason\": null, \"owner\": null, \"attempts\": 1, \"entry_ids\": \"[\\\"e553422c-1840-4ff4-b51c-a3586cc34179\\\"]\", \"row_done\": 1}, \"client_usable_after_close\": false}"
|
||||
}
|
||||
],
|
||||
"queued_cancel": {
|
||||
"queued": {
|
||||
"registered_workers": 1,
|
||||
"product_executor_queue": 1
|
||||
},
|
||||
"after_cancel": {
|
||||
"registered_workers": 0,
|
||||
"batch": {
|
||||
"state": "queued",
|
||||
"reason": "queued_worker_cancelled",
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null
|
||||
},
|
||||
"row_done": 0,
|
||||
"stored_entries": 0
|
||||
},
|
||||
"after_recovery": {
|
||||
"batch": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"stub-1\"]"
|
||||
},
|
||||
"registered_workers": 0,
|
||||
"stored_entries": 1,
|
||||
"row_done": 1
|
||||
}
|
||||
},
|
||||
"shutdown_timeout": {
|
||||
"deadline": {
|
||||
"aclose_returned_while_worker_alive": false,
|
||||
"batch_before_any_close": {
|
||||
"state": "pending",
|
||||
"reason": null,
|
||||
"owner": "4a048d364194a961365c30ee1e570233",
|
||||
"attempts": 1,
|
||||
"entry_ids": null
|
||||
},
|
||||
"client_usable": true
|
||||
},
|
||||
"before_store_close": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"19039615-6f32-48e6-979b-715d2cd56fcb\"]"
|
||||
},
|
||||
"late_worker_error": null,
|
||||
"persisted": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"19039615-6f32-48e6-979b-715d2cd56fcb\"]",
|
||||
"row_done": 1,
|
||||
"llm_used": 1,
|
||||
"gateway_posts": 1
|
||||
}
|
||||
},
|
||||
"between_rows": {
|
||||
"after_shutdown": {
|
||||
"state": "queued",
|
||||
"reason": "shutdown_between_rows",
|
||||
"owner": null,
|
||||
"attempts": 0,
|
||||
"entry_ids": null,
|
||||
"row_done": 1,
|
||||
"gateway_posts_during_shutdown": 1
|
||||
},
|
||||
"after_resume": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"f26d374a-858e-4dde-b2de-ae8af7057021\", \"258ab9a5-d523-45b0-ada1-d58fbda50ea7\"]",
|
||||
"row_done": 2,
|
||||
"gateway_posts_resume": 2
|
||||
}
|
||||
},
|
||||
"cancelled_aclose": {
|
||||
"after_cancel": {
|
||||
"client_usable": true,
|
||||
"batch": {
|
||||
"state": "pending",
|
||||
"reason": null,
|
||||
"owner": "a712e0367aa123b265b47e2d3223846a",
|
||||
"attempts": 1,
|
||||
"entry_ids": null
|
||||
},
|
||||
"closing_cleared": true
|
||||
},
|
||||
"after_worker": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"73f87468-f546-46d7-8116-37d4fe2818b1\"]"
|
||||
},
|
||||
"client_usable_after_real_close": false
|
||||
},
|
||||
"concurrent_aclose": {
|
||||
"batch": {
|
||||
"state": "done",
|
||||
"reason": null,
|
||||
"owner": null,
|
||||
"attempts": 1,
|
||||
"entry_ids": "[\"e553422c-1840-4ff4-b51c-a3586cc34179\"]",
|
||||
"row_done": 1
|
||||
},
|
||||
"client_usable_after_close": false
|
||||
},
|
||||
"failures": []
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"card": "HEL-879",
|
||||
"generated_by": "release-artifacts inventory (offline, zero downloads)",
|
||||
"release_code_commit": "01ff91985920162cddabc5f190d146d86793627d",
|
||||
"rework_head": "see delivery comment (branch hel-879-assembly, appended after 7f0a739; this inventory refreshed on that head)",
|
||||
"audit_evidence": "docs/rework/hel879/sdk-audit/sdk-audit.json (head c121c42e39174e0f627f129e7f7e5700f33acf26)",
|
||||
"single_install_entry": "deploy/hel895-release-candidate.md #4 (supersedes deploy/hel874-release.md #4)",
|
||||
"fixed_source": {
|
||||
"artifact": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/mem0-2c81af3.tar.gz",
|
||||
"sha256": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
|
||||
"lock_record": "deploy/mem0-stack.lock: 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
|
||||
"matches_lock": true,
|
||||
"source_commit": "2c81af3bbe27d9bccb09c772d583df9e9be72ee5"
|
||||
},
|
||||
"closure": {
|
||||
"total": 55,
|
||||
"missing": 41,
|
||||
"missing_pure_python": [
|
||||
"backoff",
|
||||
"coloredlogs",
|
||||
"distro",
|
||||
"fastembed",
|
||||
"filelock",
|
||||
"flatbuffers",
|
||||
"fsspec",
|
||||
"h2",
|
||||
"hpack",
|
||||
"httpcore2",
|
||||
"httpx2",
|
||||
"huggingface-hub",
|
||||
"humanfriendly",
|
||||
"hyperframe",
|
||||
"loguru",
|
||||
"mem0ai",
|
||||
"mpmath",
|
||||
"openai",
|
||||
"portalocker",
|
||||
"posthog",
|
||||
"pytz",
|
||||
"qdrant-client",
|
||||
"requests",
|
||||
"sniffio",
|
||||
"sympy",
|
||||
"tqdm",
|
||||
"truststore",
|
||||
"urllib3"
|
||||
],
|
||||
"missing_native": [
|
||||
"charset-normalizer",
|
||||
"greenlet",
|
||||
"grpcio",
|
||||
"hf-xet",
|
||||
"jiter",
|
||||
"mmh3",
|
||||
"numpy",
|
||||
"onnxruntime",
|
||||
"pillow",
|
||||
"protobuf",
|
||||
"py-rust-stemmers",
|
||||
"sqlalchemy",
|
||||
"tokenizers"
|
||||
]
|
||||
},
|
||||
"cache_reusable_reverified": [
|
||||
{
|
||||
"name": "greenlet",
|
||||
"version": "3.5.6",
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"entry": "3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"content_digest": "d7ae14625784af8a268e8c156533b2e9d7694680e80a8466c8e5808cc3f1a005"
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"entry": "3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"content_digest": "2fc3afa56933ea2cbaf6005c1e61565e769db1f202b3d8c53a67e59a2ed307f7"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pillow",
|
||||
"version": "12.3.0",
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"entry": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"content_digest": "02620d3560d2e91e0920c1045ed814b02a4d15d8c1c960f43a060739acf0dc1f"
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"entry": "12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"content_digest": "657f02aee576df196a22c130a65a3dd0f7438a8b859c2221448bea2dac6d7112"
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"entry": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"content_digest": "02620d3560d2e91e0920c1045ed814b02a4d15d8c1c960f43a060739acf0dc1f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "truststore",
|
||||
"version": "0.10.5",
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"entry": "0.10.5-py3-none-any",
|
||||
"content_digest": "54c5d4c83a1183954a6ca33169f8881c576160c722e93064ece271776ee2eac4"
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"entry": "0.10.5-py3-none-any",
|
||||
"content_digest": "54c5d4c83a1183954a6ca33169f8881c576160c722e93064ece271776ee2eac4"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"build_backend": {
|
||||
"hatchling_cached_or_local": true,
|
||||
"hatchling_version": "1.32.4",
|
||||
"hatchling_cache_evidence": [
|
||||
"uv wheels-v6: ~/.cache/uv/wheels-v6/pypi/hatchling/1.32.4-py3-none-any",
|
||||
"uv wheels-v7: ~/.cache/uv/wheels-v7/pypi/hatchling/1.32.4-py3-none-any",
|
||||
"uv archive-v0 unpacked: hatchling-1.32.4.dist-info (wheels-v6 + wheels-v7 linked copies)"
|
||||
],
|
||||
"offline_wheel_built": {
|
||||
"built_at": "2026-10-27T04:10:00Z",
|
||||
"command": "cd <extracted mem0-2c81af3 tarball> && uv build --wheel --offline --python 3.11 -o <out>",
|
||||
"tool": "uv 0.13.5, uv-managed CPython 3.11.16, build backend hatchling from the offline cache only",
|
||||
"source_tarball_sha256": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
|
||||
"source_commit": "2c81af3bbe27d9bccb09c772d583df9e9be72ee5",
|
||||
"wheel": "mem0ai-2.2.1-py3-none-any.whl",
|
||||
"wheel_sha256": "698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db",
|
||||
"wheel_bytes": 352586,
|
||||
"wheel_files": 153,
|
||||
"installed_anywhere": false,
|
||||
"network_used": false,
|
||||
"note": "HEL-879 勘误:v1 清单一面写 hatchling_cached_or_local=true 一面又称本机缺 hatchling,并把 hatchling 列入待下载——总管只读核查指出 uv v6/v7 均有 1.32.4 缓存。已核实并直接完成可离线构建:固定源码 tarball(sha 与 lock 一致)经缓存 hatchling 离线构建出上面这只 wheel,未安装、未联网、未混 3.10 site-packages。待下载清单据此只余 41 项运行闭包(不含 hatchling)。"
|
||||
}
|
||||
},
|
||||
"fastembed_models": {
|
||||
"cache_dir": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/fastembed-cache",
|
||||
"exists": true,
|
||||
"models": {
|
||||
"models--Qdrant--bge-small-zh-v1.5": {
|
||||
"files": 13,
|
||||
"bytes": 190444780
|
||||
},
|
||||
"models--Qdrant--bm25": {
|
||||
"files": 39,
|
||||
"bytes": 56247
|
||||
}
|
||||
},
|
||||
"default_cache_dir": "/home/xiaomo/.cache/fastembed",
|
||||
"default_cache_exists": false,
|
||||
"release_note": "真实 mem0 模式把 fastembed cache_dir 指向发布实例数据目录内的该缓存拷贝;本次零下载、不推理"
|
||||
},
|
||||
"downloads_this_card": 0,
|
||||
"note": "产物清单以 release_code_commit 为准;不要用旧父提交(04eff61/152479b/c121c42)说明冒充该 head。缓存命中为解包目录时记录内容摘要(目录无单一 sha256),安装时以 --offline 命中为准。"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"started": "2026-10-26T19:35:15Z",
|
||||
"venv_python": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-879-3c721d56b938/workdir/CyberGril/.venv311/bin/python",
|
||||
"venv_python_version": "3.11.16",
|
||||
"checks": [
|
||||
{
|
||||
"name": "R1_checkout_fresh_dir",
|
||||
"ok": true,
|
||||
"detail": "cloned tracked source into /tmp/multica-task-29494715/hel882-release-jk7k2a25/cybergril-hel874/current"
|
||||
},
|
||||
{
|
||||
"name": "R2_service_imports_product",
|
||||
"ok": true,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"name": "R6_mem0_coimport_status_recorded",
|
||||
"ok": true,
|
||||
"detail": "import mem0 → ModuleNotFoundError: No module named 'mem0' (resolution path in deploy/hel874-release.md §4; no download in this check)"
|
||||
},
|
||||
{
|
||||
"name": "R7_pip_download_command_exists",
|
||||
"ok": true,
|
||||
"detail": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-879-3c721d56b938/workdir/CyberGril/.venv311/bin/python -m pip download --help → rc=0"
|
||||
},
|
||||
{
|
||||
"name": "R7_removed_uv_pip_download_still_absent",
|
||||
"ok": true,
|
||||
"detail": "uv pip download --help → rc=2 (kept absent on purpose; do not re-document it)"
|
||||
},
|
||||
{
|
||||
"name": "R2_service_serves_health",
|
||||
"ok": true,
|
||||
"detail": "version=R4-assembly semantic=stub"
|
||||
},
|
||||
{
|
||||
"name": "R3_create_status_recorded",
|
||||
"ok": true,
|
||||
"detail": "create status=201(409=前次运行的档案仍在,也是保留数据的表现)"
|
||||
},
|
||||
{
|
||||
"name": "R3_dialogue_refused_honestly",
|
||||
"ok": true,
|
||||
"detail": "http=200 events=b'data: {\"t\": \"chat.error\", \"epoch\": 1, \"turn_id\": \"00000000-0000-4000-8000-000000000001\", \"phi\": \"\\xe5\\xa5\\xb9\\xe8\\xbf\\x99\\xe4\\xbc\\x9a\\xe5\\x84\\xbf\\xe6\\xb2\\xa1\\xe6\\x8e\\xa5\\xe4\\xb8\\x8a\\xe8\\xaf'"
|
||||
},
|
||||
{
|
||||
"name": "R4_service_restarts_on_same_data",
|
||||
"ok": true,
|
||||
"detail": "epoch=2"
|
||||
},
|
||||
{
|
||||
"name": "R5_epoch_bumped_on_reopen",
|
||||
"ok": true,
|
||||
"detail": "1 → 2"
|
||||
},
|
||||
{
|
||||
"name": "R4_companion_created_offline",
|
||||
"ok": true,
|
||||
"detail": "status=409"
|
||||
},
|
||||
{
|
||||
"name": "R4_dialogue_stream_http",
|
||||
"ok": true,
|
||||
"detail": "status=200 events=b'data: {\"t\": \"chat.delta\", \"epoch\": 2, \"turn_id\": \"00000000-0000-4000-8000-000000000002\", \"delta\": \"\\xe6\\x89\\xb9\"}\\n\\ndata: {\"t\": \"c'"
|
||||
},
|
||||
{
|
||||
"name": "R4_messages_roundtrip",
|
||||
"ok": true,
|
||||
"detail": "roles=['user', 'user', 'companion']"
|
||||
},
|
||||
{
|
||||
"name": "R5_data_dir_preserved",
|
||||
"ok": true,
|
||||
"detail": "['companion.db']"
|
||||
},
|
||||
{
|
||||
"name": "R5_companion_row_survives_restart",
|
||||
"ok": true,
|
||||
"detail": "companion rows=1"
|
||||
}
|
||||
],
|
||||
"mem0_import_in_release_venv": "ModuleNotFoundError: No module named 'mem0'",
|
||||
"removed_uv_pip_download_rc": 2,
|
||||
"failures": [],
|
||||
"finished": "2026-10-26T19:35:18Z"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"ok": true,
|
||||
"code_commit": "7c73d392b95f3d55091a066db0ffe9bc6c475e0d",
|
||||
"tool_version": 3,
|
||||
"checks": [
|
||||
{
|
||||
"name": "naive_online_copy_loses_wal_reproduced",
|
||||
"ok": true,
|
||||
"detail": "error=no such table: companion"
|
||||
},
|
||||
{
|
||||
"name": "busy_checkpoint_state_reproduced",
|
||||
"ok": true,
|
||||
"detail": "checkpoint=(1, 9, 8) naive_snapshot_events=error=DatabaseError:database disk image is malformed"
|
||||
},
|
||||
{
|
||||
"name": "fix1_busy_checkpoint_fails_no_archive",
|
||||
"ok": true,
|
||||
"detail": "error=yes leftovers=0"
|
||||
},
|
||||
{
|
||||
"name": "fix1b_row_mismatch_fails_no_archive",
|
||||
"ok": true,
|
||||
"detail": "error=源/快照行数不一致,不打包:/tmp/multica-task-3939683741/hel879-snapshot-s artifacts=[]"
|
||||
},
|
||||
{
|
||||
"name": "fix_verify_rejects_rows_match_false_legacy_package",
|
||||
"ok": true,
|
||||
"detail": "ok=False checks=[{'db': 'databases/companion.db', 'restored': '/tmp/multica-task-3939683741/hel879-snapshot-selftest-a4xt_3p_/verify-restore-legacy-pkg.tar-20261027-042723/by-source/tmp__multica-task-3939683741__hel879-snapshot-selftest-a4xt_3p___instance__companion.db', 'sha256_ok': True, 'rows_ok': False, 'counts': {'companion': 1, 'chat_message': 1, 'events': 1}, 'source_counts': {'companion': 1, 'chat_message': 1, 'events': 1}, 'snapshot_counts': {'companion': 1, 'chat_message': 1, 'events': 0}, 'rows_match_recorded': False, 'rejected_legacy_package': True}]"
|
||||
},
|
||||
{
|
||||
"name": "fix2_online_mode_refuses_dir_with_sqlite",
|
||||
"ok": true,
|
||||
"detail": "在线模式只允许显式单库 backup API(--db);--file /tmp/multica-task-3939683741/hel879-snapshot-selftest-a4xt_3p_/instance/history_root"
|
||||
},
|
||||
{
|
||||
"name": "fix2_stop_mode_embeds_sqlite_via_consistency_path",
|
||||
"ok": true,
|
||||
"detail": "embedded=['/tmp/multica-task-3939683741/hel879-snapshot-selftest-a4xt_3p_/instance/history_root/mem0_public_history.db']"
|
||||
},
|
||||
{
|
||||
"name": "fix3_verify_maps_source_paths_and_passes",
|
||||
"ok": true,
|
||||
"detail": "ok=True commit=7c73d392b95f3d55091a066db0ffe9bc6c475e0d plan_entries=4"
|
||||
},
|
||||
{
|
||||
"name": "backup_api_snapshot_complete",
|
||||
"ok": true,
|
||||
"detail": "{\"companion\": 1, \"chat_message\": 1, \"events\": 1}"
|
||||
},
|
||||
{
|
||||
"name": "fix_rows_match_true_cannot_bypass_source_counts",
|
||||
"ok": true,
|
||||
"detail": "ok=False counts={'companion': 1, 'chat_message': 1, 'events': 1} source={'companion': 1, 'chat_message': 1, 'events': 2}"
|
||||
},
|
||||
{
|
||||
"name": "fix_same_basename_no_overwrite_and_duplicate_refused",
|
||||
"ok": true,
|
||||
"detail": "snap_names=['ledger.db', 'ledger__0e75f128.db'] verify_ok=True restored_rows=['first', 'second'] dup_refused=yes"
|
||||
},
|
||||
{
|
||||
"name": "restore_drill_paths_persist",
|
||||
"ok": true,
|
||||
"detail": "restore_root=/tmp/multica-task-3939683741/hel879-snapshot-selftest-a4xt_3p_/same-restore"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
{
|
||||
"head": "7c73d392b95f3d55091a066db0ffe9bc6c475e0d",
|
||||
"scope": "只读审计:3.10 固定栈安装元数据按 3.11 Linux markers+extras 求闭包,对照产品 3.11 venv 与本机 uv/pip 缓存;这是依赖闭包清单,不是已完成的 cp311 解析或下载结果",
|
||||
"read_only": true,
|
||||
"zero_downloads_this_card": true,
|
||||
"closure_total": 55,
|
||||
"missing_count": 41,
|
||||
"missing_pure_python": [
|
||||
"backoff",
|
||||
"coloredlogs",
|
||||
"distro",
|
||||
"fastembed",
|
||||
"filelock",
|
||||
"flatbuffers",
|
||||
"fsspec",
|
||||
"h2",
|
||||
"hpack",
|
||||
"httpcore2",
|
||||
"httpx2",
|
||||
"huggingface-hub",
|
||||
"humanfriendly",
|
||||
"hyperframe",
|
||||
"loguru",
|
||||
"mem0ai",
|
||||
"mpmath",
|
||||
"openai",
|
||||
"portalocker",
|
||||
"posthog",
|
||||
"pytz",
|
||||
"qdrant-client",
|
||||
"requests",
|
||||
"sniffio",
|
||||
"sympy",
|
||||
"tqdm",
|
||||
"truststore",
|
||||
"urllib3"
|
||||
],
|
||||
"missing_native": [
|
||||
"charset-normalizer",
|
||||
"greenlet",
|
||||
"grpcio",
|
||||
"hf-xet",
|
||||
"jiter",
|
||||
"mmh3",
|
||||
"numpy",
|
||||
"onnxruntime",
|
||||
"pillow",
|
||||
"protobuf",
|
||||
"py-rust-stemmers",
|
||||
"sqlalchemy",
|
||||
"tokenizers"
|
||||
],
|
||||
"cache_reusable_now": [
|
||||
"greenlet",
|
||||
"pillow",
|
||||
"truststore"
|
||||
],
|
||||
"top_level": {
|
||||
"mem0ai": {
|
||||
"name": "mem0ai",
|
||||
"version": "2.2.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}",
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "fixed-source"
|
||||
},
|
||||
"qdrant-client": {
|
||||
"name": "qdrant-client",
|
||||
"version": "1.20.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
"fastembed": {
|
||||
"name": "fastembed",
|
||||
"version": "0.9.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
}
|
||||
},
|
||||
"missing_detail": [
|
||||
{
|
||||
"name": "backoff",
|
||||
"version": "2.2.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "charset-normalizer",
|
||||
"version": "3.5.2",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_17_x86_64",
|
||||
"cp310-cp310-manylinux2014_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "coloredlogs",
|
||||
"version": "15.0.1",
|
||||
"wheel_tags": [
|
||||
"py2-none-any",
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "distro",
|
||||
"version": "1.9.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "fastembed",
|
||||
"version": "0.9.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "filelock",
|
||||
"version": "4.1.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "flatbuffers",
|
||||
"version": "25.12.19",
|
||||
"wheel_tags": [
|
||||
"py2-none-any",
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "fsspec",
|
||||
"version": "2026.9.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "greenlet",
|
||||
"version": "3.5.6",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_24_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/greenlet/3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"name": "3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"sha256": null
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/greenlet/3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"name": "3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64",
|
||||
"sha256": null
|
||||
}
|
||||
],
|
||||
"cache_hits_usable_for_311": [
|
||||
"3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64"
|
||||
],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "cache-reusable"
|
||||
},
|
||||
{
|
||||
"name": "grpcio",
|
||||
"version": "1.84.1",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_17_x86_64",
|
||||
"cp310-cp310-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "h2",
|
||||
"version": "4.4.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "hf-xet",
|
||||
"version": "1.7.0",
|
||||
"wheel_tags": [
|
||||
"cp38-abi3-manylinux_2_17_x86_64",
|
||||
"cp38-abi3-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "hpack",
|
||||
"version": "4.2.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "httpcore2",
|
||||
"version": "2.14.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "httpx2",
|
||||
"version": "2.14.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "huggingface-hub",
|
||||
"version": "1.33.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "humanfriendly",
|
||||
"version": "10.0",
|
||||
"wheel_tags": [
|
||||
"py2-none-any",
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "hyperframe",
|
||||
"version": "6.1.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "jiter",
|
||||
"version": "0.18.0",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_17_x86_64",
|
||||
"cp310-cp310-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "loguru",
|
||||
"version": "0.7.3",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.2.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}",
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "fixed-source"
|
||||
},
|
||||
{
|
||||
"name": "mmh3",
|
||||
"version": "5.3.1",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_5_x86_64",
|
||||
"cp310-cp310-manylinux1_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "mpmath",
|
||||
"version": "1.3.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "numpy",
|
||||
"version": "2.2.6",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_17_x86_64",
|
||||
"cp310-cp310-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "onnxruntime",
|
||||
"version": "1.23.2",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_27_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "openai",
|
||||
"version": "3.37.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "pillow",
|
||||
"version": "12.3.0",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_27_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/pillow/12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"name": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"sha256": null
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/pillow/12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"name": "12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"sha256": null
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/pillow/12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"name": "12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64",
|
||||
"sha256": null
|
||||
}
|
||||
],
|
||||
"cache_hits_usable_for_311": [
|
||||
"12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64"
|
||||
],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "cache-reusable"
|
||||
},
|
||||
{
|
||||
"name": "portalocker",
|
||||
"version": "4.4.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "posthog",
|
||||
"version": "7.75.11",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "protobuf",
|
||||
"version": "6.33.6",
|
||||
"wheel_tags": [
|
||||
"cp39-abi3-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "py-rust-stemmers",
|
||||
"version": "0.1.8",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "pytz",
|
||||
"version": "2026.5",
|
||||
"wheel_tags": [
|
||||
"py2-none-any",
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "qdrant-client",
|
||||
"version": "1.20.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"version": "2.34.2",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "sniffio",
|
||||
"version": "1.3.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "sqlalchemy",
|
||||
"version": "2.0.55",
|
||||
"wheel_tags": [
|
||||
"cp310-cp310-manylinux_2_17_x86_64",
|
||||
"cp310-cp310-manylinux2014_x86_64",
|
||||
"cp310-cp310-manylinux_2_28_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "sympy",
|
||||
"version": "1.14.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "tokenizers",
|
||||
"version": "0.23.3",
|
||||
"wheel_tags": [
|
||||
"cp310-abi3-manylinux_2_17_x86_64",
|
||||
"cp310-abi3-manylinux2014_x86_64"
|
||||
],
|
||||
"native_extension": true,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "3.11 目标需 cp311-cp311(或 cp39+ abi3)manylinux x86_64 原生 wheel;3.10 安装标签不可复制,禁混 site-packages",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "tqdm",
|
||||
"version": "4.70.1",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
},
|
||||
{
|
||||
"name": "truststore",
|
||||
"version": "0.10.5",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [
|
||||
{
|
||||
"kind": "uv:wheels-v7",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v7/pypi/truststore/0.10.5-py3-none-any",
|
||||
"name": "0.10.5-py3-none-any",
|
||||
"sha256": null
|
||||
},
|
||||
{
|
||||
"kind": "uv:wheels-v6",
|
||||
"path": "/home/xiaomo/.cache/uv/wheels-v6/pypi/truststore/0.10.5-py3-none-any",
|
||||
"name": "0.10.5-py3-none-any",
|
||||
"sha256": null
|
||||
}
|
||||
],
|
||||
"cache_hits_usable_for_311": [
|
||||
"0.10.5-py3-none-any",
|
||||
"0.10.5-py3-none-any"
|
||||
],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "cache-reusable"
|
||||
},
|
||||
{
|
||||
"name": "urllib3",
|
||||
"version": "2.8.0",
|
||||
"wheel_tags": [
|
||||
"py3-none-any"
|
||||
],
|
||||
"native_extension": false,
|
||||
"installed_sdk": true,
|
||||
"present_product_311": false,
|
||||
"product_version": null,
|
||||
"direct_url": null,
|
||||
"cache_hits": [],
|
||||
"cache_hits_usable_for_311": [],
|
||||
"resolution": "纯 Python py3-none-any:同版本 wheel 可跨 3.10/3.11 使用",
|
||||
"classification": "missing-download-at-release"
|
||||
}
|
||||
],
|
||||
"mem0_identity": {
|
||||
"direct_url_of_installed": "{\"dir_info\": {}, \"url\": \"file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src\"}",
|
||||
"fixed_source_dir": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-src",
|
||||
"fixed_source_dir_exists": true,
|
||||
"pinned_tarball": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/mem0-2c81af3.tar.gz",
|
||||
"pinned_tarball_sha256_measured": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
|
||||
"pinned_tarball_sha256_lock": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
|
||||
"tarball_matches_lock": true,
|
||||
"index_wheel_2_2_1_proves_same_code": false,
|
||||
"note": "索引下载的 mem0ai==2.2.1 wheel 不自动等于固定源码;发布必须用本 tarball/源码目录离线构建安装",
|
||||
"built_wheel_from_hel857_install": {
|
||||
"filename": "mem0ai-2.2.1-py3-none-any.whl",
|
||||
"size": 352586,
|
||||
"sha256": "698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db",
|
||||
"ephemeral_wheel_cache": "/tmp/multica-task-1816511421 (不再存在)",
|
||||
"persisted_anywhere": false
|
||||
},
|
||||
"offline_build_backend": {
|
||||
"hatchling_cached_or_local": true,
|
||||
"note": "离线构建 mem0 wheel 需要 hatchling 及其依赖可离线获得;未命中则发布卡需一并拉取"
|
||||
}
|
||||
},
|
||||
"fastembed_models": {
|
||||
"cache_dir": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir/hel857-data/fastembed-cache",
|
||||
"exists": true,
|
||||
"models": {
|
||||
"models--Qdrant--bge-small-zh-v1.5": {
|
||||
"files": 13,
|
||||
"bytes": 190444780
|
||||
},
|
||||
"models--Qdrant--bm25": {
|
||||
"files": 39,
|
||||
"bytes": 56247
|
||||
}
|
||||
},
|
||||
"default_cache_dir": "/home/xiaomo/.cache/fastembed",
|
||||
"default_cache_exists": false,
|
||||
"release_note": "真实 mem0 模式把 fastembed cache_dir 指向发布实例数据目录内的该缓存拷贝;本次零下载、不推理"
|
||||
},
|
||||
"pip_2622_event": {
|
||||
"claim_under_review": "HEL-895 seq48–51:uv pip install --offline pip 失败后去掉 --offline 安装 pip 26.2.2 成功;前卡日志仅存尾两行",
|
||||
"installed_into": "/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-895-0261e950b9c2/workdir/CyberGril/.venv311/lib/python3.11/site-packages/pip-26.2.2.dist-info",
|
||||
"installer": "uv",
|
||||
"uv_cache_entry_created": "2026-10-27T02:10:04.960958",
|
||||
"uv_http_cache_created": "2026-10-27T02:10:04.972959",
|
||||
"recorded_source_url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl",
|
||||
"recorded_sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70",
|
||||
"other_new_cache_entries_in_window": [],
|
||||
"verdict": "勘误成立:uv 缓存中 pip-26.2.2 wheel 条目创建于 2026-10-27 02:10:04 +0800(HEL-895 运行窗口内),来源 files.pythonhosted.org,且是该窗口唯一新增缓存条目——该步发生了网络获取,前卡『零下载』对此步不成立;requirements.lock 装机同期未产生任何新缓存条目(全部命中既有缓存)。字节级『完整下载 vs 条件请求』无法由缓存取证区分,按保守口径记为下载。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# HEL-879 rework: real-SDK check regenerated from THIS head's voice sources
|
||||
# head: 78a5cfd8d75e3cfd00c60dc5c3592eb08cc757a1
|
||||
# run: 2026-10-26T20:16:05Z, .12 /opt/voice-agent/agent/venv/bin/python (livekit-agents 1.8.8), stdin-only, zero writes zero network, no service touched
|
||||
# generator: scripts/gen_hel877_sdk_check.py (10 checks; #9/#10 new: worker callback path on real SpeechHandle/SpeechCreatedEvent; assistant_interim seam through real LLMStream)
|
||||
|
||||
HEL-877/879 real-SDK check on livekit-agents 1.8.8: ALL PASS
|
||||
OK CyberGrillLLM/ChatApiLLMStream subclass real livekit-agents 1.8.8 LLM/LLMStream
|
||||
OK latest_user_text reads a real ChatContext (items/role/text_content)
|
||||
OK fake-SSE turn through real LLMStream emits ChatChunks, final text and receipt
|
||||
OK chat.error phi is spoken verbatim via ChatChunk and carries no receipt
|
||||
OK mint_token claims byte-parity with livekit.api.AccessToken (video/iss/sub/keys)
|
||||
OK mint_server_token matches SDK no-grant auth header claims
|
||||
OK worker.py SDK surface (SpeechHandle, UserInputTranscribedEvent.is_final) present
|
||||
OK barge-in task cancel closes the in-flight HTTP stream immediately
|
||||
OK worker callback path on real SpeechHandle/SpeechCreatedEvent: no-generation handle discards, next turn keeps its own stream (explicit binding)
|
||||
OK assistant_interim seam: SSE deltas through real LLMStream feed her incremental subtitle payloads (replace semantics, reset works)
|
||||
@@ -0,0 +1,9 @@
|
||||
# HEL-896 · 固定 SDK 离线余缺审计摘要(只读)
|
||||
|
||||
- 闭包 55 项,产品 3.11 venv 缺 41 项:纯 Python 28 项、原生扩展 13 项(charset-normalizer, greenlet, grpcio, hf-xet, jiter, mmh3, numpy, onnxruntime, pillow, protobuf, py-rust-stemmers, sqlalchemy, tokenizers)。
|
||||
- 三个顶层包 WHEEL 均 py3-none-any(纯 Python,可跨 3.10/3.11)。
|
||||
- mem0ai 固定源码身份:tarball SHA256 一致(9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23);索引同名 wheel 不作数;hel857 构建过的 wheel(698284b6083a…)在临时缓存、未持久。
|
||||
- 当前缓存可直接复用的缺项 wheel:['greenlet', 'pillow', 'truststore']。
|
||||
- 其余缺项(纯 Python wheel 与 cp311 原生 wheel)由发布卡按 deploy/hel874-release.md §4 获授权后下载并 sha256 留证;缺项不阻塞其余发布工作。
|
||||
- fastembed 模型缓存实测:{"models--Qdrant--bge-small-zh-v1.5": {"files": 13, "bytes": 190444780}, "models--Qdrant--bm25": {"files": 39, "bytes": 56247}}(hel857-data/fastembed-cache,随发布实例落位,零下载)。
|
||||
- pip 26.2.2 事件:勘误成立:uv 缓存中 pip-26.2.2 wheel 条目创建于 2026-10-27 02:10:04 +0800(HEL-895 运行窗口内),来源 files.pythonhosted.org,且是该窗口唯一新增缓存条目——该步发生了网络获取,前卡『零下载』对此步不成立;requirements.lock 装机同期未产生任何新缓存条目(全部命中既有缓存)。字节级『完整下载 vs 条件请求』无法由缓存取证区分,按保守口径记为下载。
|
||||
@@ -0,0 +1,16 @@
|
||||
/opt/voice-agent/agent/venv/lib/python3.12/site-packages/jwt/api_jwt.py:149: InsecureKeyLengthWarning: The HMAC key is 6 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
return self._jws.encode(
|
||||
/opt/voice-agent/agent/venv/lib/python3.12/site-packages/jwt/api_jwt.py:370: InsecureKeyLengthWarning: The HMAC key is 6 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
|
||||
decoded = self.decode_complete(
|
||||
HEL-877/879 real-SDK check on livekit-agents 1.8.8: ALL PASS
|
||||
OK CyberGrillLLM/ChatApiLLMStream subclass real livekit-agents 1.8.8 LLM/LLMStream
|
||||
OK latest_user_text reads a real ChatContext (items/role/text_content)
|
||||
OK fake-SSE turn through real LLMStream emits ChatChunks, final text and receipt
|
||||
OK chat.error phi is spoken verbatim via ChatChunk and carries no receipt
|
||||
OK mint_token claims byte-parity with livekit.api.AccessToken (video/iss/sub/keys)
|
||||
OK mint_server_token matches SDK no-grant auth header claims
|
||||
OK worker.py SDK surface (SpeechHandle, UserInputTranscribedEvent.is_final) present
|
||||
OK barge-in task cancel closes the in-flight HTTP stream immediately
|
||||
OK installed-SDK source verified: speech tasks set agents_speech_handle around the pipeline; speech_created precedes the task; llm_node calls llm.chat inside it
|
||||
OK ACTUAL worker module loaded; its registered callbacks driven on a real AgentSession: both-queued delayed generations bind exactly (manager interleaving), say never steals a stream, unattributed fails closed, interrupt cancels without final/receipt
|
||||
OK assistant_interim seam: real LLMStream deltas feed per-speech_id v2 payloads (replace semantics); unattributed streams feed nothing
|
||||
@@ -0,0 +1,15 @@
|
||||
HEL-877/879 real-SDK check on livekit-agents 1.8.8: ALL PASS
|
||||
OK CyberGrillLLM/ChatApiLLMStream subclass real livekit-agents 1.8.8 LLM/LLMStream
|
||||
OK latest_user_text reads a real ChatContext (items/role/text_content)
|
||||
OK fake-SSE turn through real LLMStream emits ChatChunks, final text and receipt
|
||||
OK chat.error phi is spoken verbatim via ChatChunk and carries no receipt
|
||||
OK mint_token claims byte-parity with livekit.api.AccessToken (video/iss/sub/keys)
|
||||
OK mint_server_token matches SDK no-grant auth header claims
|
||||
OK worker.py SDK surface (SpeechHandle, UserInputTranscribedEvent.is_final) present
|
||||
OK barge-in task cancel closes the in-flight HTTP stream immediately
|
||||
OK installed-SDK source verified: speech tasks set agents_speech_handle around the pipeline; speech_created precedes the task; llm_node calls llm.chat inside it
|
||||
OK ACTUAL worker module loaded; its registered callbacks driven on a real AgentSession: both-queued delayed generations bind exactly (manager interleaving), say never steals a stream, unattributed fails closed, interrupt cancels without final/receipt
|
||||
OK assistant_interim seam: real LLMStream deltas feed per-speech_id v2 payloads (replace semantics); unattributed streams feed nothing
|
||||
OK speak_proactive_row publishes proactive_start while the real SpeechHandle is still playing (sync-say shape), end/cancel on the handle's done callback
|
||||
OK interrupted proactive row emits proactive_cancel(reason=interrupted), never proactive_end
|
||||
OK incomplete/no-receipt attributed stream: actual worker callbacks emit turn_start -> turn_cancel only, no assistant_final, no ack
|
||||
@@ -0,0 +1,9 @@
|
||||
{"v": 2, "seq": 1, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_cut"}
|
||||
{"v": 2, "seq": 2, "kind": "assistant_interim", "role": "assistant", "text": "我正想跟你说,今天下午", "speech_id": "speech_cut"}
|
||||
{"v": 2, "seq": 3, "kind": "turn_cancel", "role": "assistant", "text": "", "speech_id": "speech_cut", "reason": "interrupted"}
|
||||
{"v": 2, "seq": 4, "kind": "interim", "role": "user", "text": "等一下"}
|
||||
{"v": 2, "seq": 5, "kind": "user_final", "role": "user", "text": "等一下,先听我说。"}
|
||||
{"v": 2, "seq": 6, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_next"}
|
||||
{"v": 2, "seq": 7, "kind": "assistant_interim", "role": "assistant", "text": "好,你先说。", "speech_id": "speech_next"}
|
||||
{"v": 2, "seq": 8, "kind": "assistant_final", "role": "assistant", "text": "好,你先说。", "speech_id": "speech_next", "turn_id": "0e2f-demo-next"}
|
||||
{"v": 2, "seq": 9, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_next", "turn_id": "0e2f-demo-next"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"v": 2, "seq": 1, "kind": "interim", "role": "user", "text": "你在听的"}
|
||||
{"v": 2, "seq": 2, "kind": "user_final", "role": "user", "text": "你在听吗?"}
|
||||
{"v": 2, "seq": 3, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_normal"}
|
||||
{"v": 2, "seq": 4, "kind": "assistant_interim", "role": "assistant", "text": "在听呢,", "speech_id": "speech_normal"}
|
||||
{"v": 2, "seq": 5, "kind": "assistant_interim", "role": "assistant", "text": "在听呢,在听呢,你刚才说到一半的那件事我还在想。", "speech_id": "speech_normal"}
|
||||
{"v": 2, "seq": 6, "kind": "assistant_final", "role": "assistant", "text": "在听呢,你刚才说到一半的那件事我还在想。", "speech_id": "speech_normal", "turn_id": "0e2f-demo-turn"}
|
||||
{"v": 2, "seq": 7, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_normal", "turn_id": "0e2f-demo-turn"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"v": 2, "seq": 1, "kind": "proactive_start", "role": "assistant", "text": "我看到一句很适合现在的话,先念给你听。", "speech_id": "speech_pro1", "message_id": 17}
|
||||
{"v": 2, "seq": 2, "kind": "proactive_end", "role": "assistant", "text": "", "speech_id": "speech_pro1", "message_id": 17}
|
||||
{"v": 2, "seq": 3, "kind": "proactive_start", "role": "assistant", "text": "还有一件事想告诉你——", "speech_id": "speech_pro2", "message_id": 18}
|
||||
{"v": 2, "seq": 4, "kind": "proactive_cancel", "role": "assistant", "text": "", "speech_id": "speech_pro2", "message_id": 18, "reason": "interrupted"}
|
||||
@@ -0,0 +1 @@
|
||||
{"remote_delete_count_after_ttl": 1, "expired_but_registered": false, "cleanup_owner_present": false}
|
||||
@@ -0,0 +1 @@
|
||||
{"remote_delete_count_after_ttl": 0, "expired_but_registered": true, "cleanup_owner_present": false}
|
||||
@@ -0,0 +1 @@
|
||||
{"proactive_before_playout_finishes": {"handle_done": false, "events": ["proactive_start"]}, "proactive_after_playout_finishes": ["proactive_start", "proactive_end"], "proactive_payload_fields": {"v": 2, "kind": "proactive_start", "role": "assistant", "text": "offline sample", "speech_id": "speech_f28fdeb15e01", "message_id": 7}, "no_receipt_incomplete_actual_worker_events": ["turn_start", "turn_cancel"], "no_receipt_incomplete_confirmed": [], "complete_with_receipt_actual_worker_events": ["turn_start", "assistant_final", "turn_end"], "complete_with_receipt_confirmed": ["ok-turn"]}
|
||||
@@ -0,0 +1 @@
|
||||
{"proactive_before_playout_finishes": {"handle_done": false, "events": []}, "proactive_after_playout_finishes": ["proactive_start", "proactive_end"], "proactive_payload_fields": {"v": 2, "kind": "proactive_start", "role": "assistant", "text": "offline sample", "speech_id": "speech_2880d36ea013", "message_id": 7}, "no_receipt_incomplete_actual_worker_events": ["turn_start", "assistant_final", "turn_end"], "no_receipt_incomplete_confirmed": [], "complete_with_receipt_actual_worker_events": ["turn_start", "assistant_final", "turn_end"], "complete_with_receipt_confirmed": ["ok-turn"]}
|
||||
@@ -0,0 +1,37 @@
|
||||
# HEL-879 契约核准后 3 处实现时机收口(2026-10-27)
|
||||
|
||||
核对对象:`hel-879-assembly @ 3228b1e0d7f822b7030f945e285eac74126771aa`(总管《HEL-879-契约核准与接续.md》)。
|
||||
本轮只修 3 处实现与已定契约 v2 不一致的问题:不改 `v` 信封字段、不改事件种类、不动 `web/src`、不重做已过绑定/备份/预算。
|
||||
|
||||
## 修前复现(全部先复现失败,与总管证据一致)
|
||||
|
||||
| # | 发现 | 修前证据 |
|
||||
|---|------|----------|
|
||||
| 1 | `open_session` 只在 `await create_dispatch` 前 kick;建房真实让出事件循环后 cleaner 空转退出,注册后无人再观察截止时间 | `delayed-dispatch-before.json`:`{"remote_delete_count_after_ttl": 0, "expired_but_registered": true, "cleanup_owner_present": false}`(与总管 delayed-dispatch.json 逐字段一致) |
|
||||
| 2 | `speak_proactive_row` 旧写法 `handle = await session.say(...)`;真实 SDK `say` 为同步函数、`await handle`=等整段播完 → 播完才发 proactive_start | `sdk-probe-before.json`:`proactive_before_playout_finishes.events=[]`,播完后才 `["proactive_start","proactive_end"]` |
|
||||
| 3 | `_after_playout` 先按 `stream.text` 发 final/end 再查回执;注入 text 非空、completed=False、receipt=None 的确切归属流 → `turn_start→assistant_final→turn_end`,与 v2「无完整同轮回执只 cancel」相反 | 同上:`no_receipt_incomplete_actual_worker_events=["turn_start","assistant_final","turn_end"]`(实际 worker `bind_voice_session` 回调路径,真实 AgentSession/SpeechHandle) |
|
||||
|
||||
新增专项测试在**未修复代码上先跑失败**(repro-first):
|
||||
`tests/test_voice_rework_hel879.py::test_open_session_with_real_dispatch_yield_re_kicks_cleanup`、
|
||||
`::test_second_registration_wakes_a_sleeping_cleaner_for_earlier_deadline` → 修前 2 failed / 修后 2 passed。
|
||||
|
||||
## 修复(精确小修)
|
||||
|
||||
1. `src/cybergril/voice/session.py` `open_session`:注册成功并 `_persist_state()` 之后**重新 `_kick_cleanup()`**——建房 await 期间旧 cleaner 可能已因「无可观察对象」退出,新截止时间需要新的所有者;自动截止、共享上限、重启补偿、有界放弃全部保留。测试用**真正让出事件循环**的建房替身(`asyncio.sleep(0.02)`),不再只用立即返回的 MockTransport 替身。
|
||||
2. `src/cybergril/voice/worker.py` `speak_proactive_row`:先取 handle(`iscoroutine` 只解包 await 型替身——真实 SpeechHandle 自身可 await,不能误等)、**立即**发布 `proactive_start`、注册该 handle 的 `proactive_end/proactive_cancel`,再按需等待这一条播完(逐条顺序念保留)。
|
||||
3. `src/cybergril/voice/worker.py` `_after_playout`:`assistant_final` 前置必要门——流 `completed` 且 `should_ack`(合法同轮 receipt、非旧 epoch)才 `finish`+`confirm`;未完整/无回执/旧 epoch 一律只 `turn_cancel`(reason=incomplete/no-receipt),**绝不先发 final 再决定不确认**;φ/打断/失败/无归属 fail-closed 原样保留,完整+合法回执路径的 final 与 ack 均保留。
|
||||
|
||||
## 修后验证
|
||||
|
||||
- 本地:`delayed-dispatch-after.json`:`{"remote_delete_count_after_ttl": 1, "expired_but_registered": false, "cleanup_owner_present": false}`(cleaner 在全部结清后按设计自行退出,无悬空协程)。
|
||||
- `.12` 真 SDK(stdin 嵌入当前源码,零写入零网络,`/opt/voice-agent/agent/venv` livekit-agents 1.8.8)`sdk-probe-after.json`:
|
||||
- `proactive_before_playout_finishes={"handle_done": false, "events": ["proactive_start"]}`(播放中即可见 start);
|
||||
- 播完 `["proactive_start","proactive_end"]`,字段 `v/kind/role/text/speech_id/message_id` 与 v2 完全一致;
|
||||
- `no_receipt_incomplete=["turn_start","turn_cancel"]`、`confirmed=[]`;
|
||||
- 完整+合法回执路径 `["turn_start","assistant_final","turn_end"]` + ack 保留。
|
||||
- 真SDK回归并入常驻检查 `scripts/gen_hel877_sdk_check.py`(**14/14 ALL PASS**,`../sdk-check-hel879-timing.log`):新增「主动播报时机」「主动播报打断只有 cancel」「无回执不 final」三负例,均驱动实际 worker 模块注册的真实回调。
|
||||
- 产品套件:pytest **352/352**(3 次连跑);ruff(src/tests/tools) **0 错**;`verify_hel877_voice_chain.py` **8/8**;需求检查 **4/4**(origin/main 与 c121c42 各带/不带 handoff-HEL-879.json)。字幕样例 `subtitle-samples/` 与 v2 契约文档无需再生成(字段与生命周期未变)。
|
||||
|
||||
## 边界
|
||||
|
||||
零真实 LLM/嵌入/ASR/TTS/麦克风;.12 仅 SSH stdin 执行只读检查(零写入零网络,stderr 仅既有 jwt 弱密钥告警);零下载零安装;未部署、未合并;PR#11 与 `hel-870-assembly` 未动;权威账/冻结/共享服务/队员配置未碰;真实 cap 仍 0。R5 真机、完整 CG-011、R6/R7 继续承接,无需求变更(v1.11/60 项)。
|
||||
@@ -0,0 +1,60 @@
|
||||
# HEL-879 · 统一候选一次复审收口(总工,2026-10-27)
|
||||
|
||||
> 范围:按总管 2026-10-27 交接,把 HEL-898 三项返修的隔离保全候选
|
||||
> (`origin/hel-898-preserved` @ `18aef2a`:9254c17 = 作者 12 文件逐字节保全,
|
||||
> 父 b0dcb17;18aef2a = 仅记录/证据)**正常快进纳入** `hel-879-assembly`,
|
||||
> 对这三项做最后一次复审收口。不开新审核串、不重审已过后台/预算/备份/receipt
|
||||
> 时机内核、不替原作者改视觉。零真实调用、零下载/安装/部署/合并,真实 cap 仍 0。
|
||||
|
||||
## 一、整合事实
|
||||
|
||||
- 快进前远端:`hel-879-assembly` @ `b0dcb17`(11fd0e2/9756db3/b0dcb17 材料成果
|
||||
全保留,未退回 f92a86f)。快进后 head **`18aef2a`**,无冲突、无强推;
|
||||
PR#11 与 `hel-870-assembly`(c121c42)未动。
|
||||
- 9254c17 恰为 12 文件(`web/src` 6 + `web/tests` 5 + `handoff-HEL-898.json`),
|
||||
与总管 preservation-review.md 记录的作者交付逐字节保全一致;18aef2a 仅新增
|
||||
`docs/rework/hel898/f92a86f-rework/` 记录与证据(31 文件)。
|
||||
- **b0dcb17→18aef2a 对 `src/`、`tests/`、`tools/`、`deploy/` 零 diff**
|
||||
(实跑 `git diff --stat` 为空)——后端/协议/预算/备份/receipt 时机零改动成立。
|
||||
|
||||
## 二、三项返修复审(源码级 + 测试级,均通过)
|
||||
|
||||
1. **agent 离场僵尸界面/麦克风不停**(`web/src/voice/livekit.ts` +
|
||||
`session.ts`):transport 按 identity 记录已见 agent(连接事件与初始
|
||||
remoteParticipants 扫描都入 Set);`ParticipantDisconnected` 仅当离场者是
|
||||
该 agent 且房内再无其它 agent 时触发 `agentLeft`;控制器走**既有** `fail()`
|
||||
路径(mic 先停 → room 关闭 → 服务器 DELETE,离线欠账照常补发)。普通参与者
|
||||
离场、旧房间迟到事件、自己挂断、重复第二 worker 均不误触发——
|
||||
`web/tests/hel898_agent_left.test.ts` 7 例(含 DELETE 欠账重发与 stale 负例)。
|
||||
2. **closed 64 挤出复活**(`session.ts`):退休 speech 集合由 64 上限环形数组
|
||||
改为**每通话 `Set<string>` 全程保留、随代次整体释放**;seq 高水位门不变,
|
||||
未以放宽乱序保护换修复。f92a86f 审核所记 DOCUMENTED GAP 就此关闭。
|
||||
3. **播放中短回复被压到 final**(`reading.ts` + `lineStage.ts` +
|
||||
`ChatPanel.tsx` + `voiceLine`):语音打开的她的行改 `live: 'speaking'`,
|
||||
末块只显示**已成句**部分(`said()` 截到句末标点+引号括号),文字 generating
|
||||
原持有规则不变;同键行 `grow()` 原位增长(保留 DOM/已演字,非续写才整段
|
||||
置换),签名纳入语音行文本驱动重呈现;阅读时钟对增长行只增不减时长。
|
||||
落库接替沿用同键(turn:mi:index),不重播、不重复演字。
|
||||
|
||||
## 三、独立检查(针对本次真实变更,均实跑)
|
||||
|
||||
- `web` vitest **118/118**(10 文件,含 hel898_agent_left 7 例、hel879_frames
|
||||
10 例、voice 45 例);typecheck 0 错;lint 0 错/1 既有 fullButton ref 警告;
|
||||
build 通过(CSS 哈希 `index-BpZaybfD.css` 与既认可视觉一致,JS 因本次修复
|
||||
变更为 `index-C23OdPgW.js`,属预期)。
|
||||
- 需求结构检查 3/3 通过:`--base-ref origin/main`、`--base-ref b0dcb17`、
|
||||
`--base-ref b0dcb17 --handoff docs/requirements/handoff-HEL-898.json`;
|
||||
本轮结束后再以 origin/main ± 本卡 handoff 复跑(见提交说明)。
|
||||
- 浏览器统一回放(两主题电脑/手机、减少动态、worker 实际录制帧、8 段 WebM)
|
||||
**不重复复跑**:总管已在 18aef2a 记录 53/53 与媒体证据
|
||||
(manager-checks/browser.log),本轮零安装策略下不重开。
|
||||
|
||||
## 四、结论与边界
|
||||
|
||||
- 三项返修**离线通过**,与后台 v2 契约无冲突;统一候选当前准确 head
|
||||
`18aef2a`(本轮复审与 D1 试听材料另追加提交,见分支历史)。
|
||||
- 仍不冒充的部分:以上全部是假 LiveKit/假媒体的离线回放与单测;真实
|
||||
WebRTC/麦克风/ASR/TTS、三轮/1 秒打断停声停麦、可信 HTTPS+WSS 设备路径、
|
||||
iOS/Android、API 重启真实释放**均未测**,待主卡批准后 R5 真机窗口执行。
|
||||
完整 CG-011 会话中主动表达、完整 mem0 统一体验/R6/R7 继续承接。
|
||||
- v1.11/60 项无变更;冻结账/权威账未写;既有五实例与共享服务零接触。
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"sdk": {
|
||||
"livekit_agents": "1.8.8",
|
||||
"livekit_api": "1.2.1"
|
||||
},
|
||||
"source": "actual fc905fe+fb7ea84 worker driven on .12 real SDK (livekit-agents 1.8.8, livekit-api 1.2.1) via SSH stdin, zero network/writes",
|
||||
"scenarios": {
|
||||
"normal": {
|
||||
"kinds": [
|
||||
"interim",
|
||||
"user_final",
|
||||
"turn_start",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"assistant_final",
|
||||
"turn_end"
|
||||
],
|
||||
"frames": 8,
|
||||
"acks": [
|
||||
"382c41d9-0d72-433e-b370-f838fa5d0bd0"
|
||||
],
|
||||
"turn_id": "382c41d9-0d72-433e-b370-f838fa5d0bd0"
|
||||
},
|
||||
"interrupt": {
|
||||
"kinds": [
|
||||
"user_final",
|
||||
"turn_start",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"turn_cancel",
|
||||
"user_final",
|
||||
"turn_start",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"assistant_final",
|
||||
"turn_end"
|
||||
],
|
||||
"frames": 11,
|
||||
"acks": [
|
||||
"edbcb837-f19f-47e7-8d5c-057010da43eb"
|
||||
],
|
||||
"turn_ids": {
|
||||
"cut": "09d05c0f-3058-4ff8-a56e-4e94bd17d7b7",
|
||||
"next": "edbcb837-f19f-47e7-8d5c-057010da43eb"
|
||||
}
|
||||
},
|
||||
"phi": {
|
||||
"kinds": [
|
||||
"user_final",
|
||||
"turn_start",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"error",
|
||||
"turn_cancel"
|
||||
],
|
||||
"frames": 7,
|
||||
"acks": [],
|
||||
"turn_id": "31e70e52-1471-4e50-ac49-cce89979a9bf"
|
||||
},
|
||||
"no_receipt": {
|
||||
"kinds": [
|
||||
"user_final",
|
||||
"turn_start",
|
||||
"assistant_interim",
|
||||
"assistant_interim",
|
||||
"turn_cancel"
|
||||
],
|
||||
"frames": 5,
|
||||
"acks": [],
|
||||
"turn_id": "36d160e3-e365-46ff-ad57-c2878c00720f"
|
||||
},
|
||||
"proactive": {
|
||||
"kinds": [
|
||||
"proactive_start",
|
||||
"proactive_end",
|
||||
"proactive_start",
|
||||
"proactive_cancel"
|
||||
],
|
||||
"frames": 4,
|
||||
"start_while_playing": true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1 @@
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1 @@
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1 @@
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,97 @@
|
||||
# R5 测试包 v2 · .12 只读实测证据(2026-10-27)
|
||||
|
||||
> 性质:本轮材料返修的事实底稿。全部为 SSH 只读命令与输出摘录(零写入、零服务变更、
|
||||
> 不发任何模型/嵌入/ASR/TTS 请求);涉及凭据的值一律以 `<REDACTED>` 脱敏,不落仓库。
|
||||
> 命令在 .12(root@192.168.200.12)执行;时间 2026-10-27(UTC 时段见 issue)。
|
||||
|
||||
## E1 · 端口监听(`ss -tlnp` 摘录)
|
||||
|
||||
监听中(与 R5 相关/受保护):
|
||||
|
||||
| 端口 | 进程 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 7880、7881 | livekit-server (pid 219106, docker `voice-livekit`, host 网络) | 7880=HTTP/WS 信令;7881=WebRTC TCP 回落口 |
|
||||
| 10095 | docker-proxy(funasr-runtime-sdk-online-cpu-0.1.13) | 流式 ASR |
|
||||
| 8090 | python (pid 2136834, `/opt/voice-agent/agent/venv/bin/python server.py`, cwd `/opt/voice-agent/web`) | 语音演示页(https-only,见 E3) |
|
||||
| 8081 | python (pid 451883, `venv/bin/python agent.py start`, cwd `/opt/voice-agent/agent`) | 通用 cascade worker |
|
||||
| 8300/8310/8320/8340/8350 | 各既有 CyberGril 实例 | 受保护,不触碰 |
|
||||
|
||||
**空闲(无监听)**:8360、8361、8363、8082、9880(TTS 停机中)。
|
||||
|
||||
## E2 · LiveKit docker 无 TLS 配置(`docker inspect` + `docker-compose.yml`)
|
||||
|
||||
- `voice-livekit`:`command: --bind 0.0.0.0`,`network_mode: host`,无任何证书挂载
|
||||
(Mounts=[]),环境仅 `LIVEKIT_KEYS`(值脱敏)。
|
||||
- 探测:`curl http://127.0.0.1:7880` → **200**(纯 HTTP 存活);
|
||||
`curl -k https://127.0.0.1:7881/` 与 `curl http://127.0.0.1:7881/` → 均失败
|
||||
(7881 不是 HTTP(S) 端点,是 WebRTC TCP 回落口,不能当 WSS 信令用)。
|
||||
|
||||
## E3 · 8090 演示页现状
|
||||
|
||||
- `https://127.0.0.1:8090/` → 200;`http://127.0.0.1:8090/` → 失败(https-only)。
|
||||
- 证书:`CN = minicpmo45-dev`,**无 subjectAltName 扩展**,notAfter 2027-02-11。
|
||||
- `server.py:66`:`LIVEKIT_URL = "ws://192.168.200.12:7880"` —— 页面 `/api/token`
|
||||
把该 ws:// 地址发给浏览器 `room.connect(url, token)`。https 页面连 ws:// 属
|
||||
**混合内容**,库存浏览器默认拦截;**没有任何证据**表明该页在标准浏览器走通过
|
||||
媒体链路。旧材料「自签一次点击同时覆盖 LiveKit WSS 7880」不成立,已删除。
|
||||
- 面板功能(index.html/server.py 实读):音色列表 `GET /api/voices`、切换
|
||||
`POST /api/voice`(写共享 `voice_state.json`)、克隆上传 `POST /api/voices/upload`
|
||||
(写共享 `voices.json` + TTS 热加载)、删除。**没有既有音色的试听播放功能**
|
||||
(`vPreview` 仅用于克隆上传的自选文件)。
|
||||
|
||||
## E4 · 通用 worker 依赖 7880 保持明文(改 TLS 的破坏面)
|
||||
|
||||
`/opt/voice-agent/agent/.env`:`LIVEKIT_URL=ws://127.0.0.1:7880`(另有
|
||||
LIVEKIT_API_KEY/SECRET,值脱敏)。**7880 一旦改 TLS-only 即断通用 worker 与
|
||||
8090 演示,禁止作为本卡路线。**
|
||||
|
||||
## E5 · .12 上没有已装 mem0 的产品 venv(「零真缺」不能笼统写)
|
||||
|
||||
- 8310(`/opt/cybergril-api/current/.venv`,r2-20261022)与 8350
|
||||
(`/opt/cybergril-hel835/current/.venv`)抽查:`python -c "import mem0"` 均
|
||||
`ModuleNotFoundError`;两者均为 **Python 3.12.3**(系统 python 建 venv)。
|
||||
- 8320/8340 同构部署(各自 `current/.venv`),未逐一导入验证。
|
||||
- 结论:所抽查的两处不能提供已装 mem0 闭包,不能据抽查推断 .12 所有实例均已
|
||||
验证不存在。完整统一体验依赖分类以本仓库
|
||||
`docs/rework/hel879/release-artifacts.json`(总管已核准)为准。
|
||||
|
||||
## E6 · worker venv 与 TTS 底座(语音专项链已备)
|
||||
|
||||
- `/opt/voice-agent/agent/venv`:`import livekit, livekit.agents` 成功,
|
||||
livekit-agents **1.8.8**(venv 为 python3.12,worker 不依赖产品 mem0 闭包,
|
||||
经 HTTP 调统一 API)。
|
||||
- CosyVoice:`/opt/voice-agent/cosyvoice/server.py` 实读——路由 `/health`、`/tts`
|
||||
存在;`uvicorn.run(host="0.0.0.0", port=9880)`;当前 9880 无监听(停机待授权
|
||||
重启);权重在位(`/opt/models/CosyVoice3-0.5B-2512`,约 9.1GB)。
|
||||
|
||||
## E7 · 音色真实清单(voices.json 实读)
|
||||
|
||||
- 11 个 ID:`bailu, bajie, default, female_soft, haimianbaobao, jiaojiao,
|
||||
male_warm, nezha, rain, sunwukong, xiaoyun`;每项含 `prompt_wav` 指向
|
||||
`voice_samples/<id>.wav`(zero-shot 参考音频,即该音色的音色来源录音,
|
||||
bailu.wav 1.77MB 等,目录实存)。
|
||||
- 共享演示当前 `voice_state.json` = `{"voice": "rain"}` —— 是通用演示状态,
|
||||
**不是本用户的选择**,不作为本用户音色依据。
|
||||
|
||||
## E8 · GPU / 内存 / 磁盘(`nvidia-smi` / `free` / `df`)
|
||||
|
||||
- RTX 5090D:总量 32607 MiB,已用 1558 MiB,**空闲 30520 MiB**(ComfyUI 仍处
|
||||
已授权暂停;GPU 进程仅 gnome-remote-desktop 996MiB)→ CosyVoice 常驻约
|
||||
5.6GB 无障碍。
|
||||
- 内存 94GB(可用 83GB);`/` 916GB 用 16%,余 735GB。
|
||||
|
||||
## E9 · /opt 无冲突目录
|
||||
|
||||
`/opt` 现有 `cybergril-api / cybergril-data / cybergril-hel822 / cybergril-hel829 /
|
||||
cybergril-hel835 / cybergril-ops / cybergril-s1 / voice-agent / models` 等;
|
||||
**无 `voice-agent-cybergril`、无 `cybergril-voice-certs`**(新建不冲突)。
|
||||
|
||||
## E10 · 预算硬闸语义(本仓库代码,非 .12)
|
||||
|
||||
`src/cybergril/think/budget.py` `acquire()`:真实 provider 出口**发送前**原子
|
||||
预占(类别日顶 `daily_requests` + 一次性总量 `total_requests` + `daily_tokens` +
|
||||
`total_tokens` 同步检查,任一到顶抛 `BudgetStop`,持久化实现落在单个
|
||||
`BEGIN IMMEDIATE` 事务)。**失败/重试/打断/后台调用同样计数**——计数粒度是
|
||||
provider 调用,不是成功聊天行数。封账 `budget_sealed` 拒绝一切 acquire。
|
||||
语音轮经统一 `/api/chat` 走同一 dialogue 闸;`POST /api/voice/session` 建房
|
||||
**当前不在 budget 闸内**(如实列为操作侧上限+工程后续小项)。
|
||||
@@ -0,0 +1,223 @@
|
||||
# HEL-879 · R5 真机测试包 v2(供主卡拍板的集中材料,未执行)
|
||||
|
||||
> 性质:**准备材料**(v2 材料返修版,按总管 2026-10-27 四项返修清单重写)。零真实
|
||||
> 模型/嵌入/ASR/TTS/麦克风请求、零下载/安装/部署/合并、真实 cap=0。.12 全部事实为
|
||||
> 2026-10-27 只读实测,底稿与命令摘录见同目录 `r5-facts-20261027.md`(脱敏)。
|
||||
> 事实三级标注:**已证**(实测/代码实读)|**拟议**(材料备齐待批,未执行)|
|
||||
> **待真机**(须获批实测后才能写通过)。
|
||||
>
|
||||
> v2 勘误(相对 v1):①删「统一实例依赖零真缺/零下载」笼统结论,改两轨拆分(§1);
|
||||
> ②删「自签一次点击同时覆盖 LiveKit WSS 7880」——7880 现为纯 ws 且被共享服务依赖,
|
||||
> WSS 改由独立 8363 桥承担(§3);③预算 30 轮表改逐类计数位置+硬闸/操作停机拆分
|
||||
> (§6);④工程项(端口/目录/健康口)由工程核定(§10),不再交用户选择;D1 改实际
|
||||
> 音色清单+零合成试听方案(§4)。
|
||||
|
||||
## 1. 依赖两轨(不能混称「零真缺」)
|
||||
|
||||
### 1a. R5 语音专项(语音链本身)——**已证**零真缺、零下载
|
||||
|
||||
| 部件 | 状态 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| LiveKit SFU | ✅ 运行中 .12:7880/7881 | 已证 7880 HTTP 200;**纯 ws 无 TLS**(E2) |
|
||||
| FunASR 流式 ASR | ✅ 运行中 .12:10095 | 已证监听(E1) |
|
||||
| worker 运行时 | ✅ 在位 `/opt/voice-agent/agent/venv` | 已证 import livekit-agents 1.8.8(py3.12;worker 经 HTTP 调统一 API,不依赖 mem0 闭包,E6) |
|
||||
| CosyVoice3 TTS | ⛔ 停机待重启授权 | 已证权重在位(约 9.1GB)、9880 无监听、`/health` `/tts` 路由存在(E6);重启=运维动作非下载 |
|
||||
| GPU/内存/磁盘 | ✅ 充足 | 已证空闲 30520MiB / 83GB / 735GB(E8) |
|
||||
| 统一 API 实例(8360) | ⛔ 待部署 | 部署物=本候选 head + 已过发布包 §2 全离线基础安装(requirements.lock 装机全部命中既有缓存,已证,见 sdk-audit) |
|
||||
|
||||
**语义边界(必须如实)**:本轨在 `semantic=stub` 下验收**语音链**——同一份档案数据、
|
||||
同一进程、同一把轮次锁,但**嵌入 0、语义记忆未激活**。本轨通过 ≠ 「同档案全记忆
|
||||
验收」;完整统一体验是 1b,两者分开批、分开验,互不冒充。
|
||||
|
||||
### 1b. 完整统一体验(真实 mem0 记忆)——41 项闭包,**真缺待授权**(已证分类)
|
||||
|
||||
- **已证范围**:.12 的 8310/8350 两个既有产品实例 venv 抽查均
|
||||
`ModuleNotFoundError`,且均为系统 3.12.3;8320/8340 未逐一验证,E5。
|
||||
不能据两处抽查声称所有实例均已验证,也没有可据此采用的已装 mem0 闭包——
|
||||
「零真缺」对完整体验**不成立**。
|
||||
- 分类(`docs/rework/hel879/release-artifacts.json`,总管已核准口径):
|
||||
- **已离线成品**:mem0ai 固定源码轮 `mem0ai-2.2.1-py3-none-any.whl`
|
||||
(sha256 `698284b6…20e9db`,352,586 B / 153 文件,源 tarball 与
|
||||
`deploy/mem0-stack.lock` 哈希一致;未安装任何位置)。
|
||||
- **缓存可复用**:greenlet 3.5.6(cp311)、pillow 12.3.0(cp311)、
|
||||
truststore 0.10.5(py3-none-any)——本机 uv wheels-v6/v7 实测命中。
|
||||
- **待准备 37 项**(26 纯 Python + 11 原生;原 28/13 分别扣除
|
||||
mem0ai、truststore 两项纯 Python 和 greenlet、pillow 两项原生):按当前闭包
|
||||
扣除已成品/可复用项的数量,尚不等于目标平台已解析完成的下载结果。需下载授权后按
|
||||
发布包 §4 第 3 步拉取,逐项 sha256 留证;**体积未下载不猜**,下载窗口逐项
|
||||
记录。hatchling 已离线解决(1.32.4 缓存),不再申请。
|
||||
- **fastembed 模型缓存**:已证在位 bge-small-zh-v1.5 190,444,780 B +
|
||||
bm25 56,247 B(artifacts 实测数;hel895 文档「约 92MB」与此不符,以
|
||||
artifacts 为准——已列勘误待修)。
|
||||
- 逐项版本/哈希:以 `release-artifacts.json` 与
|
||||
`docs/rework/hel879/sdk-audit/sdk-audit.json` 为准(不在本文件复制防漂移)。
|
||||
- 安装位置:统一实例自足 `.venv311`(uv 管 CPython 3.11.16,发布包 §2/§4 唯一
|
||||
入口);不混 .12 系统 3.12、不碰既有实例 venv。
|
||||
- 边界重申:不以关闭 mem0、改 stub、少测记忆默认缩减用户完整体验要求。
|
||||
|
||||
## 2. 资源现状(已证,2026-10-27)
|
||||
|
||||
GPU RTX 5090D 空闲 30520MiB(ComfyUI 仍处已授权暂停);内存 94GB/可用 83GB;
|
||||
盘 916GB/余 735GB。端口实测:8360/8361/8363/8082 **空闲**;受保护监听
|
||||
8300/8310/8320/8340/8350/8090/8081/7880/7881/10095 一律不动;9880 停机待授权。
|
||||
|
||||
## 3. 可信 HTTPS + WSS(D3)——已证 / 拟议 / 待真机
|
||||
|
||||
### 3.1 已证事实(为什么 v1 的说法必须撤回)
|
||||
|
||||
1. LiveKit 7880 = **纯 HTTP/WS**(docker 无证书挂载无 TLS 配置,E2);7881 监听
|
||||
但**不是 HTTP(S) 端点**(http/https 探测均失败),是 WebRTC TCP 回落口,
|
||||
**不能当 WSS 用**。
|
||||
2. 通用 worker 与 8090 演示都依赖 `ws://127.0.0.1:7880`/`ws://…:7880`(E4)——
|
||||
**7880 改 TLS 会破坏共享服务,禁止**。
|
||||
3. 8090 演示页 https-only(证书 `CN=minicpmo45-dev`、**无 SAN**、至 2027-02-11);
|
||||
它给浏览器发的就是 `ws://…:7880`(E3)——https 页连 ws 属**混合内容**,无证据
|
||||
表明曾在标准浏览器走通。旧句「自签一次点击同时覆盖页面 HTTPS 与 WSS 7880」
|
||||
**不成立,已删除**。
|
||||
4. `config/voice.yaml` 是部署本地文件(模板 `config/voice.example.yaml`),仓库内
|
||||
无该文件——v1 写法勘误。
|
||||
|
||||
### 3.2 拟议路线(材料已备齐,未执行;缺的就是这个 WSS 桥,如实列出)
|
||||
|
||||
- **新自签证书一张**:`openssl req -x509 … subjectAltName=IP:192.168.200.12`,
|
||||
存 `/opt/cybergril-voice-certs/`(600),同证书服务 8361 与 8363 两个端口。
|
||||
- **8361**:统一实例 HTTPS 入口(同进程双入口,runbook-hel877-voice §4.4;
|
||||
本地 18360/18361 已冒烟,.12 未执行——拟议)。
|
||||
- **8363(新增)**:TLS→明文 TCP 桥 `deploy/voice/wss_bridge.example.py`
|
||||
(asyncio+ssl 标准库零新依赖;本地冒烟 TLS1.3+字节穿透通过,见
|
||||
`wss-bridge-smoke.log`):`wss://192.168.200.12:8363` → `127.0.0.1:7880`。
|
||||
`voice.yaml` 的 `livekit_url` 改 `wss://192.168.200.12:8363`;
|
||||
`livekit_internal` 保持 `http://127.0.0.1:7880`。不动 LiveKit、通用 worker、
|
||||
8090(独立进程,回滚=停进程)。
|
||||
- WebRTC 媒体面(UDP/7881 TCP)不经桥,由 WebRTC 自带 DTLS/SRTP 加密、无需
|
||||
证书例外——协议性质说明,实际连通**待真机**。
|
||||
|
||||
### 3.3 每设备信任步骤(拟议;**分端口各一次**,不共享例外)
|
||||
|
||||
1. 打开 `https://192.168.200.12:8361` → 高级→继续(页面例外)。
|
||||
2. 打开 `https://192.168.200.12:8363` → 高级→继续(WSS 信令例外;该地址 GET
|
||||
返回 404/400 属预期,只看证书警告页点继续)。
|
||||
3. 允许麦克风。
|
||||
|
||||
- 两个实际端点分别打开并验证;不推断一个端点的例外必然覆盖另一个,也不把
|
||||
浏览器的例外缓存范围写成已实测事实。**不用** `--ignore-certificate-errors`、
|
||||
不装根证书、不改系统信任库(未授权,也不作为验收路径)。
|
||||
- **待真机**:iOS Safari「访问此网站」是否放行 wss、安卓 Chrome 部分版本无
|
||||
「继续」按钮的已知限制——验收时如实记录;桌面 Chrome/Firefox 为保底组合。
|
||||
|
||||
### 3.4 备选路线(不采用,写明理由)
|
||||
|
||||
- LiveKit 原生 TLS(7880 直接上证书):破坏通用 worker/8090(E4),**禁止**。
|
||||
- 系统 nginx 反代:.12 无运行中 nginx(E1/E2),安装/改系统配置超出零安装边界。
|
||||
- 忽略证书/不安全浏览器开关:明令禁止,不作为验收。
|
||||
|
||||
## 4. D1 音色(实际清单 + 零合成试听方案;不替用户定)
|
||||
|
||||
- **已证**(voices.json 实读,E7):音色共 **11 个 ID**——`bailu、bajie、default、
|
||||
female_soft、haimianbaobao、jiaojiao、male_warm、nezha、rain、sunwukong、
|
||||
xiaoyun`;每支 = zero-shot 参考音频克隆,参考文件
|
||||
`/opt/voice-agent/cosyvoice/voice_samples/<id>.wav` 实存(即该音色来源说话人
|
||||
的真实录音)。v1「6 支女声」及各主观描述系笔误+猜测,已删;不猜名字定效果,
|
||||
不引用通用演示状态(当前 `rain`)或其他测试/旧角色资料作为本用户选择。
|
||||
- **已证**:8090 面板**没有既有音色试听播放**(只有列表/切换/克隆上传/删除),
|
||||
且切换/上传写**共享** `voices.json`/`voice_state.json`、影响通用演示——v1
|
||||
「试听入口=8090 面板」勘误删除,未经授权不用它给本用户做试听。
|
||||
- **试听已交付(2026-10-27,零合成、零 GPU、零下载)**:`HEL-879-D1-参考试听包.zip`
|
||||
随 HEL-879 评论附件交付——自包含离线 `index.html` + 11 段参考 wav + `manifest.json`
|
||||
(逐文件 sha256 与服务器一致;来源与边界见 `docs/rework/hel879/d1-audition/`)。
|
||||
白栖知解压后用浏览器直接打开试听初选;音色效果以参考音频为初感 + 获批测试窗口内
|
||||
真实合成复核(计入 §6 TTS 计数)。参考录音原文按来源原样保留,不预筛。
|
||||
- 可选:克隆(录 5–20s 中文参考)需写共享 `voices.json`(新增条目为增量,但会触
|
||||
共享 TTS 热加载)→ **需单独授权**,默认不启。
|
||||
- worker 缺省回落 `default`(代码已证);专属 worker 用独立
|
||||
`voice_state_cybergril.json`,不写通用演示状态(runbook §4.1)。
|
||||
|
||||
## 5. 部署顺序(获批后执行;含回滚)
|
||||
|
||||
1. 统一实例(8360 + 8361 同进程双入口 + **8363 WSS 桥**,隔离数据目录与
|
||||
`/opt/voice-agent-cybergril/`、`/opt/cybergril-voice-certs/`;不动
|
||||
8300/8310/8320/8340/8350/8090/8081/7880/10095)。
|
||||
2. TTS 9880 重启:先核实 9880 无监听再按 cosyvoice 运维节启动;历史 `fuser -k`
|
||||
禁止照抄(当前已证停机,无需杀任何进程)。
|
||||
3. 专属 worker(`/opt/voice-agent-cybergril/`,agent venv 启动,健康口 8082,
|
||||
`CYBERGRIL_API_BASE=http://127.0.0.1:8360`)。
|
||||
4. `config/voice.yaml`(从 `config/voice.example.yaml` 复制):键
|
||||
`livekit_url: wss://192.168.200.12:8363`、`livekit_internal:
|
||||
http://127.0.0.1:7880`、api_key/api_secret 按 .12 现值只写服务器本地文件。
|
||||
- 回滚:停 worker → 停 8363 桥 → 停 8360/8361 → TTS 回停机或原状(全独立进程);
|
||||
Git 回退=本分支回退到 fc905fe 重新部署;冻结账与既有实例零接触。
|
||||
|
||||
## 6. 有限预算与停止条件(逐类;「代码硬闸」与「操作停机」分开写明)
|
||||
|
||||
**计数语义(已证,E10)**:budget 账 `acquire()` 在 provider 调用**发送前**原子
|
||||
预占——失败/重试/打断/后台调用同样计数;**LLM 调用数 ≠ 成功聊天行数**。ASR 流、
|
||||
TTS 分段、φ 播报、打断重合成、主动 say、闲置通话都**不等于** LLM 轮数,另列。
|
||||
|
||||
| 类别 | 建议上限(供拍板,不自动生效) | 计数位置 | 闸性质 | 异常停法 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| LLM 真实调用(dialogue 类,含语音轮与失败重试) | ≤40 次请求 / ≤500k tokens | 实例 budget 账 `request_usage`/`token_usage` 逐笔 | **代码硬闸已实现**:类别日顶+一次性总量+日 token 池发送前拒绝;封账库拒绝一切 | 到线自动停;异常扣费风险=立即停窗口(下 1–3 项全停) |
|
||||
| 嵌入 | **0**(R5 专项 stub) | semantic 账(`embed_ops` 未启用) | **代码硬闸已实现**(semantic cap=0 fail-closed);1b 另批 | — |
|
||||
| ASR(FunASR 流) | 测试窗口 ≤2h 且通话 ≤3 次 | worker 日志 job/stream 数(本地 CPU 服务,零外部计费) | **无代码闸(如实)**→操作停机 | 窗口到点/会话数到线/异常即停 worker |
|
||||
| TTS(CosyVoice 合成段,含 φ/打断重合成/主动 say/分段) | 窗口内 ≤200 合成段 | `tts.log` 合成行(本地 GPU,零外部计费) | **无代码闸(如实)**→操作停机 | 同上;GPU 显存异常(>8GB 或 OOM)即停 |
|
||||
| 语音会话(建房) | ≤3 次完整通话 | `POST /api/voice/session` 服务端日志 | **无代码闸(如实;列工程后续小项:建房数纳入 config 上限)** | 第 3 次结束或验收完成即停 |
|
||||
| 主动开口 | 默认关(`CYBERGRIL_PROACTIVE_VOICE` 不设) | — | 已实现默认关;如开只念已落库行、不新增 LLM 调用(已证代码路径) | — |
|
||||
|
||||
- **上限不自动生效**:所有 cap 配置当前仍 0,批准后由总管写入实例配置;权威账/
|
||||
冻结账不写、封账库永不重开;真实 cap 本轮=0。
|
||||
- 逐笔输出:budget 表逐行 + worker/tts 日志,窗口结束导出逐笔清单交主卡。
|
||||
- 本表数字为建议值,最终由白栖知定;**不以 30 轮聊天行数顶替任何账**。
|
||||
|
||||
## 7. 验收清单(R5 真机,integration-plan 原四条 + 补充)
|
||||
|
||||
1. 连续交谈 ≥3 轮(同一她、同档案、记忆延续,音色一致;R5 专项内「记忆」=
|
||||
档案与对话历史,语义记忆属 1b 不冒充)。
|
||||
2. 开口打断约 1 秒停声(VAD→TTS 截断;字幕同步消失)。
|
||||
3. 挂断立即停麦(track.stop + 房间删除;麦克风指示灯灭)。
|
||||
4. 无旧残音/残影(打断与掉线后旧帧不回显;已有普通离线用例通过,长会话防护等
|
||||
HEL-898 三项返修仍待交回统一复核,随后真机复验)。
|
||||
5. 网络失败/重开:诚实提示、重开仍是同一位她。
|
||||
6. **WSS 桥 8363 + 页面 8361 分端口信任**(§3.3),真机记录各浏览器行为;
|
||||
桌面 Chrome/Firefox 保底,移动端如实记录不硬闯。
|
||||
7. 字幕全生命周期(后台实际 v2 帧及前端状态单测已有证据;HEL-898 短回复显示、
|
||||
agent 离场与长会话防护返修尚未交回,不称界面层离线全通过;之后真机抽查
|
||||
增量→final→落库一致)。
|
||||
8. API 重启释放(房间与 worker job 真实回收)一次受控验证。
|
||||
9. 口型**不在本包**(不伪装接通)。
|
||||
|
||||
## 8. 真正待白栖知拍板的项(工程项已移出,见 §10)
|
||||
|
||||
1. D1 音色:听 `HEL-879-D1-参考试听包.zip` 后选 1 支(或另行授权克隆)。
|
||||
2. 有限真实用量与新增费用上限确认/调整(§6 各值;R5 专项嵌入 0)。
|
||||
3. 测试窗口与共享 TTS 重启授权(占 GPU ~5.6GB;ComfyUI 保持暂停到测试后与否)。
|
||||
4. 1b 完整统一体验的当前 37 项待准备依赖下载授权(目标 cp311 解析后核定实际
|
||||
产物清单,可与 R5 专项分开批;已有成品/缓存不重复下载;hatchling 已解决
|
||||
不再申请)。
|
||||
|
||||
> 集中请求口径(总管在主卡一次发出):试听包 + §6 逐类上限/费用 + §5 部署与
|
||||
> 回滚 + §1b 下载授权,四项一次说清,批准后按 §5 顺序执行;R5 语音专项
|
||||
> (stub、嵌入 0)与完整统一体验分开批、分开验,ASR/TTS/建房只有操作停机
|
||||
> 无代码硬闸,均如实表述。
|
||||
|
||||
## 9. 本包不做
|
||||
|
||||
不合并 PR#11/任何 PR;不替换 8300/8310 正式入口;不改队员运行时/模型/认证;
|
||||
不动冻结账与既有五实例;CG-011 完整会话中主动表达仍承接(默认关的「开场念一条
|
||||
旧留言」不冒充完整项);R6/R7 继续承接。
|
||||
|
||||
## 10. 工程已定项(工程核冲突后定案,不交用户选)
|
||||
|
||||
| 项 | 取值 | 冲突核对(2026-10-27 实测) |
|
||||
| --- | --- | --- |
|
||||
| 统一实例端口 | HTTP 8360 + HTTPS 8361 | 空闲(E1);与 8300–8350/8090/8081 不冲突 |
|
||||
| WSS 桥端口 | 8363 | 空闲(E1;本轮新增,v1 未列) |
|
||||
| worker 健康端口 | 8082 | 空闲(E1;8081 为通用 worker,差一位不误用) |
|
||||
| worker 目录 | `/opt/voice-agent-cybergril/` | 不存在、新建无冲突(E9) |
|
||||
| 证书目录 | `/opt/cybergril-voice-certs/` | 不存在、新建无冲突(E9) |
|
||||
|
||||
## 附 · 材料与证据指针
|
||||
|
||||
- .12 只读实测底稿:`docs/rework/hel879/unified-review/r5-facts-20261027.md`(脱敏)
|
||||
- WSS 桥:`deploy/voice/wss_bridge.example.py` + `wss-bridge-smoke.log`
|
||||
- 依赖分类:`docs/rework/hel879/release-artifacts.json`、`sdk-audit/sdk-audit.json`
|
||||
- 预算闸:`src/cybergril/think/budget.py`(acquire 语义)、`docs/budget-package-hel895.md`
|
||||
- 部署草案:`docs/runbook-hel877-voice.md` §4、`deploy/hel895-release-candidate.md` §2/§4
|
||||
@@ -0,0 +1,42 @@
|
||||
# HEL-879 统一整合审核 · 焦点2:seq 命名空间与 job/房间/控制器代次(2026-10-27)
|
||||
|
||||
问题(原作者 HEL-898 提出):`SubtitleBus` 的 `seq` 按 worker job 从 1 计(`src/cybergril/voice/worker.py` `entrypoint` 内建 bus),而前端 `VoiceController` 按**控制器会话**(一个 `Live`,web/src/voice/session.ts `live.seq`)记高水位。若同一房间在控制器会话存活期间换了新 job(worker 重连/新派发),新 job 的 seq 归零,前端要么误丢新帧、要么误收旧帧。本文件用 .12 真实 SDK 源码(livekit-agents 1.8.8 / livekit-api 1.2.1,`/opt/voice-agent/agent/venv/lib/python3.12/site-packages/`,SSH 只读取证,零写入零网络)与仓库代码逐条核清。
|
||||
|
||||
## 1. 一个 job 只跑一次 entrypoint(seq 计数器只生一次)
|
||||
|
||||
真实源码 `livekit/agents/ipc/job_proc_lazy_main.py`(默认进程执行器路径):
|
||||
|
||||
- L241–246:进程内已有运行中 job 时再收到 `StartJobRequest` 只告警跳过(`"trying to start a new job while one is already running"` → `continue`)——**一个 job 进程最多承载一个 job**。
|
||||
- L318–323:`_start_job` 为该 job 建 `JobContext` 并启动**唯一** `job_task`。
|
||||
- `_run_job_task`(L327 起)里 `self._job_entrypoint_fnc(job_ctx)` 只被创建为一个 task(`job_user_entrypoint`);job 在以下任一时刻结束:entrypoint 返回、`_shutdown_fut` 置位(room disconnected / shutdown 请求 / job 崩溃),随后 `_shutdown_job` → `_on_cleanup`,进程随 `_exit_proc_flag` 退出。
|
||||
|
||||
结论:worker.py 的 `entrypoint`(含 `SubtitleBus` 实例)在**一个 job 内恰好执行一次**,job 结束即进程结束,不存在「同 job 二次 entrypoint 导致 seq 归零」。
|
||||
|
||||
## 2. 断线重连不重跑 entrypoint(seq 不归零)
|
||||
|
||||
真实源码 `livekit/rtc/room.py`:
|
||||
|
||||
- L85–86 / L1130–1133:瞬断走 `reconnecting` / `reconnected` 房间事件——这是 `rtc.Room` 内部的信号/媒体重连,job 任务不重建,entrypoint 不重跑。
|
||||
- 只有房间真正关闭才发 `disconnected`;job_proc_lazy_main L288–293 将其置入 `_shutdown_fut`(reason=`room disconnected`)→ job 结束。此时房间已不存在,前端收到 `dropped`(livekit.ts L75),整个 `Live` 连同 `live.seq` 一起终结。
|
||||
|
||||
结论:**同一 job 内的 worker 重连,`SubtitleBus` 实例保留,seq 连续递增**;房间没了 = job 没了 = 前端这次通话也没了,不存在跨 job 的旧高水位。
|
||||
|
||||
## 3. 本产品没有「同房间第二个 job」的路径
|
||||
|
||||
- 后端唯一派发点:`src/cybergril/voice/session.py:396` `open_session` 内 `livekit_wire.create_dispatch(...)`,房间名 `cg-voice-` + 10 位随机(L394),每次 POST /api/voice/session 都是**新房间新派发**;`create_dispatch` 在 src/ 下仅此一处(grep 证据在库)。没有任何对既有房间补发/重发 job 的代码路径。
|
||||
- 前端 join 的就是该房间(grant.room),一个控制器会话 ↔ 一个房间 ↔ 一个显式派发 ↔ 一个 job ↔ 一个 `SubtitleBus`,五者一一对应。
|
||||
- 服务器侧 job 重投(resend)只发生在 job 从未启动时(无可供 worker 接单/启动超时):那时第一个 entrypoint 从未运行、从未发过任何帧,前端 `live.seq` 仍为 0,重投 job 从 1 计与前端水位天然一致。SDK 源码中不存在「已运行的 job 迁移到另一进程」的机制(job 进程即 job 本体,崩溃即终止;livekit-agents 侧无 resend 代码,grep `resend` 仅命中无关模块)。
|
||||
|
||||
## 4. 真实残余缺口(非 seq,但同场景):worker job 中途死亡 = 前端僵尸「listening」
|
||||
|
||||
job 死亡(worker 进程崩溃/被运维重启)时:agent participant 离开房间,但**房间仍在**(用户还在),前端收不到 `dropped`(它只挂 `Disconnected`,web/src/voice/livekit.ts L69–75 只处理 `ParticipantConnected`/`DataReceived`/`Disconnected`/`AudioPlaybackStatusChanged`),页面停在最后相位(多为 listening),再无任何帧。`RoomEvents` 无 agentLeft。这不破坏 seq 或旧帧防护(没有第二命名空间),但用户会面对一个没有她的「通话中」。有限修复建议(归原作者,前端):`livekit.ts` 增加 `ParticipantDisconnected`(agent 身份)→ `RoomEvents.agentLeft` → `VoiceController` 以诚实话术收场(与 dropped 同路径、不删她的话轮)。后端无需改动。
|
||||
|
||||
## 5. 可执行复核方式
|
||||
|
||||
- 证据 A(本轮):`scripts/gen_hel879_unified_frames.py` 在 .12 真 SDK 上驱动实际 worker 回调生成全部 v2 帧(见 frames/),各场景 seq 严格递增、每 speech 恰一次 `turn_start`。
|
||||
- 证据 B(源码引用,可重复):本文第 1/2/3 节的 file:line 均可在 .12 venv 直接核对;`.12` 只读 SSH stdin。
|
||||
- 证据 C(前端侧):`web/tests/hel879_frames.test.ts` 用实际 worker 帧验证 replay/迟到/乱序帧全部被 seq+speech 双门拦下;「新通话 = 新房间 = 新序列」在 drop+重开用例中验证(旧房间帧不再影响新 `Live`)。
|
||||
|
||||
## 6. 结论
|
||||
|
||||
**现网代码内,seq 命名空间 = job = 房间 = 控制器会话,一一对应;不存在「同房间新 job 序号归零」的产品路径或 SDK 机制。** 前端以控制器会话记高水位是安全的,且对任何假设的第二命名空间表现为 fail-closed(旧水位丢弃全部低 seq 新帧,不会把旧帧当新帧收)。唯一真实缺口是 §4 的 agent 离场僵尸界面(🟡 建议项,归原作者)。
|
||||
@@ -0,0 +1,44 @@
|
||||
# HEL-879 统一整合审核记录(2026-10-27)
|
||||
|
||||
整合对象:`hel-879-assembly` @ fc905fe(后台时机收口)+ `origin/hel-898-preserved` @ fb7ea84(总管逐字节保全的 HEL-898 前端接线,作者代码 46ac0a7)。本审核不开新串、不重审已过内核(预算/备份/恢复/绑定/三处时机/总管 898 六项检查均沿用其通过记录)。
|
||||
|
||||
## 统一 head
|
||||
|
||||
`hel-879-assembly` 快进至 **fb7ea84 + 本轮审核提交**(审核物:帧生成脚本、fixtures、前端用例、浏览器回放 harness、SDK 取证、R5 测试包、handoff 追加)。与 fc905fe 的差异 = HEL-898 的 8 文件(6 web/tests+docs/handoff-898+2 web/src:ChatPanel.tsx/session.ts,总管已核逐字节=作者附件)+ 本轮审核新增文件;`web/src` 除作者两文件外零改动,`src/` 零改动,PR#11 与 hel-870-assembly 未动。
|
||||
|
||||
## 焦点1 · 实际 worker 帧 → 当前前端(功能)
|
||||
|
||||
**帧来源**:`scripts/gen_hel879_unified_frames.py` 在 .12 真实 SDK(livekit-agents 1.8.8 / livekit-api 1.2.1,SSH 只读 stdin,零写入零网络)驱动**实际 worker 接线**(`make_voice_llm` 归属绑定 + `bind_voice_session` 真回调 + 真 SpeechHandle 生命周期 + `speak_proactive_row`)生成五套 v2 帧(frames/ + web/tests/fixtures/hel879/)。唯一替身=HTTP 客户端(离线注入 /api/chat SSE)与播放时钟(`_mark_done`/`interrupt` 定时器)。
|
||||
|
||||
场景与结果(vitest `web/tests/hel879_frames.test.ts` 9/9,实际帧逐帧喂 `VoiceController`):
|
||||
|
||||
| 场景 | 帧序列(实测) | 前端行为(断言) |
|
||||
| --- | --- | --- |
|
||||
| normal | interim→user_final→turn_start→3×interim→**final→end**(receipt ack ✓) | 增量替换生长;final=存稿原文;落库回读接替:`voice-<speech>` 键→存档行 id(同 turn),mergeTurns 恰加一次 |
|
||||
| interrupt | cut 轮 **无 final 只有 cancel(interrupted)**;下一轮干净 final+ack | 被切行立即清;迟到帧(新 seq、死 speech)不回显;下一轮独立落库一次 |
|
||||
| φ | interim(φ)→error(φ 原句)→cancel(error_phi),无 ack | spoken 槽可闻不入档;her 即清 |
|
||||
| no_receipt | **cancel(no-receipt),无 final**(fc905fe 门实测生效) | 无任何「她的回复」残留 |
|
||||
| proactive | start **播放中**(sidecar `start_while_playing=true`)→end;打断变体→cancel(interrupted) | 她的位置整句显示、绑定存档行 id;打断即清;页面零写请求 |
|
||||
|
||||
打断/文字接管/掉线重开(vitest 全过):user_final 立即关她当前 speech;interim 不抢清(由服务端 cancel 定夺);`hush()` 文字接管即清行+静音;`dropped` 整通话收场清残影;重开=新房新序列,旧房间帧无效。舞台身份与键接替(voiceLine/mergeTurns)用实际帧 turn_id 验证。
|
||||
|
||||
**界面层**:本机缺浏览器 GUI 系统库(libatk/libgbm 等十项,chromium/chrome-headless-shell 均无法启动),零安装授权下**未独立复跑浏览器**。`web/tests/hel879_unified_shots.mjs`(自持 mock+CDP,回放实际 worker 帧、双主题双尺寸)已入库,供有 Edge/Chromium 环境一键回放。界面证据仍=作者 61/61 假 LiveKit 浏览器与两主题截图(总管保全,未复跑,不称独立通过)。**录屏:本轮无新连续帧/视频产出,如实列未交。**
|
||||
|
||||
## 焦点2 · seq 命名空间与 job/房间/控制器代次
|
||||
|
||||
结论(详见 `seq-job-analysis.md`,全部 file:line 指向 .12 真 SDK 源码):**job=房间=派发=控制器会话一一对应,无「同房间新 job 序号归零」路径**。一 job 恰一次 entrypoint(job_proc_lazy_main L241-246/L318-323);RTC 瞬断走 reconnecting/reconnected 不重跑(rtc/room.py L85-86/L1130-1133),房间真断=job 结束=前端 Live 同终;后端唯一派发点 session.py:396 每次新房(10 位随机名);服务器重投仅限从未启动的 job(从未发帧,水位天然一致)。若假设性第二命名空间出现,前端表现为 fail-closed(丢帧而非收旧帧)。**残余缺口(🟡 归原作者前端)**:worker job 中途死亡(进程崩溃/重启)时 agent participant 离场但房间仍在,livekit.ts 无 agentLeft → 页面僵尸「listening」。有限修复:`ParticipantDisconnected`(agent 身份)→ 诚实话术收场;后端无需改。
|
||||
|
||||
## 焦点3 · closed 64 上限与长会话/清理边界
|
||||
|
||||
- **DOCUMENTED GAP(🟡 归原作者,非现网可触发)**:64+ 后续关闭把退休 speech 挤出窗口后,**重复高 seq turn_start 可重进**(vitest 用例以对抗帧实证:重进→重放终稿可再次立为她的行)。现 worker 无法产生该帧(SDK speech_id 每话唯一、`interim.begin` 每流一次、无重发路径),触发需第二/失常来源——与焦点2结论一致。防御性修复建议:`closed` 改无界 `Set<string>`(每通话 speech 数有 TTL 上界,内存可忽略)或对 turn_start 增加按 job 内序号窗的约束。
|
||||
- 新用户 interim→cancel/终稿清理边界(vitest 全过):interim 只更新 heard 不清她的行(打断由服务端定夺);user_final 立即关闭当前 speech 并清行;cancel/final/turn_end 的即时清理由实际帧验证。
|
||||
- 舞台回读/身份与键接替:实际帧 turn_id 驱动(焦点1 表),非单测数字。
|
||||
|
||||
## 回归与需求检查
|
||||
|
||||
- pytest **352/352**(py3.11 venv);vitest **108/108**(作者 99+本轮 9);typecheck/build 通过;lint 0 错 1 既有 fullButton 警告;ruff(src/tests/tools) 0 错。
|
||||
- 需求检查 4/4:origin/main 与 fc905fe 各带/不带更新后的 handoff-HEL-879.json(本目录 requirements-*.log)。v1.11/60 项无需求变更;总管 898 四项检查沿用。
|
||||
|
||||
## 边界
|
||||
|
||||
零真实 LLM/嵌入/ASR/TTS/麦克风、零下载/安装/部署/合并;.12 仅 SSH 只读取证(含一次 nvidia-smi/端口探活供 R5 包);真实 cap=0;不选音色、不改绑定、不碰旧服务/档案/冻结账。R5 真机包材料(`r5-test-package.md`)仅备齐待批,未执行。完整 CG-011、R6/R7 继续承接。
|
||||
@@ -0,0 +1,21 @@
|
||||
# wss_bridge.example.py 本地冒烟(2026-10-27,零外网、回环 only)
|
||||
|
||||
- 一次性本地自签证书(CN=local-bridge-test,2 天有效期,仅本测试)。
|
||||
- dummy 上游 127.0.0.1:18880(读 5 字节回 echo);桥 127.0.0.1:18363 → 18880。
|
||||
- 结果:TLSv1.3 握手成功;`sendall(b"hello")` 经桥穿透后收到 `b"echo:hello"`。
|
||||
- 结论:TLS 终结 + 字节透明转发路径可用;.12 实部署与 LiveKit 真实信令仍属
|
||||
拟议/待真机(本冒烟不证明后者)。
|
||||
|
||||
```
|
||||
$ python3 deploy/voice/wss_bridge.example.py --listen 127.0.0.1:18363 \
|
||||
--upstream 127.0.0.1:18880 --cert cert.pem --key key.pem &
|
||||
wss-bridge(PROPOSAL) tls ('127.0.0.1', 18363) -> ('127.0.0.1', 18880)
|
||||
|
||||
$ python3 - <<'PY'
|
||||
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
|
||||
s = ctx.wrap_socket(socket.create_connection(("127.0.0.1", 18363), timeout=5))
|
||||
print("TLS:", s.version()); s.sendall(b"hello"); print("payload:", s.recv(64))
|
||||
PY
|
||||
TLS: TLSv1.3
|
||||
payload: b'echo:hello'
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# HEL-879 · 语音接缝接口与验收(交原视觉作者接线;总工不改已认可 web/src 视觉)
|
||||
|
||||
> 背景:总管独立复核指出「CG-011 语音主动开口 no-op、她的增量字幕缺失」是
|
||||
> **实现缺口**,不能归为「等音色/预算就能自动完成」。本文件给出后台已落地的
|
||||
> 最小接缝、前端需要补的**精确接口**与**验收标准**。代码基点:分支
|
||||
> `hel-879-assembly`(见交付评论精确 head);后台实现
|
||||
> `src/cybergril/voice/seam.py` + `worker.py` 接线。
|
||||
>
|
||||
> **2026-10-27 收口更新**:字幕消息已升级为**版本化契约 v2**(信封
|
||||
> `v/seq`、确切轮次 `speech_id`、显式起止/取消事件、迟到/乱序保护、主动
|
||||
> 播报同源 `message_id`)。完整可执行契约与录制样例见
|
||||
> `docs/voice-subtitle-contract-v2.md` 与
|
||||
> `docs/rework/hel879/subtitle-samples/*.jsonl`——**接线卡(HEL-898)以
|
||||
> 该契约为准**,本文 A 节的 v1 语义作为兼容背景保留。
|
||||
|
||||
## A. 她的增量回复字幕(assistant_interim)
|
||||
|
||||
### 现状与缺口
|
||||
- 用户侧 ASR 字幕已齐:`interim`(ASR 草稿)与 `user_final`(纠错终稿)在
|
||||
`web/src/voice/session.ts` 已渲染(现有 `interim` 分支当「听到用户在说」处理,
|
||||
**不能复用来渲染她的回复**——那是另一个语义)。
|
||||
- 缺的是**她**的回复在 TTS 首包前/播放中的增量文字。后台现已补齐:worker 把
|
||||
/api/chat SSE 的每个 delta 累积后发布到同一 `cg.subtitle` 通道。
|
||||
|
||||
### 后台已交付(无需前端改动即生效的数据)
|
||||
- 数据通道:topic `cg.subtitle`(不变)。
|
||||
- 新 kind:`assistant_interim`,`role: "assistant"`,`text` 为**截至当前的完整
|
||||
回复文本**(替换语义,同用户 interim;发布时机=SSE 分片到达,先于/伴随 TTS)。
|
||||
- 既有 kind 不变:`assistant_final`(已落库的完整回复,播放完成且未打断后发)、
|
||||
`error`(φ 原句,未落库)、`interim`/`user_final`(用户侧)。
|
||||
- fail-closed 语义不变:打断/失败/无回执的轮**只有 interim,不会有 final**——
|
||||
前端必须把无 final 收尾的 interim 按「这轮没有成为她的正式回复」处理。
|
||||
|
||||
### 前端需补(原视觉作者;具体视觉由其定)
|
||||
1. `web/src/voice/session.ts` 字幕分支:新增 `assistant_interim` 处理(当前
|
||||
`if (s.kind === 'interim')` 只覆盖用户草稿)。
|
||||
2. 渲染位置/样式:与 `assistant_final` 同一气泡位(建议替换式过渡),与用户
|
||||
`heard` 视觉可区分;**不进入正式聊天记录**(只有 final 落库)。
|
||||
3. 收尾规则:`assistant_final` 到达 → interim 消失、final 生效;`error` 到达 →
|
||||
interim 消失、按现有 error 呈现;被打断(无任何收尾)→ 下一次 interim 或
|
||||
新轮开始时清掉;挂断 → 清掉。
|
||||
|
||||
### 验收(离线可测,无需真实通话)
|
||||
- [ ] 用假 LiveKit/假音频脚本回放 `cg.subtitle` 序列
|
||||
`interim(user) → user_final → assistant_interim("她在") →
|
||||
assistant_interim("她在打字中") → assistant_final("她在打字中。")`,
|
||||
页面按序呈现且 interim 被 final 原子替换。
|
||||
- [ ] 打断负例:`assistant_interim` 后直接新的 `interim(user)`(无 final/error),
|
||||
她的 interim 残影不越过新用户轮。
|
||||
- [ ] φ 负例:`assistant_interim` → `error`,error 文案呈现且不落正式记录。
|
||||
- [ ] 全程零新增权限申请、零非回环出站(沿用 HEL-896 浏览器检查口径)。
|
||||
|
||||
## B. CG-011 语音主动开口(最小接缝,默认关闭)
|
||||
|
||||
### 后台已交付
|
||||
- `worker.py _maybe_open_proactive`(真实代码路径,`CYBERGRIL_PROACTIVE_VOICE=1`
|
||||
才启用;**默认关**,等接线卡与呈现批准):
|
||||
- 数据来源:与文字收件箱同一 `GET /api/messages`(`ChatApiClient.recent_messages`),
|
||||
只选**服务端已落库**(`proactive=1`、`role=companion`、`status=sent`、非空、
|
||||
本房间未念过)的行,最新优先,每会话 ≤1 条(`PROACTIVE_PER_SESSION`)。
|
||||
- 播报:`session.say(text, allow_interruptions=True)`——可被用户打断;
|
||||
无新模型调用、无第二人格、**无第二条回执通道**(文字回执规则独立且已存在)。
|
||||
- 失败 fail-closed:接缝任何异常只记日志跳过,不影响会话开启。
|
||||
- 选择逻辑独立在 `seam.py pick_proactive/pending_proactive`(离线可测,
|
||||
`tests/test_voice_rework_hel879.py` 三例覆盖)。
|
||||
|
||||
### 前端/接线卡需定(原视觉作者 + 总管,经用户批准后启用)
|
||||
1. 入口呈现:她主动开口时页面如何呈现(字幕走 `assistant_final` 同位?
|
||||
是否复用留言卡「回她」入口语义?未读标记如何联动——**worker 不代标已读**,
|
||||
阅读态仍归页面)。
|
||||
2. 节奏:会话开启后何时念(现在=开启即查一次、每会话最多一条);是否需要在
|
||||
会话中轮询新 proactive(后续卡,不默记完成)。
|
||||
3. 打断呈现:用户开口打断她的主动播报时,字幕按打断负例处理(无 final 收尾)。
|
||||
|
||||
### 验收(启用后)
|
||||
- [ ] 预置一条落库 proactive 行 → 开语音会话 → 她原文念出该行(与文字收件箱
|
||||
同一行、一字不差),字幕按定稿呈现。
|
||||
- [ ] 播报中用户开口 → 立即被打断、无残音;该行不被标记已读(除非用户在页面
|
||||
读它)、无回执伪造。
|
||||
- [ ] 无 proactive 行 → 会话正常开启,静默。
|
||||
- [ ] API 不可达 → 接缝跳过,语音会话不受影响。
|
||||
|
||||
## 边界
|
||||
- 本文件只交接口与验收;**总工未改任何已认可 web/src 视觉**(本卡 web/src 仅
|
||||
勘正 session.ts 一处无证据注释,无逻辑/样式改动,diff 见交付评论)。
|
||||
- R5 真机验收(≥3 轮、1 秒打断、停麦无残音、可信 HTTPS/WSS、D1 音色、有限
|
||||
预算)仍待主卡集中批准,接缝通过≠R5 完成。
|
||||
@@ -37,5 +37,5 @@
|
||||
}
|
||||
],
|
||||
"failures": [],
|
||||
"service_stderr_tail": "INFO: Started server process [1535952]\nINFO: Waiting for application startup.\nINFO: Application startup complete.\nINFO: Uvicorn running on http://127.0.0.1:8395 (Press CTRL+C to quit)\nINFO: Shutting down\nINFO: Waiting for application shutdown.\nINFO: Application shutdown complete.\nINFO: Finished server process [1535952]\n"
|
||||
"service_stderr_tail": "INFO: Started server process [1595966]\nINFO: Waiting for application startup.\nINFO: Application startup complete.\nINFO: Uvicorn running on http://127.0.0.1:8395 (Press CTRL+C to quit)\nINFO: Shutting down\nINFO: Waiting for application shutdown.\nINFO: Application shutdown complete.\nINFO: Finished server process [1595966]\n"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"head": "04eff611e7d85e539d43717711172441420ac853",
|
||||
"head": "7c73d392b95f3d55091a066db0ffe9bc6c475e0d",
|
||||
"no_provider_calls": true,
|
||||
"temp_only": true,
|
||||
"scenes": {
|
||||
@@ -59,6 +59,20 @@
|
||||
"after_restart": "budget_total_requests_exhausted:55/55",
|
||||
"archive_after_backup_api": "budget_total_requests_exhausted:55/55"
|
||||
},
|
||||
"S6_early_seal": {
|
||||
"plan_total": 51,
|
||||
"used_at_seal": 51,
|
||||
"marker": {
|
||||
"requests": 51,
|
||||
"tokens": 510,
|
||||
"sealed_at": 1793046443
|
||||
},
|
||||
"next_after_seal": "budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零)",
|
||||
"other_scene_after_seal": "budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零)",
|
||||
"after_restart": "budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零)",
|
||||
"after_archive_restore": "budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零)",
|
||||
"idempotent_reseal": true
|
||||
},
|
||||
"S3_user_instance": {
|
||||
"B_after_A": "allowed",
|
||||
"C_after_A": "allowed",
|
||||
@@ -103,6 +117,16 @@
|
||||
"ok": true,
|
||||
"detail": "restart=budget_total_requests_exhausted:55/55 archive=budget_total_requests_exhausted:55/55 used=55"
|
||||
},
|
||||
{
|
||||
"name": "S6a_early_finish_51_seals_real_usage_no_fake_no_zero",
|
||||
"ok": true,
|
||||
"detail": "used=51 dialogue_next=budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零) semantic_next=budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零) after=51 dialogue_daily=19/60"
|
||||
},
|
||||
{
|
||||
"name": "S6b_seal_survives_restart_and_archive_restore",
|
||||
"ok": true,
|
||||
"detail": "restart=budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零) archive=budget_sealed:51/55(封账于 1793046443,真实用量保留,不伪造、不清零) arch_used=51 marker={'requests': 51, 'tokens': 510, 'sealed_at': 1793046443}"
|
||||
},
|
||||
{
|
||||
"name": "S3_user_instance_B_and_C_unblocked_after_A",
|
||||
"ok": true,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# HEL-897 MANIFEST
|
||||
|
||||
Base: HEL-878 author draft as preserved by the supervisor, b95ce798b4a9455d941d247a46865b3ac202bf2c (13 files verified against preservation-878.json before work). Patch is relative to that tree; full tree (no node_modules/dist) in HEL-897-source.zip. Nothing else changed; HEL-878 files and handoff-HEL-878.json untouched except the two test helpers listed below.
|
||||
|
||||
## Changed/added files (sha256)
|
||||
- docs/requirements/handoff-HEL-897.json 56CF0E97B5035CCFAAFF3F7DD219CA30352DF600FA4C9684EB690E4E44731BC9
|
||||
- web/src/voice/session.ts 4B74731B7DC34534FF8F046B9487E115B3A38CFB202B045E34A12FA27FE528FE
|
||||
- web/tests/hel878_mock.mjs 1B1608D5E0858842E9A0CF51FAAF9BAECE5E52B546757464B046DF0E66A4B815
|
||||
- web/tests/hel878_shots.mjs E5706C5A4459315ECDB991CBD8D3776DB3EFE035FE520930FFB09E726EB639C7
|
||||
- web/tests/review878_late_cleanup.test.ts 65261CD249A36693D0C6B06CE279DE8A60AA74DD7BAAFAD6999D2FEC790F9935
|
||||
- web/tests/voice.test.ts 4C120B9449CE3AF0B66B3B8A18668D5012D286D6C18C0DEE1347A880F7780471
|
||||
|
||||
HEL-897.patch 1D8C43B0FE9984066406E60FAC715764DCD375546713DEB6BD034160F2050070
|
||||
@@ -0,0 +1,18 @@
|
||||
HEL-897 返修保全与独立补查(总管,2026-10-27 北京时间)
|
||||
|
||||
原作者交付评论:01a1798b-60b0-7562-9a70-82584f52dd54;主卡 HEL-797。需求 v1.11 / 60项,无需求变更。关联 CG-010/018、VIS-013、UX-005/006、GOV-001–006、DEL-001。
|
||||
|
||||
本次原样保全提交:761a2331f5898bacada8d9665da517c816af2367;作者原稿基点 b95ce798b4a9455d941d247a46865b3ac202bf2c,实际追加基点 f259bd34e19959db210578b11076e6f29a6bd15e。仅作者补丁所列6文件,业务代码仅 web/src/voice/session.ts;未改视觉、后台或HEL-896。补丁哈希及6文件SHA-256与独立MANIFEST、源码包逐字节匹配,git apply反向检查通过;总管负例与上一轮文件逐字节一致。源码包其余127文件经CRLF/LF规范化匹配;源码包携带旧source-manifest,按作者交接保留实际分支从04eff611补全的版本,逐字节核对未回退。另在本记录及preservation.json保存证据,不改作者handoff中的历史未测记录。
|
||||
|
||||
独立实际执行:
|
||||
|
||||
- 使用相同的33项语音测试,仅将隔离副本session.ts替换为b95ce798原版:5失败/28通过。原负例仍为Expected listening / Received off;交错start负例有两间未释放房间。
|
||||
- 修后完整8文件88/88单测通过(包含上述33项)。typecheck通过;lint 0错/1条既有警告;build通过。沿用同锁文件的本机既有node_modules复制树,没有安装或下载。
|
||||
- Python原版scripts/check_requirements.py:对origin/main、实际总装origin/hel-870-assembly(04eff611)、后台origin/agent/2/6a08a4c18f23(aba2aeb)、本次实际追加目标origin/hel-878-voice-wiring(f259bd3),各不带handoff、带handoff-HEL-878、带handoff-HEL-897,共12次通过。各精确提交在preservation.json。
|
||||
- 作者的无头Edge浏览器49/49及修前45/49已随卡交证据;本轮未独立复跑浏览器,不把它当真WebRTC、真实麦克风或真机证据。
|
||||
|
||||
代码核对:fail同步让出旧会话所有权,启动仅清旧资源的teardown后立即提交本次OFF提示,清理等待后不再提交状态;start在await前取得代次,清理旧会话后先核对是否过期。负例与新增回归覆盖迟到DELETE/settle、掉线、撤销权限、worker超时、正常结束慢DELETE、交错start和迟到麦克风授权;原旧会话资源与新会话存活断言保持。
|
||||
|
||||
本次有限返修可交HEL-879独立统一审核,不代表完整R5通过。完整需求原话已核对R5/D1/D3、视觉分工、动态认可、成熟复用、完整体验指令;CG-011主动开口仍为后台no-op、增量字幕仍缺,须列有限接缝修正;真实远端断线清理/租约/API重启、SDK回调和播放回执、真实三轮/打断/停麦/HTTPS/WSS/Safari全未测。R6/R7、CG-028/029继续主卡承接。
|
||||
|
||||
推进顺序:总工HEL-896仍在运行,不能并行修改其母线;HEL-879接到此保全候选后,待HEL-896交回且总管核对实际母线再启动统一审核,避免覆盖新预算/发布成果。真实调用、音色、设备路径、预算和部署仍按主卡最终批准,默认真实cap=0。不合并、不部署、不碰旧服务/档案/凭据/运行时绑定。
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"author_comment": "01a1798b-60b0-7562-9a70-82584f52dd54",
|
||||
"baseline_version": "1.11",
|
||||
"author_base": "b95ce798b4a9455d941d247a46865b3ac202bf2c",
|
||||
"applied_to": "f259bd34e19959db210578b11076e6f29a6bd15e",
|
||||
"patch_sha256": "1d8c43b0fe9984066406e60fac715764dcd375546713deb6bd034160f2050070",
|
||||
"files": [
|
||||
{
|
||||
"path": "docs/requirements/handoff-HEL-897.json",
|
||||
"sha256": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9"
|
||||
},
|
||||
{
|
||||
"path": "web/src/voice/session.ts",
|
||||
"sha256": "4b74731b7dc34534ff8f046b9487e115b3a38cfb202b045e34a12fa27fe528fe"
|
||||
},
|
||||
{
|
||||
"path": "web/tests/hel878_mock.mjs",
|
||||
"sha256": "1b1608d5e0858842e9a0cf51faaf9baece5e52b546757464b046df0e66a4b815"
|
||||
},
|
||||
{
|
||||
"path": "web/tests/hel878_shots.mjs",
|
||||
"sha256": "e5706c5a4459315ecdb991cbd8d3776db3efe035fe520930ffb09e726eb639c7"
|
||||
},
|
||||
{
|
||||
"path": "web/tests/review878_late_cleanup.test.ts",
|
||||
"sha256": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935"
|
||||
},
|
||||
{
|
||||
"path": "web/tests/voice.test.ts",
|
||||
"sha256": "4c120b9449ce3af0b66b3b8a18668d5012d286d6c18c0dee1347a880f7780471"
|
||||
}
|
||||
],
|
||||
"archive_other_files_matched_normalized_lf": 127,
|
||||
"archive_extras": [],
|
||||
"source_manifest": "archive retains older manifest; actual branch retains 04eff611 authoritative manifest as author instructed; bytewise verified",
|
||||
"preserved_commit": "761a2331f5898bacada8d9665da517c816af2367",
|
||||
"requirement_ref_commits": {
|
||||
"origin/main": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
|
||||
"origin/hel-870-assembly": "04eff611e7d85e539d43717711172441420ac853",
|
||||
"origin/agent/2/6a08a4c18f23": "aba2aebeb08d87854b5b037b5e69d76ddfba446e",
|
||||
"origin/hel-878-voice-wiring": "f259bd34e19959db210578b11076e6f29a6bd15e"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
|
||||
RUN v3.1.2 /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-897-cfe565d5b1bd/workdir/baseline-web
|
||||
|
||||
❯ tests/review878_late_cleanup.test.ts (1 test | 1 failed) 12ms
|
||||
× old room disconnect cleanup cannot overwrite a newly opened call 11ms
|
||||
→ expected 'off' to be 'listening' // Object.is equality
|
||||
❯ tests/voice.test.ts (32 tests | 4 failed) 49ms
|
||||
✓ honest switch > reads status only: no room, no microphone 4ms
|
||||
✓ honest switch > configured && tts_ok never means "verified": voice:false is not read as connected 1ms
|
||||
✓ honest switch > refuses honestly before touching the microphone when the server cannot speak 1ms
|
||||
✓ honest switch > an insecure page is told why, without asking for the microphone 0ms
|
||||
✓ lifecycle > opens, joins, publishes the microphone, follows her state; ending stops everything 1ms
|
||||
✓ lifecycle > permission denied: nothing opened on the server, nothing left running 1ms
|
||||
✓ lifecycle > server refusal shows its own words and releases the microphone 0ms
|
||||
✓ lifecycle > a failed join releases the microphone and gives the room back 0ms
|
||||
✓ lifecycle > ending while the microphone prompt is open: the late microphone is stopped, no room is asked for 2ms
|
||||
✓ lifecycle > ending while the room is being opened: the late grant is given back, never joined 3ms
|
||||
✓ lifecycle > ending while joining: the join is aborted, a late room is closed, the server room deleted 3ms
|
||||
✓ lifecycle > rapid on/off/on: only the last call stays, every earlier microphone and room is released 3ms
|
||||
✓ lifecycle > late events from an ended call never bring it back 2ms
|
||||
✓ lifecycle > the room dropping on its own (network, server ended it) cleans up and says so 1ms
|
||||
✓ lifecycle > microphone permission revoked mid-call ends the call 1ms
|
||||
✓ lifecycle > her worker never joining is a failure, not an endless wait 2ms
|
||||
✓ lifecycle > offline ends with its own words; unload releases at once and the DELETE survives the page 2ms
|
||||
✓ lifecycle > a DELETE that cannot get through (offline) is owed, survives a new page, and is sent once back online 1ms
|
||||
✓ lifecycle > an owed room already gone on the server (404) counts as settled 0ms
|
||||
✓ lifecycle > never confirms a receipt or posts chat itself: the worker owns both 0ms
|
||||
× a late end of an older call > the room dropped, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 6ms
|
||||
→ expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'off' }
|
||||
(4 matching properties omitted from actual)
|
||||
× a late end of an older call > the microphone was taken away, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 2ms
|
||||
→ expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'off' }
|
||||
(4 matching properties omitted from actual)
|
||||
× a late end of an older call > her worker never joined, the DELETE is slow, and a new call is started before it answers 1ms
|
||||
→ expected { phase: 'joining', heard: '', …(3) } to match object { phase: 'off', …(1) }
|
||||
(3 matching properties omitted from actual)
|
||||
✓ a late end of an older call > a normal end whose DELETE is slow, then a new call, then the old answer arrives 2ms
|
||||
× a late end of an older call > starting again while the previous start still waits for the old room to be given back: only the newest call survives 5ms
|
||||
→ expected [ Room{ closed: +0, …(3) }, …(1) ] to deeply equal [ Room{ closed: +0, …(3) } ]
|
||||
✓ a late end of an older call > a microphone granted late to an older start is stopped without touching the new call 3ms
|
||||
✓ subtitles and the two channels > interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded 0ms
|
||||
✓ subtitles and the two channels > without the agent state attribute, subtitles still move the state honestly 0ms
|
||||
✓ subtitles and the two channels > typing over her spoken answer silences what is left of it until she is listening again 0ms
|
||||
✓ record re-read > adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream 0ms
|
||||
✓ record re-read > a pending voice row settles in place (sent or cancelled), never duplicated 0ms
|
||||
✓ record re-read > reads newest pages down to what the page holds, bounded 1ms
|
||||
|
||||
Test Files 2 failed (2)
|
||||
Tests 5 failed | 28 passed (33)
|
||||
Start at 02:51:41
|
||||
Duration 268ms (transform 81ms, setup 0ms, collect 150ms, tests 61ms, environment 0ms, prepare 76ms)
|
||||
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL tests/review878_late_cleanup.test.ts > old room disconnect cleanup cannot overwrite a newly opened call
|
||||
AssertionError: expected 'off' to be 'listening' // Object.is equality
|
||||
|
||||
Expected: "listening"
|
||||
Received: "off"
|
||||
|
||||
❯ tests/review878_late_cleanup.test.ts:39:29
|
||||
37| await new Promise(resolve => setTimeout(resolve, 0));
|
||||
38| expect(controller.active).toBe(true);
|
||||
39| expect(view?.phase).toBe('listening');
|
||||
| ^
|
||||
40| } finally {
|
||||
41| releaseOld();
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > a late end of an older call > the room dropped, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone
|
||||
FAIL tests/voice.test.ts > a late end of an older call > the microphone was taken away, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone
|
||||
AssertionError: expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'off' }
|
||||
(4 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
- "phase": "off",
|
||||
+ "phase": "listening",
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:274:30
|
||||
272| const r = await connected(), slow = hold(r, 's1');
|
||||
273| cause(r); await flush();
|
||||
274| expect(r.view()).toMatchObject({ phase: 'off' });
|
||||
| ^
|
||||
275| expect(r.transport.mics[0].stopped).toBeGreaterThan(0); …
|
||||
276| expect(r.c.active).toBe(false);
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > a late end of an older call > her worker never joined, the DELETE is slow, and a new call is started before it answers
|
||||
AssertionError: expected { phase: 'joining', heard: '', …(3) } to match object { phase: 'off', …(1) }
|
||||
(3 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
- "notice": "她没有接上这次通话,麦克风已经关闭。文字仍可使用。",
|
||||
- "phase": "off",
|
||||
+ "notice": "",
|
||||
+ "phase": "joining",
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:293:26
|
||||
291| const r = rig(); await r.c.start(); const slow = hold(r, 's1');
|
||||
292| await vi.advanceTimersByTimeAsync(1100);
|
||||
293| expect(r.view()).toMatchObject({ phase: 'off', notice: WORDS.n…
|
||||
| ^
|
||||
294| await reopened(r, 1);
|
||||
295| slow.open(); await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > a late end of an older call > starting again while the previous start still waits for the old room to be given back: only the newest call survives
|
||||
AssertionError: expected [ Room{ closed: +0, …(3) }, …(1) ] to deeply equal [ Room{ closed: +0, …(3) } ]
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
@@ -14,6 +14,23 @@
|
||||
"ended": [Function anonymous],
|
||||
"stopped": 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
+ Room {
|
||||
+ "closed": 0,
|
||||
+ "hushed": [],
|
||||
+ "on": {
|
||||
+ "agentJoined": [Function agentJoined],
|
||||
+ "agentState": [Function agentState],
|
||||
+ "audioBlocked": [Function audioBlocked],
|
||||
+ "dropped": [Function dropped],
|
||||
+ "subtitle": [Function subtitle],
|
||||
+ },
|
||||
+ "published": [
|
||||
+ Mic {
|
||||
+ "ended": [Function anonymous],
|
||||
+ "stopped": 0,
|
||||
+ },
|
||||
+ ],
|
||||
+ },
|
||||
]
|
||||
|
||||
❯ tests/voice.test.ts:316:58
|
||||
314| slow.open(); await Promise.all([b, c]); await flush(); // B w…
|
||||
315| const last = r.transport.rooms.length - 1;
|
||||
316| expect(r.transport.rooms.filter(x => !x.closed)).toEqual([r.tr…
|
||||
| ^
|
||||
317| expect(r.transport.mics.filter(m => !m.stopped)).toEqual([r.tr…
|
||||
318| expect(r.server.open.size).toBe(1);
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
> cybergril-web@0.1.0 build
|
||||
> tsc --noEmit && vite build
|
||||
|
||||
vite v6.3.6 building for production...
|
||||
transforming...
|
||||
✓ 57 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
dist/index.html 0.49 kB │ gzip: 0.35 kB
|
||||
dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB
|
||||
dist/assets/index-BzbZ2t4t.js 306.87 kB │ gzip: 102.13 kB
|
||||
✓ built in 620ms
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
> cybergril-web@0.1.0 lint
|
||||
> eslint src
|
||||
|
||||
|
||||
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-897-cfe565d5b1bd/workdir/CyberGril/web/src/chat/ChatPanel.tsx
|
||||
222:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
|
||||
|
||||
✖ 1 problem (0 errors, 1 warning)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-878.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-897.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/main
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-870-assembly --handoff docs/requirements/handoff-HEL-878.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-870-assembly --handoff docs/requirements/handoff-HEL-897.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-870-assembly
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/agent/2/6a08a4c18f23 --handoff docs/requirements/handoff-HEL-878.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/agent/2/6a08a4c18f23 --handoff docs/requirements/handoff-HEL-897.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/agent/2/6a08a4c18f23
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-878-voice-wiring --handoff docs/requirements/handoff-HEL-878.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-878-voice-wiring --handoff docs/requirements/handoff-HEL-897.json
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,2 @@
|
||||
python3 scripts/check_requirements.py --base-ref origin/hel-878-voice-wiring
|
||||
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
> cybergril-web@0.1.0 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
> cybergril-web@0.1.0 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v3.1.2 /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-897-cfe565d5b1bd/workdir/CyberGril/web
|
||||
|
||||
✓ tests/r3memory.test.ts (5 tests) 7ms
|
||||
✓ tests/review878_late_cleanup.test.ts (1 test) 5ms
|
||||
✓ tests/real.test.ts (3 tests) 11ms
|
||||
✓ tests/domain.test.ts (12 tests) 9ms
|
||||
✓ tests/inbox.test.ts (13 tests) 24ms
|
||||
✓ tests/r2wiring.test.ts (11 tests) 27ms
|
||||
✓ tests/reading.test.ts (11 tests) 34ms
|
||||
✓ tests/voice.test.ts (32 tests) 51ms
|
||||
|
||||
Test Files 8 passed (8)
|
||||
Tests 88 passed (88)
|
||||
Start at 02:51:00
|
||||
Duration 305ms (transform 533ms, setup 0ms, collect 875ms, tests 168ms, environment 1ms, prepare 331ms)
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
diff --git a/docs/requirements/handoff-HEL-898.json b/docs/requirements/handoff-HEL-898.json
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/docs/requirements/handoff-HEL-898.json
|
||||
@@ -0,0 +1,39 @@
|
||||
+{
|
||||
+ "baseline_version": "1.11",
|
||||
+ "read_commit": "3228b1e0d7f822b7030f945e285eac74126771aa",
|
||||
+ "task": "HEL-898(HEL-879 接线卡,视觉总监本人编码):在已认可的交谈界面接上她的增量字幕(cg.subtitle 契约 v2)与语音内主动开口呈现,处理打断/文字接管/错误/挂断收尾;不另做聊天页、不改主题与整体动效",
|
||||
+ "requirement_ids": [
|
||||
+ "CG-004", "CG-006", "CG-010", "CG-011", "CG-018", "CG-020",
|
||||
+ "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": "自总管输入包 3228b1e(INPUT-MANIFEST 逐文件 SHA-256)起的前端补丁,只动 web/src/voice/session.ts、web/src/chat/ChatPanel.tsx 与测试:1) session.ts 严格解析 v2 信封(v===2、正整数 seq、kind 白名单、角色与种类对应、她的事件必带 speech_id、assistant_final 必带 turn_id、proactive_* 必带正整数 message_id;不合格整条丢弃且不推进序号);每次通话(控制器代次)独立 seq 高水位,seq<=已应用即丢;按 speech 生命周期只接受当前打开的一轮,已关闭/被取代的 speech 不再显示,旧 speech 的高 seq 事件不能关闭新轮。VoiceView 新增 her(speech/text/state open|final|done/turnId/messageId/proactive),与用户 ASR 草稿 heard 分槽。2) turn_start 开轮,assistant_interim 整句替换,assistant_final 原子替换为落库文本并请页面回读 /api/messages,turn_end 关轮(无 final 的关轮按未成为正式回复即清),turn_cancel/error(φ)/user_final/文字接管 hush/结束/掉线/卸载/重开即时清;φ 原句仍在语音行以“未记入聊天”显示。3) proactive_start 以落库原文、同源 message_id 显示在她的位置,proactive_end 保留至记录或下一轮接替,proactive_cancel 即清;不发任何请求、不标已读、不开麦、不重 TTS、不进入正式记录。4) ChatPanel:她的语音行进入与文字回复相同的舞台槽位(当前一两句、自动续读、暂停/回看、全文),生长中沿用文字流的“最新一句先压住”规则,逐分片不重启动画;落库行到来时同一 turn 同一位置接替,行键不变不重放;被撤回的语音行以 instant 方式立即离场(不淡出);减少动态设置沿用既有逻辑。你用语音开始新一轮时与发送文字一致恢复自动呈现(原先暂停状态下她的回答不会进来)。5) 测试:voice.test.ts 改为 v2 帧并新增 11 项(录制样例三场景回放、信封负例、乱序/重放/迟到/双 handle、φ、主动开口及打断、文字接管/挂断/重开、舞台行键接替);fake-livekit.js 未带信封的帧自动套 v2;hel878_mock.mjs 增 /__proactive;hel878_shots.mjs 的她的 final 与 φ 改为 v2 生命周期;新增 hel898_shots.mjs(两主题×电脑/手机)",
|
||||
+ "acceptance": [
|
||||
+ "假 LiveKit 有序回放 normal-turn.jsonl:用户草稿→用户终稿→turn_start→两次增量(整句替换)→final(带 turn_id)→turn_end;她的字在舞台出现时 /__rows 尚无该回复;落库后记录中只出现一次(页面只回读,不写)",
|
||||
+ "interrupt.jsonl 与负例:turn_cancel 后她的未完成行立即离场;被取消轮之后的高 seq 增量/final/重复 turn_start 均不显示;整段录制重放不改变界面;双 handle 时旧 speech 的 turn_end/turn_cancel 不关闭新轮;无 final 的 turn_end 视为未成为正式回复并清除",
|
||||
+ "φ:error 原句在语音行显示并标“未记入聊天”,她的增量行立即清除,随后 turn_cancel(error_phi) 不抹掉 φ 原句;不落正式记录",
|
||||
+ "proactive.jsonl:原文一字不差显示在她的位置并保留落库行身份(id/turn),可被你的语音打断(proactive_cancel 即清);全程页面无任何非 GET 请求(无回执、无已读、无消息写入),不调用 onSpoke/onRecord",
|
||||
+ "文字接管、结束语音、掉线、新通话:她的行即时清;旧房间迟到帧被代次挡住;新通话序号从 1 重新开始并正常显示",
|
||||
+ "两主题(月光/雾光)电脑 1440×900 与手机 390×844 截图与连续帧:沿用已认可位置与组件,无新增组件或样式;减少动态设置下她的字照常出现、无入场动画",
|
||||
+ "工程:tsc 0 错;eslint 0 错 1 条既有警告(ChatPanel fullButton ref,HEL-897 起即有);vitest 8 文件 99/99;vite build 通过;浏览器 hel898 61/61,HEL-878 回归 49/49;修前对照:新 voice.test.ts 在原 session.ts/ChatPanel 上 12 项失败"
|
||||
+ ],
|
||||
+ "deferred": [
|
||||
+ "真实通话未测:真实 LiveKit/WebRTC、真实 worker 与 SDK 回调时序、真机/Safari/iOS、可信 HTTPS+WSS、R5 连续三轮/1 秒打断/停麦无残音/有限预算,均仍由 HEL-797 主卡承接;本卡全部为假 LiveKit、假音频设备与 Chromium 无头 Edge",
|
||||
+ "CG-011 语音内主动开口仅是“会话开时念一条已落库旧留言”的最小接缝(后台默认关,本卡不启用);会话中持续主动联系、激活节奏、未读联动策略未完成,不得记为 CG-011 完成",
|
||||
+ "后台并行小修(建房延迟后的清理唤醒、主动 say 的 start 发得过晚、无完整 receipt 错误发 final)由原总工在 HEL-879 处理;本卡按 v2 正确语义实现,最终后台 commit 出来后需按其统一复核(特别是 proactive_start 晚于音频开始时的呈现)",
|
||||
+ "已知边界:seq 按“一次 worker job 一个序列”实现;若同一房间内 worker 重连且序号归零,前端会按契约丢弃其帧直到超过旧高水位——需后台确认 job 与房间一一对应",
|
||||
+ "原版 Python 需求检查(scripts/check_requirements.py --base-ref origin/main,含本交接)未在本运行时执行(无 Python、无 git),须总管对实际目标分支复跑",
|
||||
+ "D1 音色、cap=0、R6/R7、整体沉浸体验由主卡与白栖知决定;工程自测不是用户体验验收"
|
||||
+ ],
|
||||
+ "requirement_changes": [],
|
||||
+ "verification": [
|
||||
+ "读取:HEL-898 卡(启动交接与原说明)、输入包 3228b1e:docs/voice-subtitle-contract-v2.md、docs/rework/hel879/voice-seam-interfaces.md、subtitle-samples/*.jsonl、src/cybergril/voice/seam.py 与 worker.py(事件发出点)、AGENTS.md、docs/requirements/README.md/current.md(v1.11)/coverage.md/changes.md、sources/integration-plan-v1.md R5",
|
||||
+ "工具:沿用本机既有离线工具链(HEL-834 留存 node v22.11.0;HEL-897 留存 web/node_modules,package-lock 哈希与本包一致,目录联接使用);未下载、未安装",
|
||||
+ "web:tsc --noEmit 0;eslint src 0 错 1 既有警告;vitest run 8 文件 99/99(原 88 + 新增);vite build 通过;日志随交付包",
|
||||
+ "浏览器:node tests/hel878_mock.mjs + node tests/hel898_shots.mjs(无头 Edge、脚本化 LiveKit 替身、Chromium 假音频设备)61/61;node tests/hel878_shots.mjs 回归 49/49;截图与连续帧随交付包",
|
||||
+ "无模型、嵌入、ASR、TTS 请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台、总装分支或 PR#11"
|
||||
+ ]
|
||||
+}
|
||||
diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx
|
||||
--- a/web/src/chat/ChatPanel.tsx
|
||||
+++ b/web/src/chat/ChatPanel.tsx
|
||||
@@ -10,7 +10,7 @@
|
||||
import { motionOff } from '../stage/roomScene';
|
||||
import { LineStage, type PresentHow } from './lineStage';
|
||||
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
|
||||
+import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
|
||||
-import { mergeTurns, OFF, recentRows, VoiceController, voiceStatus, WORDS, type VoiceView } from '../voice/session';
|
||||
import { loadTransport } from '../voice/livekit';
|
||||
import { newestId, serverId } from '../adapters/inbox';
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
|
||||
|
||||
const NO_IDS: string[] = [], NO_MESSAGES: Message[] = [];
|
||||
+// Marks "your next turn is one you said by voice" until its row is read back and its turn key is known.
|
||||
+const SPOKEN = '\u0000spoken';
|
||||
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).
|
||||
@@ -68,7 +70,8 @@
|
||||
};
|
||||
const spoke = useRef(() => {});
|
||||
// Speaking cancels a text reply still streaming (the server's turn gate already did); it is kept as a cut fragment.
|
||||
+ // Like sending, it starts a new turn that comes in by itself, even if you had paused reading.
|
||||
+ spoke.current = () => { if (busyNow.current) stop(true); sentTurn.current = SPOKEN; if (!flow) setFlash('新的一轮会自动呈现,随时可以暂停'); };
|
||||
- spoke.current = () => { if (busyNow.current) stop(true); };
|
||||
useEffect(() => {
|
||||
if (!real) return;
|
||||
const sync = recordSync.current;
|
||||
@@ -99,17 +102,27 @@
|
||||
// Real messages are trimmed by the server before transmission; their lifeIds point at records this page has not
|
||||
// necessarily loaded, so the demo's local filter would wrongly hide them.
|
||||
const view = useMemo(() => real ? state : publicView(state), [state, real]);
|
||||
+ // What she says by voice takes the place her text reply would take (HEL-898). It is shown, never stored: once the
|
||||
+ // record holds it (same turn, or the same stored reach-out as the newest line) the row takes over under the same
|
||||
+ // line keys; a finished line also gives way as soon as anything else comes into the record after it.
|
||||
+ const her = real ? voiceView.her : null, herBase = useRef({ speech: '', at: 0 });
|
||||
+ if (her && herBase.current.speech !== her.speech) herBase.current = { speech: her.speech, at: view.messages.length };
|
||||
+ const herLine = useMemo(() => reply ? null : voiceLine(her, view.messages, herBase.current.at, incoming), [her, reply, view.messages, incoming]);
|
||||
+ const herOpen = !!herLine && her?.state === 'open', talking = busy || herOpen;
|
||||
// The cut-off part of a stopped reply stays with its own turn (it is shown, never stored).
|
||||
const shown = useMemo<ShownMessage[]>(() => {
|
||||
const list: ShownMessage[] = [...view.messages];
|
||||
if (reply) list.push({ id: 'live-reply', role: 'companion', text: reply, lifeIds: [], turnId: activeTurn, live: 'generating' });
|
||||
+ else {
|
||||
+ if (interrupted) {
|
||||
+ const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
|
||||
+ list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
|
||||
+ }
|
||||
+ if (herLine) list.push(herLine);
|
||||
- else if (interrupted) {
|
||||
- const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
|
||||
- list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
|
||||
}
|
||||
return list;
|
||||
+ }, [view.messages, reply, activeTurn, interrupted, herLine]);
|
||||
+ const turn = useMemo(() => groupTurns(shown).at(-1), [shown]), opener = useRef(''); opener.current = turn?.items[0]?.role || '';
|
||||
- }, [view.messages, reply, activeTurn, interrupted]);
|
||||
- const turn = useMemo(() => groupTurns(shown).at(-1), [shown]);
|
||||
const stageRef = useRef<HTMLDivElement>(null), lines = useRef<LineStage | null>(null), meter = useRef<HTMLElement>(null);
|
||||
const [metrics, setMetrics] = useState<Metrics>(() => metricsFor(Math.min(900, innerWidth * .88)));
|
||||
const pages = useMemo(() => pagesOf(turn, metrics), [turn, metrics]);
|
||||
@@ -126,7 +139,7 @@
|
||||
// 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: talking || voiceView.phase === 'thinking' || voiceView.phase === 'speaking', sheet: log || full };
|
||||
- waiting.current = { count: incoming.length, busy: busy || voiceView.phase === 'thinking' || voiceView.phase === 'speaking', 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(quiet = false) { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); if (!quiet) onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
|
||||
@@ -154,8 +167,12 @@
|
||||
const key = turn?.key || '';
|
||||
if (key === turnKey.current) return;
|
||||
turnKey.current = key; how.current = 'jump'; head.current = START; setAnchor(START);
|
||||
+ // Your own new turn (sent, or said by voice: its server turn id is only known once the row is read back).
|
||||
+ if (key && (key === sentTurn.current || (sentTurn.current === SPOKEN && opener.current === 'user'))) { sentTurn.current = key; setFlowState(true); }
|
||||
- if (key && key === sentTurn.current) setFlowState(true);
|
||||
}, [turn?.key]);
|
||||
+ // Her voice line withdrawn (cut, φ, text took over, you spoke, the call ended): it leaves at once, no fade.
|
||||
+ const hadLine = useRef(false);
|
||||
+ useLayoutEffect(() => { if (hadLine.current && !her) how.current = 'instant'; hadLine.current = !!herLine; }, [her, herLine]);
|
||||
const signature = pages.map(p => p.key).join('|');
|
||||
useLayoutEffect(() => {
|
||||
lines.current?.present(pages, cursor, how.current, name, !motionOff());
|
||||
@@ -165,8 +182,8 @@
|
||||
useLayoutEffect(() => {
|
||||
if (!page) return;
|
||||
const mine = page.message, cancelled = mine.live === 'cancelled' || (mine.status === 'cancelled' && mine.role === 'companion');
|
||||
+ const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && talking && mine.role === 'companion' ? '还在说' : '';
|
||||
+ lines.current?.mark({ text, waiting: atEnd && talking && mine.role === 'companion' });
|
||||
- const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && busy && mine.role === 'companion' ? '还在说' : '';
|
||||
- lines.current?.mark({ text, waiting: atEnd && busy && mine.role === 'companion' });
|
||||
});
|
||||
useEffect(() => { lines.current?.rename(name); }, [name]);
|
||||
|
||||
@@ -306,7 +323,7 @@
|
||||
useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]);
|
||||
const typing = (on: boolean) => document.documentElement.classList.toggle('typing', on);
|
||||
useEffect(() => () => { document.documentElement.classList.remove('typing'); }, []);
|
||||
+ const record = shown.filter(m => m !== herLine && (!reply || !m.live));
|
||||
- const record = reply ? shown.filter(m => !m.live) : shown;
|
||||
// While a reading sheet is open the table words are out of reach (the sheet holds them all); layout effect so the
|
||||
// focus hand-back after closing never lands on an inert button.
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
@@ -327,7 +344,7 @@
|
||||
<div className="reader-bar">
|
||||
<button className="flow" aria-pressed={flow} onClick={() => setFlow(!flow)}><Icon name={flow ? 'pause' : 'play'}/><span>{flow ? '暂停' : '继续'}</span></button>
|
||||
<button className="icon" aria-label="上一句" disabled={cursor <= 0} onClick={() => back()}><Icon name="back"/></button>
|
||||
+ <span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${talking ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${talking ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
|
||||
- <span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${busy ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${busy ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
|
||||
<button className="icon" aria-label="下一句" disabled={atEnd} onClick={() => forward()}><Icon name="next"/></button>
|
||||
<button className="read-all" ref={fullButton} disabled={!page} onClick={() => setFull(true)}>全文</button>
|
||||
{busy && <button className="stop" onClick={() => stop()}>停止回复</button>}
|
||||
@@ -341,7 +358,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<aside className="record log-panel glass read" hidden={!log} role="dialog" aria-modal="false" aria-labelledby="record-title"><header><div><p className="eyebrow">{real ? '服务器聊天记录' : '完整记录 · 本机演示'}</p><h2 id="record-title">你们说过的话</h2></div><button className="btn-round sm" ref={logClose} aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header>
|
||||
+ <div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-mid={m.id} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${(focusLife && m.lifeIds.includes(focusLife)) || focusIds.includes(m.id) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}{herLine && !herLine.proactive && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{herLine.text}</p><span className="incomplete">{herOpen ? '还在说…' : '语音刚说完 · 正在读回聊天记录'}</span></article>}</div></aside>
|
||||
- <div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-mid={m.id} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${(focusLife && m.lifeIds.includes(focusLife)) || focusIds.includes(m.id) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
|
||||
{full && fullMessage && <aside className="record full glass read" role="dialog" aria-modal="false" aria-labelledby="full-title"><header><div><p className="eyebrow">{fullMessage.role === 'user' ? '你' : name} · 全文</p><h2 id="full-title">这一段,慢慢看</h2></div><button className="btn-round sm" ref={fullClose} aria-label="关闭全文" onClick={() => setFull(false)}><Icon name="x"/></button></header>
|
||||
<div className="scroll"><p className="guide">整段完整可读。关闭后回到刚才那一句。</p><article className="message" data-role={fullMessage.role}><div className="author"><i/>{fullMessage.role === 'user' ? '你' : name}</div><p>{fullMessage.text}</p>{fullMessage.live === 'generating' && <span className="incomplete">还在说…</span>}{fullMessage.live === 'cancelled' && <span className="incomplete">这段回复已停止,内容未完成,未存入聊天记录。</span>}</article></div></aside>}
|
||||
{real && permission === 'mic' && <Modal title={`开始和${name}语音?`} onClose={() => { setPermission(''); setNotice('没有开启语音,文字仍可使用。'); }} confirm="开始语音" onConfirm={() => { setPermission(''); setNotice(''); void voiceCtl.current?.start(); }}>浏览器会请求麦克风权限。你的声音只送到这台服务器上{name}的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。<br/><small className="voice-note">语音通路还没有经过真机验收,效果可能不稳定。</small></Modal>}
|
||||
diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts
|
||||
--- a/web/src/voice/session.ts
|
||||
+++ b/web/src/voice/session.ts
|
||||
@@ -7,6 +7,7 @@
|
||||
// One rule holds the whole lifecycle together: every call starts a new generation, and anything that answers for an
|
||||
// older generation (a late token, a late join, a late microphone, a late room event) is cleaned up, never shown.
|
||||
import type { Message } from '../domain/model';
|
||||
+import type { ShownMessage } from '../chat/reading';
|
||||
import { api, toMessage, within, type MessagePage } from '../adapters/real';
|
||||
import { serverId } from '../adapters/inbox';
|
||||
|
||||
@@ -17,17 +18,38 @@
|
||||
heard: string;
|
||||
// Her line spoken after a failure (φ): heard, not part of the record.
|
||||
spoken: string;
|
||||
+ // What she is saying right now, shown on the stage where her text replies appear; never stored by the page.
|
||||
+ her: HerLine | null;
|
||||
// Why voice is off or what went wrong; empty while it simply runs.
|
||||
notice: string;
|
||||
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
|
||||
blocked: boolean;
|
||||
}
|
||||
+// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
|
||||
+// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
|
||||
+// stays only until the record (or the next turn) takes its place.
|
||||
+export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
|
||||
+export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
|
||||
+
|
||||
+// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
|
||||
+const USER_KINDS = ['interim', 'user_final'] as const;
|
||||
+const HER_KINDS = ['turn_start', 'assistant_interim', 'assistant_final', 'turn_end', 'turn_cancel', 'error', 'proactive_start', 'proactive_end', 'proactive_cancel'] as const;
|
||||
+export type SubtitleKind = typeof USER_KINDS[number] | typeof HER_KINDS[number];
|
||||
+export interface Subtitle { seq: number; kind: SubtitleKind; role: 'user' | 'assistant'; text: string; speech: string; turnId?: string; messageId?: number; reason?: string }
|
||||
+const id = (v: unknown) => typeof v === 'string' && v.length > 0 && v.length <= 200 ? v : '';
|
||||
-export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', notice: '', blocked: false };
|
||||
-export interface Subtitle { kind: 'interim' | 'user_final' | 'assistant_final' | 'error'; role: 'user' | 'assistant'; text: string }
|
||||
export function subtitle(v: unknown): Subtitle | null {
|
||||
const s = v as Record<string, unknown> | null;
|
||||
+ if (!s || typeof s !== 'object' || Array.isArray(s) || s.v !== 2 || !Number.isSafeInteger(s.seq) || (s.seq as number) <= 0 || typeof s.text !== 'string') return null;
|
||||
+ const kind = s.kind as SubtitleKind, user = (USER_KINDS as readonly string[]).includes(kind);
|
||||
+ if (!user && !(HER_KINDS as readonly string[]).includes(kind)) return null;
|
||||
+ if (s.role !== (user ? 'user' : 'assistant')) return null;
|
||||
+ const out: Subtitle = { seq: s.seq as number, kind, role: user ? 'user' : 'assistant', text: s.text, speech: user ? '' : id(s.speech_id) };
|
||||
+ if (!user && !out.speech) return null;
|
||||
+ if (s.turn_id !== undefined && s.turn_id !== null) { if (!id(s.turn_id)) return null; out.turnId = s.turn_id as string; }
|
||||
+ if (kind === 'assistant_final' && !out.turnId) return null;
|
||||
+ if (kind.startsWith('proactive_')) { if (!Number.isSafeInteger(s.message_id) || (s.message_id as number) <= 0) return null; out.messageId = s.message_id as number; }
|
||||
+ if (typeof s.reason === 'string') out.reason = s.reason;
|
||||
+ return out;
|
||||
- if (!s || typeof s !== 'object' || typeof s.text !== 'string' || !['interim', 'user_final', 'assistant_final', 'error'].includes(s.kind as string)) return null;
|
||||
- return { kind: s.kind as Subtitle['kind'], role: s.role === 'assistant' ? 'assistant' : 'user', text: s.text };
|
||||
}
|
||||
|
||||
// ── What the controller needs from a real-time client. livekit.ts implements it with livekit-client; tests use fakes. ──
|
||||
@@ -77,7 +99,10 @@
|
||||
secure?: () => boolean;
|
||||
}
|
||||
|
||||
+// seq: the highest subtitle sequence applied in this call (one worker job, one sequence). speech: her speech open now;
|
||||
+// closed: speeches that ended, were cancelled or were superseded here; nothing for them is shown again.
|
||||
+interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType<typeof setTimeout>; seq: number; speech: string; pro: boolean; message?: number; closed: string[] }
|
||||
+const CLOSED_KEPT = 64;
|
||||
-interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType<typeof setTimeout> }
|
||||
|
||||
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
|
||||
|
||||
@@ -127,7 +152,7 @@
|
||||
// The generation is taken before anything is awaited, so a start that wakes up after a newer one never takes
|
||||
// the call over; the call it replaces is released first, as end() would.
|
||||
const prev = this.live;
|
||||
+ const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false, seq: 0, speech: '', pro: false, closed: [] };
|
||||
- const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false };
|
||||
this.live = live;
|
||||
this.view = { ...OFF, phase: 'opening' }; this.deps.onView(this.view);
|
||||
const stale = () => gen !== this.gen;
|
||||
@@ -189,11 +214,11 @@
|
||||
},
|
||||
subtitle: payload => {
|
||||
if (stale()) return;
|
||||
+ // Replays, late frames of an older turn and frames that overtook each other all sit at or below the
|
||||
+ // highest sequence already applied in this call.
|
||||
+ const s = subtitle(payload); if (!s || s.seq <= live.seq) return;
|
||||
+ live.seq = s.seq;
|
||||
+ this.hear(live, s);
|
||||
- const s = subtitle(payload); if (!s) return;
|
||||
- if (s.kind === 'interim') { this.show({ heard: s.text, spoken: '', ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); if (live.hushed && !live.stated) { live.hushed = false; live.room?.hush(false); } return; }
|
||||
- if (s.kind === 'user_final') { this.show({ heard: s.text, spoken: '', ...(live.stated ? {} : { phase: 'thinking' as const }) }, gen); this.deps.onSpoke(); this.deps.onRecord(); return; }
|
||||
- if (s.kind === 'assistant_final') { this.show({ heard: '', spoken: '', ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); this.deps.onRecord(); return; }
|
||||
- this.show({ heard: '', spoken: s.text, ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); this.deps.onRecord();
|
||||
},
|
||||
dropped: () => { if (!stale()) void fail(WORDS.dropped); },
|
||||
audioBlocked: blocked => this.show({ blocked }, gen),
|
||||
@@ -224,10 +249,73 @@
|
||||
if (live) void this.teardown(live, true);
|
||||
}
|
||||
|
||||
+ // One subtitle that passed the sequence gate. Your draft and her line never share a slot: `heard` is yours, `her`
|
||||
+ // is hers. Her frames act only on the speech open now; a speech once closed here is never shown again.
|
||||
+ private hear(live: Live, s: Subtitle) {
|
||||
+ const gen = live.gen, cur = this.view.her, honest = (phase: VoicePhase) => live.stated ? {} : { phase };
|
||||
+ if (s.kind === 'interim') {
|
||||
+ // Your draft. Her open line is left to the server's cancel (an SDK barge-in decides, not every sound).
|
||||
+ this.show({ heard: s.text, spoken: '', ...honest('listening') }, gen);
|
||||
+ if (live.hushed && !live.stated) { live.hushed = false; live.room?.hush(false); }
|
||||
+ return;
|
||||
+ }
|
||||
+ if (s.kind === 'user_final') {
|
||||
+ // A new turn of yours: nothing of hers from before may stand after it.
|
||||
+ if (live.speech) this.close(live, live.speech);
|
||||
+ this.show({ heard: s.text, spoken: '', her: null, ...honest('thinking') }, gen); this.deps.onSpoke(); this.deps.onRecord();
|
||||
+ return;
|
||||
+ }
|
||||
+ const pro = s.kind.startsWith('proactive_');
|
||||
+ if (s.kind === 'turn_start' || s.kind === 'proactive_start') {
|
||||
+ if (live.closed.includes(s.speech) || live.speech === s.speech) return;
|
||||
+ if (live.speech) this.close(live, live.speech);
|
||||
+ live.speech = s.speech; live.pro = pro; live.message = s.messageId;
|
||||
+ // A reach-out arrives whole (its stored text); a reply starts empty and grows.
|
||||
+ this.show({ spoken: '', ...(pro ? { heard: '' } : {}), her: pro ? { speech: s.speech, text: s.text, state: 'open', messageId: s.messageId, proactive: true } : { speech: s.speech, text: '', state: 'open' }, ...honest(pro ? 'speaking' : 'thinking') }, gen);
|
||||
+ return;
|
||||
+ }
|
||||
+ if (s.speech !== live.speech || pro !== live.pro || (pro && s.messageId !== live.message)) return;
|
||||
+ const mine = cur?.speech === s.speech ? cur : null;
|
||||
+ switch (s.kind) {
|
||||
+ case 'assistant_interim':
|
||||
+ // Replace, never append: each interim is the whole reply so far.
|
||||
+ if (mine?.state === 'open' && mine.text !== s.text) this.show({ her: { ...mine, text: s.text } }, gen);
|
||||
+ return;
|
||||
+ case 'assistant_final':
|
||||
+ if (mine?.state !== 'open') return;
|
||||
+ // Her stored answer: your draft it answered has done its part (as before v2).
|
||||
+ this.show({ heard: '', her: { ...mine, text: s.text, state: 'final', turnId: s.turnId }, ...honest('listening') }, gen); this.deps.onRecord();
|
||||
+ return;
|
||||
+ case 'error':
|
||||
+ // φ: heard, not stored; her unfinished line goes at once. The cancel that follows closes the speech.
|
||||
+ this.show({ heard: '', spoken: s.text, ...(mine ? { her: null } : {}), ...honest('listening') }, gen); this.deps.onRecord();
|
||||
+ return;
|
||||
+ case 'turn_end': case 'proactive_end':
|
||||
+ this.close(live, s.speech);
|
||||
+ // A reply closed without its final never became her reply; a reach-out read to the end stays until the
|
||||
+ // record or the next turn takes its place.
|
||||
+ this.show({ ...(mine ? { her: mine.state === 'final' || pro ? { ...mine, state: 'done' as const } : null } : {}), ...honest('listening') }, gen);
|
||||
+ if (!pro) this.deps.onRecord();
|
||||
+ return;
|
||||
+ case 'turn_cancel': case 'proactive_cancel':
|
||||
+ this.close(live, s.speech);
|
||||
+ this.show({ ...(mine ? { her: null } : {}), ...honest('listening') }, gen);
|
||||
+ if (!pro) this.deps.onRecord();
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ private close(live: Live, speech: string) {
|
||||
+ if (live.speech === speech) { live.speech = ''; live.pro = false; live.message = undefined; }
|
||||
+ if (!live.closed.includes(speech)) { live.closed.push(speech); if (live.closed.length > CLOSED_KEPT) live.closed.shift(); }
|
||||
+ }
|
||||
+
|
||||
// You sent text while she was answering by voice: the server cancels her voice turn; keep its last audio silent.
|
||||
+ // Her line goes at once, whatever state it was in: your words come next on the stage.
|
||||
hush() {
|
||||
+ const live = this.live; if (!live) return;
|
||||
+ if (live.speech) this.close(live, live.speech);
|
||||
+ if (this.view.her) this.show({ her: null }, live.gen);
|
||||
+ if (!live.room || (this.view.phase !== 'speaking' && this.view.phase !== 'thinking')) return;
|
||||
- const live = this.live;
|
||||
- if (!live?.room || (this.view.phase !== 'speaking' && this.view.phase !== 'thinking')) return;
|
||||
live.hushed = true; live.room.hush(true);
|
||||
this.show({ spoken: '' }, live.gen);
|
||||
}
|
||||
@@ -285,6 +373,21 @@
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+// Her voice line as the stage shows it, in the place her text reply would take; null once it has nothing to add.
|
||||
+// `since` is how many record rows there were when this speech was first seen. It gives way to the record: her reply
|
||||
+// once its own row (same turn) is there, a reach-out once its stored row is the newest line; a finished line also as
|
||||
+// soon as anything else came into the record after it, so it never stands in front of a newer turn.
|
||||
+export function voiceLine(her: HerLine | null, messages: Message[], since: number, incoming: Message[] = []): ShownMessage | null {
|
||||
+ if (!her || !her.text) return null;
|
||||
+ const own = (m: Message) => her.proactive ? m.id === String(her.messageId) : !!her.turnId && m.turnId === her.turnId;
|
||||
+ if (her.proactive ? messages.at(-1)?.id === String(her.messageId) : messages.some(m => m.role === 'companion' && own(m))) return null;
|
||||
+ if (her.state !== 'open' && messages.slice(since).some(m => !own(m))) return null;
|
||||
+ // A reach-out keeps its stored row's identity (id, turn), so the row taking over keeps the same lines on the stage.
|
||||
+ const row = her.proactive ? [...messages, ...incoming].find(own) : undefined;
|
||||
+ // Her reply still growing holds back its newest line, as a streamed text reply does.
|
||||
+ return { id: row?.id ?? `voice-${her.speech}`, role: 'companion', text: her.text, lifeIds: [], turnId: row ? row.turnId : her.turnId, ...(her.proactive ? { proactive: true } : {}), ...(her.state === 'open' && !her.proactive ? { live: 'generating' as const } : {}) };
|
||||
+}
|
||||
+
|
||||
// Newest rows down to `since` (exclusive), at most `pages` pages of 50. Voice turns are few per read; a longer gap is
|
||||
// left to the next re-read rather than read without bound.
|
||||
export async function recentRows(since: number, signal: AbortSignal, pages = 4): Promise<Message[]> {
|
||||
diff --git a/web/tests/fake-livekit.js b/web/tests/fake-livekit.js
|
||||
--- a/web/tests/fake-livekit.js
|
||||
+++ b/web/tests/fake-livekit.js
|
||||
@@ -26,7 +26,8 @@
|
||||
return true;
|
||||
};
|
||||
lk.state = s => { const room = lk.rooms.at(-1), p = [...room.remoteParticipants.values()][0]; if (!p) return false; p.attributes['lk.agent.state'] = s; room.emit(RoomEvent.ParticipantAttributesChanged, { 'lk.agent.state': s }, p); return true; };
|
||||
+ // Frames without an envelope get the worker's v2 one (next seq of this room); explicit frames are sent as given.
|
||||
+ lk.say = obj => { const room = lk.rooms.at(-1), frame = 'v' in obj ? obj : { v: 2, seq: (room.seq || 0) + 1, ...obj }; if (Number.isSafeInteger(frame.seq)) room.seq = Math.max(room.seq || 0, frame.seq); room.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(frame)), undefined, 0, 'cg.subtitle'); return true; };
|
||||
- lk.say = obj => { const room = lk.rooms.at(-1); room.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(obj)), undefined, 0, 'cg.subtitle'); return true; };
|
||||
lk.drop = () => { const room = live(); room.state = 'gone'; room.emit(RoomEvent.Disconnected); return true; };
|
||||
lk.block = () => { const room = live(); room.canPlaybackAudio = false; room.emit(RoomEvent.AudioPlaybackStatusChanged); return true; };
|
||||
async function createLocalAudioTrack(constraints) {
|
||||
diff --git a/web/tests/hel878_mock.mjs b/web/tests/hel878_mock.mjs
|
||||
--- a/web/tests/hel878_mock.mjs
|
||||
+++ b/web/tests/hel878_mock.mjs
|
||||
@@ -4,7 +4,7 @@
|
||||
// answered with tests/fake-livekit.js (a scripted stand-in, no WebRTC); the real vendored build stays reachable at
|
||||
// /__real-sdk.js for the API-surface check only. Synthetic data only: no model, no LiveKit, no TTS, no network.
|
||||
// Usage: node tests/hel878_mock.mjs <port>. Control GETs: /__voice?configured=&tts=, /__refuse/<code|0>,
|
||||
+// /__voice-turn?stage=user|reply|cancel&text=, /__proactive?text=, /__cut, /__sessions, /__log, /__rows.
|
||||
-// /__voice-turn?stage=user|reply|cancel&text=, /__cut, /__sessions, /__log, /__rows.
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
@@ -41,6 +41,8 @@
|
||||
if (stage === 'cancel') rows.forEach(r => { if (r.turn_id === `voice-${voiceTurn}`) r.status = 'cancelled'; });
|
||||
return send(res, 200, { turn: voiceTurn });
|
||||
}
|
||||
+ // A reach-out the life runtime stored (HEL-898): the worker may read it aloud; the page sees it through the inbox.
|
||||
+ if (p === '/__proactive') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '', turn_id: `pro-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); }
|
||||
// A voice turn took the server's turn gate: the text stream in flight ends without a final; its row is cancelled.
|
||||
if (p === '/__cut') { const s = textStream; textStream = null; if (s) { rows.forEach(r => { if (r.turn_id === s.turn_id && r.role === 'user') r.status = 'cancelled'; }); s.cut = true; s.res.end(); } return send(res, 200, { cut: !!s }); }
|
||||
if (p === '/__real-sdk.js') return send(res, 200, await readFile(join(dist, 'vendor/livekit-client-2.22.4.umd.min.js')), 'text/javascript');
|
||||
diff --git a/web/tests/hel878_shots.mjs b/web/tests/hel878_shots.mjs
|
||||
--- a/web/tests/hel878_shots.mjs
|
||||
+++ b/web/tests/hel878_shots.mjs
|
||||
@@ -114,7 +114,7 @@
|
||||
await frames('m2-voice-speaking', 900);
|
||||
await shot('08-moon-desktop-voice-speaking');
|
||||
await ctl('/__voice-turn?stage=reply&text=' + encodeURIComponent('是橘色的那只吗?它最近总在桥下晒太阳。'));
|
||||
+await lk(`__lk.say({ kind: 'turn_start', role: 'assistant', text: '', speech_id: 'sp-878-1' })`); await lk(`__lk.say({ kind: 'assistant_final', role: 'assistant', text: '是橘色的那只吗?它最近总在桥下晒太阳。', speech_id: 'sp-878-1', turn_id: 'voice-1' })`); await lk(`__lk.say({ kind: 'turn_end', role: 'assistant', text: '', speech_id: 'sp-878-1', turn_id: 'voice-1' })`); await lk(`__lk.state('listening')`);
|
||||
-await lk(`__lk.say({ kind: 'assistant_final', role: 'assistant', text: '是橘色的那只吗?它最近总在桥下晒太阳。' })`); await lk(`__lk.state('listening')`);
|
||||
await until(async () => (await said()).includes('晒太阳'), 6000);
|
||||
check('her spoken answer reaches the stage once it is in the record', (await said()).includes('晒太阳'), await said());
|
||||
await shot('09-moon-desktop-voice-reply-on-stage');
|
||||
@@ -125,7 +125,7 @@
|
||||
await click('.log-panel .btn-round'); await sleep(400);
|
||||
|
||||
// ── 6. φ: her failure line is heard and shown, never recorded. ──
|
||||
+await lk(`__lk.say({ kind: 'turn_start', role: 'assistant', text: '', speech_id: 'sp-878-phi' })`); await lk(`__lk.say({ kind: 'error', role: 'assistant', text: '我刚刚没接上话,你再说一遍好吗?', speech_id: 'sp-878-phi' })`); await lk(`__lk.say({ kind: 'turn_cancel', role: 'assistant', text: '', speech_id: 'sp-878-phi', reason: 'error_phi' })`); await sleep(400);
|
||||
-await lk(`__lk.say({ kind: 'error', role: 'assistant', text: '我刚刚没接上话,你再说一遍好吗?' })`); await sleep(400);
|
||||
check('φ line shown as heard, marked not recorded; not in the record', (await row()).includes('没接上话') && (await row()).includes('未记入聊天') && !(await ctl('/__rows')).some(r => r.text.includes('没接上话')));
|
||||
await shot('11-moon-desktop-voice-phi');
|
||||
|
||||
diff --git a/web/tests/hel898_shots.mjs b/web/tests/hel898_shots.mjs
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/web/tests/hel898_shots.mjs
|
||||
@@ -0,0 +1,172 @@
|
||||
+// HEL-898 browser check: her words while she speaks (cg.subtitle contract v2) on the approved conversation. Drives
|
||||
+// headless Edge over CDP against tests/hel878_mock.mjs, with tests/fake-livekit.js in place of the LiveKit client and
|
||||
+// Chromium's fake audio device in place of a microphone: no model, no TTS, no real call. Frames are the worker's v2
|
||||
+// envelopes (docs/voice-subtitle-contract-v2.md). Usage: node tests/hel898_shots.mjs <edge.exe> <outDir> [port].
|
||||
+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 || 8878), base = `http://127.0.0.1:${port}`, cdpPort = 9498;
|
||||
+mkdirSync(out, { recursive: true });
|
||||
+const profile = join(tmpdir(), `hel898-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', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '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 id = 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 n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, 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 frames = async (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; };
|
||||
+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)})`);
|
||||
+// The stage's own text (every line now on it) and the line read out to assistive tech.
|
||||
+const stage = () => js(`document.querySelector('.conversation .stage')?.innerText || ''`);
|
||||
+const said = () => js(`document.querySelector('.conversation .sr')?.textContent || ''`);
|
||||
+const row = () => js(`document.querySelector('.voice-row.real')?.innerText || ''`);
|
||||
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
|
||||
+const lk = expr => js(`window.__lk ? (${expr}) : null`);
|
||||
+const say = frame => lk(`__lk.say(${JSON.stringify(frame)})`);
|
||||
+const her = (kind, speech, extra = {}) => say({ kind, role: 'assistant', text: '', speech_id: speech, ...extra });
|
||||
+const type = t => js(`(() => { const el = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, ${JSON.stringify(t)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
|
||||
+const until = async (fn, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
|
||||
+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) {
|
||||
+ await cdp('Page.navigate', { url: `${base}/` }); await sleep(500);
|
||||
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`);
|
||||
+ await cdp('Page.navigate', { url: `${base}/` }); await sleep(1600);
|
||||
+}
|
||||
+async function startVoice() {
|
||||
+ await click('.voice-button'); await until(() => exists('.modal.open'));
|
||||
+ await click('.modal.open .btn-primary', '开始语音');
|
||||
+ await until(async () => (await phase()) === 'joining');
|
||||
+ await until(async () => (await lk('__lk.rooms.at(-1)?.state')) === 'connected');
|
||||
+ await lk('__lk.join()'); await sleep(200);
|
||||
+}
|
||||
+const calls = async () => ctl('/__log');
|
||||
+// One voice turn of yours as the worker leaves it: the stored row, then user_final; waits for it on the stage.
|
||||
+const youSay = async words => {
|
||||
+ const { turn } = await ctl('/__voice-turn?stage=user&text=' + encodeURIComponent(words));
|
||||
+ await say({ kind: 'user_final', role: 'user', text: words }); await lk(`__lk.state('thinking')`);
|
||||
+ await until(async () => (await stage()).includes(words.slice(0, 4)), 6000);
|
||||
+ return turn;
|
||||
+};
|
||||
+const rows = async () => ctl('/__rows');
|
||||
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable');
|
||||
+
|
||||
+const REPLY = '是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。';
|
||||
+const STEPS = ['是橘色的那只吗?它最近总在桥下晒太阳,', '是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。', `${REPLY}`];
|
||||
+
|
||||
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
|
||||
+ const tag = `${theme}-${v}`;
|
||||
+ await view(v); await boot(theme); await click('.nav-item', '交谈'); await sleep(1400);
|
||||
+ await startVoice(); await calls();
|
||||
+
|
||||
+ // ── 1. A voice turn: your draft and final, then her words growing on the stage before anything is stored. ──
|
||||
+ await say({ kind: 'interim', role: 'user', text: '今天下班的路上' }); await sleep(200);
|
||||
+ const prior = (await rows()).filter(r => r.text === REPLY).length;
|
||||
+ const turn = await youSay('今天下班的路上看到一只猫');
|
||||
+ await her('turn_start', `a-${tag}`); await lk(`__lk.state('speaking')`);
|
||||
+ const shots = [];
|
||||
+ for (const [i, step] of STEPS.entries()) {
|
||||
+ await her('assistant_interim', `a-${tag}`, { text: step });
|
||||
+ shots.push(frames(`${tag}-m1-growing-${i}`, 450));
|
||||
+ await shots.at(-1);
|
||||
+ }
|
||||
+ await until(async () => (await stage()).includes('晒太阳'), 8000);
|
||||
+ check(`${tag}: her reply shows on the stage while she speaks, before it is stored`, (await stage()).includes('晒太阳') && (await rows()).filter(r => r.text === REPLY).length === prior, await stage());
|
||||
+ check(`${tag}: your draft stays in the voice line, her words never there`, !(await row()).includes('晒太阳'), await row());
|
||||
+ await shot(`${tag}-01-her-words-growing`);
|
||||
+
|
||||
+ // ── 2. Pause and look back while she is still speaking: reading holds, nothing jumps. ──
|
||||
+ await click('.reader-bar .flow'); await sleep(300);
|
||||
+ check(`${tag}: pause while she speaks`, await exists('.conversation.reading-paused'));
|
||||
+ await click('.reader-bar .icon[aria-label="上一句"]'); await sleep(400);
|
||||
+ await shot(`${tag}-02-paused-looking-back`);
|
||||
+ await ctl('/__voice-turn?stage=reply&text=' + encodeURIComponent(REPLY));
|
||||
+ await her('assistant_final', `a-${tag}`, { text: REPLY, turn_id: `voice-${turn}` }); await her('turn_end', `a-${tag}`, { turn_id: `voice-${turn}` }); await lk(`__lk.state('listening')`);
|
||||
+ await sleep(900);
|
||||
+ await click('.reader-bar .latest'); await sleep(2500);
|
||||
+ await click('.log-btn'); await sleep(700);
|
||||
+ const rec = await text('.log-panel .scroll');
|
||||
+ check(`${tag}: once stored, the record holds her reply exactly once (re-read, not written by the page)`, rec.split(REPLY).length - 1 === prior + 1, rec.slice(-160).replace(/\n/g, ' '));
|
||||
+ await shot(`${tag}-03-record-after-final`);
|
||||
+ await click('.log-panel .btn-round'); await sleep(400);
|
||||
+ await click('.read-all'); await sleep(500); await shot(`${tag}-04-full-text`); await click('.record.full .btn-round'); await sleep(300);
|
||||
+
|
||||
+ // ── 3. Interrupted: no final; her line goes at once; late frames of the cut turn change nothing. ──
|
||||
+ // Paused reading, then you speak: like sending, your new turn and her answer come in by themselves.
|
||||
+ await click('.reader-bar .flow'); await sleep(200);
|
||||
+ check(`${tag}: paused before speaking`, await exists('.conversation.reading-paused'));
|
||||
+ await youSay('你今天去哪儿了');
|
||||
+ check(`${tag}: speaking resumes reading for the new turn`, !(await exists('.conversation.reading-paused')));
|
||||
+ await her('turn_start', `b-${tag}`); await lk(`__lk.state('speaking')`);
|
||||
+ await her('assistant_interim', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,' });
|
||||
+ check(`${tag}: second reply growing`, await until(async () => (await stage()).includes('白鹭'), 8000), await stage());
|
||||
+ await shot(`${tag}-05-before-cut`);
|
||||
+ await say({ kind: 'interim', role: 'user', text: '等一下' });
|
||||
+ await her('turn_cancel', `b-${tag}`, { reason: 'interrupted' }); await lk(`__lk.state('listening')`); await ctl('/__voice-turn?stage=cancel');
|
||||
+ await sleep(150);
|
||||
+ check(`${tag}: interrupted: her unfinished line is gone at once`, !(await stage()).includes('白鹭'), await stage());
|
||||
+ await her('assistant_interim', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,真好看。' });
|
||||
+ await her('assistant_final', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,真好看。', turn_id: 'late' });
|
||||
+ await sleep(400);
|
||||
+ check(`${tag}: late frames of the cut turn stay out`, !(await stage()).includes('白鹭'));
|
||||
+ await shot(`${tag}-06-after-cut`);
|
||||
+
|
||||
+ // ── 4. Text takes over while she speaks: her line goes, your typed words come next. ──
|
||||
+ await youSay('我们聊点别的吧');
|
||||
+ await her('turn_start', `c-${tag}`); await lk(`__lk.state('speaking')`);
|
||||
+ await her('assistant_interim', `c-${tag}`, { text: '那我们换个话题吧,你今天晚饭吃了什么,好不好吃?我今天' });
|
||||
+ check(`${tag}: her line before you type`, await until(async () => (await stage()).includes('换个话题'), 8000), await stage());
|
||||
+ await type('先等等,我打字说'); await js(`document.querySelector('.send').click(), true`); await sleep(400);
|
||||
+ check(`${tag}: text over voice: her line is gone, your words are on the stage`, !(await stage()).includes('换个话题') && (await stage()).includes('先等等'), await stage());
|
||||
+ await until(async () => !(await exists('.reader-bar .stop')), 9000); await lk(`__lk.state('listening')`); await sleep(400);
|
||||
+
|
||||
+ // ── 5. A stored reach-out read aloud: its own text in her place, no request, no read mark, interruptible. ──
|
||||
+ const { id: pro } = await ctl('/__proactive?text=' + encodeURIComponent('刚才路过花店,想起你说过喜欢白色的洋桔梗。'));
|
||||
+ await calls();
|
||||
+ await say({ kind: 'proactive_start', role: 'assistant', text: '刚才路过花店,想起你说过喜欢白色的洋桔梗。', speech_id: `p-${tag}`, message_id: pro }); await lk(`__lk.state('speaking')`);
|
||||
+ await sleep(1500);
|
||||
+ check(`${tag}: reach-out shown in her place, word for word`, (await stage()).includes('洋桔梗'), await stage());
|
||||
+ await shot(`${tag}-07-reach-out`);
|
||||
+ await say({ kind: 'interim', role: 'user', text: '是吗' });
|
||||
+ await say({ kind: 'proactive_cancel', role: 'assistant', text: '', speech_id: `p-${tag}`, message_id: pro, reason: 'interrupted' }); await lk(`__lk.state('listening')`);
|
||||
+ await sleep(200);
|
||||
+ const after = await calls();
|
||||
+ check(`${tag}: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)`, !after.some(l => !l.startsWith('GET ')) , after.join(' | '));
|
||||
+
|
||||
+ // ── 6. Hanging up mid-sentence clears her line with the call. ──
|
||||
+ await youSay('我先去睡了');
|
||||
+ await her('turn_start', `d-${tag}`); await her('assistant_interim', `d-${tag}`, { text: '晚安,明天见。今天说了好多好多话,你要早点睡哦。我会在窗边等你,' });
|
||||
+ check(`${tag}: her line before hanging up`, await until(async () => (await stage()).includes('早点睡'), 8000), await stage());
|
||||
+ await click('.voice-row.real .voice-end'); await sleep(400);
|
||||
+ check(`${tag}: hung up: her line gone, voice off`, !(await stage()).includes('早点睡') && (await phase()) === 'off', await stage());
|
||||
+ await shot(`${tag}-08-ended`);
|
||||
+}
|
||||
+
|
||||
+// ── 7. Reduced motion: her words still arrive, without animated entrances. ──
|
||||
+await view('desktop'); await cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||
+await boot('moon'); await click('.nav-item', '交谈'); await sleep(1400); await startVoice();
|
||||
+await youSay('慢一点说给我听');
|
||||
+await her('turn_start', 'rm'); await lk(`__lk.state('speaking')`);
|
||||
+for (const step of STEPS) { await her('assistant_interim', 'rm', { text: step }); await sleep(300); }
|
||||
+check('reduced motion: her words arrive', await until(async () => (await stage()).includes('晒太阳'), 8000), await stage());
|
||||
+await frames('reduced-motion-growing', 600);
|
||||
+await click('.voice-row.real .voice-end'); await sleep(300);
|
||||
+
|
||||
+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);
|
||||
diff --git a/web/tests/voice.test.ts b/web/tests/voice.test.ts
|
||||
--- a/web/tests/voice.test.ts
|
||||
+++ b/web/tests/voice.test.ts
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
+import { mergeTurns, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type HerLine, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
|
||||
+import { groupTurns, pagesOf, type ShownMessage } from '../src/chat/reading';
|
||||
-import { mergeTurns, recentRows, VoiceController, voiceStatus, WORDS, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
|
||||
import type { ServerMessage } from '../src/adapters/real';
|
||||
import type { Message } from '../src/domain/model';
|
||||
+import { readFileSync } from 'node:fs';
|
||||
|
||||
+// The backend's recorded cg.subtitle frames (contract v2, docs/rework/hel879/subtitle-samples, generated offline).
|
||||
+type Frame = Record<string, unknown> & { seq: number };
|
||||
+const sample = (name: string): Frame[] => readFileSync(new URL(`../../docs/rework/hel879/subtitle-samples/${name}.jsonl`, import.meta.url), 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
||||
+
|
||||
// Fake transport and media objects: no LiveKit, no microphone, no network. The fake server follows the HEL-877 voice
|
||||
// router (src/cybergril/voice/router.py): GET /api/voice/status, POST /api/voice/session -> 201 grant, DELETE
|
||||
// /api/voice/session/{id} -> 200 or 404.
|
||||
@@ -176,7 +182,7 @@
|
||||
const r = await connected(), old = r.transport.rooms[0];
|
||||
await r.c.end(); r.record.mockClear();
|
||||
const seen = r.views.length;
|
||||
+ old.on.agentState('speaking'); old.on.subtitle({ v: 2, seq: 1, kind: 'user_final', role: 'user', text: '旧的话' }); old.on.subtitle({ v: 2, seq: 2, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'old' }); old.on.agentJoined(); old.on.dropped(); old.on.audioBlocked(true);
|
||||
- old.on.agentState('speaking'); old.on.subtitle({ kind: 'user_final', role: 'user', text: '旧的话' }); old.on.agentJoined(); old.on.dropped(); old.on.audioBlocked(true);
|
||||
r.transport.mics[0].ended?.();
|
||||
await flush();
|
||||
expect(r.views.length).toBe(seen);
|
||||
@@ -243,10 +249,12 @@
|
||||
});
|
||||
it('never confirms a receipt or posts chat itself: the worker owns both', async () => {
|
||||
const r = await connected(), room = r.transport.rooms[0];
|
||||
+ for (const line of sample('normal-turn')) room.on.subtitle(line);
|
||||
+ for (const line of sample('proactive')) room.on.subtitle({ ...line, seq: line.seq + 100 });
|
||||
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '今天好冷' });
|
||||
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '那多穿一件。' });
|
||||
await r.c.end();
|
||||
expect(r.server.calls.some(c => c.url.startsWith('/api/chat'))).toBe(false);
|
||||
+ // No receipt, no read mark, no message write: the page only talks to the voice routes.
|
||||
+ expect(r.server.calls.every(c => c.url.startsWith('/api/voice/'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,24 +346,32 @@
|
||||
});
|
||||
});
|
||||
|
||||
+// A v2 frame as the worker's SubtitleBus emits it (src/cybergril/voice/seam.py).
|
||||
+const frame = (seq: number, kind: string, extra: Record<string, unknown> = {}) => ({ v: 2, seq, kind, role: kind === 'interim' || kind === 'user_final' ? 'user' : 'assistant', text: '', ...extra });
|
||||
describe('subtitles and the two channels', () => {
|
||||
it('interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded', async () => {
|
||||
const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.subtitle(frame(1, 'interim', { text: '今天' }));
|
||||
- room.on.subtitle({ kind: 'interim', role: 'user', text: '今天' });
|
||||
expect(r.view().heard).toBe('今天'); expect(r.record).not.toHaveBeenCalled();
|
||||
+ room.on.subtitle(frame(2, 'user_final', { text: '今天好冷' }));
|
||||
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '今天好冷' });
|
||||
expect(r.spoke).toHaveBeenCalledTimes(1); expect(r.record).toHaveBeenCalledTimes(1);
|
||||
+ room.on.subtitle(frame(3, 'turn_start', { speech_id: 'a' }));
|
||||
+ room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'a', text: '我刚' }));
|
||||
+ room.on.subtitle(frame(5, 'error', { speech_id: 'a', text: '我刚才走神了,你再说一遍?' }));
|
||||
+ expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her: null });
|
||||
+ room.on.subtitle(frame(6, 'turn_cancel', { speech_id: 'a', reason: 'error_phi' }));
|
||||
+ expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her: null });
|
||||
+ room.on.subtitle(frame(7, 'assistant_interim', { speech_id: 'a', text: '我刚才走神了' })); // after its cancel
|
||||
+ expect(r.view().her).toBeNull();
|
||||
- room.on.subtitle({ kind: 'error', role: 'assistant', text: '我刚才走神了,你再说一遍?' });
|
||||
- expect(r.view().spoken).toBe('我刚才走神了,你再说一遍?');
|
||||
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '那多穿一件。' });
|
||||
- expect(r.view()).toMatchObject({ heard: '', spoken: '' });
|
||||
room.on.subtitle({ kind: 'bogus', text: 1 }); room.on.subtitle(null);
|
||||
expect(r.record).toHaveBeenCalledTimes(3);
|
||||
+ expect(r.server.calls.some(c => c.url.startsWith('/api/chat') || c.url.startsWith('/api/messages'))).toBe(false);
|
||||
});
|
||||
it('without the agent state attribute, subtitles still move the state honestly', async () => {
|
||||
const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.subtitle(frame(1, 'user_final', { text: '在吗' })); expect(r.view().phase).toBe('thinking');
|
||||
+ room.on.subtitle(frame(2, 'turn_start', { speech_id: 'a' })); expect(r.view().phase).toBe('thinking');
|
||||
+ room.on.subtitle(frame(3, 'assistant_final', { speech_id: 'a', turn_id: 't', text: '在。' })); expect(r.view().phase).toBe('listening');
|
||||
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '在吗' }); expect(r.view().phase).toBe('thinking');
|
||||
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '在。' }); expect(r.view().phase).toBe('listening');
|
||||
});
|
||||
it('typing over her spoken answer silences what is left of it until she is listening again', async () => {
|
||||
const r = await connected(), room = r.transport.rooms[0];
|
||||
@@ -367,6 +383,187 @@
|
||||
});
|
||||
});
|
||||
|
||||
+// HEL-898: her words while she speaks (contract v2). The page shows them; it never stores, confirms or marks them.
|
||||
+describe('her voice line (cg.subtitle v2)', () => {
|
||||
+ const play = (room: Room, frames: Frame[], after?: (f: Frame) => void) => { for (const f of frames) { room.on.subtitle(f); after?.(f); } };
|
||||
+ it('rejects anything that is not exactly a v2 frame', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0], seen = r.views.length;
|
||||
+ const bad = [
|
||||
+ { kind: 'user_final', role: 'user', text: 'v1 没有信封' },
|
||||
+ { ...frame(1, 'user_final', { text: '版本不对' }), v: 3 },
|
||||
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: 0 },
|
||||
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: 1.5 },
|
||||
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: '1' },
|
||||
+ frame(1, 'turn_start'), // no speech_id
|
||||
+ { ...frame(1, 'turn_start', { speech_id: 'a' }), role: 'user' }, // her kind, your role
|
||||
+ { ...frame(1, 'interim', { text: '草稿' }), role: 'assistant' }, // your kind, her role
|
||||
+ frame(1, 'assistant_final', { speech_id: 'a', text: '无归属' }), // final without turn_id
|
||||
+ frame(1, 'proactive_start', { speech_id: 'p', text: '无行号' }), // reach-out without message_id
|
||||
+ frame(1, 'proactive_start', { speech_id: 'p', text: '行号不对', message_id: '17' }),
|
||||
+ frame(1, 'assistant_text', { speech_id: 'a', text: '未知种类' }),
|
||||
+ [frame(1, 'user_final', { text: '数组' })],
|
||||
+ ];
|
||||
+ for (const b of bad) room.on.subtitle(b);
|
||||
+ expect(r.views.length).toBe(seen); expect(r.record).not.toHaveBeenCalled(); expect(r.spoke).not.toHaveBeenCalled();
|
||||
+ // A rejected frame does not move the sequence: the first good one still lands.
|
||||
+ room.on.subtitle(frame(1, 'interim', { text: '好的' })); expect(r.view().heard).toBe('好的');
|
||||
+ });
|
||||
+ it('normal turn: your draft and final, her growing reply replaced in place, then her stored final', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('normal-turn');
|
||||
+ const steps: (VoiceView['her'] | string)[] = [];
|
||||
+ play(room, frames, () => steps.push(r.view().her ? { ...r.view().her! } : r.view().heard));
|
||||
+ expect(steps).toEqual([
|
||||
+ '你在听的', '你在听吗?',
|
||||
+ { speech: 'speech_normal', text: '', state: 'open' },
|
||||
+ { speech: 'speech_normal', text: '在听呢,', state: 'open' },
|
||||
+ { speech: 'speech_normal', text: '在听呢,在听呢,你刚才说到一半的那件事我还在想。', state: 'open' },
|
||||
+ { speech: 'speech_normal', text: '在听呢,你刚才说到一半的那件事我还在想。', state: 'final', turnId: '0e2f-demo-turn' },
|
||||
+ { speech: 'speech_normal', text: '在听呢,你刚才说到一半的那件事我还在想。', state: 'done', turnId: '0e2f-demo-turn' },
|
||||
+ ]);
|
||||
+ expect(r.view().heard).toBe(''); // her stored answer retires the draft it answered
|
||||
+ // Her words never land in your draft slot, and yours never in hers.
|
||||
+ expect(r.views.every(v => !v.her || !v.heard.includes('在听呢'))).toBe(true);
|
||||
+ expect(r.spoke).toHaveBeenCalledTimes(1);
|
||||
+ expect(r.record).toHaveBeenCalledTimes(3); // your final, her final, her turn's end: re-read, never written
|
||||
+ // The whole recording replayed (same seq) changes nothing.
|
||||
+ const seen = r.views.length; play(room, frames);
|
||||
+ expect(r.views.length).toBe(seen);
|
||||
+ // Your next turn: her finished line does not stay in front of it.
|
||||
+ room.on.subtitle(frame(8, 'interim', { text: '那' })); expect(r.view().her?.state).toBe('done');
|
||||
+ room.on.subtitle(frame(9, 'user_final', { text: '那你想到了吗' })); expect(r.view().her).toBeNull();
|
||||
+ });
|
||||
+ it('interrupted: no final, her line goes at once, its late frames never reach the new turn', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('interrupt');
|
||||
+ play(room, frames.slice(0, 2));
|
||||
+ expect(r.view().her).toMatchObject({ speech: 'speech_cut', text: '我正想跟你说,今天下午', state: 'open' });
|
||||
+ play(room, frames.slice(2, 3));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ // Late increments of the cut turn, even with a higher seq, and a replay of its start.
|
||||
+ room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'speech_cut', text: '我正想跟你说,今天下午去了' }));
|
||||
+ room.on.subtitle(frame(5, 'assistant_final', { speech_id: 'speech_cut', turn_id: 'old', text: '我正想跟你说,今天下午去了河边。' }));
|
||||
+ room.on.subtitle(frame(6, 'turn_start', { speech_id: 'speech_cut' }));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ play(room, frames.slice(3).map(f => ({ ...f, seq: f.seq + 10 })));
|
||||
+ expect(r.view()).toMatchObject({ heard: '', her: { speech: 'speech_next', text: '好,你先说。', state: 'done', turnId: '0e2f-demo-next' } });
|
||||
+ });
|
||||
+ it('frames that overtook each other: a lower seq is stale; an older speech never touches or closes the new one', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
|
||||
+ room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a', text: '一二三' }));
|
||||
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '一二' })); // overtaken: dropped
|
||||
+ expect(r.view().her?.text).toBe('一二三');
|
||||
+ // Two handles: b starts while a is still open (queued then generated). a is closed here by b.
|
||||
+ room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
|
||||
+ room.on.subtitle(frame(5, 'assistant_interim', { speech_id: 'a', text: '一二三四' }));
|
||||
+ room.on.subtitle(frame(6, 'turn_end', { speech_id: 'a', turn_id: 'ta' }));
|
||||
+ room.on.subtitle(frame(7, 'turn_cancel', { speech_id: 'a', reason: 'interrupted' }));
|
||||
+ room.on.subtitle(frame(8, 'proactive_cancel', { speech_id: 'b', message_id: 3, reason: 'interrupted' })); // wrong family
|
||||
+ expect(r.view().her).toEqual({ speech: 'b', text: '', state: 'open' });
|
||||
+ room.on.subtitle(frame(9, 'assistant_interim', { speech_id: 'b', text: '好' }));
|
||||
+ room.on.subtitle(frame(10, 'turn_end', { speech_id: 'b', turn_id: 'tb' })); // closed without final
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ room.on.subtitle(frame(11, 'assistant_final', { speech_id: 'b', turn_id: 'tb', text: '好。' }));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ });
|
||||
+ it('reach-out read aloud: shown whole with its stored message id, interruptible, nothing marked or stored', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('proactive');
|
||||
+ play(room, frames.slice(0, 1));
|
||||
+ expect(r.view()).toMatchObject({ phase: 'speaking', her: { speech: 'speech_pro1', text: '我看到一句很适合现在的话,先念给你听。', state: 'open', messageId: 17, proactive: true } });
|
||||
+ room.on.subtitle(frame(2, 'proactive_end', { speech_id: 'speech_pro1', message_id: 99 })); // another row's end
|
||||
+ expect(r.view().her?.state).toBe('open');
|
||||
+ play(room, frames.slice(1, 2).map(f => ({ ...f, seq: 3 })));
|
||||
+ expect(r.view().her).toMatchObject({ messageId: 17, state: 'done' });
|
||||
+ play(room, frames.slice(2).map(f => ({ ...f, seq: f.seq + 10 })));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ // Reaching out is not a turn of yours: no "you spoke", no record write, no request at all.
|
||||
+ expect(r.spoke).not.toHaveBeenCalled(); expect(r.record).not.toHaveBeenCalled();
|
||||
+ expect(r.server.calls.filter(c => !c.url.startsWith('/api/voice/'))).toEqual([]);
|
||||
+ });
|
||||
+ it('a reach-out cut by your voice: the cancel clears it, your words follow', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.subtitle(frame(1, 'proactive_start', { speech_id: 'p', message_id: 18, text: '还有一件事想告诉你——' }));
|
||||
+ room.on.subtitle(frame(2, 'interim', { text: '等等' }));
|
||||
+ expect(r.view()).toMatchObject({ heard: '等等', her: { speech: 'p', state: 'open' } });
|
||||
+ room.on.subtitle(frame(3, 'proactive_cancel', { speech_id: 'p', message_id: 18, reason: 'interrupted' }));
|
||||
+ expect(r.view()).toMatchObject({ heard: '等等', her: null });
|
||||
+ });
|
||||
+ it('text taking over, hanging up and starting again each clear her line at once', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.agentState('speaking');
|
||||
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
|
||||
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '那我们' }));
|
||||
+ r.c.hush();
|
||||
+ expect(r.view().her).toBeNull(); expect(room.hushed).toEqual([true]);
|
||||
+ room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a', text: '那我们明天' }));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ // A finished line is cleared by text too: your typed line comes next on the stage.
|
||||
+ room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
|
||||
+ room.on.subtitle(frame(5, 'assistant_interim', { speech_id: 'b', text: '好。' }));
|
||||
+ room.on.subtitle(frame(6, 'assistant_final', { speech_id: 'b', turn_id: 'tb', text: '好。' }));
|
||||
+ room.on.subtitle(frame(7, 'turn_end', { speech_id: 'b', turn_id: 'tb' }));
|
||||
+ expect(r.view().her?.state).toBe('done');
|
||||
+ r.c.hush(); expect(r.view().her).toBeNull();
|
||||
+ // Hanging up mid-sentence.
|
||||
+ room.on.subtitle(frame(8, 'turn_start', { speech_id: 'c' }));
|
||||
+ room.on.subtitle(frame(9, 'assistant_interim', { speech_id: 'c', text: '嗯' }));
|
||||
+ await r.c.end(); expect(r.view().her).toBeNull();
|
||||
+ room.on.subtitle(frame(10, 'assistant_interim', { speech_id: 'c', text: '嗯,晚安' }));
|
||||
+ expect(r.view().her).toBeNull();
|
||||
+ // A new call is a new worker job: its sequence starts again; the old room's frames stay dead.
|
||||
+ await r.c.start(); const next = r.transport.rooms[1]; next.on.agentJoined();
|
||||
+ next.on.subtitle(frame(1, 'turn_start', { speech_id: 'n' }));
|
||||
+ next.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'n', text: '又见面了' }));
|
||||
+ room.on.subtitle(frame(11, 'turn_start', { speech_id: 'z' }));
|
||||
+ room.on.subtitle(frame(12, 'assistant_interim', { speech_id: 'n', text: '旧房间' }));
|
||||
+ expect(r.view().her).toEqual({ speech: 'n', text: '又见面了', state: 'open' });
|
||||
+ await r.c.end();
|
||||
+ });
|
||||
+ it('the room dropping mid-sentence clears her line with the call', async () => {
|
||||
+ const r = await connected(), room = r.transport.rooms[0];
|
||||
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
|
||||
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '我' }));
|
||||
+ room.on.dropped(); await flush();
|
||||
+ expect(r.view()).toMatchObject({ phase: 'off', her: null, notice: WORDS.dropped });
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
+describe('her voice line on the stage', () => {
|
||||
+ const M = (id: number, role: Message['role'], text: string, turnId?: string, extra: Partial<Message> = {}): Message => ({ id: String(id), role, text, lifeIds: [], turnId, status: 'sent', ...extra });
|
||||
+ const metrics = { width: 900, font: 23, compact: false };
|
||||
+ const stage = (list: ShownMessage[]) => pagesOf(groupTurns(list).at(-1), metrics).map(p => `${p.key}=${p.text}`);
|
||||
+ const you = M(4, 'user', '你在听吗?', 'T');
|
||||
+ it('grows in her slot after your voice turn; the stored row takes over under the same line keys', () => {
|
||||
+ const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直还在心里想着。后来呢?', state: 'open' };
|
||||
+ const growing = voiceLine(open, [you], 1)!;
|
||||
+ expect(growing).toMatchObject({ role: 'companion', live: 'generating' });
|
||||
+ // The newest line of a reply still growing is held back, as in a streamed text reply.
|
||||
+ expect(stage([you, growing])).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。']);
|
||||
+ const done: HerLine = { ...open, state: 'done', turnId: 'T' };
|
||||
+ const shown = stage([you, voiceLine(done, [you], 1)!]);
|
||||
+ expect(shown).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。', 'T:1:1=后来呢?']);
|
||||
+ const stored = [you, M(5, 'companion', done.text, 'T')];
|
||||
+ expect(voiceLine(done, stored, 1)).toBeNull();
|
||||
+ expect(stage(stored)).toEqual(shown);
|
||||
+ });
|
||||
+ it('a finished line never stands in front of a newer turn; an open one waits for its own end', () => {
|
||||
+ const done: HerLine = { speech: 's', text: '好。', state: 'done', turnId: 'T' };
|
||||
+ expect(voiceLine(done, [you, M(6, 'user', '还有', 'U')], 1)).toBeNull();
|
||||
+ expect(voiceLine({ ...done, state: 'open' }, [you, M(6, 'user', '还有', 'U')], 1)).not.toBeNull();
|
||||
+ expect(voiceLine({ ...done, text: '' }, [you], 1)).toBeNull();
|
||||
+ });
|
||||
+ it('a reach-out read aloud keeps its stored row identity and gives way when that row is the newest line', () => {
|
||||
+ const line: HerLine = { speech: 'p', text: '我看到一句很适合现在的话,先念给你听。', state: 'open', messageId: 17, proactive: true };
|
||||
+ const row = M(17, 'companion', line.text, 'R', { proactive: true });
|
||||
+ const before = [you, M(5, 'companion', '在。', 'T')];
|
||||
+ const waiting = voiceLine(line, before, 2, [row])!;
|
||||
+ expect(waiting).toMatchObject({ id: '17', turnId: 'R', proactive: true }); expect(waiting.live).toBeUndefined();
|
||||
+ expect(stage([...before, waiting])).toEqual(stage([...before, row]));
|
||||
+ expect(voiceLine({ ...line, state: 'done' }, [...before, row], 2)).toBeNull();
|
||||
+ // Not on the page at all: shown under its own key; nothing is created for the record.
|
||||
+ expect(voiceLine(line, before, 2)).toMatchObject({ id: 'voice-p', proactive: true });
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
describe('record re-read', () => {
|
||||
const msg = (id: number, role: Message['role'], turnId: string, extra: Partial<Message> = {}): Message => ({ id: String(id), role, text: `${role}${id}`, lifeIds: [], turnId, status: 'sent', ...extra });
|
||||
it('adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream', () => {
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"base_commit": "3228b1e0d7f822b7030f945e285eac74126771aa",
|
||||
"changed": {
|
||||
"docs/requirements/handoff-HEL-898.json": {
|
||||
"before": null,
|
||||
"after": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d"
|
||||
},
|
||||
"web/src/chat/ChatPanel.tsx": {
|
||||
"before": "419c2f4243d7a147ea8ebcf377d74c97dc558049a3bf76dee39bba0746d2c9e6",
|
||||
"after": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669"
|
||||
},
|
||||
"web/src/voice/session.ts": {
|
||||
"before": "b13ba87bf3cc860da6d89b16bd0d23284f83ecb338487a03ac6f44aa82cea2c1",
|
||||
"after": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14"
|
||||
},
|
||||
"web/tests/fake-livekit.js": {
|
||||
"before": "d16e794308073e967566f3ebda16e72d0f5eb0132b657338baa6a29115b02508",
|
||||
"after": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc"
|
||||
},
|
||||
"web/tests/hel878_mock.mjs": {
|
||||
"before": "1b1608d5e0858842e9a0cf51faaf9baece5e52b546757464b046df0e66a4b815",
|
||||
"after": "f87e61ffce879ec7e7c9287321e9d73a6b276b71633e68d84716205fd36380f0"
|
||||
},
|
||||
"web/tests/hel878_shots.mjs": {
|
||||
"before": "e5706c5a4459315ecdb991cbd8d3776db3efe035fe520930ffb09e726eb639c7",
|
||||
"after": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7"
|
||||
},
|
||||
"web/tests/hel898_shots.mjs": {
|
||||
"before": null,
|
||||
"after": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393"
|
||||
},
|
||||
"web/tests/voice.test.ts": {
|
||||
"before": "4c120b9449ce3af0b66b3b8a18668d5012d286d6c18c0dee1347a880f7780471",
|
||||
"after": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7",
|
||||
"INPUT-MANIFEST.json": "099bf7575295fc6ecf376d3d805463f761f65e0452bd7cc73f87f1f3f61fa983",
|
||||
"docs/requirements/README.md": "9b1fe8e94c74950ed671c381ebd0b9e254673b4f4cc226978153a8cd16549e73",
|
||||
"docs/requirements/baseline.json": "d36bd3d4e2187925645790637468ee116d5701a6182ef1fc78db77a994ce1bdf",
|
||||
"docs/requirements/changes.json": "8b815a75ae6978e8d62929b8708a235acd90f7d2be1037e2fb4adaadd82e9dd1",
|
||||
"docs/requirements/changes.md": "c2a50b361bed40238c5d64e78485c08b4055d40eb3d7427c9100371c6999410c",
|
||||
"docs/requirements/coverage.json": "0f631bf848c4331b3281086a2265c29d8d381dda3ca93164a05ca0d71bd99254",
|
||||
"docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658",
|
||||
"docs/requirements/current.md": "3ecae2bf182e19d990cf5030b1fe585adb514fd1415260e3da9e766a6fc8fafa",
|
||||
"docs/requirements/discussion-takeaways-20261025.md": "e2ee666e5193b237a739a8d022a82bbb868cd144f8f4e1fe34f0e3981f94cf7d",
|
||||
"docs/requirements/handoff-HEL-697.json": "2ddfca357e170e864d482ca469da0129c1cf26ef6b437ee24b8a3d34947b4510",
|
||||
"docs/requirements/handoff-HEL-811.json": "07a596b9328224dd621c9d1d4b428f2d76ea5dc81c173d9d7a55c0b70ee8d8be",
|
||||
"docs/requirements/handoff-HEL-816.json": "4577524f25db9b301a3bc68fb970d31e4320cae11e4354d23881bb134223f642",
|
||||
"docs/requirements/handoff-HEL-821.json": "7f3be159f017a9600708c7d0ff2809c67bd2811498f0c232d040a9601b78862f",
|
||||
"docs/requirements/handoff-HEL-822.json": "8753bd145ae4ed0d045d0d4f6160101e200b4f1071601228ef2c0b43db1a5303",
|
||||
"docs/requirements/handoff-HEL-823.json": "5d6337ad15e82538b8b284b4aa7889579a1b3fc89029af221ffdf35d8f0a0fef",
|
||||
"docs/requirements/handoff-HEL-824.json": "302fd163a35f296d64cecbd521d84b2de525d5bddf27bc1a9dbb9f7e969f6f1d",
|
||||
"docs/requirements/handoff-HEL-825.json": "60e2c4f87ee1c2a814e934337a2a48801fcef7750cfcc9134578f6c77238d898",
|
||||
"docs/requirements/handoff-HEL-827.json": "b1e625dac3163d09967c7c8d43de2f0927becefac535f58844ee6045fa2b1f6a",
|
||||
"docs/requirements/handoff-HEL-828.json": "89f05d82366835a425d0fc5db9bce9a7b600dd0a782608f2b0a494f1d9ffe054",
|
||||
"docs/requirements/handoff-HEL-830.json": "16fa3048878b08dafc2a8ca02e8d3db417530a1678c5cb31a20987ad5b684de6",
|
||||
"docs/requirements/handoff-HEL-857.json": "382392235666d735655e241cb4bd3c8208e15b6fdc2427b86de30b9d472064a9",
|
||||
"docs/requirements/handoff-HEL-858.json": "68c1975f89a926822321c4159d7fe60d6a8569cf382321290aa23fc05b47b538",
|
||||
"docs/requirements/handoff-HEL-859.json": "388c620e99367a24e499efb01361a04c8dfdcafd36cf575f0a69d28eb79cd09f",
|
||||
"docs/requirements/handoff-HEL-860.json": "1222921c61630b072f58ee1c643a4da02721d592199b34bd0806d8c5be4e58d7",
|
||||
"docs/requirements/handoff-HEL-861.json": "d604581abc60ff553fe62ccfe4d98c8b8532da948f8f33ea2bd53333fa7961ff",
|
||||
"docs/requirements/handoff-HEL-862.json": "d08fda4d2c4f2ef0ca9fd8c4e265cef85b5d520e40da6a322a2d099648b94acc",
|
||||
"docs/requirements/handoff-HEL-863.json": "30d5322a2673d6d4d71bb07153d3d974a636af1f04f96f31e85a7c082b568c86",
|
||||
"docs/requirements/handoff-HEL-864.json": "909213044fa7a75f8043c95bb071636ff376200a124e4d1300f8ff65bbec802e",
|
||||
"docs/requirements/handoff-HEL-865.json": "1e361d2b7b2a66f56f88da021fda5a8432308c855975b0613cf35c9da525852d",
|
||||
"docs/requirements/handoff-HEL-866.json": "539759490fb6f1795b5a325632e3720a7332c06640038fbbc958eca11df59324",
|
||||
"docs/requirements/handoff-HEL-867.json": "3eb2fd69e5231e257967160195bcc3ee93975f27c339d3eefe4f295e0efd818e",
|
||||
"docs/requirements/handoff-HEL-868.json": "dd609e7828b8ff20c13a6b938fce40d77f38aa30b978bd451b468b2169d5db0c",
|
||||
"docs/requirements/handoff-HEL-869.json": "8954ea038f375934732adc2cfbe60e75eaa23969b4fb163bca29512d6520b7b4",
|
||||
"docs/requirements/handoff-HEL-870.json": "71baea0fb2cec30b7ff40d3382fa749b0c46213ad34ac64d02456ac1c0527bdc",
|
||||
"docs/requirements/handoff-HEL-874.json": "73cd0a585b86c2f3b03d7b4bdca65f933065efc0c55e82fdef550d1fed253a9f",
|
||||
"docs/requirements/handoff-HEL-875.json": "984f362ba3cb42434c53194d479c1dfe4b03113261a74b46e7e6ff599e8ce5ae",
|
||||
"docs/requirements/handoff-HEL-876.json": "df18ad67b29ab72b9ac9a2928d88b2ab25d4ae0975b3fc2c41802f97ddd5e8cd",
|
||||
"docs/requirements/handoff-HEL-877.json": "238dc7f98fe7617051a7d121175f450cf9fbe8aaeb2153e86fc9ea5daa93fc1e",
|
||||
"docs/requirements/handoff-HEL-878.json": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516",
|
||||
"docs/requirements/handoff-HEL-879.json": "45740707a1d5711de2d98e78150c8fee4682829c8589069ec348993ffd288bbf",
|
||||
"docs/requirements/handoff-HEL-880.json": "9506c3c5c1278b090d1e3eaf0f6191290af6114031e1f0e1ec514efeb74fb275",
|
||||
"docs/requirements/handoff-HEL-882.json": "8901262dc1714b4f6264c2f375e7b12e977653ed5ad80a5efacf48754c1c1089",
|
||||
"docs/requirements/handoff-HEL-885.json": "caf97d45ef8c23777942ed504d5ca6d3411ab5b86fda58c5ba308e31ffb80d32",
|
||||
"docs/requirements/handoff-HEL-886.json": "5e965ee60fba01a4e7ebde534c76177aa509421aea31e33beb83d01387b7413b",
|
||||
"docs/requirements/handoff-HEL-889.json": "bc28ee707a433d28f4efef9c33088781f98e1fe323f0c228f9444789d37363d3",
|
||||
"docs/requirements/handoff-HEL-894.json": "932251b76b952705a97e5b4259c0bb5596d8b51347c4c91ee082146a4a3fa3fa",
|
||||
"docs/requirements/handoff-HEL-895.json": "afb68a2db07dbf791a59e7878a646bd6cc30e26c8d94943551eb769024a79bbf",
|
||||
"docs/requirements/handoff-HEL-896.json": "9b3f7533e4ead1ffbca2b502c3be35223648314e74aac907f29c38cb5b8b4955",
|
||||
"docs/requirements/handoff-HEL-897.json": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9",
|
||||
"docs/requirements/handoff-HEL-898.json": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d",
|
||||
"docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819",
|
||||
"docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989",
|
||||
"docs/requirements/source-manifest.json": "9dc69b281f7a7b05b481d0604cde80756d9d499a60d9eb49e138d551f5e076f3",
|
||||
"docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b",
|
||||
"docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
|
||||
"docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d",
|
||||
"docs/requirements/sources/comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
|
||||
"docs/requirements/sources/comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
|
||||
"docs/requirements/sources/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
|
||||
"docs/requirements/sources/comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
|
||||
"docs/requirements/sources/comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
|
||||
"docs/requirements/sources/design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
|
||||
"docs/requirements/sources/dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e",
|
||||
"docs/requirements/sources/frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
|
||||
"docs/requirements/sources/governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
|
||||
"docs/requirements/sources/integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
|
||||
"docs/requirements/sources/mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
|
||||
"docs/requirements/sources/other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
|
||||
"docs/requirements/sources/product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
|
||||
"docs/requirements/sources/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
|
||||
"docs/requirements/sources/visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
|
||||
"docs/requirements/task-handoff-template.md": "8ee4d6d4c237e8a2c134ba751426975b0e24f03bfd6ac048b80642c69b59e445",
|
||||
"docs/requirements/task-migration.json": "a5131adbdf14a79df01992d2cbbc4b7aea5b8b377c7115a80cedb04073f9bc7d",
|
||||
"docs/requirements/verification.json": "7da6c2732cf28d46f8deaafc7ec571b8fc37bbd7efec0126ba339e51bbe12ace",
|
||||
"docs/rework/hel879/subtitle-samples/interrupt.jsonl": "611f58303849588c6c80efb73d0cf254c5968e1ae4b9dab191756d00356335bc",
|
||||
"docs/rework/hel879/subtitle-samples/normal-turn.jsonl": "5f503ff0efe3c677f787d287fc29b17616fa9b6294eb3bfe2f7be1b5742a7c31",
|
||||
"docs/rework/hel879/subtitle-samples/proactive.jsonl": "3f7f058cc137b7128b0815f9fd28ec96b752a472cb551a303183685798da6024",
|
||||
"docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293",
|
||||
"docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4",
|
||||
"src/cybergril/voice/__init__.py": "d550b2c9008681594edd34fd25ae05b31387332e3d4960f2323b6b21ec9cea9e",
|
||||
"src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd",
|
||||
"src/cybergril/voice/livekit_wire.py": "8aead2faa0b50677a7d8bccfea52fe17f55a8c53b090a2bb9bd868e450ce9d32",
|
||||
"src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007",
|
||||
"src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9",
|
||||
"src/cybergril/voice/router.py": "6d2afadeed807b442bbfda9f894097fda7df691539afad9cdfb2f8620039107e",
|
||||
"src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa",
|
||||
"src/cybergril/voice/session.py": "f1c0b84a0b0aea0a7a1c66cf35113e4f052b835146f7f608334716c82638951a",
|
||||
"src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2",
|
||||
"src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba",
|
||||
"src/cybergril/voice/worker.py": "95616f077cba302e2051b5b76ad1b24359d5e9da2554a82d9bcd7510f89f3f80",
|
||||
"web/.gitignore": "f99ead27b34cbc6756d707e7e7ee7340eb9a71e02dfec2b40d5fb848dd3f4d4f",
|
||||
"web/README.md": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726",
|
||||
"web/VENDOR.md": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6",
|
||||
"web/eslint.config.js": "c59a6a7e9f0dbeb5c8b90a66d1405606a5b0f909fafae47ca8e6737a9a386982",
|
||||
"web/index.html": "ecf71d805fa2d20a6e244ac1f6fc729a55a44dc24c4a7ac75f966179a51cf61d",
|
||||
"web/package-lock.json": "f950de58b5e86380705d979029894e62b39aba451cdb349922b97b4177677a27",
|
||||
"web/package.json": "f58c320d8b4d35c586016dc002d5e83a26ceeb05d8729552c831c39d0791336e",
|
||||
"web/public/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
|
||||
"web/public/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
|
||||
"web/public/assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
|
||||
"web/public/demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
|
||||
"web/public/vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626",
|
||||
"web/src/adapters/demo.ts": "be4593f70337e87dd4b3c0cb499cd73cb4dda1ee921d579d8bafe0b6cea53083",
|
||||
"web/src/adapters/inbox.ts": "8e145b914af1247f066ebb3e99574d43303a915173e6206950657e462461c85c",
|
||||
"web/src/adapters/real.ts": "5aa12ec2367c8505f663e52885138adf33ceeef4cfe2ae02d7d6402ff42864ee",
|
||||
"web/src/adapters/receipt.ts": "cdd7f41d79c843886167947f05bdbed159919e288c6196c6d94d5080b821876d",
|
||||
"web/src/adapters/sha256.ts": "c65b58368858ed13bff0fcb0710dc72c9cc5b112fd443dc6820fc66e62e08f22",
|
||||
"web/src/adapters/storage.ts": "e06afb3f8f801c2a4ff5a5ea109f27f59ad2d3187c915d48d50fba3f68cc08be",
|
||||
"web/src/app/App.tsx": "6d266a532c832fd8d93f447bf516158c17f517363155a47df7642cbc62ae1b90",
|
||||
"web/src/app/RealApp.tsx": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e",
|
||||
"web/src/chat/ChatPanel.tsx": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669",
|
||||
"web/src/chat/lineStage.ts": "21ddd34964237f537262f557e56dced2fd72a51c8a24577d867bc3933b1d40f7",
|
||||
"web/src/chat/reading.ts": "c00aa08141d00047bc9097a50197e5223772baa7af8b1c747bb259c9a5ada16b",
|
||||
"web/src/demo/fixtures.ts": "17e3525a155a4d07f37f4dc4e557a259788e943e39ee7ceca1e6fbf6a272e81c",
|
||||
"web/src/domain/id.ts": "b03e2c6a7120d1af5065a61103a0aa0679bdfedf3437ce31374d63df1fba8137",
|
||||
"web/src/domain/model.ts": "88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5",
|
||||
"web/src/domain/privacy.ts": "3bb318fae0704f09d1bb657bf54c10dea6ce8b0847daaee36a8dbda22d9c00db",
|
||||
"web/src/life/LifePanel.tsx": "a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192",
|
||||
"web/src/main.tsx": "4af93f56bbcb00d8d3327ed9f64ab57288234838ed311a1a8c3e825d4ada46b6",
|
||||
"web/src/manage/CreationWizard.tsx": "7c4e8945b3baf705c9fbc88ec8a50d3b4be289bd892789427061bbe97d396d5c",
|
||||
"web/src/manage/ManagePanel.tsx": "df0c968ec91c571124f9707c853a17587569448195745b4757d7afd753cfef3c",
|
||||
"web/src/shared/Viewport.tsx": "2302c18e00019134184a70d038bb0bcaea4e9230ae8c83ee9da0b7c285590bc0",
|
||||
"web/src/shared/motion.ts": "fb397e28411c59349efd75cb5aac1a2722f047ece2e9b7b305803a523576c9f6",
|
||||
"web/src/shared/navigation.ts": "f4820f6fad24bcb5e89dc497562aacd52ef264608f0a77718cade3a9efc4cb25",
|
||||
"web/src/shared/space.ts": "5d32075facc0a4e3a56577a126942cee85f724185afd4a43de5f1f85979a1fca",
|
||||
"web/src/shared/ui.tsx": "6900ffc625d4db2e8ef4772f9371927f85bd47c7464257a36c78e2c49449183f",
|
||||
"web/src/stage/Background.tsx": "0e36869e3b0600238fc380941ab355429996966fb1aa8bebfc7c5ef9cf807706",
|
||||
"web/src/stage/Presenter.tsx": "d4cf57bd990bfcf94ca567a1a681339c31474696fe2a92dbc6af218781522a7c",
|
||||
"web/src/stage/ambient.ts": "076a02ec6152d245d8be5a40cda364f298518cfd7e4a4c3315b4f245afe3a59f",
|
||||
"web/src/stage/camera.ts": "f3fd15fc4546d0572e03c2f82ae39bb7d2bcc78f65e1d7a3646e297f28dc8128",
|
||||
"web/src/stage/roomScene.ts": "f01befa8068d88f0f62b845bd4a3cc4421fd7a28d4f5e5ade9c11d0df95488e3",
|
||||
"web/src/styles/components.css": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d",
|
||||
"web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475",
|
||||
"web/src/voice/livekit.ts": "c42475d37f7eef7abe0903b66656aedc5379bc9de3dcba11c1b88450a470ab04",
|
||||
"web/src/voice/session.ts": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14",
|
||||
"web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656",
|
||||
"web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45",
|
||||
"web/tests/fake-livekit.js": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc",
|
||||
"web/tests/hel825_shot.py": "75f6075ae3d3c493360797e7e2f592fa5f4701f99e99981b8b9674ce70e01c92",
|
||||
"web/tests/hel825_tag_probe.py": "f8c2f51a0403adba71ac4c925700d248efad5b7ad8c3eda14ec81ff98ca3aca0",
|
||||
"web/tests/hel834_mock.mjs": "303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f",
|
||||
"web/tests/hel834_shots.mjs": "cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c",
|
||||
"web/tests/hel875_mock.mjs": "00b17de26604c7ca0999da12139fd4006468ced3e0536b60d239ccae0f949cc6",
|
||||
"web/tests/hel875_shots.mjs": "41b04fb9b9ee9569f39dd2dc57f694f5efcd2890124fbc6ced14e6d546878aa2",
|
||||
"web/tests/hel878_mock.mjs": "f87e61ffce879ec7e7c9287321e9d73a6b276b71633e68d84716205fd36380f0",
|
||||
"web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
|
||||
"web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b",
|
||||
"web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
|
||||
"web/tests/import_check.py": "a7eea222bfdf823a2bcc2fe139780d1d44279b2629e81f2ef8c5c5353abab990",
|
||||
"web/tests/inbox.test.ts": "649bc6e09c93427369aca63ce214ee3d288197d0f886bfced1696f41228b0ffb",
|
||||
"web/tests/r2wiring.test.ts": "d497adceb2c5187d980acb8801dcedbb1b5c991148048b9138bcb33820642b6f",
|
||||
"web/tests/r3memory.test.ts": "166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e",
|
||||
"web/tests/reading.test.ts": "55573c3cfeb613fb5c2990f8b5274f0c8c75fb25d7f723e05fec95761e6389b4",
|
||||
"web/tests/real.test.ts": "f63d377c148c901f97d3d5104bc648d72f0301fc2f28652d39693e3ce2836cfd",
|
||||
"web/tests/real_browser_check.py": "0f716011d3b25d9c9578aba4e2d4e10e5f38ddc572e54d4528fdaa8645e84ed0",
|
||||
"web/tests/review878_late_cleanup.test.ts": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935",
|
||||
"web/tests/rework_browser_check.py": "aee2600a4672b6052ab9372a20d9342a47be637a7271af7aed421b73256567d5",
|
||||
"web/tests/voice.test.ts": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73",
|
||||
"web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
|
||||
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-60677d46f686/workdir/before/web
|
||||
|
||||
❯ tests/voice.test.ts (43 tests | 12 failed) 459ms
|
||||
✓ honest switch > reads status only: no room, no microphone 14ms
|
||||
✓ honest switch > configured && tts_ok never means "verified": voice:false is not read as connected 1ms
|
||||
✓ honest switch > refuses honestly before touching the microphone when the server cannot speak 1ms
|
||||
✓ honest switch > an insecure page is told why, without asking for the microphone 0ms
|
||||
✓ lifecycle > opens, joins, publishes the microphone, follows her state; ending stops everything 1ms
|
||||
✓ lifecycle > permission denied: nothing opened on the server, nothing left running 1ms
|
||||
✓ lifecycle > server refusal shows its own words and releases the microphone 0ms
|
||||
✓ lifecycle > a failed join releases the microphone and gives the room back 0ms
|
||||
✓ lifecycle > ending while the microphone prompt is open: the late microphone is stopped, no room is asked for 8ms
|
||||
✓ lifecycle > ending while the room is being opened: the late grant is given back, never joined 31ms
|
||||
✓ lifecycle > ending while joining: the join is aborted, a late room is closed, the server room deleted 36ms
|
||||
✓ lifecycle > rapid on/off/on: only the last call stays, every earlier microphone and room is released 17ms
|
||||
✓ lifecycle > late events from an ended call never bring it back 14ms
|
||||
✓ lifecycle > the room dropping on its own (network, server ended it) cleans up and says so 15ms
|
||||
✓ lifecycle > microphone permission revoked mid-call ends the call 16ms
|
||||
✓ lifecycle > her worker never joining is a failure, not an endless wait 2ms
|
||||
✓ lifecycle > offline ends with its own words; unload releases at once and the DELETE survives the page 25ms
|
||||
✓ lifecycle > a DELETE that cannot get through (offline) is owed, survives a new page, and is sent once back online 1ms
|
||||
✓ lifecycle > an owed room already gone on the server (404) counts as settled 0ms
|
||||
✓ lifecycle > never confirms a receipt or posts chat itself: the worker owns both 11ms
|
||||
✓ a late end of an older call > the room dropped, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 62ms
|
||||
✓ a late end of an older call > the microphone was taken away, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 58ms
|
||||
✓ a late end of an older call > her worker never joined, the DELETE is slow, and a new call is started before it answers 2ms
|
||||
✓ a late end of an older call > a normal end whose DELETE is slow, then a new call, then the old answer arrives 2ms
|
||||
✓ a late end of an older call > starting again while the previous start still waits for the old room to be given back: only the newest call survives 71ms
|
||||
✓ a late end of an older call > a microphone granted late to an older start is stopped without touching the new call 31ms
|
||||
× subtitles and the two channels > interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded 9ms
|
||||
→ expected { phase: 'listening', heard: '', …(3) } to match object { spoken: '我刚才走神了,你再说一遍?', her: null }
|
||||
(4 matching properties omitted from actual)
|
||||
✓ subtitles and the two channels > without the agent state attribute, subtitles still move the state honestly 1ms
|
||||
✓ subtitles and the two channels > typing over her spoken answer silences what is left of it until she is listening again 1ms
|
||||
× her voice line (cg.subtitle v2) > rejects anything that is not exactly a v2 frame 1ms
|
||||
→ expected 10 to be 3 // Object.is equality
|
||||
× her voice line (cg.subtitle v2) > normal turn: your draft and final, her growing reply replaced in place, then her stored final 2ms
|
||||
→ expected [ '你在听的', '你在听吗?', '你在听吗?', …(4) ] to deeply equal [ '你在听的', '你在听吗?', { …(3) }, …(4) ]
|
||||
× her voice line (cg.subtitle v2) > interrupted: no final, her line goes at once, its late frames never reach the new turn 6ms
|
||||
→ expected undefined to match object { speech: 'speech_cut', …(2) }
|
||||
× her voice line (cg.subtitle v2) > frames that overtook each other: a lower seq is stale; an older speech never touches or closes the new one 1ms
|
||||
→ expected undefined to be '一二三' // Object.is equality
|
||||
× her voice line (cg.subtitle v2) > reach-out read aloud: shown whole with its stored message id, interruptible, nothing marked or stored 1ms
|
||||
→ expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'speaking', her: { …(5) } }
|
||||
(4 matching properties omitted from actual)
|
||||
× her voice line (cg.subtitle v2) > a reach-out cut by your voice: the cancel clears it, your words follow 1ms
|
||||
→ expected { phase: 'listening', …(4) } to match object { heard: '等等', …(1) }
|
||||
(4 matching properties omitted from actual)
|
||||
× her voice line (cg.subtitle v2) > text taking over, hanging up and starting again each clear her line at once 1ms
|
||||
→ expected undefined to be null
|
||||
× her voice line (cg.subtitle v2) > the room dropping mid-sentence clears her line with the call 9ms
|
||||
→ expected { phase: 'off', heard: '', …(3) } to match object { phase: 'off', her: null, …(1) }
|
||||
(3 matching properties omitted from actual)
|
||||
× her voice line on the stage > grows in her slot after your voice turn; the stored row takes over under the same line keys 0ms
|
||||
→ (0 , voiceLine) is not a function
|
||||
× her voice line on the stage > a finished line never stands in front of a newer turn; an open one waits for its own end 0ms
|
||||
→ (0 , voiceLine) is not a function
|
||||
× her voice line on the stage > a reach-out read aloud keeps its stored row identity and gives way when that row is the newest line 0ms
|
||||
→ (0 , voiceLine) is not a function
|
||||
✓ record re-read > adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream 0ms
|
||||
✓ record re-read > a pending voice row settles in place (sent or cancelled), never duplicated 0ms
|
||||
✓ record re-read > reads newest pages down to what the page holds, bounded 2ms
|
||||
node :
|
||||
At line:4 char:24
|
||||
+ ... efore\web"; node node_modules\vitest\vitest.mjs run tests/voice.test. ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (:String) [], RemoteException
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
⎯⎯⎯⎯⎯⎯ Failed Tests 12 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL tests/voice.test.ts > subtitles and the two channels > interim is a draft; user_final cancels a text reply and
|
||||
asks for the record; φ is heard but not recorded
|
||||
AssertionError: expected { phase: 'listening', heard: '', …(3) } to match object { spoken: '我刚才走神了,你再说一遍?', her: null }
|
||||
(4 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
- "her": null,
|
||||
"spoken": "我刚才走神了,你再说一遍?",
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:361:26
|
||||
359| room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'a…
|
||||
360| room.on.subtitle(frame(5, 'error', { speech_id: 'a', text: '我刚…
|
||||
361| expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her:…
|
||||
| ^
|
||||
362| room.on.subtitle(frame(6, 'turn_cancel', { speech_id: 'a', rea…
|
||||
363| expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her:…
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > rejects anything that is not exactly a v2 frame
|
||||
AssertionError: expected 10 to be 3 // Object.is equality
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
- 3
|
||||
+ 10
|
||||
|
||||
❯ tests/voice.test.ts:407:32
|
||||
405| ];
|
||||
406| for (const b of bad) room.on.subtitle(b);
|
||||
407| expect(r.views.length).toBe(seen); expect(r.record).not.toHave…
|
||||
| ^
|
||||
408| // A rejected frame does not move the sequence: the first good…
|
||||
409| room.on.subtitle(frame(1, 'interim', { text: '好的' })); expect(…
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > normal turn: your draft and final, her growing reply
|
||||
replaced in place, then her stored final
|
||||
AssertionError: expected [ '你在听的', '你在听吗?', '你在听吗?', …(4) ] to deeply equal [ '你在听的', '你在听吗?', { …(3) }, …(4) ]
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
[
|
||||
"你在听的",
|
||||
"你在听吗?",
|
||||
- {
|
||||
- "speech": "speech_normal",
|
||||
- "state": "open",
|
||||
- "text": "",
|
||||
- },
|
||||
- {
|
||||
- "speech": "speech_normal",
|
||||
- "state": "open",
|
||||
- "text": "在听呢,",
|
||||
- },
|
||||
- {
|
||||
- "speech": "speech_normal",
|
||||
- "state": "open",
|
||||
- "text": "在听呢,在听呢,你刚才说到一半的那件事我还在想。",
|
||||
- },
|
||||
- {
|
||||
- "speech": "speech_normal",
|
||||
- "state": "final",
|
||||
- "text": "在听呢,你刚才说到一半的那件事我还在想。",
|
||||
- "turnId": "0e2f-demo-turn",
|
||||
- },
|
||||
- {
|
||||
- "speech": "speech_normal",
|
||||
- "state": "done",
|
||||
- "text": "在听呢,你刚才说到一半的那件事我还在想。",
|
||||
- "turnId": "0e2f-demo-turn",
|
||||
- },
|
||||
+ "你在听吗?",
|
||||
+ "你在听吗?",
|
||||
+ "你在听吗?",
|
||||
+ "",
|
||||
+ "",
|
||||
]
|
||||
|
||||
❯ tests/voice.test.ts:415:23
|
||||
413| const steps: (VoiceView['her'] | string)[] = [];
|
||||
414| play(room, frames, () => steps.push(r.view().her ? { ...r.view…
|
||||
415| expect(steps).toEqual([
|
||||
| ^
|
||||
416| '你在听的', '你在听吗?',
|
||||
417| { speech: 'speech_normal', text: '', state: 'open' },
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > interrupted: no final, her line goes at once, its late
|
||||
frames never reach the new turn
|
||||
AssertionError: expected undefined to match object { speech: 'speech_cut', …(2) }
|
||||
|
||||
- Expected:
|
||||
{
|
||||
"speech": "speech_cut",
|
||||
"state": "open",
|
||||
"text": "我正想跟你说,今天下午",
|
||||
}
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ tests/voice.test.ts:438:30
|
||||
436| const r = await connected(), room = r.transport.rooms[0], fram…
|
||||
437| play(room, frames.slice(0, 2));
|
||||
438| expect(r.view().her).toMatchObject({ speech: 'speech_cut', tex…
|
||||
| ^
|
||||
439| play(room, frames.slice(2, 3));
|
||||
440| expect(r.view().her).toBeNull();
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > frames that overtook each other: a lower seq is stale;
|
||||
an older speech never touches or closes the new one
|
||||
AssertionError: expected undefined to be '一二三' // Object.is equality
|
||||
|
||||
- Expected:
|
||||
"一二三"
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ tests/voice.test.ts:454:36
|
||||
452| room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a…
|
||||
453| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
|
||||
454| expect(r.view().her?.text).toBe('一二三');
|
||||
| ^
|
||||
455| // Two handles: b starts while a is still open (queued then ge…
|
||||
456| room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > reach-out read aloud: shown whole with its stored
|
||||
message id, interruptible, nothing marked or stored
|
||||
AssertionError: expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'speaking', her: { …(5) } }
|
||||
(4 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
- "her": {
|
||||
- "messageId": 17,
|
||||
- "proactive": true,
|
||||
- "speech": "speech_pro1",
|
||||
- "state": "open",
|
||||
- "text": "我看到一句很适合现在的话,先念给你听。",
|
||||
- },
|
||||
- "phase": "speaking",
|
||||
+ "phase": "listening",
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:471:26
|
||||
469| const r = await connected(), room = r.transport.rooms[0], fram…
|
||||
470| play(room, frames.slice(0, 1));
|
||||
471| expect(r.view()).toMatchObject({ phase: 'speaking', her: { spe…
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 12 failed | 31 passed (43)
|
||||
Start at 17:45:16
|
||||
Duration 875ms (transform 138ms, setup 0ms, collect 219ms, tests 459ms, environment 0ms, prepare 60ms)
|
||||
|
||||
| ^
|
||||
472| room.on.subtitle(frame(2, 'proactive_end', { speech_id: 'speec…
|
||||
473| expect(r.view().her?.state).toBe('open');
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > a reach-out cut by your voice: the cancel clears it,
|
||||
your words follow
|
||||
AssertionError: expected { phase: 'listening', …(4) } to match object { heard: '等等', …(1) }
|
||||
(4 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
"heard": "等等",
|
||||
- "her": {
|
||||
- "speech": "p",
|
||||
- "state": "open",
|
||||
- },
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:486:26
|
||||
484| room.on.subtitle(frame(1, 'proactive_start', { speech_id: 'p',…
|
||||
485| room.on.subtitle(frame(2, 'interim', { text: '等等' }));
|
||||
486| expect(r.view()).toMatchObject({ heard: '等等', her: { speech: '…
|
||||
| ^
|
||||
487| room.on.subtitle(frame(3, 'proactive_cancel', { speech_id: 'p'…
|
||||
488| expect(r.view()).toMatchObject({ heard: '等等', her: null });
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > text taking over, hanging up and starting again each
|
||||
clear her line at once
|
||||
AssertionError: expected undefined to be null
|
||||
|
||||
- Expected:
|
||||
null
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ tests/voice.test.ts:496:30
|
||||
494| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
|
||||
495| r.c.hush();
|
||||
496| expect(r.view().her).toBeNull(); expect(room.hushed).toEqual([…
|
||||
| ^
|
||||
497| room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a…
|
||||
498| expect(r.view().her).toBeNull();
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > the room dropping mid-sentence clears her line with the
|
||||
call
|
||||
AssertionError: expected { phase: 'off', heard: '', …(3) } to match object { phase: 'off', her: null, …(1) }
|
||||
(3 matching properties omitted from actual)
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
{
|
||||
- "her": null,
|
||||
"notice": "通话断开了,麦克风已经关闭。文字仍可使用。",
|
||||
"phase": "off",
|
||||
}
|
||||
|
||||
❯ tests/voice.test.ts:526:26
|
||||
524| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
|
||||
525| room.on.dropped(); await flush();
|
||||
526| expect(r.view()).toMatchObject({ phase: 'off', her: null, noti…
|
||||
| ^
|
||||
527| });
|
||||
528| });
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line on the stage > grows in her slot after your voice turn; the stored row
|
||||
takes over under the same line keys
|
||||
TypeError: (0 , voiceLine) is not a function
|
||||
❯ tests/voice.test.ts:537:25
|
||||
535| it('grows in her slot after your voice turn; the stored row takes …
|
||||
536| const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直…
|
||||
537| const growing = voiceLine(open, [you], 1)!;
|
||||
| ^
|
||||
538| expect(growing).toMatchObject({ role: 'companion', live: 'gene…
|
||||
539| // The newest line of a reply still growing is held back, as i…
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line on the stage > a finished line never stands in front of a newer turn; an
|
||||
open one waits for its own end
|
||||
TypeError: (0 , voiceLine) is not a function
|
||||
❯ tests/voice.test.ts:550:16
|
||||
548| it('a finished line never stands in front of a newer turn; an open…
|
||||
549| const done: HerLine = { speech: 's', text: '好。', state: 'done'…
|
||||
550| expect(voiceLine(done, [you, M(6, 'user', '还有', 'U')], 1)).toB…
|
||||
| ^
|
||||
551| expect(voiceLine({ ...done, state: 'open' }, [you, M(6, 'user'…
|
||||
552| expect(voiceLine({ ...done, text: '' }, [you], 1)).toBeNull();
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/12]⎯
|
||||
|
||||
FAIL tests/voice.test.ts > her voice line on the stage > a reach-out read aloud keeps its stored row identity and
|
||||
gives way when that row is the newest line
|
||||
TypeError: (0 , voiceLine) is not a function
|
||||
❯ tests/voice.test.ts:558:25
|
||||
556| const row = M(17, 'companion', line.text, 'R', { proactive: tr…
|
||||
557| const before = [you, M(5, 'companion', '在。', 'T')];
|
||||
558| const waiting = voiceLine(line, before, 2, [row])!;
|
||||
| ^
|
||||
559| expect(waiting).toMatchObject({ id: '17', turnId: 'R', proacti…
|
||||
560| expect(stage([...before, waiting])).toEqual(stage([...before, …
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/12]⎯
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
PASS status read on arrival only: no session, no microphone, no LiveKit client — GET /api/voice/status
|
||||
PASS voice button offers to start, honestly labelled
|
||||
PASS not configured: refusal in her own words, no dialog, no session, no microphone — 语音还没有在这台服务上配置,文字仍可使用。
|
||||
PASS TTS down: says her voice service is away — 她的声音服务这会儿不在,稍后再试试。文字仍可使用。
|
||||
PASS the dialog explains microphone, destination, same memory, how to stop, and the unverified path — 开始和栖语音? 浏览器会请求麦克风权限。你的声音只送到这台服务器上栖的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。 语音通路还没有经过真机验收,效果可能不稳定。 取消 开始语音
|
||||
PASS cancel: no microphone request, no session
|
||||
PASS waiting for her is shown as waiting, not as connected — 在等栖接起…
|
||||
结束语音
|
||||
PASS one session opened with a server token; one microphone; mic published to that room
|
||||
PASS her worker joined: listening, wave in your colour, her audio attached — 在听你说
|
||||
结束语音
|
||||
PASS interim words show as a draft line beside the state
|
||||
PASS your spoken words enter the stage from the server record (not from the subtitle) — 你:今天下班的路上看到一只猫
|
||||
PASS speaking: her name, and that you can cut in by speaking
|
||||
PASS her spoken answer reaches the stage once it is in the record — 栖:是橘色的那只吗?它最近总在桥下晒太阳。
|
||||
PASS the record holds the voice turn once, same conversation as text
|
||||
PASS φ line shown as heard, marked not recorded; not in the record
|
||||
PASS text over voice: her remaining audio is muted
|
||||
PASS …and unmuted once she is listening again
|
||||
PASS text reply streaming
|
||||
PASS voice over text: no failure notice, no retry, draft not refilled
|
||||
PASS …the cut text turn is marked cancelled in the record; the voice words are there once — 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳。 你 等等,我先打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 你 再给我讲讲那只猫 已取消 栖 我在。今天的雨停了没有?要是还在 已停止 · 这是未完成片段,未存入聊天记录 你 算了先不说了
|
||||
PASS blocked audio offers a tap
|
||||
PASS …and the offer goes away after the tap
|
||||
PASS 结束语音: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS ended: says the microphone is closed; DELETE sent — 语音已结束,麦克风已关闭。
|
||||
PASS late events after the end do not revive anything
|
||||
PASS permission refused: told plainly, no session opened, text still there — 没有拿到麦克风权限,语音没有开始。文字仍可使用。
|
||||
PASS microphone revoked mid-call: call ends, said so — 麦克风被关掉了,语音已结束。
|
||||
PASS revoked: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS room dropped: call ends, said so — 通话断开了,麦克风已经关闭。文字仍可使用。
|
||||
PASS dropped: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS slow DELETE: dropped call says so and its microphone stops before the server answers — 通话断开了,麦克风已经关闭。文字仍可使用。
|
||||
PASS …a new call is up meanwhile: listening
|
||||
PASS …the old DELETE answers late: the new call stays listening, no old notice, one live microphone, her audio, only the new room open — phase=listening status= open=sess-5
|
||||
PASS new call after the late old DELETE: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS server refusal shows its detail; microphone released — 她的声音服务这会儿不在,稍后再试试。
|
||||
PASS rapid off/on: exactly one open session and one live microphone — {"open":["sess-7"],"made":7}
|
||||
PASS rapid toggle: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS nav away: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS Esc: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS 结束交谈: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS network lost: call ends with its own words — 网络断开了,语音已结束,麦克风已关闭。
|
||||
PASS network lost: microphone and her audio stop at once; the server room is owed, not pretended deleted — mics=0 audio=0 open=1
|
||||
PASS offline, after the network is back: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
|
||||
PASS …the owed DELETE is sent once back online and forgotten
|
||||
PASS page unload: the server room is deleted (keepalive) — DELETE /api/voice/session/sess-12
|
||||
PASS moon/phone: voice line fits the screen
|
||||
PASS fog/phone: voice line fits the screen
|
||||
PASS real livekit-client 2.22.4: every name the adapter uses exists — {"events":true,"values":["participantAttributesChanged","dataReceived","audioPlaybackChanged"],"mic":"microphone","agent":4,"create":"function","methods":true,"playback":"boolean","remote":true,"publish":"function","version":"2.22.4"}
|
||||
PASS demo entry makes no voice requests
|
||||
49/49 passed
|
||||
@@ -0,0 +1,289 @@
|
||||
PASS moon-desktop: her reply shows on the stage while she speaks, before it is stored — 你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
|
||||
PASS moon-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
|
||||
「今天下班的路上看到一只猫」
|
||||
结束语音
|
||||
PASS moon-desktop: pause while she speaks
|
||||
PASS moon-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 你 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
|
||||
PASS moon-desktop: paused before speaking
|
||||
PASS moon-desktop: speaking resumes reading for the new turn
|
||||
PASS moon-desktop: second reply growing — 你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
|
||||
PASS moon-desktop: interrupted: her unfinished line is gone at once — 你
|
||||
|
||||
你今天去哪儿了
|
||||
PASS moon-desktop: late frames of the cut turn stay out
|
||||
PASS moon-desktop: her line before you type — 你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
|
||||
PASS moon-desktop: text over voice: her line is gone, your words are on the stage — 你
|
||||
|
||||
先等等,我打字说
|
||||
PASS moon-desktop: reach-out shown in her place, word for word — 栖
|
||||
还在说
|
||||
|
||||
刚才路过花店,想起你说过喜欢白色的洋桔梗。
|
||||
PASS moon-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
|
||||
PASS moon-desktop: her line before hanging up — 你
|
||||
|
||||
我先去睡了
|
||||
|
||||
你
|
||||
|
||||
我先去睡了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
晚安,明天见。今天说了好多好多话,你要早点睡哦。
|
||||
PASS moon-desktop: hung up: her line gone, voice off — 你
|
||||
|
||||
我先去睡了
|
||||
PASS fog-desktop: her reply shows on the stage while she speaks, before it is stored — 你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
|
||||
PASS fog-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
|
||||
「今天下班的路上看到一只猫」
|
||||
结束语音
|
||||
PASS fog-desktop: pause while she speaks
|
||||
PASS fog-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
|
||||
PASS fog-desktop: paused before speaking
|
||||
PASS fog-desktop: speaking resumes reading for the new turn
|
||||
PASS fog-desktop: second reply growing — 你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
|
||||
PASS fog-desktop: interrupted: her unfinished line is gone at once — 你
|
||||
|
||||
你今天去哪儿了
|
||||
PASS fog-desktop: late frames of the cut turn stay out
|
||||
PASS fog-desktop: her line before you type — 你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
|
||||
PASS fog-desktop: text over voice: her line is gone, your words are on the stage — 你
|
||||
|
||||
先等等,我打字说
|
||||
PASS fog-desktop: reach-out shown in her place, word for word — 栖
|
||||
还在说
|
||||
|
||||
刚才路过花店,想起你说过喜欢白色的洋桔梗。
|
||||
PASS fog-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
|
||||
PASS fog-desktop: her line before hanging up — 你
|
||||
|
||||
我先去睡了
|
||||
|
||||
你
|
||||
|
||||
我先去睡了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
晚安,明天见。今天说了好多好多话,你要早点睡哦。
|
||||
PASS fog-desktop: hung up: her line gone, voice off — 你
|
||||
|
||||
我先去睡了
|
||||
PASS moon-phone: her reply shows on the stage while she speaks, before it is stored — 你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
|
||||
PASS moon-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
|
||||
「今天下班的路上看到一只猫」
|
||||
结束语音
|
||||
PASS moon-phone: pause while she speaks
|
||||
PASS moon-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
|
||||
PASS moon-phone: paused before speaking
|
||||
PASS moon-phone: speaking resumes reading for the new turn
|
||||
PASS moon-phone: second reply growing — 你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
|
||||
PASS moon-phone: interrupted: her unfinished line is gone at once — 你
|
||||
|
||||
你今天去哪儿了
|
||||
PASS moon-phone: late frames of the cut turn stay out
|
||||
PASS moon-phone: her line before you type — 你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
|
||||
PASS moon-phone: text over voice: her line is gone, your words are on the stage — 你
|
||||
|
||||
先等等,我打字说
|
||||
PASS moon-phone: reach-out shown in her place, word for word — 栖
|
||||
还在说
|
||||
|
||||
刚才路过花店,想起你说过喜欢白色的洋桔梗。
|
||||
PASS moon-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
|
||||
PASS moon-phone: her line before hanging up — 你
|
||||
|
||||
我先去睡了
|
||||
|
||||
你
|
||||
|
||||
我先去睡了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
晚安,明天见。今天说了好多好多话,你要早点睡哦。
|
||||
PASS moon-phone: hung up: her line gone, voice off — 你
|
||||
|
||||
我先去睡了
|
||||
PASS fog-phone: her reply shows on the stage while she speaks, before it is stored — 你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
你
|
||||
|
||||
今天下班的路上看到一只猫
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
|
||||
PASS fog-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
|
||||
「今天下班的路上看到一只猫」
|
||||
结束语音
|
||||
PASS fog-phone: pause while she speaks
|
||||
PASS fog-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
|
||||
PASS fog-phone: paused before speaking
|
||||
PASS fog-phone: speaking resumes reading for the new turn
|
||||
PASS fog-phone: second reply growing — 你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
你
|
||||
|
||||
你今天去哪儿了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
|
||||
PASS fog-phone: interrupted: her unfinished line is gone at once — 你
|
||||
|
||||
你今天去哪儿了
|
||||
PASS fog-phone: late frames of the cut turn stay out
|
||||
PASS fog-phone: her line before you type — 你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
你
|
||||
|
||||
我们聊点别的吧
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
|
||||
PASS fog-phone: text over voice: her line is gone, your words are on the stage — 你
|
||||
|
||||
先等等,我打字说
|
||||
PASS fog-phone: reach-out shown in her place, word for word — 栖
|
||||
还在说
|
||||
|
||||
刚才路过花店,想起你说过喜欢白色的洋桔梗。
|
||||
PASS fog-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
|
||||
PASS fog-phone: her line before hanging up — 你
|
||||
|
||||
我先去睡了
|
||||
|
||||
你
|
||||
|
||||
我先去睡了
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
晚安,明天见。今天说了好多好多话,你要早点睡哦。
|
||||
PASS fog-phone: hung up: her line gone, voice off — 你
|
||||
|
||||
我先去睡了
|
||||
PASS reduced motion: her words arrive — 你
|
||||
|
||||
慢一点说给我听
|
||||
|
||||
栖
|
||||
还在说
|
||||
|
||||
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
|
||||
61/61 passed
|
||||
@@ -0,0 +1,9 @@
|
||||
vite v6.3.6 building for production...
|
||||
transforming...
|
||||
✓ 57 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
dist/index.html 0.49 kB │ gzip: 0.35 kB
|
||||
dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB
|
||||
dist/assets/index-8mbVmtoD.js 310.56 kB │ gzip: 103.40 kB
|
||||
✓ built in 706ms
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-898-60677d46f686\workdir\src0\web\src\chat\ChatPanel.tsx
|
||||
239:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
|
||||
|
||||
✖ 1 problem (0 errors, 1 warning)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-60677d46f686/workdir/src0/web
|
||||
|
||||
✓ tests/reading.test.ts (11 tests) 56ms
|
||||
✓ tests/inbox.test.ts (13 tests) 40ms
|
||||
✓ tests/domain.test.ts (12 tests) 11ms
|
||||
✓ tests/r2wiring.test.ts (11 tests) 45ms
|
||||
✓ tests/voice.test.ts (43 tests) 439ms
|
||||
✓ tests/r3memory.test.ts (5 tests) 19ms
|
||||
✓ tests/real.test.ts (3 tests) 23ms
|
||||
✓ tests/review878_late_cleanup.test.ts (1 test) 15ms
|
||||
|
||||
Test Files 8 passed (8)
|
||||
Tests 99 passed (99)
|
||||
Start at 17:41:51
|
||||
Duration 959ms (transform 262ms, setup 0ms, collect 620ms, tests 647ms, environment 1ms, prepare 532ms)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user