Compare commits

...
Author SHA1 Message Date
施工员2号andmultica-agent 2e91a3c91e HEL-916 rework: finite non-bool severity only; one-time chat-affect historical cutoff
Co-authored-by: multica-agent <github@multica.ai>
2026-11-03 11:26:11 +08:00
施工员2号andmultica-agent adf4d5c4f7 HEL-916: auditable dialogue input, natural two-world boundary, live-activity wiring, bounded judgment retry, chat-driven mood
Co-authored-by: multica-agent <github@multica.ai>
2026-11-03 11:07:38 +08:00
总管andmultica-agent e74531cf98 HEL-914: supervise approved user trial and enforce its absolute expiry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-30 11:33:29 +08:00
hel914-total-workerandmultica-agent d300d87092 HEL-914: handoff — install/deploy/team-check/user-instance readiness evidence
Co-authored-by: multica-agent <github@multica.ai>
2026-10-30 11:15:48 +08:00
hel914-total-workerandmultica-agent ffbc8c2496 HEL-914: persona claim lease must cover the 120s generation window (finished result was discarded at the 40s lease check)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-30 10:27:47 +08:00
hel914-total-workerandmultica-agent 236fe3d741 HEL-914: persona creation needs a real-scale timeout/token budget (first real run timed out at 30s/1200tok)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-30 10:19:08 +08:00
总管andmultica-agent 8cd4265182 HEL-797: allocate first-use memory budget across team verification and user trial
Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 22:41:31 +08:00
总管andmultica-agent 049d3dbdff HEL-913: correct runtime wiring and closeout instructions after coordinator checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 22:39:04 +08:00
总工andmultica-agent 8ea5fdc4c9 HEL-913 close-out: executable package version/dir entry, consistent pending-approval enablement, complete auditable mem0 install (isolated venv), identity-checked ctl scripts + voice-window wiring (42/42 offline stub checks), fact-table accuracy fixes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 22:30:56 +08:00
总工andmultica-agent 0146e370b1 HEL-913: unified-experience run package — fixed-version manifest, sanitized templates, offline zero-real-call verification (34/34), mem0 cp311 gap verified, capability list + one-shot enablement proposal
Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 22:04:52 +08:00
施工员2号andmultica-agent 4a38106eef HEL-911: fix AgentHandoff/shutdown seams, attribute & fix the 3.66s pre-generation wait
- worker _on_item: filter conversation_item_added by the real 1.8.8 item
  discriminator (ChatMessage | AgentHandoff | _TypeDiscriminator); only
  assistant str-part body feeds the echo filter (HEL-910 AttributeError)
- bind_interrupt_channel unload: async (JobContext shutdown callbacks are
  awaited), idempotent, disconnect-safe; interrupt/receipt/budget paths
  untouched (HEL-910 TypeError await None)
- TURN_HANDLING: explicit turn_detection=None — 1.8.8 silently installs an
  English EOU TurnDetector whose unlikely verdicts on Chinese finals push
  endpointing to max_delay=6.0 (measured 3.667/3.654s HEL-910 call2; offline
  A/B on the real SDK: baseline 3.704s vs fixed 0.004s). Endpointing
  0.8/6.0 kept verbatim; no VAD/buffer_turn/semantic change
- scripts/gen_hel911_wait_repro.py: offline reproduction harness (real
  AgentSession + real silero VAD + HEL-910 wav and measured timings)
- tests: +3 real-SDK checks (11/11 on .12 agent venv; prior 8 regressions
  kept, unload call sites await the new protocol)
- hel910_call1.mjs reliability: segmented probe/recorder dumps to disk,
  timing-A trigger requires the server-committed voice turn row (not a
  we-sent-it proxy); node --check only, no real run
- window-record.md §9: appended factual corrections (companion 9 not 18,
  turn_start +6.099 not +4.10, assistant_final is the playout/receipt gate,
  post-window ListRooms token failure is not empty-room evidence); original
  record untouched
- docs/rework/hel911/summary.md + handoff-HEL-911.json; requirement checks
  pass vs origin/main and 407fc81

Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 21:25:25 +08:00
xiaomoandmultica-agent 407fc8158c HEL-910: real-call retest window record — web text interrupt, wait chain, budgets, cleanup evidence
Co-authored-by: multica-agent <github@multica.ai>
2026-10-29 20:24:52 +08:00
xiaomoandmultica-agent 958c6962fb HEL-908 R1/R2 rework (HEL-909 review): whole-turn reasoning gate + cg.interrupt data channel
R1 — reasoning separation truly closed. The streaming hold leaked wholesale
on real token arrival (review A1: 'private reasoning '*20 beyond the 128-char
bound, no sentence ender; review A2: a Chinese sentence ender released the
hold) AND the adapter consumed the stray closer, so the app-level turn split
could never recover — the previous 'persistence and receipt always clean'
claim was false and is retracted. Per the engineering ruling the adapter now
has two explicit endpoint modes (GOV-006, no heuristics, no new engine):
buffer_turn (TurnReasoningGate) emits NOTHING during generation, splits the
whole turn at stream end and only then delivers the clean visible text; it
is set explicitly for all tiers in config/models.yaml (first visible text
waits for the full generation; cancelled/timed-out turns emit nothing and
end exactly as before — recorded and test-pinned, low latency NOT claimed).
stream (default, backward compatible) keeps explicit-tag/field semantics and
passes stray closers through unmodified so the turn-level split keeps its
signal. Both review strings and the recorded id=12 stream replay clean at
every chunk size through the real adapter and the real app into chat.delta,
the archive and the receipt; normal zh/en preserved byte-for-byte; unclosed
explicit thinking still fails honestly.

R2 — an authoritative interrupt when the LLM already finalized and TTS is
still playing (review evidence C: zero interrupts, old audio played out).
The page's existing hush() entry (ChatPanel untouched) now also publishes
{"v":1} on the room's own reliable data channel topic cg.interrupt
(fire-and-forget: a dropped publish never blocks or breaks the text send);
the worker subscribes data_received, validates topic/sender/payload and
calls session.interrupt(force=True) — the SDK's own programmatic interrupt,
force confirmed against the installed 1.8.8 source (non-force raises
RuntimeError on allow_interruptions=False speech). Failures log loudly; the
listener is unloaded via add_shutdown_callback; the server-cancel path in
llm_chat_api also interrupts with force and logs failures.

Verification (offline, zero real requests): real-SDK harness 8/8 including a
real SpeechHandle with a genuinely pending playout wait released by the
cg.interrupt message after a clean chat.final (evidence-C shape) plus the
full validation matrix; .venv311 full suite 480 passed / 8 skipped (2
test_r3 failures pre-exist at 5625de6); web npm ci from the existing lock,
typecheck 0 errors, lint 0 errors + 1 pre-existing warning, vitest 148/148,
build ok; ruff clean; check_requirements vs origin/main and b8ec832 with the
corrected handoff.

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 22:21:16 +08:00
xiaomoandmultica-agent b8ec832f35 HEL-908: terminal chat.cancelled + no-revive gate for text interrupt; reasoning/content separation at the adapter seam
Bug 1 (recorded call1): server-side cancel ended the voice SSE without a
terminal frame BEFORE the first delta (worker.log:108 SDK retry 13ms after
the text POST); the adapter raised a connection error, livekit-agents 1.8.8
silently retried the same turn_id, the retry re-acquired the gate, cancelled
the user's text turn and re-spoke a full ~32s reply. The HEL-907 claim that
the _retry_on_chunk_sent guard never worked is corrected: the guard only
applies after output (output_sent), verified against the installed SDK
source. Fix: explicit chat.cancelled terminal frame; a cancelled turn_id is
refused re-activation (never touches the newer active turn); the worker ends
cancelled turns on the SDK's own 499 path (no retry) and stops the cached
playout via SpeechHandle.interrupt(); a server_cancelled latch short-circuits
any re-entry with zero network. Failed-turn retries, completed-turn replay
and receipt validation are unchanged (dedicated tests).

Bug 2 (recorded row id=12): the endpoint streams implicit-open reasoning
plus a stray </think> inline in content into subtitles and TTS. Fix:
ReasoningSeparator at the OpenAI-compat adapter seam (cross-chunk safe,
reasoning_content field side-channel on LLMDelta.reasoning, never persisted)
plus a turn-level split before persist/final/receipt. Recorded stream
replays clean at every chunk size; normal zh/en content preserved
byte-for-byte; documented residual: implicit-open reasoning whose closer
lies beyond the 128-char hold streams partially (persistence stays clean).

Offline verification: .venv311 full suite 445 passed / 5 skipped (2
pre-existing test_r3 failures fail identically at 5625de6); real-SDK harness
(livekit-agents 1.8.8, agent venv) 5/5; reasoning separation 37/37; ruff
clean. Zero real requests, zero deployment, no dependency changes.

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 21:27:08 +08:00
xiaomoandmultica-agent 5625de6606 HEL-907: custom-voice web flow + 3 real calls window record, budget accounts, recycle evidence; hel900 harness minimal fix
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 20:29:23 +08:00
总工andmultica-agent 038f1eeae7 HEL-906 rework materials: r5-followup-package §4 dual-accounting + fail-closed wording, handoff update
- §4: conservative pre-record vs confirmed-delivered accounting, ledger
  failure fail-closed, guarantees scoped to armed process (cross-process
  bounded overshoot / crash undercount stay monitored boundaries, no
  'never exceeds' claims), window execution convention (3 sequential
  calls, tightened params with margin), verification command now 24 cases
- milestone table registers rework commit cf395d4
- handoff-HEL-906.json: rework deliverable/acceptance/verification as above

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 09:04:29 +08:00
总工andmultica-agent cf395d4e06 HEL-906 rework: persistence fail-closed stop + conservative pre-record accounting
- asr_usage: ledger write/read failure, corruption or mid-window vanish now
  stop all window outbound (fail-closed), preserve known consumption
  (monotonic, no zero-budget default), log + record guard_stop (durable
  across same-window restarts)
- asr_usage: conservative reserve before send (failed/cancelled sends are
  NOT refunded) + separately tracked confirmed-delivered seconds; replay
  restores both
- stt_funasr: reserve_audio before ws.send, note_audio_delivered after
  successful await only
- tests: 24 deterministic offline cases incl. manager's budget-regression
  repro, read/corrupt/missing rejection, record-then-raise/cancel fake ws,
  guard_stop across restart

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 09:02:51 +08:00
总工andmultica-agent ccfc04edcd HEL-906: close r5-followup-package §4 blocker, register milestone, add handoff
- §4: ASR audio/retry rows now program-gated (pre-send/pre-connect refusal
  when armed, fail-closed default); mechanism, arming env, reproducible
  verification commands, program-guarantee vs monitor-only boundary
  (bounded cross-process staleness, bounded crash undercount, .12 real-stack
  pre-window check still required); wall-clock proxy demoted to lower-bound
  cross-check; first-line status updated
- milestone table: register hel-906-asr-usage-counting @ ecec9df (candidate,
  PR not merged, pending 总管 review)
- handoff-HEL-906.json (v1.12, read at 0b804a4); requirement checks 4/4 pass
  (origin/main and 0b804a4, with/without handoff)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 08:46:07 +08:00
总工andmultica-agent ecec9df8e8 HEL-906: continuous-call ASR usage metering + one-shot window guard in dedicated worker seam
- asr_usage.py (stdlib-only): append-only JSONL ledger, per-call/window audio
  seconds counted from actually-sent frames (sample-rate aware, resend counted),
  connect attempt/failure/retry semantics (normal per-utterance reconnects are
  not retries), fail-closed default (real cap=0 unless a window is armed),
  pre-send blocking so no byte crosses the cap, ledger replay for restart
  recovery and cross-window isolation, resumable summarize CLI
- stt_funasr.py: guard hooks on the real send path (check_connect before
  open_utt, precheck before send, count after send, recv-side death recorded,
  per-utterance flush aggregation); usage=None keeps prior behavior
- worker.py: entrypoint builds guard from env per job, room name as call id,
  on_exhausted -> ctx.shutdown ends the call, window summary in start log
- tests: 17 deterministic offline tests incl. fake livekit/websockets SDK
  driving the real _run seam, sample-rate variants, silence/cancel/failure/
  reconnect/repeated-end, cap rejection before crossing, ledger persistence
  across restart, room deletion keeps history, other windows unaffected

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 08:44:20 +08:00
总工andmultica-agent 0b804a49ef HEL-905: clarify verified baseline vs registration commits in milestone table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:41:25 +08:00
总工andmultica-agent fdfa3424b7 HEL-905: register final verified head 03cec26 in follow-up package milestone table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:41:12 +08:00
总工andmultica-agent 03cec2679e HEL-905: unified review handoff + follow-up package honesty fixes (first-line blocker, register_asr scope, ledger jq boundary, mic-interrupt T0 time base)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:40:57 +08:00
总工 7cb73429f7 HEL-905: integrate HEL-904 backend rework (recoverable register-failure mapping, call-window voice pin) 2026-10-28 01:14:35 +08:00
总工 7c387db0a0 HEL-905: integrate HEL-903 preserved frontend rework (author source 51669aa, 11 files byte-preserved) 2026-10-28 01:14:32 +08:00
总管andmultica-agent 088ac621dc HEL-903: retain author log bytes without newline conversion
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:11:54 +08:00
总管andmultica-agent 3efecced3f HEL-903: record exact source preservation and four requirement checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:11:41 +08:00
总管andmultica-agent 51669aae6d HEL-903: preserve visual author voice status, call voice and recovery fixes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:10:48 +08:00
施工员2号andmultica-agent 07dda2bfa2 HEL-904: backend rework — recoverable register-failure mapping, worker-state corruption docstring, call-window voice pin closes capture/worker race
- register(): local metadata write failure -> LocalRecordFailed -> 503 Chinese
  message with the cg- id (shared registration NOT rolled back, refresh list to
  recover, never re-register); register_asr journaled BEFORE local persist so
  failed registrations still count in the resource ledger
- write_worker_voice(): docstring now states the whole-file rewrite on
  unreadable/corrupt state (other keys preserved only when parseable)
- capture->worker race closed with a session-immutable-snapshot equivalent:
  _capture_voice holds the captured voice (no await gap); apply_selection
  defers its worker-state write while any call is live; open_session releases
  the hold on failure/cancel; VoiceSessionService.on_session_settled releases
  on explicit end, end-into-debt and TTL expiry; response voice == the voice
  the worker actually reads for that call, mid-call changes affect the next
  call only; overlapping-call residual documented honestly
- tests: hel902 fault-injection case 1 rewritten for the new contract
  (recovery + restart + 409 gate kept), 4 new deterministic Event-gated race
  tests (worker read delayed past dispatch, concurrent apply, failure/cancel
  cleanup, next-call voice), zero sleep-based assertions
- docs: contract §2.2/§2.5/§3 updated; r5 followup package tightened (head
  milestones d167ba9/b09a907/pending-review, register ASR vs call ASR split,
  call-ASR counting means + honest pending blocker, single-timebase frame
  silence measurement, D1 female_soft reference audio pinned by sha256,
  recycling checklist, shared-TTS snapshot/restore, git-revert is not
  recycling, caps count failures/retries/outbound-no-response)
- evidence: pytest 380/380 (376 baseline + 4), verify chain 12/12, ruff clean,
  requirement checks 4/4 (origin/main and b09a907, with/without handoff)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:09:19 +08:00
267 changed files with 16686 additions and 152 deletions
+5
View File
@@ -23,6 +23,9 @@ tiers:
api_key_env: null
timeout: 60.0
temperature: 0.85
# HEL-908/909:该端点会把隐式开头思考(无 <think>、模板预填)混入 content,
# 只能整轮缓冲后分离;首段可见文本因此等整轮生成完成(见 reasoning.py)。
reasoning: buffer_turn
# ---- 中档:可以指向同一个模型,但配更短的上下文预算 ----
# 省钱的杠杆通常不是换更小的模型,而是少喂上下文。
@@ -33,6 +36,7 @@ tiers:
api_key_env: null
timeout: 40.0
temperature: 0.7
reasoning: buffer_turn
# ---- 本地小模型:后台任务的主力 ----
# 没有本地服务时,把它指向 openai_compat 的远端小模型即可。
@@ -43,6 +47,7 @@ tiers:
api_key_env: null
timeout: 20.0
temperature: 0.4
reasoning: buffer_turn
# ---- 场景 → 档位 ----
# 改这里就能调成本结构,不用改代码。
+393
View File
@@ -0,0 +1,393 @@
# HEL-913 · 统一体验运行包(固定版本,未部署、cap=0)
> 本目录是把已通过 HEL-912 审核的统一候选装成的**可交接运行包**:精确源码
> 版本、产物与哈希、脱敏配置模板、唯一启动方式、独立空资料空间、停止/
> 备份/保留资料回退说明、一次零真实调用的运行准备验证记录、真实记忆的
> 完整可审安装清单,以及语音窗口的唯一布防接线。**本卡未部署任何现网
> 服务、未监听 836x/8082/9880/8090、未改任何现有实例;实际启用与新用量
> 由总管统一向白栖知说明并获批准后再执行。**
## 0. 精确版本:产品基线 vs 运行包提交(不要混同)
- **已审产品基线**:`agent/2/8e99cc2cc592` @ `4a38106eef21c43ba24ab7d07992b1bfe539137d`
(HEL-911 接缝修复;HEL-912 审核「通过,无必须修复项」)。父线:
`407fc81`(HEL-910 窗口记录)← `958c696`(HEL-908 R1/R2 返工)。
**产品代码(src/web/config/tools/tests)以这个提交为准。**
- **运行包提交**:同一分支上、`4a38106` 之后的追加提交——只新增/修改
`deploy/hel913-unified/` 与 `docs/` 下文件,**不改任何产品代码**。交付
评论给出运行包精确 head;本文件自身就在该提交里,所以不能在文件内
写死自己的哈希。部署**照抄入口是运行包提交**(本目录随它一起走),
只 checkout `4a38106` 会没有本目录——这是收口修正点,别再踩。
- **部署前自校验**(两条都必须通过):
```bash
git merge-base --is-ancestor 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD && echo ok
git diff --stat 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD -- src web config tools tests # 必须为空
```
- 需求基准 v1.12(`docs/requirements/`),需求检查见 `handoff-HEL-913.json`。
## 1. 交付物清单(本目录)
| 文件 | 作用 |
| --- | --- |
| `README.md` | 本运行说明(唯一照抄入口;不跳旧 HEL-895 通用步骤) |
| `manifest.json` | 机器可读清单:产品基线、dist 哈希、环境引用、边界 |
| `dist-manifest.json` | web dist 8 文件 SHA256(复用 HEL-910 实测构建) |
| `models.template.yaml` | 脱敏模型/预算配置(**一切预算=0**;占位符标注待批写入位置) |
| `voice.template.yaml` | 脱敏语音配置(livekit_url 必须是 wss 桥地址,见 §5) |
| `worker.env.template` | 语音 worker 环境模板(含 ASR 窗口闸三项,默认未布防=拒绝一切 ASR) |
| `voice-window-arm.template.env` | 语音窗口一次性布防参数模板(默认全 0/未布防) |
| `api_ctl.sh` / `worker_ctl.sh` | 统一实例 API(单进程 HTTP+HTTPS)与语音 worker 启停(身份核验版) |
| `bridge_ctl.template.sh` | wss TLS 桥启停(获批语音窗口用;浏览器侧语音的前提) |
| `tts_ctl.template.sh` | 共享 TTS 9880 窗口内临时恢复/停回(只停自己起的 PID) |
| `force_end.template.sh` | 窗口到期强制收尾(`now` 立即 / `watch <epoch>` 到点;封窗停服) |
| `verify_offline.py` | 零真实调用运行准备验证(34 项;TestClient 进程内,不监听端口) |
| `test_ctl_scripts.sh` | 启停脚本离线替身检查(42 项;stub,零真实服务) |
| `mem0-cp311-wheels.txt` | 真实 Mem0 记忆栈 cp311 缺项轮清单(40 项,待批一次性下载) |
运行记录:`docs/rework/hel913/verify-offline-run.txt`(.12 产品 3.11 venv
实跑 34/34)、`docs/rework/hel913/ctl-checks-run.txt`(收口轮启停检查 42/42)。
## 2. 版本固定与产物复用
- **源码**:产品基线 `4a38106`(含 `requirements.lock` 19 项固定版本,
Python 3.11 实测)+ 运行包提交(仅本目录与 docs)。
- **web dist 复用**:`git diff 958c696 4a38106 -- web/` 为空(网页源码零变动),
直接复用 HEL-910 已实测部署的 dist(.12 `/opt/cybergril-hel910/current/web/dist`,
REVISION 标记 `958c6962…`),只读拷贝,不安装 node、不重建、不跳任何会
`npm install` 或创建旧端口实例的旧发布步骤(如 `deploy/hel895-release-candidate.md`
的通用总装流程——那不是本包的入口)。8 文件哈希见 `dist-manifest.json`
(2026-10-29 于 .12 逐文件实测),部署时核对一致即可。
- **解释器(既有环境引用,不新建、不增改)**:
- 产品 API:`.12 /opt/cybergril-hel879/current/.venv311`(Python 3.11.16,
requirements.lock 全量,fastapi 0.159.1 / uvicorn 0.54.0 / httpx 0.28.1 /
pydantic 2.14.1)。**只借用运行,绝不向它安装任何东西**(记忆栈装进
本实例自己的隔离 venv,见 §7)。
- 语音 worker:`.12 /opt/voice-agent/agent/venv`(livekit-agents 1.8.8,
含 silero VAD)。
- 证书:`.12 /opt/cybergril-voice-certs/cybergril-voice.{pem,key}`(既有
自签,覆盖 192.168.200.12;8361 与 wss 桥 8363 共用)。
- 本机 LiveKit `ws://127.0.0.1:7880`(常驻);TTS 9880 / 面板 8090 /
wss 桥 8363 只在获批窗口内启动(§5),未获批保持停机。
## 3. 目标实例布局(唯一布局;用户实例 data 必须为空)
```
/opt/cybergril-hel913/ # 实例根(名字可换;以下结构固定)
current/ # git checkout <运行包提交>(见 §0,不是 4a38106)
web/dist/ # 按 dist-manifest.json 核哈希后放入(只读复用件)
etc/
models.yaml # 由 models.template.yaml 复制,填入获批网关与预算
voice.yaml # 由 voice.template.yaml 复制,填入服务器本地值
semantic.yaml # (仅批准真实记忆时)由 config/semantic.example.yaml 复制,见 §7
data/ # 独立空资料空间:新库从零初始化
worker/.env # 由 worker.env.template 复制并填好(占位全清)
logs/
```
**不打包进她的正式档案**:任何历史测试库(8360 团队库、HEL-907/910 窗口库)、
聊天记录、LiveKit/TTS 凭据、自签证书私钥、agent venv 内实物。用户实例在用户
亲自创建前 `companion` 表必须为空(离线验证第 3 项复现该守门)。
## 4. 首次准备与启停(唯一照抄入口)
### 4.1 首次准备(获批部署窗口内执行一次;不真启动服务)
```bash
# 0) 取运行包提交(精确 head 见交付评论;下述校验两条都要过,见 §0)
git clone -b agent/2/8e99cc2cc592 ssh://git@192.168.200.36:222/leefer/CyberGril.git \
/opt/cybergril-hel913/current
cd /opt/cybergril-hel913/current
git checkout --detach <本轮交付评论的运行包完整提交号>
git merge-base --is-ancestor 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD
git diff --stat 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD -- src web config tools tests # 空
# 1) 目录骨架(固定布局)
mkdir -p /opt/cybergril-hel913/{etc,data,logs,worker}
# 2) 配置落位(占位符必须全部替换;预算值未经批准一律保持 0)
cp deploy/hel913-unified/models.template.yaml /opt/cybergril-hel913/etc/models.yaml
cp deploy/hel913-unified/voice.template.yaml /opt/cybergril-hel913/etc/voice.yaml
cp deploy/hel913-unified/worker.env.template /opt/cybergril-hel913/worker/.env
vi /opt/cybergril-hel913/etc/models.yaml /opt/cybergril-hel913/etc/voice.yaml /opt/cybergril-hel913/worker/.env
# 3) web dist 只读复用(核哈希后拷入;不装 node、不重建)
rsync -a /opt/cybergril-hel910/current/web/dist/ /opt/cybergril-hel913/current/web/dist/
python3 - <<'EOF'
import hashlib, json, pathlib
m = json.loads(pathlib.Path("deploy/hel913-unified/dist-manifest.json").read_text())
bad = [k for k, want in m.items()
if hashlib.sha256((pathlib.Path("web/dist")/k).read_bytes()).hexdigest() != want]
raise SystemExit(f"dist hash mismatch: {bad}" if bad else 0)
EOF
# 4) 未创建档案守门复验(进程内 TestClient,零监听零出站;34 项全过才继续)
PYTHONPATH=src /opt/cybergril-hel879/current/.venv311/bin/python \
deploy/hel913-unified/verify_offline.py --web web/dist
```
### 4.2 启动与停止(唯一方式;子命令只认 start|stop|status)
```bash
# API:单进程双入口(HTTP 8360 + HTTPS 8361 共用同一 app 状态;lifespan 只进一次)
cd /opt/cybergril-hel913/current
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/api_ctl.sh start
# 健康检查:curl -s http://127.0.0.1:8360/api/health(version=R4-assembly,
# semantic_backend=stub;llm.ok=false 是诚实状态——cap=0 且未配网关)
# 语音 worker(语音窗口获批后;接线与前置见 §5)
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh start
# 停止顺序:先 worker 后 API(语音会话先释放,再关统一进程)
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh stop
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/api_ctl.sh stop
```
启停脚本安全口径(收口版,`test_ctl_scripts.sh` 42 项离线替身验证):
非法子命令拒绝退出 2,绝不静默当 start;重复 start 拒绝(不丢既有 pid
句柄);stop/失败收尾只杀**身份核验通过**(cmdline 含本启动器/模块名且
cwd 是本实例目录)的进程,PID 被复用时拒杀并报告;启动健康/注册检查
不过时清掉自己刚起的进程、保留日志退出 1;`worker_ctl.sh` 启动前拒绝
含未填 `<...>` 占位或解析失败的 `.env`。
同一数据目录**绝不允许**两个写入实例并存(双入口由 `deploy/voice/serve_dual_8360_8361.py`
在同一进程内保证;另起独立 uvicorn 会触发 lifespan 重入 fail-closed)。
## 5. 语音窗口唯一接线(获批窗口才动;默认全 0/未布防)
浏览器从 HTTPS(8361) 页面连 LiveKit **必须走 wss**(混合内容策略),
`voice.yaml` 的 `livekit_url` 因此必须是 wss 桥地址(默认拟 `wss://192.168.200.12:8363`,
`bridge_ctl.template.sh` 启停,与 8361 同证书、回环转发 7880)——**不是
可选件**;服务器内部(开房/删房/worker 注册)仍走 `livekit_internal` 的
本机明文 7880。窗口内各上限的唯一接线:
| 上限 | 接线(唯一) | 性质 |
| --- | --- | --- |
| ASR 音频总量 / 每通重连 | `worker/.env` 三项 `CYBERGRIL_ASR_WINDOW_ID`、`CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS`、`CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL`(空/0=拒绝一切 ASR;布防值见 `voice-window-arm.template.env`) | 程序硬闸,发送前拒绝(`src/cybergril/voice/asr_usage.py`,逐通落账) |
| 每通墙钟/房间寿命 | `voice.yaml` `session_ttl_seconds: 600` + token TTL 300;挂断即删房(失败留账重试) | 程序硬闸 + 到期失效 |
| TTS 段数 | `tts_ctl.template.sh` start 时记录 tts.log 起始字节,`grep -c "first chunk"` 对照窗口段数上限 | 监控口径(到顶即人工 force_end;程序不硬闸) |
| 窗口总时限 | `force_end.template.sh watch <deadline>` 到点强制收尾(DELETE 全部会话→worker→api→bridge→tts,各按专属句柄);更可靠的等价物是获批后装 systemd timer 调 `now` | 可执行停服,非口头承诺 |
窗口启动顺序:tts_ctl snapshot→start → bridge_ctl start → 改 `worker/.env`
布防三项并 worker_ctl start → api_ctl start(见 §4.2)→ force_end watch
挂上。窗口结束(到期/触线/人工):force_end now → 按 `seal_budget_hel879.py`
封账归档(**封账不解除**,旧三窗口余额也不结转进新窗)。
## 6. 备份与保留资料回退
- **发布/阶段归档**(本包口径,沿用 HEL-896 收口工具,一致性快照):
```bash
# 停服口径(推荐;目录内 SQLite 一并 checkpoint+逐表核对)
bash deploy/hel913-unified/api_ctl.sh stop
/opt/cybergril-hel913/venv311/bin/python tools/release_snapshot_hel896.py snapshot \
--name <阶段名> --db /opt/cybergril-hel913/data/companion.db \
--file /opt/cybergril-hel913/data --out /opt/cybergril-hel913/archives/
```
产物含逐文件 SHA256、代码精确提交、逐表行数与回退演练;旧归档不删。
- **回退**:停服 → 解包快照到新 data 目录(按 rollback_plan 源路径映射)→
`current` 切回上一运行包提交(`git checkout <旧head>`)→ 起服务 → `verify` 通过。
- **整实例撤销但保留资料**:`api_ctl.sh stop`,data/ 原样保留,重新启用 =
同一 data 目录再 start(epoch 递增、聊天记忆保留,离线验证第 11 组复现)。
- **CG-009 每日自动备份/用户手动完整导出(R6)尚未实现**,本节只是发布归档,
不得冒充 R6 完成。
## 7. 零真实调用运行准备验证(已执行,34/34;口径如实)
在 .12 产品 3.11 venv 对产品基线树实跑 `verify_offline.py`(进程内
TestClient,零监听、零出站——网关地址重写为 127.0.0.1:1 + provider.stream
出站哨兵双保险):
- 包完整性:Python 3.11.16、候选树 import、**4 个关键包版本打印**
(fastapi/uvicorn/httpx/pydantic——不是 19 项锁依赖的逐项核对)、记忆栈
四项缺置如实。
- 入口/创建守门:health 诚实(llm.ok=false)、空库 404 无伪造档案、预览
本地计算、创建 201、二次创建 409、底色落库与预览一致、persona 在 cap=0
下诚实 failed(无编造诠释)。
- 传输:SSE 对话在预算 0 下诚实 `chat.error`,零 provider 出站;失败轮如实
落库;同轮重试幂等拒绝。
- 日子与回忆:**空库上的**未分享生活服务端裁剪(told=1;未做正反种子对照,
不夸成实质权限全覆盖)、分页形状、未知回执/详情 404。
- 语音:status 诚实 voice=false;TTS 不可达时开房 503 拒绝(绝不建房);
未知会话 DELETE 404。
- 重开:epoch=2、身份/关系/聊天保留、无伪造回复。
- dist:GET / 出 index.html;8 文件哈希与 manifest 全符。
- worker 接线:agent venv(livekit-agents 1.8.8)下 `import cybergril.voice.worker`
通过(只读 import,未启动注册)。
**预算 0 下只能诚实拒绝的**:一切真实创建诠释/习惯、对话生成、生活事件
生成、判定/成长/gist、主动措辞、嵌入、语音开房。`/api/health` 的 200 不代表
模型已运行——`llm.ok=false` 才是诚实状态。
## 8. 真实记忆(Mem0)最少安装与接线 —— 完整可审清单(零安装零下载,待批)
### 8.1 现状(2026-10-29 只读实测,收口轮复核沿用)
- 服务器 .12 产品 `.venv311`(3.11.16)内 `mem0 / qdrant_client /
fastembed / onnxruntime` 四项均未安装;服务器全盘无 mem0 安装、无固定
tarball、无已建轮、无 fastembed 缓存、uv 缓存仅 28K。**没有另一个已
准备好的 3.11 兼容环境**;旧 3.10 `hel857-venv`(工作站)不能混入 3.11
进程。
- 工作站本地实物(未上服务器):固定源 tarball `mem0-2c81af3.tar.gz`
(sha256 `9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23`,
与 `deploy/mem0-stack.lock` 一致)、固定源已建轮
`mem0ai-2.2.1-py3-none-any.whl`(sha256
`698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db`)、
fastembed 模型缓存 92MB(Qdrant/bge-small-zh-v1.5 + bm25)、uv 缓存可
复用 3 轮(greenlet/pillow/truststore cp311)。
### 8.2 安装目标环境:本实例自己的隔离 venv(不是产品 .venv311)
**绝不向借用的 `/opt/cybergril-hel879/current/.venv311` 增改任何依赖。**
目标环境是 `/opt/cybergril-hel913/venv311`(服务器既有 python3.11 基座
新建,3.11.16),装两套东西:产品锁 19 项 + 记忆栈 41 项(40 缺轮 + 固定
源 mem0ai 轮)。装好后本实例 API 用它跑(`api_ctl.sh` 里
`HEL913_VENV=/opt/cybergril-hel913/venv311/bin/python`),与 hel879 实例
彻底隔离;`.venv311` 保持只读借用前的原样(部署后可直接改回借用口径,
两者兼容——同一 requirements.lock)。
### 8.3 一次性批准窗口内的完整命令(逐项哈希留证;任一步失败即停)
```bash
# W0 · 固定源身份核对(零下载;哈希不符立即停,禁用索引同名轮替代)
sha256sum <hel857-data>/mem0-2c81af3.tar.gz # 必须等于 9dcef930…2dda23
sha256sum <hel857-data>/mem0ai-2.2.1-py3-none-any.whl # 必须等于 698284b6…20e9db
# W1 · 获批下载窗口(工作站联网执行;cp311/manylinux2014_x86_64 定版)
mkdir -p wheels-product wheels-mem0
python3 -m pip download --only-binary :all: --implementation cp \
--python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 \
-r requirements.lock -d wheels-product/ # 产品锁 19 项
python3 -m pip download --only-binary :all: --implementation cp \
--python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 \
-c requirements.lock -r deploy/hel913-unified/mem0-cp311-wheels.txt -d wheels-mem0/ # 记忆栈 40 项
# 其中 greenlet/pillow/truststore 3 项可从工作站 uv 缓存离线复用
# (release-artifacts.json 记录),命中即从 wheels-mem0/ 删除同版本再放缓存件
cp <hel857-data>/mem0ai-2.2.1-py3-none-any.whl wheels-mem0/
( cd wheels-product && sha256sum * > ../wheels-product.sha256 )
( cd wheels-mem0 && sha256sum * > ../wheels-mem0.sha256 ) # 逐项留证入库
# W2 · 传输与落位(scp 到 .12;约 wheels 250–350MB + 模型缓存 92MB)
scp -r wheels-product wheels-mem0 wheels-product.sha256 wheels-mem0.sha256 <hel857-data>/fastembed-cache root@192.168.200.12:/opt/cybergril-hel913/
ssh root@192.168.200.12 'cd /opt/cybergril-hel913/wheels-product && sha256sum -c ../wheels-product.sha256 && \
cd ../wheels-mem0 && sha256sum -c ../wheels-mem0.sha256' # 传输后复核
# W3 · 隔离 venv 创建 + 仅本地轮安装(--no-index 全程,失败即停)
ssh root@192.168.200.12
/opt/cybergril-hel879/runtime/cpython-3.11.16/bin/python3.11 -m venv /opt/cybergril-hel913/venv311
/opt/cybergril-hel913/venv311/bin/python -m pip install --no-index \
--find-links /opt/cybergril-hel913/wheels-product -r /opt/cybergril-hel913/current/requirements.lock
/opt/cybergril-hel913/venv311/bin/python -m pip install --no-index \
--find-links /opt/cybergril-hel913/wheels-mem0 \
--find-links /opt/cybergril-hel913/wheels-product \
-c /opt/cybergril-hel913/current/requirements.lock \
mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1
# mem0ai 的安装来源必须是 W0 的固定源码轮(同版本号不同来源不互认)
# W4 · co-import 冒烟(同进程;失败即停,不降级)
cd /opt/cybergril-hel913/current
MEM0_TELEMETRY=False HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 PYTHONPATH=src /opt/cybergril-hel913/venv311/bin/python -c \
"import cybergril.api, mem0, qdrant_client, fastembed; print('co-import ok')"
# W5 · 模型缓存落位(真实本地嵌入零下载)
mkdir -p /opt/cybergril-hel913/data/sem
cp -a /opt/cybergril-hel913/fastembed-cache /opt/cybergril-hel913/data/sem/fastembed-cache
# api_ctl.sh 通过 FASTEMBED_CACHE_PATH 指向它,模型资源禁止联网获取。
# W6 · 清理暂存(轮文件传输件用毕即删;哈希清单留档)
rm -rf /opt/cybergril-hel913/wheels-product /opt/cybergril-hel913/wheels-mem0 /opt/cybergril-hel913/fastembed-cache
```
**空间/影响(.12 实测口径估)**:`venv311` 成品约 450–550MB(产品 19 项
约 120–150MB + 记忆栈约 330–400MB,参照 3.10 同栈实测 366MB)+ 传输暂存
约 350MB(W6 删除)+ 模型缓存 92MB(常驻 data/sem)。首次本地嵌入零下载
(缓存就位)。**当前不触发任何嵌入**:装完不批 B 预算时
`CYBERGRIL_SEMANTIC=stub`,长期检索走词面 FTS,不以 stub 冒充真实 Mem0。
### 8.4 记忆账本与配置接线(新空用户库续账,不搬测试聊天)
- `CYBERGRIL_SEMANTIC`(api_ctl.sh 的 `HEL913_SEMANTIC`):`stub`(默认,
全链可跑、非语义)→ 批准后 `mem0`(真实)。
- 配置:`cp config/semantic.example.yaml /opt/cybergril-hel913/etc/semantic.yaml`,
启动器显式传 `CYBERGRIL_SEMANTIC_CONFIG=$BASE/etc/semantic.yaml`(不再误读仓库默认路径)。
`MEM0_TELEMETRY=False`、`FASTEMBED_CACHE_PATH=$BASE/data/sem/fastembed-cache`、
`HF_HUB_OFFLINE=1`、`TRANSFORMERS_OFFLINE=1` 由启动器在 import 前设置。
填 `ledger_path=/opt/cybergril-hel913/data/embed_ledger.sqlite3`、
`vector_root/history_root=/opt/cybergril-hel913/data/sem/{vec,hist}`、
网关三键(与 models.yaml 同源)、`authority` 块。
- **权威旧账续账(498 已用 / 原批 500 / 冻结 2 不动)**:把 HEL-857 系列
唯一权威嵌入账文件复制为实例 `data/embed_ledger.sqlite3`(只复制账本,
**不搬任何测试聊天/向量**——聊天在旧测试库,向量在新库从零生成);
`authority` 按 `config/semantic.example.yaml` 算式填
`prefix_rows: 181`、`prefix_sha256: <权威账前 181 行逐行摘要,总管按账现值填>`、
`min_used: 498`。批准 B 嵌入 180 条后写累计口径
`embed_cap: 680`、`embed_reserve: 2`(可用 = 680−498−2 = 180;**禁止**
把 180 直接填成 cap、禁止动冻结 2 条;缺/错 authority 在构造前拒绝,
指向新空文件重开余额同样被拒——`src/cybergril/memory/semantic.py`
`verify_ledger_authority`)。
- **不把旧实验余额当新用量**:39/60(HEL-857 真实请求旧账)与旧三窗口
余额一律不结转;用户实例的 LLM/请求账从 0 起记(自己的 SQLite 日账)。
- **副作用如实分开**:真实 embed(写入向量库+权威账)、SDK 内部提取 LLM
(`llm_ops` 独立 cap + 统一请求闸 "semantic" 类)、co-import 冒烟(零
调用,只 import)——三者互不冒充;当前阶段三者全部 0。
## 9. 本包不做的事
不部署、不监听现网端口、不恢复 9880/8090/836x/8082、不触 4080、不下载任何
依赖/模型/浏览器、不改模型/认证/运行时绑定、不合并 PR、不替换正式界面、
8300/8310/8320/8350 与既有资料不动、8340/ComfyUI 保持停机、不向
`/opt/cybergril-hel879/current/.venv311` 增改依赖。旧三次测试窗口余额不
结转;一切提额、安装、部署都是待批准建议。
## 10. 总管独立取件勘误与最终执行约束(2026-10-29)
本节优先于上文笼统模板描述,当前仍全部待批,零真实调用。
- 运行包基线8ea5fdc之后仅机械收口:补语义配置路径/缓存/禁遥测离线变量;
force_end按真实持久化sessions列表取session_id;补桥/TTS非法命令拒绝和失败收尾;
传输hash清单与目标工作目录、既有3.11解释器路径纠正;每通TTL改600秒与10分钟一致。
- 40项记忆缺轮与产品19项锁是现有版本清单。下载使用两份requirements合并约束,
不得让第二次下载/安装解析器静默换掉产品锁版本:W1增加`-c requirements.lock`,
W3记忆安装同时传`--find-links .../wheels-product -c .../requirements.lock`。
解析冲突/缺cp311轮即停并留首错,不自动换版本、不引入新模型。
- B记忆日顶明确为embed_daily=60、llm_daily=40(SDK提取另受累计llm_cap=60),
authority/累计embed_cap=680、reserve=2原样。语义日账按UTC(北京时间08:00)
切日,产品对话/生活日账显式TZ=Asia/Shanghai按北京时间00:00切日,不能混说。
- 不运行测试脚本里的失管sleep替身作为长期任务。未来实际截止保护用获批后的
systemd一次性定时服务(服务/日志/停机句柄可查);不能把本轮本地watch留到结束后。
部署卡执行`systemd-run --unit=cybergril-hel913-expiry --on-calendar="<明确UTC截止时间>" \
/usr/bin/env HEL913_BASE=/opt/cybergril-hel913 /usr/bin/bash \
/opt/cybergril-hel913/current/deploy/hel913-unified/force_end.template.sh now`,
放行前必须核`systemctl list-timers cybergril-hel913-expiry.timer`及临时实例停止句柄。
这是未来待批动作,本轮没有创建服务/定时器。
- 语音为团队陪同的3次预约短用,每次600秒,总ASR1800秒,建房尝试最多6次,
每通ASR重连最多2次,TTS60段及声音登记+试听合计10次由当班执行者监督计数。
这些软上限不冒充程序硬闸;不在无人值守时开启语音窗口。
TTS段顶/语音配额到顶只结束语音,B文字生活仍可用;到期/用户叫停才停全实例。
自定义声音需要既有8090面板的实例专属启动句柄与前后快照,
复用HEL907已有面板启动方法,不动8081;句柄或快照未备齐不放行上传/试听,
默认已有工程声音仍能通话。共享voices列表需恢复前快照,自定义参考材料在独立实例保留。
- 到期脚本负责停本实例入口与自己启动的语音服务。封账归档、LiveKit只读核空、
voices快照恢复由当班执行者另行执行并核实,不能把日志里的done当全部清理完成。
清理失败保留原始错误/账/日志,不解除封账、不重启服务来“证明已停”。
自定义声音细分额度(同一次待批方案):最多登记2段参考音频,每段由陪同人员先核≤20秒;
自动转写最多2次/合计40秒,单列监督账,不冒充通话ASR的1800秒硬闸。
最多试听8次;登记2次+试听8次共10次。试听合成并入60段TTS总上限。
两类ASR合计最大1840秒(通话1800+参考40);失败/重试同样记账,不返余额。
本轮未执行登记、转写或试听。
### 最终统一预算勘误:团队先验真实记忆,再交用户(总管)
此条取代上文A“0嵌入”的旧建议,仅修改待批方案,当前真实额度仍0。
团队先用独立测试资料核真实Mem0接入,不把首次故障留给用户:
A的55请求/50万tokens内,最多10次用于SDK提取;先用最多20条新嵌入。
这10次/20条从同一批新申请的60次提取/180条嵌入中先扣,
不是另加额度;余量(至少50次提取/160条嵌入)留给用户期,使用实账计算。
两阶段共用唯一活动记账文件、串行写入,测试向量/档案与用户向量/档案分开,
只共用计数账,不拷贝测试聊天或测试记忆给用户。旧权威embed_ops前缀不改,
旧39/60实验的授权和账原封保存,不挪用其余额;新提取批建立独立可审计操作记录。
如旧llm_ops表已有用量,新60次必须按现值加增量并保存旧行,不可清表/重置;
操作前核旧基数并留证,实际新增始终≤60。团队检查最多2小时,从首个真实调用计时;
检查不通过则停止并报告首个错误,用户期不自动开始。
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# HEL-913 统一体验运行包 · 统一实例 API 启停(单进程 HTTP+HTTPS 双入口)
#
# 启动器 deploy/voice/serve_dual_8360_8361.py 保证 HTTP 与 HTTPS 共用同一
# API 进程/同一份状态(lifespan 只进一次)。本脚本只做本实例常规启停安全:
# - 子命令只认 start|stop|status,其他一律拒绝退出 2(不会静默当 start);
# - start 前核对 pid 句柄:本实例已在跑 → 拒绝重复 start(不覆盖句柄);
# 句柄 pid 活着但不属于本实例(PID 复用)→ 拒绝动手,提示人工处理;
# - stop/start 失败收尾只杀经身份核验的进程(/proc/<pid> 的 cmdline 含
# 本启动器且 cwd 是本实例代码目录),身份不符一律不杀;
# - start 健康检查不过 → 清掉自己刚起的进程、保留日志、退出 1。
#
# 用法: [ENV=..] bash deploy/hel913-unified/api_ctl.sh start|stop|status
# 环境变量(默认值按部署说明的实例布局):
# HEL913_BASE 实例根目录(含 current/ etc/ data/ logs/)
# HEL913_HTTP HTTP 端口(默认 8360)
# HEL913_HTTPS HTTPS 端口(默认 8361;语音页需要 secure context)
# HEL913_VENV 产品 3.11 venv python(默认 /opt/cybergril-hel879/current/.venv311/bin/python)
# HEL913_CERT / HEL913_KEY 证书路径(默认 /opt/cybergril-voice-certs/ 下既有证书)
set -u
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
CODE=${HEL913_CODE:-$BASE/current}
HTTP=${HEL913_HTTP:-8360}
HTTPS=${HEL913_HTTPS:-8361}
VENV=${HEL913_VENV:-/opt/cybergril-hel879/current/.venv311/bin/python}
CERT=${HEL913_CERT:-/opt/cybergril-voice-certs/cybergril-voice.pem}
KEY=${HEL913_KEY:-/opt/cybergril-voice-certs/cybergril-voice.key}
LOG=$BASE/logs/api.log
PIDF=$BASE/logs/api.pid
MARKER="serve_dual_8360_8361.py"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
# 进程身份核验:pid 存活、cmdline 含本启动器、cwd 是本实例代码目录。
# 任一不满足即视为“不是我们的进程”(含 PID 被系统复用),一律不动它。
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$CODE" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then
echo "api not running (no pid file)"
return 0
fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p is alive but is NOT this instance's api (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then
kill "$p"
sleep 3
if ours "$p"; then kill -9 "$p"; sleep 1; fi
fi
rm -f "$PIDF"
echo "api stopped"
return 0
}
start() {
local p
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
if ours "$p"; then
echo "REFUSED: api already running (pid $p) — stop first" >&2
else
echo "REFUSED: pid file has live pid $p that is not this instance's api (pid reuse?) — resolve $PIDF manually" >&2
fi
exit 1
fi
rm -f "$PIDF" # 死句柄:清掉再起
[ -d "$CODE" ] || { echo "REFUSED: code dir missing: $CODE" >&2; exit 1; }
[ -x "$VENV" ] || { echo "REFUSED: venv python not executable: $VENV" >&2; exit 1; }
[ -r "$CERT" ] && [ -r "$KEY" ] || { echo "REFUSED: cert/key not readable ($CERT / $KEY)" >&2; exit 1; }
mkdir -p "$BASE/logs" "$BASE/data"
cd "$CODE" || exit 1
set -a
export CYBERGRIL_DB=$BASE/data/companion.db
export CYBERGRIL_SEMANTIC=${HEL913_SEMANTIC:-stub}
export CYBERGRIL_R2_ENABLED=${HEL913_R2:-1}
export TZ=Asia/Shanghai
export CYBERGRIL_SEMANTIC_CONFIG=$BASE/etc/semantic.yaml
export MEM0_TELEMETRY=False
export FASTEMBED_CACHE_PATH=$BASE/data/sem/fastembed-cache
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export CYBERGRIL_MODELS=$BASE/etc/models.yaml
export CYBERGRIL_VOICE_CONFIG=$BASE/etc/voice.yaml
export CYBERGRIL_WEB=$CODE/web/dist
export PYTHONPATH=$CODE/src
set +a
nohup "$VENV" deploy/voice/serve_dual_8360_8361.py \
--http-port "$HTTP" --https-port "$HTTPS" --cert "$CERT" --key "$KEY" \
> "$LOG" 2>&1 &
p=$!
echo "$p" > "$PIDF"
echo "api starting (pid $p) http=$HTTP https=$HTTPS"
sleep 6
if curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" >/dev/null \
&& curl -sk --max-time 3 "https://127.0.0.1:$HTTPS/api/health" >/dev/null; then
echo "OK: $HTTP + $HTTPS healthy"
return 0
fi
echo "FAIL: health check did not pass — full log kept at $LOG; tail:" >&2
tail -20 "$LOG" >&2
# 失败收尾:只清自己刚起的这个进程(身份核验),日志保留
if ours "$p"; then
kill "$p"; sleep 2
ours "$p" && kill -9 "$p"
fi
rm -f "$PIDF"
echo "cleaned up failed start (log kept: $LOG)" >&2
exit 1
}
status() {
local p
p=$(pidfile_pid)
if [ -n "$p" ]; then
if ours "$p"; then echo "running pid $p (identity verified)"
elif kill -0 "$p" 2>/dev/null; then echo "pid $p alive but NOT ours (stale pid file?)"
else echo "pid $p not alive (stale pid file)"; fi
else
echo "no pid file"
fi
curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" && echo " <- health" \
|| echo "health not answering"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) usage ;;
esac
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# HEL-913 运行包 · wss TLS 桥启停模板(8363 → 127.0.0.1:7880;脱敏待批件)
#
# 浏览器从 HTTPS(8361) 页面连 LiveKit 必须走 wss(混合内容策略),本桥在新
# 端口终结 TLS 并只向回环 7880 转发(字节级,协议无关;WebRTC 媒体面不经
# 本桥)。与 8361 同一张自签证书。改编自 HEL-910 已实测 bridge_ctl.sh;
# 本文件默认不运行,获批语音窗口才使用。子命令只认 start|stop|status。
#
# 用法: [ENV=..] bash bridge_ctl.template.sh start|stop|status
# 环境变量: HEL913_BASE / HEL913_BRIDGE_PORT(8363) / HEL913_CERT / HEL913_KEY
set -u
case "${1:-}" in start|stop|status) ;; *) echo "invalid subcommand" >&2; exit 2 ;; esac
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
PORT=${HEL913_BRIDGE_PORT:-8363}
CERT=${HEL913_CERT:-/opt/cybergril-voice-certs/cybergril-voice.pem}
KEY=${HEL913_KEY:-/opt/cybergril-voice-certs/cybergril-voice.key}
LOG=$BASE/logs/bridge.log
PIDF=$BASE/logs/bridge.pid
MARKER="wss_bridge.example.py"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$BASE/current/deploy/voice" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then echo "bridge not running (no pid file)"; return 0; fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p alive but not this bridge (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then kill "$p"; sleep 2; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
echo "bridge stopped"
}
case "${1:-}" in
stop) stop; exit $? ;;
status)
p=$(pidfile_pid)
if [ -n "$p" ]; then
ours "$p" && echo "running pid $p (identity verified)" \
|| { kill -0 "$p" 2>/dev/null && echo "pid $p alive but NOT ours" || echo "pid $p not alive (stale)"; }
else
echo "no pid file"
fi
exit 0 ;;
esac
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
echo "REFUSED: bridge already running (pid $p)" >&2; exit 1
fi
rm -f "$PIDF"
[ -r "$CERT" ] && [ -r "$KEY" ] || { echo "REFUSED: cert/key not readable" >&2; exit 1; }
cd "$BASE/current/deploy/voice" || exit 1
nohup /usr/bin/python3 wss_bridge.example.py \
--listen "0.0.0.0:$PORT" --upstream 127.0.0.1:7880 \
--cert "$CERT" --key "$KEY" > "$LOG" 2>&1 &
echo $! > "$PIDF"
echo "bridge starting (pid $(cat "$PIDF")) on :$PORT"
sleep 3
timeout 8 openssl s_client -connect "127.0.0.1:$PORT" -servername 192.168.200.12 </dev/null 2>/dev/null \
| grep -q "TLSv1.3" && echo "OK: bridge TLS up" \
|| { echo "FAIL: TLS probe failed — check $LOG"; tail -5 "$LOG"; stop; exit 1; }
+10
View File
@@ -0,0 +1,10 @@
{
"assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
"assets/index-B8TdHoQm.css": "a0af3163f564f2ff9cfe7d995378b7ac436bfe2f9fabda9f370ca0aa30c2412f",
"assets/index-Vm-EFPLb.js": "81f55e20c5c2afe4673ccc9020264a2bf99b3c84998c275f03fa48fe4f940bb0",
"demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
"index.html": "8d84f131b6922fe92ac0c562f5fec73aacd9c6e4db2cfa28f6190b4246f9c8d0",
"vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626"
}
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 窗口到期强制收尾模板(脱敏待批件;默认不布防)
#
# 用途:获批体验/语音窗口到截止时点的**可执行**停服与封窗:先经统一实例
# API 逐个 DELETE 本实例房间(欠清理的房间会留在登记账重试),再按专属
# pid 句柄停 worker → api → wss 桥 → 共享 TTS(都不是我们的就如实报告,
# 绝不按进程名/端口扫杀,绝不触碰 8300/8310/8320/8350、7880/7881、10095、
# 8081 等共享服务)。改编自 HEL-910 已实测 force_end.sh/watchdog.sh。
#
# 用法:
# bash force_end.template.sh now # 立即收尾(幂等,可重复)
# bash force_end.template.sh watch <epoch秒> # 到点自动收尾(受监督后台任务;
# # 更可靠的等价物是获批后装
# # systemd timer 调本脚本 now)
# 环境变量: HEL913_BASE / HEL913_HTTP(8360)
set -u
set -o pipefail
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
HTTP=${HEL913_HTTP:-8360}
PKG=$BASE/current/deploy/hel913-unified
LOG=$BASE/logs/force_end.log
usage() { echo "usage: [ENV=..] bash $0 now|watch <deadline_epoch_seconds>" >&2; exit 2; }
force_end() {
mkdir -p "$BASE/logs"
ts() { date -u +%FT%TZ; }
{
echo "[$(ts)] force_end begin"
# 1) 结清本实例房间(DELETE 走统一实例所有者路径;失败房间留账重试)
for sid in $(python3 - "$BASE/data" <<'EOF' 2>/dev/null
import json, sys, pathlib
p = pathlib.Path(sys.argv[1]) / "companion-voice-sessions.json"
try:
d = json.loads(p.read_text())
except Exception:
raise SystemExit
# VoiceSessionService._persist_state stores sessions as a LIST, not a dict.
# Debts already follow bounded SDK cleanup; do not reset their retry budgets here.
for entry in d.get("sessions", []):
sid = entry.get("session_id", "")
if sid and all(c.isalnum() or c in "-_" for c in sid):
print(sid)
EOF
); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -X DELETE "http://127.0.0.1:$HTTP/api/voice/session/$sid" || echo err)
echo "[$(ts)] DELETE session $sid -> $code"
done
# 2) 按专属句柄逐个停(每步幂等;不是我们的如实报告)
bash "$PKG/worker_ctl.sh" stop 2>&1 | sed 's/^/ worker: /'
bash "$PKG/api_ctl.sh" stop 2>&1 | sed 's/^/ api: /'
bash "$PKG/bridge_ctl.template.sh" stop 2>&1 | sed 's/^/ bridge: /'
bash "$PKG/tts_ctl.template.sh" stop 2>&1 | sed 's/^/ tts: /'
echo "[$(ts)] force_end done(封账归档另由 seal_budget_hel879.py 按账执行,封账不解除)"
} >> "$LOG" 2>&1
}
case "${1:-}" in
now) force_end ;;
watch)
DL=${2:?deadline epoch seconds required}
case "$DL" in *[!0-9]*|'') usage ;; esac
mkdir -p "$BASE/logs"
echo "[$(date -u +%FT%TZ)] watch start, deadline $(date -u -d "@$DL" +%FT%TZ) (pid $$)" >> "$LOG"
while [ "$(date +%s)" -lt "$DL" ]; do sleep 30; done
echo "[$(date -u +%FT%TZ)] DEADLINE REACHED — force end" >> "$LOG"
force_end
;;
*) usage ;;
esac
+86
View File
@@ -0,0 +1,86 @@
{
"task": "HEL-913",
"product_baseline_head": "4a38106eef21c43ba24ab7d07992b1bfe539137d",
"package_head": "运行包提交 = 本文件所在分支 tip(交付评论给出精确 hash;本文件无法写死自身提交)。部署照抄入口是运行包提交,只 checkout 4a38106 会没有 deploy/hel913-unified/",
"package_only_change_check": "git diff --stat 4a38106 <package_head> -- src web config tools tests 必须为空(运行包只动 deploy/hel913-unified 与 docs)",
"branch": "agent/2/8e99cc2cc592",
"review": "HEL-912 01a187e8-1058-7a2d-84e7-bd1896f212cb passed, no must-fix items (product baseline)",
"requirements_lock": "requirements.lock (19 pinned, Python 3.11.16 verified; offline verify printed 4 key package versions only — not a per-item lock audit)",
"web_dist_reuse": {
"source": "/opt/cybergril-hel910/current/web/dist (REVISION 958c6962fb1f2a4462a6df794d893aee1fcb0667)",
"justification": "git diff 958c696 4a38106 -- web/ is empty",
"manifest": "deploy/hel913-unified/dist-manifest.json (8 files, sha256 measured on .12 2026-10-29)"
},
"environments": {
"api_python_default": "/opt/cybergril-hel879/current/.venv311 (Python 3.11.16) — borrow-run only, NEVER install into it",
"api_python_after_mem0": "/opt/cybergril-hel913/venv311 — isolated instance venv created only inside the approved one-shot window (README §8)",
"worker_python": "/opt/voice-agent/agent/venv (livekit-agents 1.8.8)",
"certs": "/opt/cybergril-voice-certs/cybergril-voice.{pem,key} (shared by 8361 and wss bridge 8363)",
"livekit": "ws://127.0.0.1:7880 internal (standing); browser MUST use wss bridge (8363) — not optional from HTTPS pages",
"tts_panel_bridge": "9880/8090/8363 only inside approved voice windows (README §5); stopped otherwise"
},
"package_files": [
"deploy/hel913-unified/README.md",
"deploy/hel913-unified/manifest.json",
"deploy/hel913-unified/dist-manifest.json",
"deploy/hel913-unified/models.template.yaml",
"deploy/hel913-unified/voice.template.yaml",
"deploy/hel913-unified/worker.env.template",
"deploy/hel913-unified/voice-window-arm.template.env",
"deploy/hel913-unified/api_ctl.sh",
"deploy/hel913-unified/worker_ctl.sh",
"deploy/hel913-unified/bridge_ctl.template.sh",
"deploy/hel913-unified/tts_ctl.template.sh",
"deploy/hel913-unified/force_end.template.sh",
"deploy/hel913-unified/verify_offline.py",
"deploy/hel913-unified/test_ctl_scripts.sh",
"deploy/hel913-unified/mem0-cp311-wheels.txt",
"docs/rework/hel913/verify-offline-run.txt",
"docs/rework/hel913/ctl-checks-run.txt",
"docs/hel913-capability-and-enablement.md"
],
"offline_verification": {
"where": ".12 product .venv311, candidate tree at /tmp/hel913-verify (cleaned after run); re-run by manager: 34/34 passed, dist 8-file hashes match",
"result": "34/34 checks passed; worker import ok under livekit-agents 1.8.8",
"zero_real_calls": "models base_url rewritten to 127.0.0.1:1 + provider.stream outbound sentinel; no listening sockets; no rooms; no gateway contact",
"scope_limits": "deps check printed 4 key versions only; unshared-life trimming verified on an empty DB (told=1) without positive/negative seed contrast — not claimed as full dependency or permission coverage",
"ctl_scripts": "test_ctl_scripts.sh 42/42 offline stub checks (invalid subcommand, duplicate start, pid-reuse refusal, failed-start cleanup, .env gating, templates default 0)"
},
"voice_window_wiring": {
"asr_hard_gate": "worker/.env CYBERGRIL_ASR_WINDOW_ID/_MAX_AUDIO_SECONDS/_MAX_CONNECT_RETRIES_PER_CALL (empty/0 = deny all ASR)",
"room_lifetime": "voice.yaml session_ttl_seconds=900 + token_ttl 300 + delete-on-hangup (bounded cleanup ledger)",
"tts_segments": "monitored via tts.log first-chunk count since window start (tts_ctl.template.sh records start byte); hard stop = window deadline",
"deadline": "force_end.template.sh now|watch <epoch> (DELETE sessions -> worker -> api -> bridge -> tts, pid-handle stops only)"
},
"memory_gap": {
"server_venv311": "mem0/qdrant_client/fastembed/onnxruntime all absent (verified 2026-10-29; never install into this borrowed venv)",
"no_other_311_env_on_server": true,
"local_artifacts": {
"mem0-2c81af3.tar.gz": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
"mem0ai-2.2.1-py3-none-any.whl": "698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db",
"fastembed_cache": "92MB (Qdrant/bge-small-zh-v1.5 + bm25)",
"hel857_venv": "Python 3.10.12 — must not mix into 3.11 process"
},
"install_target": "isolated /opt/cybergril-hel913/venv311 (python3.11 base + product lock 19 wheels + mem0 stack 40 wheels + fixed-source mem0ai wheel; all --no-index; complete auditable steps in README §8.3 with per-file sha256 evidence and fail-stop)",
"ledger_continuation": "authoritative embed ledger copied to instance data/embed_ledger.sqlite3 (ledger only — no test chats/vectors moved); authority prefix_rows=181, prefix_sha256 by manager, min_used=498, embed_cap=680 cumulative formula, frozen 2 untouched; 39/60 and old window balances never carried over"
},
"enablement_doc": "docs/hel913-capability-and-enablement.md §2 — single consistent pending-approval proposal: A/B/C separate accounts, all awaiting approval (manager does not self-approve A), B voice = 3 calls x <=10min = <=1800s ASR hard gate with per-cap consequences and executable window end, B/C boundary fixed",
"boundaries": "not deployed; nothing listening; caps all 0; no PR; existing instances and data untouched; no downloads this card; protected venvs untouched",
"coordinator_close_verification": "docs/rework/hel913/coordinator-close-checks.txt: 17/17 targeted offline checks; product baseline unchanged",
"file_sha256": {
"deploy/hel913-unified/README.md": "580e3100b207c8c0e3da9cddc644b00d7b3d66ae0d195ac058dba279f63e242e",
"deploy/hel913-unified/api_ctl.sh": "3eeff54fcdc1becb79d9fe63767eceee9209ed6239663ff7d0a3e90d27379c55",
"deploy/hel913-unified/bridge_ctl.template.sh": "43e951f01ceed07b9c1606cfc046aa1d34eefc8fad3a40c66db2de57f16c1d14",
"deploy/hel913-unified/dist-manifest.json": "c2ebc6e515096b535f5e08cce18dc9c4f104c1b2cabe3f3afb5fdd88f12d63db",
"deploy/hel913-unified/force_end.template.sh": "3f250942ff68e26e4a71750e17fd804fa57be921097d83ee76f1b5b0024fe893",
"deploy/hel913-unified/mem0-cp311-wheels.txt": "2a6fd3d287fe0713a2ac683584ee7f3723c83c236696f39551015ca2db358b94",
"deploy/hel913-unified/models.template.yaml": "0b8f051cedfcf0218929a652fed91b00762c33dd17fbb8f7eadcc50c65d4e837",
"deploy/hel913-unified/test_ctl_scripts.sh": "702f7b911598fc0433f63f250f2f1e58dec9d727dd7eb6ae8a773d521fcad5cd",
"deploy/hel913-unified/tts_ctl.template.sh": "f638f24a41f07cfef64e66eee996b4280fdd7aac80a5fc20be6d47a41b72cabf",
"deploy/hel913-unified/verify_offline.py": "86ea3da6f2c682ad8ee9a932f91d713e0778c61be5d4c15f916e38d15e3e66e4",
"deploy/hel913-unified/voice-window-arm.template.env": "4002b99d63fa556a5f1e8cb86bf74846e7b944571dd76ed40bfb549cf64f3307",
"deploy/hel913-unified/voice.template.yaml": "55e556d5df149ab4fa257640b4d87dc40bb43107a5e90279826516a7ec423f4c",
"deploy/hel913-unified/worker.env.template": "be80fc87cc04a379a776732ad94f392e08af4ecb9eaad4190a090f4071f40383",
"deploy/hel913-unified/worker_ctl.sh": "57fd028e3b0d932cdbde483925cc7e39a5b994fa53742f9350cc5fc22381077f"
}
}
@@ -0,0 +1,45 @@
# HEL-913 记忆栈 cp311 缺项轮清单(40 项;来源 docs/rework/hel879/sdk-audit/sdk-audit.json 闭包 55-产品已有)
# mem0ai 不在本表:只认固定源码轮 sha256 698284b6…(deploy/hel895-release-candidate.md §4 唯一入口)。
# 获批下载命令:pip download --only-binary :all: --implementation cp --python-version 3.11 \
# --platform manylinux2014_x86_64 --abi cp311 -r 本文件 -d wheels/ 逐项 sha256 留证。
# 其中 greenlet/pillow/truststore 三项已在工作站 uv 缓存命中(hel-879 release-artifacts 记录),可离线复用。
backoff==2.2.1
charset-normalizer==3.5.2
coloredlogs==15.0.1
distro==1.9.0
fastembed==0.9.1
filelock==4.1.0
flatbuffers==25.12.19
fsspec==2026.9.0
greenlet==3.5.6
grpcio==1.84.1
h2==4.4.1
hf-xet==1.7.0
hpack==4.2.0
httpcore2==2.14.0
httpx2==2.14.0
huggingface-hub==1.33.0
humanfriendly==10.0
hyperframe==6.1.0
jiter==0.18.0
loguru==0.7.3
mmh3==5.3.1
mpmath==1.3.0
numpy==2.2.6
onnxruntime==1.23.2
openai==3.37.0
pillow==12.3.0
portalocker==4.4.1
posthog==7.75.11
protobuf==6.33.6
py-rust-stemmers==0.1.8
pytz==2026.5
qdrant-client==1.20.0
requests==2.34.2
sniffio==1.3.1
sqlalchemy==2.0.55
sympy==1.14.0
tokenizers==0.23.3
tqdm==4.70.1
truststore==0.10.5
urllib3==2.8.0
@@ -0,0 +1,89 @@
# HEL-913 统一体验运行包 · models 配置模板(脱敏;本文件一切预算=0)
#
# 用法:复制到 <实例>/etc/models.yaml,按部署说明只改 base_url 指向获批网关、
# 按批准包写入预算值。未经批准一律保持 0(发送前拒绝,fail-closed)。
# 候选分支 config/models.yaml 的 tiers/routing 与本模板一致;本模板把
# base_url 换成占位符并保留 HEL-908 reasoning: buffer_turn 与 HEL-911/912
# 审核通过的调度修正(turn_detection=None 在代码侧,见 src/cybergril/voice/worker.py)。
tiers:
high:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 60.0
temperature: 0.85
reasoning: buffer_turn
standard:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 40.0
temperature: 0.7
reasoning: buffer_turn
low:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 20.0
temperature: 0.4
reasoning: buffer_turn
routing:
dialogue_main: high
dialogue_followup: standard
proactive_message: high
crisis: high
life_event: low
memory_gist: low
fact_extract: low
output_guard: low
pattern_detect: low
filler: none
tick: none
reach_out: none
avatar_drive: none
budget:
daily_tokens: 0
warn_ratio: 0.7
hard_stop_ratio: 0.95
daily_requests:
dialogue: 0
life: 0
background: 0
semantic: 0
# 用户实例永不配置一次性总量(total_requests/total_tokens 保持缺省):
# 一次性总量只属于独立团队测试实例,见 docs/budget-package-hel895.md §6。
asr:
provider: scripted
model: qwen3-asr-1.7b
language: zh
paralinguistic:
provider: scripted
model: sensevoice-small
window_ms: 1500
enabled: true
tts:
provider: silence
model: fun-cosyvoice3-0.5b
voice_ref: null
sample_rate: 16000
vad:
provider: energy
model: silero-v6
sample_rate: 16000
turn:
provider: heuristic
silence_min_ms: 150
silence_hard_end_ms: 800
embedding:
provider: openai_compat
model: qwen3-embedding-0.6b
base_url: null
api_key_env: null
dim: 1024
avatar:
route: vrm
renderer: three.js
lipsync: envelope
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 启停脚本离线替身检查(零真实服务、零真实端口、零出站)
#
# 用离线替身(stub 解释器 = 只休眠的脚本)与静态检查验证收口后的常规启停
# 安全:非法子命令拒绝、重复 start 拒绝、stop 的 PID 复用防护、启动失败
# 收尾清理、.env 占位/解析守门。不启动真实 API/worker/共享服务,不监听
# 任何端口(curl 只连本机高位死端口,必然失败,正是失败路径所需的输入)。
#
# 用法: bash deploy/hel913-unified/test_ctl_scripts.sh (在检出根目录)
set -u
PKG=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$PKG/../.." && pwd)
TMP=$(mktemp -d /tmp/hel913-ctl-test.XXXXXX)
PASS=0; FAIL=0
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT
ok() { PASS=$((PASS+1)); echo "PASS $1"; }
bad() { FAIL=$((FAIL+1)); echo "FAIL $1"; }
expect_rc() { # expect_rc <want> <got> <label>
[ "$2" = "$1" ] && ok "$3" || bad "$3 (want rc=$1 got rc=$2)"
}
# ---- S1. 静态:语法 + 包文件齐全 ----
for f in api_ctl.sh worker_ctl.sh bridge_ctl.template.sh tts_ctl.template.sh force_end.template.sh; do
bash -n "$PKG/$f" 2>/dev/null && ok "syntax $f" || bad "syntax $f"
done
for f in README.md manifest.json dist-manifest.json models.template.yaml voice.template.yaml \
worker.env.template verify_offline.py mem0-cp311-wheels.txt \
voice-window-arm.template.env test_ctl_scripts.sh; do
[ -f "$PKG/$f" ] && ok "package file $f" || bad "package file $f missing"
done
# ---- 沙盒实例布局(镜像 README §4 的目标布局) ----
BASE=$TMP/inst
mkdir -p "$BASE/current" "$BASE/worker" "$BASE/etc" "$BASE/data" "$BASE/logs"
CODE=$BASE/current; WDIR=$BASE/worker
touch "$BASE/etc/models.yaml" "$BASE/etc/voice.yaml"
printf 'dummy cert\n' > "$TMP/c.pem"; printf 'dummy key\n' > "$TMP/c.key"
# stub 解释器:保持 argv 不变(cmdline 保留 marker),留一行输出后休眠
STUB=$TMP/stub-python
printf '#!/usr/bin/env bash\necho "stub alive pid=$$ argv=$*" >&2\nsleep 600\n' > "$STUB"; chmod +x "$STUB"
HTTP=53881; HTTPS=53882
API_ENV="HEL913_BASE=$BASE HEL913_HTTP=$HTTP HEL913_HTTPS=$HTTPS HEL913_VENV=$STUB HEL913_CERT=$TMP/c.pem HEL913_KEY=$TMP/c.key"
alive_marker() { pgrep -f "$1" | while read -r p; do [ -d "/proc/$p" ] && echo "$p"; done; }
# ---- S2. 非法子命令拒绝(不静默当 start) ----
env $API_ENV bash "$PKG/api_ctl.sh" bogus >/dev/null 2>&1; expect_rc 2 $? "api_ctl invalid subcommand refused (rc=2)"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl invalid subcommand started nothing" || bad "api_ctl invalid subcommand left a pid file"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" frobnicate >/dev/null 2>&1
expect_rc 2 $? "worker_ctl invalid subcommand refused (rc=2)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "worker_ctl invalid subcommand started nothing" || bad "worker_ctl invalid subcommand left a pid file"
# ---- S3. api 启动失败收尾:健康不过 → 清自己、留日志、rc=1 ----
env $API_ENV bash "$PKG/api_ctl.sh" start >/dev/null 2>&1; expect_rc 1 $? "api_ctl failed start exits 1"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl failed start removed pid file" || bad "api_ctl failed start left pid file"
[ -s "$BASE/logs/api.log" ] && ok "api_ctl failed start kept log" || bad "api_ctl failed start lost log"
sleep 1
N=$(alive_marker "$STUB.*serve_dual_8360_8361" | wc -l)
[ "$N" = "0" ] && ok "api_ctl failed start left no stub process" || bad "api_ctl failed start left $N stub process(es)"
# ---- S4. api 重复 start 拒绝(活进程 + 有效句柄) ----
( cd "$CODE" && nohup "$STUB" deploy/voice/serve_dual_8360_8361.py --http-port $HTTP --https-port $HTTPS > "$BASE/logs/api.log" 2>&1 & echo $! > "$BASE/logs/api.pid" )
LIVEPID=$(cat "$BASE/logs/api.pid")
kill -0 "$LIVEPID" 2>/dev/null && ok "stub api process running (pid $LIVEPID)" || bad "stub api process failed to start"
env $API_ENV bash "$PKG/api_ctl.sh" start >/dev/null 2>&1; expect_rc 1 $? "api_ctl duplicate start refused (rc=1)"
[ "$(cat "$BASE/logs/api.pid" 2>/dev/null)" = "$LIVEPID" ] && ok "api_ctl duplicate start kept original handle" || bad "api_ctl duplicate start overwrote handle"
kill "$LIVEPID" 2>/dev/null; wait 2>/dev/null
# ---- S5. api stop 的 PID 复用防护:活 pid 但身份不符 → 拒杀 ----
sleep 600 & UNRELATED=$!
echo "$UNRELATED" > "$BASE/logs/api.pid"
env $API_ENV bash "$PKG/api_ctl.sh" stop >/dev/null 2>&1; expect_rc 1 $? "api_ctl stop refused reused pid (rc=1)"
kill -0 "$UNRELATED" 2>/dev/null && ok "unrelated process untouched" || bad "api_ctl stop killed an unrelated process!"
kill "$UNRELATED" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/api.pid"
# ---- S6. api stop 死句柄:直接清 ----
echo 999999999 > "$BASE/logs/api.pid"
env $API_ENV bash "$PKG/api_ctl.sh" stop >/dev/null 2>&1; expect_rc 0 $? "api_ctl stop with dead pid exits 0"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl stop removed stale pid file" || bad "api_ctl stop kept stale pid file"
# ---- S7. worker .env 守门:占位未填 → 拒绝 ----
cp "$PKG/worker.env.template" "$WDIR/.env"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl refuses .env with unfilled placeholders (rc=1)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "placeholder refusal started nothing" || bad "placeholder refusal left pid file"
# ---- S8. worker .env 守门:解析失败 → 拒绝 ----
printf 'CYBERGRIL_API_BASE=http://127.0.0.1:8360\nFOO=bar baz\n' > "$WDIR/.env"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl refuses unparseable .env (rc=1)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "parse refusal started nothing" || bad "parse refusal left pid file"
# ---- S9. worker 启动失败收尾(注册不过 → 清自己、留日志) ----
cat > "$WDIR/.env" <<'EOF'
CYBERGRIL_API_BASE=http://127.0.0.1:8360
CYBERGRIL_AGENT_NAME=cybergril-voice-agent
LIVEKIT_URL=ws://127.0.0.1:7880
LIVEKIT_API_KEY=filled
LIVEKIT_API_SECRET=filled
CYBERGRIL_WORKER_HEALTH_PORT=8082
CYBERGRIL_VOICE_STATE=/tmp/x/voice_state.json
CYBERGRIL_ASR_USAGE_LEDGER=/tmp/x/asr-usage.jsonl
CYBERGRIL_ASR_WINDOW_ID=
CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=0
CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=0
EOF
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl failed start exits 1"
[ ! -f "$BASE/logs/worker.pid" ] && ok "worker_ctl failed start removed pid file" || bad "worker_ctl failed start left pid file"
[ -s "$BASE/logs/worker.log" ] && ok "worker_ctl failed start kept log" || bad "worker_ctl failed start lost log"
sleep 1
N=$(alive_marker "$STUB.*cybergril.voice.worker" | wc -l)
[ "$N" = "0" ] && ok "worker_ctl failed start left no stub process" || bad "worker_ctl failed start left $N stub process(es)"
# ---- S10. worker 重复 start 拒绝 ----
( cd "$WDIR" && nohup "$STUB" -m cybergril.voice.worker start > "$BASE/logs/worker.log" 2>&1 & echo $! > "$BASE/logs/worker.pid" )
LIVEPID=$(cat "$BASE/logs/worker.pid")
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl duplicate start refused (rc=1)"
kill "$LIVEPID" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/worker.pid"
# ---- S11. worker stop 的 PID 复用防护 ----
sleep 600 & UNRELATED=$!
echo "$UNRELATED" > "$BASE/logs/worker.pid"
env HEL913_BASE=$BASE bash "$PKG/worker_ctl.sh" stop >/dev/null 2>&1; expect_rc 1 $? "worker_ctl stop refused reused pid (rc=1)"
kill -0 "$UNRELATED" 2>/dev/null && ok "worker stop left unrelated process alive" || bad "worker stop killed unrelated process!"
kill "$UNRELATED" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/worker.pid"
# ---- S12. 模板可解析性:arm 模板是合法 KEY=value(可被 source,默认全 0) ----
if ( set -a; . "$PKG/voice-window-arm.template.env"; set +a
[ "${HEL913_ASR_MAX_AUDIO_SECONDS:-x}" = "0" ] \
&& [ -n "${HEL913_WINDOW_ID+x}" ] && [ -z "$HEL913_WINDOW_ID" ] \
&& [ "${HEL913_TTS_SEGMENT_CAP:-x}" = "0" ] ) 2>/dev/null; then
ok "voice-window-arm.template.env parses, defaults 0/unarmed"
else
bad "voice-window-arm.template.env unparseable or non-zero default"
fi
echo
echo "$PASS passed, $FAIL failed"
[ "$FAIL" = "0" ]
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 共享 CosyVoice TTS 9880 窗口内临时恢复/停回模板(脱敏待批件)
#
# 只在获批语音窗口内使用;窗口结束必须 stop 停回(force_end.template.sh 会调
# 本脚本 stop)。只按自己写的 pid 句柄停止,快照先行;9880 已被别人启动时
# 不碰。改编自 HEL-910 已实测 tts_ctl.sh。子命令只认 start|stop|status|snapshot。
#
# 用法: [ENV=..] bash tts_ctl.template.sh start|stop|status|snapshot
# 环境变量: HEL913_BASE / HEL913_TTS_VENV(/opt/voice-agent/cosyvoice/venv/bin/python)
set -u
case "${1:-}" in start|stop|status|snapshot) ;; *) echo "invalid subcommand" >&2; exit 2 ;; esac
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
CC=${HEL913_TTS_HOME:-/opt/voice-agent/cosyvoice}
TTSVENV=${HEL913_TTS_VENV:-$CC/venv/bin/python}
PIDF=$BASE/logs/tts9880.pid
SNAP=$BASE/logs/tts9880-before.txt
usage() { echo "usage: [ENV=..] bash $0 start|stop|status|snapshot" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"server.py"*) ;; *) return 1 ;; esac
[ "$cw" = "$CC" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
case "${1:-}" in
status)
p=$(pidfile_pid)
if [ -n "$p" ]; then ours "$p" && echo "running pid $p (ours)" || echo "pid $p not verified as ours"; else echo "not started by us"; fi
curl -s --max-time 3 http://127.0.0.1:9880/health && echo " <- health" || echo "health not answering"
exit 0 ;;
snapshot)
mkdir -p "$BASE/logs"
{ echo "ts=$(date -u +%FT%TZ)"
if curl -s --max-time 3 http://127.0.0.1:9880/health >/dev/null 2>&1; then echo "was=up"; else echo "was=down"; fi
ss -ltn 2>/dev/null | grep ':9880 ' || echo "port9880=free"; } > "$SNAP"
cat "$SNAP"; exit 0 ;;
stop)
p=$(pidfile_pid)
if [ -z "$p" ]; then echo "not ours to stop (no pid file)"; exit 0; fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p alive but not this tts (pid reuse?) — not killing; resolve $PIDF manually" >&2
exit 1
fi
if ours "$p"; then kill "$p"; sleep 5; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
echo "tts stopped (pid $p)"
exit 0 ;;
esac
# start(仅 start 子命令落到这里)
mkdir -p "$BASE/logs"
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
echo "REFUSED: live pid handle already exists; inspect/stop first" >&2; exit 1
fi
if curl -s --max-time 3 http://127.0.0.1:9880/health >/dev/null 2>&1; then
echo "9880 already up — NOT ours, not touching"; exit 0
fi
if ss -ltn 2>/dev/null | grep -q ':9880 '; then
echo "port 9880 occupied by non-healthy process — abort"; exit 1
fi
[ -x "$TTSVENV" ] || { echo "REFUSED: tts venv python not executable: $TTSVENV" >&2; exit 1; }
cd "$CC" || exit 1
nohup "$TTSVENV" server.py >> "$CC/tts.log" 2>&1 &
echo $! > "$PIDF"
echo "tts starting (pid $(cat "$PIDF")) — model load takes up to ~120s"
for i in $(seq 1 60); do
sleep 2
if curl -s --max-time 3 http://127.0.0.1:9880/health 2>/dev/null | grep -q '"ok"[[:space:]]*:[[:space:]]*true'; then
echo "OK: tts healthy after ~$((i*2))s"
wc -c < "$CC/tts.log" > "$BASE/logs/tts-log-start-byte" # TTS 段数计数的窗口起点
exit 0
fi
kill -0 "$(cat "$PIDF")" 2>/dev/null || { echo "FAIL: tts process died"; tail -10 "$CC/tts.log"; exit 1; }
done
echo "FAIL: tts not healthy in 120s"; tail -10 "$CC/tts.log"
p=$(pidfile_pid)
if ours "$p"; then kill "$p"; sleep 2; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
exit 1
+273
View File
@@ -0,0 +1,273 @@
"""HEL-913 运行准备离线验证:零真实调用(cap=0 + 死地址双重保险)。
在进程内用 TestClient 走统一实例的关键接线:入口健康、创建守门、传输
(SSE 对话在预算 0 下诚实拒绝且绝不触发出站)、关系调整、日子与回忆
读取的服务端裁剪、语音诚实拒绝、重开(epoch/资料保留)、静态页与 dist
哈希。不监听任何端口(836x/8082/9880/8090 全程不出现监听 socket),
不访问真实网关(models.base_url 重写为 127.0.0.1:1,livekit 指向死地址,
即使预算闸失效也只能连本机死端口)。
用法(在候选 head 检出根目录、产品 3.11 venv 下):
PYTHONPATH=src <venv311>/python deploy/hel913-unified/verify_offline.py \
[--web <dist目录>] [--keep]
退出码 0 = 全部通过;任一 FAIL 即非 0。临时库写在 <root>/.hel913-verify-tmp,
默认结束后删除(--keep 保留)。
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
RESULTS: list[tuple[bool, str]] = []
def check(ok: bool, label: str) -> bool:
RESULTS.append((bool(ok), label))
print(("PASS " if ok else "FAIL ") + label)
return bool(ok)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--web", default=None, help="web dist 目录(只读;默认不挂静态)")
parser.add_argument("--keep", action="store_true")
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
os.chdir(root)
tmp = Path(tempfile.mkdtemp(prefix="hel913-verify-", dir=root))
if not args.keep:
atexit.register(shutil.rmtree, tmp, ignore_errors=True)
# ---- 0. 包完整性:Python / 锁定依赖 / 模块树 -----------------------------
check(sys.version_info[:2] == (3, 11), f"python 3.11 (actual {sys.version_info[:3]})")
import cybergril # noqa: E402
check(str(Path(cybergril.__file__).resolve()).startswith(str(root / "src")),
f"cybergril imported from candidate tree ({cybergril.__file__})")
import fastapi # noqa: E402
import httpx # noqa: E402
import pydantic # noqa: E402
import uvicorn # noqa: E402
import yaml # noqa: E402
check(True, f"deps: fastapi {fastapi.__version__} uvicorn {uvicorn.__version__} "
f"httpx {httpx.__version__} pydantic {pydantic.__version__}")
from cybergril.api.app import create_app # noqa: E402
from cybergril.voice.router import build_voice_router # noqa: E402
from cybergril.voice.session import load_voice_config # noqa: E402
check(True, "product modules import (api.app / voice.router / voice.session)")
# 记忆栈缺项如实呈现(不允许 stub 冒充真实 mem0)
for mod, expect in (("mem0", False), ("qdrant_client", False), ("fastembed", False)):
try:
__import__(mod)
got = True
except Exception:
got = False
check(got == expect, f"{mod} {'absent' if not expect else 'present'} in product venv (as documented)")
# ---- 1. 脱敏配置:cap=0 + 死地址 ----------------------------------------
models_src = (root / "config" / "models.yaml").read_text()
assert "daily_tokens" in models_src, "models.yaml missing budget block"
dead = models_src.replace("192.168.200.11:8731", "127.0.0.1:1")
assert "192.168" not in dead, "real gateway address survived rewrite"
models_path = tmp / "models.yaml"
models_path.write_text(dead)
voice_path = tmp / "voice.yaml"
voice_path.write_text(
"# HEL-913 offline template: everything points at dead local ports;\n"
"# no real LiveKit/TTS/panel is contacted by this verification.\n"
"livekit_url: ws://127.0.0.1:1\n"
"livekit_internal: http://127.0.0.1:1\n"
"api_key: dead\n"
"api_secret: dead\n"
"agent_name: cybergril-voice-agent\n"
"tts_health_url: http://127.0.0.1:1/health\n"
"panel_url: https://127.0.0.1:1\n"
"tts_url: http://127.0.0.1:1\n"
"registry_file: " + str(tmp / "voices.json") + "\n"
"worker_state_file: " + str(tmp / "voice_state.json") + "\n"
"token_ttl_seconds: 300\n"
"session_ttl_seconds: 900\n"
)
db_path = tmp / "companion.db"
env = {
"CYBERGRIL_DB": str(db_path),
"CYBERGRIL_MODELS": str(models_path),
"CYBERGRIL_VOICE_CONFIG": str(voice_path),
"CYBERGRIL_SEMANTIC": "stub",
"CYBERGRIL_R2_ENABLED": "1",
**({"CYBERGRIL_WEB": args.web} if args.web else {}),
}
old = {k: os.environ.get(k) for k in env}
os.environ.update(env)
from fastapi.testclient import TestClient # noqa: E402
app = create_app()
# 出站哨兵:把所有已装配 provider 的 stream 打上标记——预算 0 下任何
# 真实发送意图都会把本验证置为 FAIL(而不是悄悄连死地址)。
outbound = []
client = TestClient(app)
with client:
st = app.state
router = st.life.router # LifeRuntime 持有本实例同一 router
for tier in router.available_tiers():
prov = router.provider_for(tier)
def _guard_stream(self, *a, __orig=prov.stream, __name=getattr(prov, "name", tier), **kw):
outbound.append(__name)
raise AssertionError(f"unexpected outbound stream via {__name}")
prov.stream = _guard_stream.__get__(prov, type(prov))
birth = {"birth": "2001-08-16", "hour": "辰时", "name": "临川", "relation": "朋友",
"nickname": "", "boundary": "轻松相处"}
# 2. 入口健康
r = client.get("/api/health")
h = r.json()
check(r.status_code == 200 and h["ok"] and h["capabilities"]["text"] is True,
"GET /api/health 200, text capability true")
check(h["companion_created"] is False, "health: companion_created=false before creation")
check(h["llm"]["ok"] is False, "health: llm honestly not ok (dead addr, cap=0)")
# 3. 创建守门:空库 404,不能读出任何伪造档案
check(client.get("/api/companion").status_code == 404,
"GET /api/companion 404 before creation (no fabricated profile)")
# 4. 生辰预览:纯本地计算,无模型参与(BirthInput 只收 name/birth/hour)
r = client.post("/api/companion/preview",
json={k: birth[k] for k in ("name", "birth", "hour")})
preview_pillars = r.json().get("pillars") if r.status_code == 200 else None
check(r.status_code == 200 and "pillars" in r.json() and "sections" in r.json(),
"POST /api/companion/preview 200 (local bazi compile)")
# 5. 创建 + 底色只读落库
r = client.post("/api/companion", json=birth)
check(r.status_code == 201 and r.json()["name"] == "临川",
"POST /api/companion 201")
row = st.store.query_one("SELECT pillars FROM bazi_core WHERE id=1")
saved_day = json.loads(row["pillars"])["day"][0] if row else None
prev_day = (preview_pillars or {}).get("day", [None])[0]
check(saved_day is not None and saved_day == prev_day,
f"bazi_core persisted and matches preview (day master {saved_day})")
check(client.post("/api/companion", json=birth).status_code == 409,
"second create refused 409 (single companion guard)")
pv = client.get("/api/persona").json()
check(pv["interpretation"] is None and pv["state"] in {"failed", "loading", "disabled"},
f"persona honest at cap=0 (state={pv['state']}, no fabricated interpretation)")
# 6. 传输:SSE 对话在预算 0 下诚实拒绝,零出站
turn1 = str(uuid.uuid4())
with client.stream("POST", "/api/chat",
json={"text": "在吗", "turn_id": turn1, "epoch": st.epoch}) as resp:
check(resp.status_code == 200 and resp.headers["content-type"].startswith("text/event-stream"),
"POST /api/chat opens SSE stream")
kinds = [json.loads(line[6:])["t"] for line in resp.iter_lines()
if line.startswith("data: ")]
check("chat.error" in kinds and "chat.final" not in kinds,
f"chat honestly errors at cap=0 (frames={kinds})")
check(not outbound, f"zero provider stream calls (outbound={outbound})")
msgs = client.get("/api/messages").json()["messages"]
check(msgs and msgs[-1]["role"] == "user" and msgs[-1]["status"] in {"failed", "cancelled"},
"failed turn persisted as user row, no fake companion reply")
with client.stream("POST", "/api/chat",
json={"text": "在吗", "turn_id": turn1, "epoch": st.epoch}) as resp:
kinds2 = [json.loads(line[6:])["t"] for line in resp.iter_lines()
if line.startswith("data: ")]
check("chat.error" in kinds2 and not outbound,
"retry same failed turn: honest error again, still zero outbound")
# 7. 关系可调整、名字保留
r = client.patch("/api/relation", json={"relation": "恋人"})
check(r.status_code == 200 and r.json()["relation"] == "恋人" and r.json()["name"] == "临川",
"PATCH /api/relation 200 (relation adjustable, name kept)")
# 8. 日子与回忆:服务端裁剪 + 分页形状(told=1 之外不可见)
r = client.get("/api/life")
check(r.status_code == 200 and r.json()["events"] == [],
"GET /api/life 200 empty (only told events, none yet)")
r = client.get("/api/memories")
check(r.status_code == 200 and r.json()["memories"] == [],
"GET /api/memories 200 empty")
check(client.get("/api/life/1").status_code == 404,
"GET /api/life/1 404 (unshared detail refused)")
bogus = {"message_id": 99999, "turn_id": str(uuid.uuid4()), "epoch": st.epoch,
"token": "x" * 40, "text_hash": "0" * 64}
check(client.post("/api/chat/received", json=bogus).status_code == 404,
"POST /api/chat/received 404 on unknown receipt")
# 9. 语音诚实拒绝:配置指向死地址 → 开房必须 503,绝不建房
r = client.get("/api/voice/status")
check(r.status_code == 200 and r.json()["configured"] is True and r.json()["voice"] is False,
"GET /api/voice/status 200, voice honestly false")
r = client.post("/api/voice/session")
check(r.status_code == 503,
f"POST /api/voice/session honest 503 when TTS unreachable ({r.status_code})")
check(client.delete("/api/voice/session/no-such").status_code == 404,
"DELETE /api/voice/session/{unknown} honest 404")
# 10. 静态页与 dist 哈希(只读)
if args.web:
page = client.get("/")
check(page.status_code == 200 and b"<div id=" in page.content,
"GET / serves web dist index.html")
# 11. 重开:新进程入口、epoch 递增、资料保留
with TestClient(app) as client2:
h2 = client2.get("/api/health").json()
check(h2["epoch"] == 2, f"reopen: epoch=2 (got {h2['epoch']})")
c2 = client2.get("/api/companion").json()
check(c2["name"] == "临川" and c2["relation"] == "恋人" and c2["birth"] == "2001-08-16",
"reopen: companion identity + adjusted relation retained")
m2 = client2.get("/api/messages").json()["messages"]
check(len(m2) == 1 and m2[0]["role"] == "user",
"reopen: chat history retained, still no fabricated replies")
if args.web:
manifest = json.loads((Path(__file__).parent / "dist-manifest.json").read_text())
bad = []
for rel, want in manifest.items():
p = Path(args.web) / rel
if not p.is_file():
bad.append(f"{rel} missing")
continue
import hashlib
got = hashlib.sha256(p.read_bytes()).hexdigest()
if got != want:
bad.append(f"{rel} {got[:12]}!={want[:12]}")
check(not bad, f"dist matches manifest ({len(manifest)} files; bad={bad})")
for k, v in old.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
failed = [label for ok, label in RESULTS if not ok]
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed")
if failed:
print("FAILED:")
for f in failed:
print(" - " + f)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,12 @@
# HEL-913 · 语音窗口一次性布防模板(脱敏;默认全 0/未布防,随包不生效)
#
# 获批语音窗口的负责人在窗口启动时显式填写并在启动 worker 前写入
# <实例>/worker/.env(ASR 三项)与本文件(窗口参数)。唯一接线见
# deploy/hel913-unified/README.md §5;建议值口径见
# docs/hel913-capability-and-enablement.md §2 B。
# 已实测同形工具(HEL-910 窗口):docs/rework/hel910/tools/asr-arm.env。
HEL913_WINDOW_ID= # 例 hel913-20261106-a;空=未布防=拒绝一切 ASR
HEL913_ASR_MAX_AUDIO_SECONDS=0 # 全窗口实际送入 ASR 音频硬闸(秒)
HEL913_ASR_MAX_RETRIES_PER_CALL=0 # 每通连接重试硬闸(失败后的再建连)
HEL913_TTS_SEGMENT_CAP=0 # TTS 段数上限(监控口径;到顶即手动 force_end)
HEL913_WINDOW_DEADLINE_EPOCH= # date -d '<第3日> 24:00' +%s;到点 force_end
+23
View File
@@ -0,0 +1,23 @@
# HEL-913 统一体验运行包 · voice 配置模板(脱敏)
#
# 用法:复制到 <实例>/etc/voice.yaml,把 <...> 占位替换成服务器本地真实值。
# livekit_url 是 POST /api/voice/session 发给**浏览器**的接入地址:统一实例
# 页面走 HTTPS(8361),HTTPS 页面连 ws:// 明文会被混合内容策略阻断,因此
# 语音可用的前提是 wss TLS 桥(deploy/voice/wss_bridge.example.py 在新端口
# 终结 TLS、回环转发 7880,与 8361 同一张自签证书)——livekit_url 必须填
# 桥的 wss:// 地址,不是可选件(桥的启停见 README §5)。服务器内部
# (开房/删房/worker 注册)仍走 livekit_internal 的本机明文 7880。
# TTS/面板地址在语音窗口获批启动后指向本机 9880/8090;registry/worker_state
# 落实例数据目录,与产品库同空间,随实例备份。
livekit_url: wss://192.168.200.12:8363
livekit_internal: http://127.0.0.1:7880
api_key: <LiveKit api key,服务器本地>
api_secret: <LiveKit api secret,服务器本地>
agent_name: cybergril-voice-agent
tts_health_url: http://127.0.0.1:9880/health
panel_url: https://127.0.0.1:8090
tts_url: http://127.0.0.1:9880
registry_file: /opt/cybergril-hel913/data/voices.json
worker_state_file: /opt/cybergril-hel913/data/voice_state_cybergril.json
token_ttl_seconds: 300
session_ttl_seconds: 600
+26
View File
@@ -0,0 +1,26 @@
# HEL-913 统一体验运行包 · 语音 worker 环境模板(脱敏)
#
# 用法:复制到 <实例>/worker/.env,把所有 <...> 占位替换成服务器本地真实值。
# worker_ctl.sh 启动前会拒绝:仍含 <...> 占位的文件、解析失败的文件、缺文件。
# 运行解释器是 agent venv(livekit-agents 1.8.8),不是产品 .venv311。
CYBERGRIL_API_BASE=http://127.0.0.1:8360
CYBERGRIL_AGENT_NAME=cybergril-voice-agent
LIVEKIT_URL=ws://127.0.0.1:7880
LIVEKIT_API_KEY=<LiveKit api key,服务器本地>
LIVEKIT_API_SECRET=<LiveKit api secret,服务器本地>
CYBERGRIL_WORKER_HEALTH_PORT=8082
CYBERGRIL_VOICE_STATE=/opt/cybergril-hel913/data/voice_state_cybergril.json
# ASR 用量账本独立于产品库,落在实例数据目录(逐通历史不随删房消失):
CYBERGRIL_ASR_USAGE_LEDGER=/opt/cybergril-hel913/data/asr-usage.jsonl
# —— ASR 一次性窗口闸(HEL-906;本段是布防接线,默认未布防 = 真实 cap=0)——
# 三项保持空/0 时,worker 拒绝一切真实 ASR 新连接与新音频(fail-closed,
# 拒绝本身落账,src/cybergril/voice/asr_usage.py)。只有获批语音窗口的
# 负责人在窗口启动时显式改这里并重启 worker(接线与建议值见 README §5
# 与 docs/hel913-capability-and-enablement.md §2 B):
# CYBERGRIL_ASR_WINDOW_ID=hel913-<日期>-a # 新窗口 id;空=未布防
# CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=1800 # 3 通×10 分钟=30 分钟硬闸
# CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=2 # 每通连接重试上限
CYBERGRIL_ASR_WINDOW_ID=
CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=0
CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=0
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# HEL-913 统一体验运行包 · 专属语音 worker 启停
#
# 运行解释器是 agent venv(livekit-agents 1.8.8),产品代码经 PYTHONPATH 注入。
# worker 只连接本实例 API 与本机 LiveKit。本脚本只做本实例常规启停安全:
# - 子命令只认 start|stop|status,其他一律拒绝退出 2(不会静默当 start);
# - start 前核对 pid 句柄与 worker/.env(缺文件/含未填 <...> 占位/解析失败
# 一律拒绝),重复 start 拒绝,句柄 pid 复用拒绝;
# - stop/失败收尾只杀经身份核验的进程(cmdline 含 cybergril.voice.worker
# 且 cwd 是本实例 worker 目录),绝不按进程名扫杀;
# - start 注册检查不过 → 清掉自己刚起的进程、保留日志、退出 1。
#
# 用法: [ENV=..] bash deploy/hel913-unified/worker_ctl.sh start|stop|status
# 环境变量:
# HEL913_BASE 实例根目录(含 current/ worker/ 等)
# HEL913_AGENT_VENV agent venv python(默认 /opt/voice-agent/agent/venv/bin/python)
# HEL913_WORKER_HEALTH 健康端口(默认 8082;与 worker/.env 中一致)
set -u
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
VENV=${HEL913_AGENT_VENV:-/opt/voice-agent/agent/venv/bin/python}
HEALTH=${HEL913_WORKER_HEALTH:-8082}
WDIR=$BASE/worker
ENVF=$WDIR/.env
LOG=$BASE/logs/worker.log
PIDF=$BASE/logs/worker.pid
MARKER="cybergril.voice.worker"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$WDIR" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then
echo "no pid file; nothing stopped"
return 0
fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p is alive but is NOT this worker (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then
kill "$p"
sleep 3
if ours "$p"; then kill -9 "$p"; sleep 1; fi
fi
rm -f "$PIDF"
echo "worker stopped (pid $p)"
return 0
}
start() {
local p bad
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
if ours "$p"; then
echo "REFUSED: worker already running (pid $p) — stop first" >&2
else
echo "REFUSED: pid file has live pid $p that is not this worker (pid reuse?) — resolve $PIDF manually" >&2
fi
exit 1
fi
rm -f "$PIDF"
[ -d "$WDIR" ] || { echo "REFUSED: worker dir missing: $WDIR" >&2; exit 1; }
[ -f "$ENVF" ] || { echo "REFUSED: $ENVF missing (copy worker.env.template and fill it)" >&2; exit 1; }
[ -x "$VENV" ] || { echo "REFUSED: agent venv python not executable: $VENV" >&2; exit 1; }
# 占位符守门:模板 <...> 未替换一律拒绝(不让占位串进进程环境)
bad=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]*=.*<' "$ENVF" || true)
if [ -n "$bad" ]; then
echo "REFUSED: $ENVF still has unfilled <...> placeholders:" >&2
echo "$bad" >&2
exit 1
fi
# 解析守门:.env 必须能作为 KEY=value 被 source(坏引号/裸空格拒绝)
if ! (set -a; . "$ENVF"; set +a) 2>/dev/null; then
echo "REFUSED: $ENVF does not parse as KEY=value lines" >&2
exit 1
fi
mkdir -p "$BASE/logs"
cd "$WDIR" || exit 1
set -a
. "$ENVF"
set +a
PYTHONPATH="$BASE/current/src" nohup "$VENV" -m cybergril.voice.worker start > "$LOG" 2>&1 &
p=$!
echo "$p" > "$PIDF"
echo "worker starting (pid $p)"
sleep 18
if grep -m1 "registered worker" "$LOG" >/dev/null; then
echo "OK: worker registered"
return 0
fi
echo "FAIL: worker did not register — full log kept at $LOG; tail:" >&2
tail -8 "$LOG" >&2
if ours "$p"; then
kill "$p"; sleep 2
ours "$p" && kill -9 "$p"
fi
rm -f "$PIDF"
echo "cleaned up failed start (log kept: $LOG)" >&2
exit 1
}
status() {
local p
p=$(pidfile_pid)
if [ -n "$p" ]; then
if ours "$p"; then echo "running pid $p (identity verified)"
elif kill -0 "$p" 2>/dev/null; then echo "pid $p alive but NOT ours (stale pid file?)"
else echo "pid $p not alive (stale pid file)"; fi
else
echo "no pid file"
fi
curl -s --max-time 3 "http://127.0.0.1:$HEALTH/" >/dev/null && echo "health ok" \
|| echo "health port not answering"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) usage ;;
esac
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Authorized once by HEL-797 comment 01a18a81-21fd-7322-b2c7-295c3fa517aa.
# Install the deadline FIRST, then start the empty user instance. No model probe.
set -euo pipefail
[ "$#" -eq 0 ] || { echo "No deadline override: this command opens only the approved three-day window" >&2; exit 2; }
BASE=/opt/cybergril-hel913
UNIT=cybergril-hel913-user
if systemctl is-active --quiet "$UNIT.service" || [ -f "$BASE/window-active" ]; then
echo 'REFUSED: user window already active; no second activation' >&2; exit 1
fi
"$BASE/venv311/bin/python" - "$BASE" <<'PY'
import pathlib,sqlite3,sys
b=pathlib.Path(sys.argv[1]);p=b/'data/companion.db'
if p.exists():
c=sqlite3.connect('file:'+str(p)+'?mode=ro',uri=True)
if c.execute('select count(*) from companion').fetchone()[0]:
raise SystemExit('REFUSED: an existing companion must not be reinitialized')
c.close()
PY
cp "$BASE/etc/models.yaml" "$BASE/etc/models-before-activation.yaml"
failed() {
rm -f "$BASE/window-active"
systemctl stop "$UNIT.service" "$UNIT-expiry.timer" || true
systemctl disable "$UNIT.service" "$UNIT-expiry.timer" || true
cp "$BASE/etc/models-before-activation.yaml" "$BASE/etc/models.yaml"
echo 'Activation failed; owned instance stopped, previous budget restored; logs preserved' >&2
}
trap failed ERR
"$BASE/venv311/bin/python" - "$BASE" <<'PY'
import datetime,json,pathlib,sys,zoneinfo
b=pathlib.Path(sys.argv[1]);tz=zoneinfo.ZoneInfo('Asia/Shanghai')
now=datetime.datetime.now(tz)
end=datetime.datetime.combine(now.date()+datetime.timedelta(days=3),datetime.time(),tzinfo=tz)
w={'approved':True,'authorization_comment':'01a18a81-21fd-7322-b2c7-295c3fa517aa','started_at':now.isoformat(),'deadline_at':end.isoformat(),'deadline_epoch':int(end.timestamp())}
(b/'window.json').write_text(json.dumps(w,indent=2)+'\n');(b/'window.json').chmod(0o600)
calendar=end.astimezone(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
p=pathlib.Path('/etc/systemd/system/cybergril-hel913-user-expiry.timer')
p.write_text('[Unit]\nDescription=Deadline of approved CyberGril first-use window\n\n[Timer]\nOnCalendar='+calendar+'\nPersistent=true\nUnit=cybergril-hel913-user-expiry.service\n\n[Install]\nWantedBy=timers.target\n')
PY
install -m 644 "$BASE/ops/$UNIT.service" /etc/systemd/system/
install -m 644 "$BASE/ops/$UNIT-expiry.service" /etc/systemd/system/
systemd-analyze verify "/etc/systemd/system/$UNIT.service" "/etc/systemd/system/$UNIT-expiry.service" "/etc/systemd/system/$UNIT-expiry.timer"
systemctl daemon-reload
systemctl enable --now "$UNIT-expiry.timer"
systemctl is-active --quiet "$UNIT-expiry.timer"
next=$(systemctl show "$UNIT-expiry.timer" -p NextElapseUSecRealtime --value)
[ -n "$next" ] && [ "$next" != n/a ]
install -m 600 "$BASE/etc/models-user-approved.yaml" "$BASE/etc/models.yaml.next"
mv "$BASE/etc/models.yaml.next" "$BASE/etc/models.yaml"
touch "$BASE/window-active"
systemctl enable --now "$UNIT.service"
ready=0
for attempt in $(seq 1 20); do
if curl --fail --silent --max-time 2 http://127.0.0.1:8360/api/health > "$BASE/logs/user-activation-health.json"; then ready=1; break; fi
sleep 1
done
[ "$ready" = 1 ]
"$BASE/venv311/bin/python" - "$BASE/logs/user-activation-health.json" <<'PY'
import json,sys
h=json.load(open(sys.argv[1]))
assert h['ok'] and h['llm']['ok'] and not h['companion_created']
print('PASS: empty user instance ready, model routes configured; no model probe sent')
PY
trap - ERR
cat "$BASE/window.json"
systemctl list-timers "$UNIT-expiry.timer" --no-pager
@@ -0,0 +1,9 @@
[Unit]
Description=Close CyberGril first-use window and preserve records
[Service]
Type=oneshot
ExecStart=/usr/bin/bash /opt/cybergril-hel913/ops/stop.sh
TimeoutStartSec=180
StandardOutput=append:/opt/cybergril-hel913/logs/user-expiry.log
StandardError=append:/opt/cybergril-hel913/logs/user-expiry.log
@@ -0,0 +1,36 @@
[Unit]
Description=CyberGril approved first-use instance
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Type=exec
WorkingDirectory=/opt/cybergril-hel913/current
Environment=TZ=Asia/Shanghai
Environment=PYTHONPATH=/opt/cybergril-hel913/current/src
Environment=CYBERGRIL_DB=/opt/cybergril-hel913/data/companion.db
Environment=CYBERGRIL_MODELS=/opt/cybergril-hel913/etc/models.yaml
Environment=CYBERGRIL_VOICE_CONFIG=/opt/cybergril-hel913/etc/voice.yaml
Environment=CYBERGRIL_WEB=/opt/cybergril-hel913/current/web/dist
Environment=CYBERGRIL_SEMANTIC=mem0
Environment=CYBERGRIL_SEMANTIC_CONFIG=/opt/cybergril-hel913/etc/semantic.yaml
Environment=CYBERGRIL_R2_ENABLED=1
Environment=CYBERGRIL_LIFE_TIMEZONE=Asia/Shanghai
Environment=MEM0_TELEMETRY=False
Environment=FASTEMBED_CACHE_PATH=/opt/cybergril-hel913/data/sem/fastembed-cache
Environment=HF_HUB_OFFLINE=1
Environment=TRANSFORMERS_OFFLINE=1
ExecStartPre=/opt/cybergril-hel913/venv311/bin/python /opt/cybergril-hel913/ops/window_guard.py /opt/cybergril-hel913/window.json
ExecStart=/opt/cybergril-hel913/venv311/bin/python deploy/voice/serve_dual_8360_8361.py --http-port 8360 --https-port 8361 --cert /opt/cybergril-voice-certs/cybergril-voice.pem --key /opt/cybergril-voice-certs/cybergril-voice.key
ExecStartPost=/usr/bin/bash -c 'echo "$MAINPID" > /opt/cybergril-hel913/logs/api.pid'
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
KillMode=mixed
StandardOutput=append:/opt/cybergril-hel913/logs/api.log
StandardError=append:/opt/cybergril-hel913/logs/api.log
[Install]
WantedBy=multi-user.target
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Close only this window; preserve user data and all accounting history.
set -u
BASE=/opt/cybergril-hel913
UNIT=cybergril-hel913-user
rc=0
rm -f "$BASE/window-active"
HEL913_BASE="$BASE" bash "$BASE/current/deploy/hel913-unified/force_end.template.sh" now || rc=1
systemctl stop "$UNIT.service" || rc=1
systemctl disable "$UNIT.service" || rc=1
systemctl stop "$UNIT-expiry.timer" || rc=1
systemctl disable "$UNIT-expiry.timer" || rc=1
if [ -f "$BASE/data/companion.db" ]; then
"$BASE/venv311/bin/python" "$BASE/current/tools/seal_budget_hel879.py" --db "$BASE/data/companion.db" --reason 'approved first-use window closed' || rc=1
"$BASE/venv311/bin/python" "$BASE/current/tools/release_snapshot_hel896.py" snapshot --name "user-window-$(date -u +%Y%m%dT%H%M%SZ)" --db "$BASE/data/companion.db" --file "$BASE/data" --out "$BASE/archives" || rc=1
fi
"$BASE/venv311/bin/python" - "$BASE/window.json" "$rc" <<'PY'
import datetime,json,pathlib,sys
p=pathlib.Path(sys.argv[1])
if p.exists():
w=json.loads(p.read_text());w['closed_at']=datetime.datetime.now(datetime.timezone.utc).isoformat();w['close_exit_code']=int(sys.argv[2]);p.write_text(json.dumps(w,indent=2)+'\n')
PY
exit "$rc"
+12
View File
@@ -0,0 +1,12 @@
"""Refuse startup outside the explicitly approved first-use window (no network)."""
import json
import sys
import time
from pathlib import Path
path = Path(sys.argv[1])
window = json.loads(path.read_text())
if not path.with_name('window-active').is_file():
raise SystemExit('first-use window is inactive')
if not window.get('approved') or time.time() >= int(window['deadline_epoch']):
raise SystemExit('first-use window has expired or is not approved')
+135
View File
@@ -0,0 +1,135 @@
# HEL-913 · 一页能力清单与一次真实启用建议
依据:产品基线 `4a38106`(HEL-912 审核通过)+ 运行包提交(仅 deploy/docs,
见运行包 README §0)、HEL-907/910 真实窗口记录、HEL-913 离线验证 34/34
(`docs/rework/hel913/verify-offline-run.txt`;其中依赖核对只打印 4 个关键
包版本、未分享裁剪只在空库验证——口径限制见运行包 README §7)。分级只分
三种:**✅接好且已验证**(真实窗口或真实服务验证过)/ **🟡有实现、缺真实验证**
(代码与离线/替身验证在,真实链路未跑或未批预算)/ **❌尚未接入**。不照抄
旧表:语音已接(HEL-907/910 实测),下述端口/能力以当前源码为准。
## 1. 能力清单(按用户使用顺序)
| # | 用户动作 | 现状 | 具体行为与证据 |
| --- | --- | --- | --- |
| 1 | 打开网页 | ✅ | HTTPS 入口出界面(自签证书需浏览器「高级→继续」);`GET /` 出 index.html、dist 8 文件哈希核过(HEL-913 验证)。导航:此刻/交谈/日子/设置(`web/src/app/RealApp.tsx`) |
| 2 | 自己给她起名、选生日、选关系 | ✅(起名/生日/关系)/ 🟡(自主诠释与初始习惯) | 用户亲选关系(不预选,AM-002)、亲起名(AM-003);创建守门 404/409、八字底色落库只读、公历农历四柱展示(`src/cybergril/api/app.py:427`、`src/cybergril/core/birth_view.py`)。**自主诠释/习惯生成(persona)从未真实跑过**:所有真实窗口 background=0 诚实拒绝(HEL-910 预检证实),首次用户体验需预算 |
| 3 | 文字聊天 | ✅ | 真实网页 Enter 发送、SSE 流式回复、完整落库;语音在播/待播时发文字打断=旧声停+完整文字回复+语音恢复(HEL-910 两通实测);**生成中的文字取消未取得有效真实样本**(HEL-908 的修复只有离线验证,HEL-910 想测没测成,如实记未验证);失败轮诚实 error 不伪造 |
| 4 | 她的生活(自己运行) | 🟡 | 生活循环/活动线程/分享判定/回执代码接好(`src/cybergril/life/`);未分享生活服务端裁剪(told=1,AM-001)空库离线验证(未做正反种子对照);**真实生活事件生成未发生**(life 预算 0),日子页真实模式下尚无真实事件可看 |
| 5 | 记忆 | 🟡(分两层) | 聊天记忆:逐轮落库、遗忘下限、词面检索(FTS)已接,真实窗口对话随轮落库(团队测试库);**语义记忆(真实 Mem0)缺依赖**:.12 venv311 四项未装(2026-10-29 实测),完整安装与接线见运行包 README §8;激活还需 B 部分嵌入/提取预算 |
| 6 | 关掉网页再回来 | ✅ | 同一位她保留:聊天/关系/八字/记忆持久;epoch 递增同步、分页缺口不跳留言、未读标记(HEL-895/896 真实服务浏览器 9 项检查;HEL-913 离线重开验证) |
| 7 | 她主动留言 | 🟡 | 时机判定+网页内留言卡+回来即看的前端链路已接并经真实服务浏览器验证(种子数据);**真实 LLM 主动措辞生成未发生**(proactive 预算 0);不接手机推送(CG-011 首版范围) |
| 8 | 语音通话 | ✅(工程验证) | 真实网页开通话、连续对话、字幕同步、挂断删房;文字打断在播旧声实测停(作者粗略观察约秒级,原始探针未保留,**不作精确口径、不承诺可独立精测**);首声原实测 9.53s,HEL-911 移除 3.66s 等待后**未实测**(不向用户承诺任何推算值);真人麦克风打断、生成中文字取消的正面样本未取得(承接) |
| 9 | 自定义声音 | ✅(工程验证) | 网页上传参考音频→登记→真 TTS 试听→应用→下一通生效;通话中改选不换声(HEL-907 全链 13/14 步实测,call1/2 以 cg- 音色真实合成;第 14 步真机复测未做);用户本人尚未亲自用过 |
| 10 | 换城市/天气跟随 | ❌ | 设置页城市项为演示(`web/src/manage/ManagePanel.tsx` cap=demo/off);后端无天气源(CG-023 承接 R6,T-07) |
| 11 | 换外观素材 | ❌ | 设置页外观项为演示;素材规范/导入未定稿(CG-005/030,T-03,R6;视觉参考见 PR#8 prototypes/hel-793/) |
| 12 | 备份导出/每日自动备份 | ❌(演示导出除外) | 设置页「本地演示导出」为演示件;CG-009 每日自动备份+手动完整导出未实现(R6);现阶段只有发布归档工具 `tools/release_snapshot_hel896.py`(工程用,不冒充 R6) |
| 13 | 摄像头观察 | ❌ | 无视觉服务接入;CG-021/022(R7),4080 未触,部署由用户负责(T-05) |
| 14 | 真实人物口型/交谈半身 | ❌ | 交谈时人物为静帧参考图(`assets/her-talk.jpg`);成品视频选片与实时驱动器未选型(CG-029,T-09;8340/ComfyUI 停机) |
机器可读口径:`GET /api/health` 的 `capabilities`(voice/vision/weather/materials/
backup=false 为诚实声明;text=true)。
## 2. 一次真实启用建议(**全部待批准**;当前配置仍全 0,旧窗口余额不结转)
> 更正声明:本节早前写「A 总管权限内新批、不用问用户」——与本轮真实
> cap=0 及主卡授权矛盾,**作废**。所有新增真实用量、安装、部署(含 A)
> 一律待批,总管不自行放行;批准前一切 cap=0。三本账彼此独立(独立实例/
> 独立库,HEL-896 §6 分账语义),任何一本用完不自动转另一本;所有硬闸
> 发送前拒绝、碰顶即停、**封账不解除**;提额永远是新批准。费用均为内网
> 自持服务(LLM 网关 .11:8731、TTS/ASR/LiveKit 在 .12),**无新增现金支出**,
> 消耗的是授权额度与本机资源。用户不需要理解 token/EOU,也不需要选算法
> 或决定"要不要真记忆"——工程侧按批准直接配置。
### A · 团队验收检查(建议顶:55 次 / 500,000 tokens / 0 嵌入——待批)
- 沿用 `docs/budget-package-hel895.md` §6 的形状:**独立团队实例**自己的
SQLite 账,体验链 1–7 走完(创建→诠释→对话→生活→情绪关系→主动联系→
重开同步,即本清单 🟡 项的团队级补证)即 `seal_budget_hel879.py` 封账
归档。
- A 是团队实例的账:**不并入用户实例、不给用户实例配置一次性总量**
(一次性总量只属于团队测试实例,见 budget-package-hel895 §6)。
### B · 用户首体验(一次批准;最小够用)
- **前置(窗口开始前,团队完成,不占用户额度)**:如批准真实记忆,先按
运行包 README §8 一次性窗口完成安装+co-import+缓存落位(约 1–2 小时
含验证;+450–550MB 磁盘);不批安装则记忆停在词面检索(stub 已验证
全链可跑),其余体验不受影响。四步检查(安装→独立部署→团队检查→用户
首用)见运行包 README §4/§8 与本节。
- **窗口与时点**:建议 3 个自然日,服务器本地时区(Asia/Shanghai)。T0 =
团队完成检查、把地址交给用户的时刻(如周五 19:00 交付,则周日 24:00
到期);日账按本地自然日零点重置;团队准备耗时全部在 T0 之前。
- **文字与生活(models.yaml 日账,程序硬闸)**:对话 60 轮/日、生活 8 次/
日、判定/成长/摘要 40 次/日、日 token 池 2,000,000、创建+诠释+习惯一次
性 4 次。**语音通话里的对话轮同样计 dialogue 日账**(不另开语音 LLM 账)。
- **记忆(若批安装)**:提取 LLM 累计 60 次(`llm_ops`)、新增嵌入累计
180 条(累计口径 `embed_cap=680`、冻结 2 不动,见运行包 README §8.4)、
语义类 40 次/日;嵌入日顶60条,提取日顶40次(两项按UTC切日,即北京时间08:00)。
- **语音(3 通 × ≤10 分钟 = 合计 ≤30 分钟)**:ASR 音频合计 ≤1800 秒
(程序硬闸,发送前拒绝,重启进程不重置);每通连接重试 ≤2(硬闸);
每通墙钟 ≤10 分钟(session_ttl 600 秒硬顶之内,超时房间失效);TTS
合成 ≤60 段(监督计数,到顶人工仅关闭语音服务,不停文字生活;程序不硬闸);建房即用即删(开房失败 503 不重试建房;挂断清理
失败留账自动重试);声音上传登记+试听合计 ≤10 次(监督口径:由陪同
团队按次计数,程序不硬闸)。需要用户当场在浏览器允许麦克风(HTTPS+
自签证书「高级→继续」)。自定义声音当场可用(上传→试听→应用)。
- **碰顶后果(逐项如实)**:LLM 日顶→当次诚实拒绝、界面报错、次日恢复;
ASR 触顶→发送前拒绝、本通结束、全窗不再接受新音频;TTS 段顶→陪同执行者关闭语音入口和worker/桥(文字生活仍可用);窗口到期→统一停服封账(见下)。
- **截止停服(可执行,不口头承诺)**:`force_end.template.sh watch <到期
epoch>`(受监督后台任务)或获批后装 systemd timer 调 `now`;到期动作 =
逐个 DELETE 本实例房间 → worker → api → wss 桥 → 共享 TTS 按各自专属
pid 句柄停 → `seal_budget_hel879.py` 封账归档。**封账不解除**。
- **断开与恢复**:关网页/断开后她的档案、聊天、关系、记忆原样保留(重开
验证过);共享 TTS 9880/面板 8090/wss 桥 8363 只在批准窗口内启动、到期
停回;LiveKit 7880 常驻不动。
- **与 C 的边界(本轮更正)**:B 窗口**内**的每日生活/主动留言/背景循环
运行,计入 B 的 life/proactive 日账与日 token 池(上表);B 窗口**结束**
后一切停,不自动转任何常驻。C 指窗口之外的长期后台——暂不批。不存在
同一份日用量既写进"B 已包含"又写进"C 未批准"的矛盾口径。
- **如实告知用户的已知边界**:首声延迟修复后未实测(原实测 9.53s,不承诺
任何推算值);真人打断、生成中文字取消的正面样本未取得;日子/主动留言
的真实生成首次发生在这个窗口里,质量如不足属正常迭代范围。
### C · 长期后台
- 暂不批。B 窗口结束后凭实际用量与质量报告(`llm_ops`/`embed_ops`/日账
导出)再定常驻节奏与**新的独立账本**;在此之前不自动续跑,
`CYBERGRIL_R2_ENABLED` 不在窗口外保持开启。
## 3. 不变边界
本建议不改变任何需求与验收状态:8300/8310/8320/8350 与资料不动、正式界面
不替换、PR 不合并、4080 不触、旧三窗口余额不结转;阶段体验不等于完整产品
完成,R5–R7 未完项继续按主卡承接。
## 4. 最终启用边界(总管取件勘误)
运行包README §10为操作勘误,优先于旧模板。全部仍待白栖知批准。
语音仅团队陪同预约,每通600秒、总ASR1800秒、总建房尝试6次;
TTS60段和登记+试听合计10次由当班人员监督,不冒称自动硬停。
文字生活在语音配额耗尽后仍可运行到总截止;到期按受监督定时服务停服,
资料保留、账封存,房间/共享音色与临时服务由团队核实清理。
完整自定义声音的8090面板句柄需在放行前复用HEL907方法准备,不得占用8081。
装包缺轮或解析冲突即停,不改锁版本。准备预计1–2小时,团队真链检查另限2小时,
检查不过不把失败实例交用户。用户首次可用地址交付后计3个自然日(当日算第一日),
用户也可先约定开始日期,避免地址发出时本人不在而浪费体验期。
自定义声音细分额度(同一次待批方案):最多登记2段参考音频,每段由陪同人员先核≤20秒;
自动转写最多2次/合计40秒,单列监督账,不冒充通话ASR的1800秒硬闸。
最多试听8次;登记2次+试听8次共10次。试听合成并入60段TTS总上限。
两类ASR合计最大1840秒(通话1800+参考40);失败/重试同样记账,不返余额。
本轮未执行登记、转写或试听。
### 最终统一预算勘误:团队先验真实记忆,再交用户(总管)
此条取代上文A“0嵌入”的旧建议,仅修改待批方案,当前真实额度仍0。
团队先用独立测试资料核真实Mem0接入,不把首次故障留给用户:
A的55请求/50万tokens内,最多10次用于SDK提取;先用最多20条新嵌入。
这10次/20条从同一批新申请的60次提取/180条嵌入中先扣,
不是另加额度;余量(至少50次提取/160条嵌入)留给用户期,使用实账计算。
两阶段共用唯一活动记账文件、串行写入,测试向量/档案与用户向量/档案分开,
只共用计数账,不拷贝测试聊天或测试记忆给用户。旧权威embed_ops前缀不改,
旧39/60实验的授权和账原封保存,不挪用其余额;新提取批建立独立可审计操作记录。
如旧llm_ops表已有用量,新60次必须按现值加增量并保存旧行,不可清表/重置;
操作前核旧基数并留证,实际新增始终≤60。团队检查最多2小时,从首个真实调用计时;
检查不通过则停止并报告首个错误,用户期不自动开始。
+57
View File
@@ -0,0 +1,57 @@
{
"baseline_version": "1.12",
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"VIS-003",
"VIS-004",
"VIS-008",
"VIS-010",
"VIS-013",
"UX-001",
"UX-003",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
"acceptance": [
"外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
"通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
"登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
"回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
],
"deferred": [
"后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
"语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
"编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
"_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
"真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
],
"requirement_changes": [],
"verification": [
"web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
"浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
"对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
"需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
"未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"baseline_version": "1.12",
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"task": "HEL-904:HEL-902 统一审核后的后台有限返修(HEL-899 原作者)。三项:①登记在8090成功而本地元数据落盘失败→503中文映射(编号可恢复、共享登记不回滚、不逼重复登记),ASR流水先记再落盘;②worker状态文件损坏整体重写的实况补入docstring/合同,正常可读文件仍保留其他键;③修通话捕获音色与worker实读竞态:以会话不可变快照(通话窗口持有)保证响应voice=worker本通话实读声音、通话中改选只影响下一通,覆盖失败/取消/挂断失败转欠账/TTL到期结算,确定性Event门控无sleep;④补测申请材料收紧(仅材料不执行)。零真实调用、零部署、零下载、不动web/与共享worker、不新增依赖",
"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": "独立分支(自b09a907)后台返修:custom.py登记失败映射LocalRecordFailed+ASR流水先记、write_worker_voice损坏实况docstring、通话持有hold/release+apply暂缓写、router捕获登记/失败取消释放/503映射/settle挂钩、session.py on_session_settled结算(挂断成功/转欠账/TTL到期);tests/test_voice_review_hel902.py第1例按新契约改写(含重启恢复与409守门断言)、新增tests/test_voice_rework_hel904.py确定性时序4例;合同docs/voice-custom-contract-HEL899.md §2.2/§2.5/§3更新;docs/rework/hel902/r5-followup-package.md收紧(head里程碑表:d167ba9=整合提交、b09a907=审核head、本卡head待统一复审登记,登记ASR与连续通话ASR区分、连续通话ASR计数手段与待补阻塞如实声明、停声量测单一时间基准帧级录音、参考音频明确D1包female_soft段含sha256/长度、回收清单与不未经确认保留测试垃圾、共享TTS启停快照恢复、git回退不代替共享回收、上限含失败口径与硬闸/监看界线);docs/rework/hel904/REWORK.md返修记录",
"acceptance": [
"登记成功+本地元数据写失败:503中文文案含编号、明确共享登记不回滚、刷新列表可找回并试听/应用、不逼重复登记(tests/test_voice_review_hel902.py改写例:含重启按编号label=编号继续用、新音色未试听409守门不放松、11支内置与通用助手零改写)",
"worker状态文件损坏实况:docstring/合同如实注明整体重写仅剩voice,不再宣称任何情况下保留其他键;正常可读文件其他键保留(既有用例继续通过)",
"竞态收口:确定性Event门控测试把worker读时点延迟到dispatch返回之后、并发应用新声音后——本通话worker读到=响应voice=旧音色;挂断结算后下一通capture用新声音;挂断失败转欠账、TTL到期、dispatch失败、请求取消四条路径都释放持有、应用随即写透;无sleep凑测试(tests/test_voice_rework_hel904.py 4例)",
"补测材料:仅材料不执行,候选上限只收紧不放宽,一切失败/取消/重试/出站无响应计入口径;LLM/嵌入=程序硬闸与其余工程监看界线写明;连续通话ASR无程序计数手段处如实标待补阻塞;真实cap=0保持"
],
"deferred": [
"真实登记/可选转写/真实试听/应用与下一通验证、克隆相似度、打断停声帧级量测、真机麦克风路径:待主卡批准补测包后执行,未批前真实cap=0",
"本卡后台修复与前端另卡(外接服务文案、会话页显示本次音色)需原总工一次统一复审;通过后登记最终已验证head,此前任何head不得当作执行依据",
"连续通话ASR分钟级音频时长与连接重试的程序计数手段:待补阻塞,窗口前由总管在.12验证LiveKit/worker侧指标可执行性,不通过则窗口不得启动含连续通话步骤",
"前端同锁文件npm安装复跑:单独待批项,本轮不执行",
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;R5整体验收与R1-R7完整功能持续承接;本卡通过≠自定义音色用户验收,更≠完整产品完成"
],
"requirement_changes": [],
"verification": [
"pytest tests -q:380通过(基线376+新增4,py3.11.16复用既有venv零安装),全量含HEL-899 18项与总工故障注入全数通过",
"python -I scripts/verify_hel899_custom_voice.py:12/12(含通话中改选定住本通话、下一通生效)",
"ruff check src tests scripts:通过",
"需求检查4/4:--base-ref origin/main 与 --base-ref b09a907 各带/不带本handoff全部通过",
"未验证(如实):真实worker读时点、真实dispatch/房间、打断停声与真机路径——全部留在获批补测窗口;重叠通话不同音色的残余竞态如实记录于合同§2.5与REWORK.md,不宣称消除"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"baseline_version": "1.12",
"read_commit": "7cb73429f7a0585bed17873179a627c890336c70",
"task": "HEL-905:HEL-903 前端返修与 HEL-904 后台返修的统一离线复审(总工)。在自己独立分支 hel-905-unified-review@7cb73429 集成前端088ac62(原作者源码51669aa,11文件逐字节一致)与后台07dda2b,不重出架构、不另起视觉、不重复派工;一次审完三处前端修正(语音合成五态与ASR无单独健康检查的诚实说明、演示零API外发、session voice固定本次声音)与500/503/断连恢复流(先刷新找cg-编号条目、缺资料标注、不自动重登记/不自动删除)、后台通话持有方案的失败/取消/挂断转欠账/TTL结算路径、以及补测包材料可批性。真实cap=0,零部署、零下载安装、不发真实请求、不合并PR",
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "VIS-003", "VIS-004", "VIS-008", "VIS-010", "VIS-013", "UX-001", "UX-003", "UX-005", "UX-006", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
"deliverable": "统一复审结论(本卡评论)+集成分支 origin/hel-905-unified-review @ 7cb73429f7a0585bed17873179a627c890336c70(merge 前端088ac62与后台07dda2b于 b09a907 之上,历史正常保留)。独立核验:11文件SHA256与原作者改后清单逐一一致;前端三处修正与恢复流按源码逐行复核(callVoiceOf只认严格id、default=默认声音、缺答=声音未说明不猜、失败/结束复位OFF、late grant被stale/gen挡住;演示模式全部效果守if(!real)不发任何请求;uncertain分类422/409/离线=不确定false、5xx/发送后断=不确定true,主按钮换刷新列表、从不自动重登记);后台持有hold在capture写文件后无await间隙登记、挂断成功/挂断失败转欠账/TTL到期/dispatch失败/请求取消五路释放、Event门控无sleep。总工独立复跑(复用既有venv311,零安装):pytest tests 380/380、verify_hel899_custom_voice 12/12、ruff src tests scripts 通过;需求检查4/4(origin/main与b09a907各带/不带本handoff)。前端构建/单测/浏览器因web/node_modules缺失未独立复跑,如实列为待批项,不以作者148/148、90/90冒充",
"acceptance": [
"集成核验:7cb73429含088ac62与07dda2b两个先祖、基点b09a907;author-manifest.json 11文件改后SHA256与统一head工作树逐一一致(含handoff JSON共11项match 11/11);前端仅作者源码,总工未替作者改视觉方向",
"前端三处修正复核:readVoiceStatus五态(none/off/ok/down/unknown)+ASR行如实写「没有单独的健康检查,以实际通话为准」;演示模式services/voiceStatus效果全守if(!real)零API外发;session voice只取本通grant严格id、命名查找按id匹配、跨id答案不显示、结束/失败复位、下一通用新选择",
"恢复流复核:uncertain(5xx/发送后断/体不完整)→提示先刷新找cg-新条目、刷新前后集合比对指认新增项、idOnly条目如实标注「只有编号,缺本地资料」并说明可试听可用、无删除入口、绝不自动重登记或自动删除;Chromium自动重发登记POST的服务端非幂等风险如实保留(重试另立新cg-编号+共享注册表孤儿条目,合同§2.2如实声明,不宣称前端提示已解决服务端幂等)",
"后台通话持有复核:capture写文件后同步登记持有(无await间隙);挂断成功、挂断失败转清理欠账、TTL到期_prune、dispatch失败、开房请求在途取消五条路径均释放持有;期间apply只记选择不写worker文件,会话响应voice=worker本通话实读;重叠通话较新capture重写文件的残余如实记录于合同§2.5,顺序用例通过不称竞态根除",
"总工独立复跑:pytest tests -q 380/380、python -I scripts/verify_hel899_custom_voice.py 12/12、ruff check src tests scripts 通过(venv311=Python3.11.16复用,零安装零下载);需求检查4/4通过",
"补测包核对(材料,不执行):连续ASR精确计数仍为待补阻塞且包内如实声明;包首行不声称材料齐备;停声量测单一时基、female_soft参考段sha256、8090新增cg-清单与回收、共享TTS快照恢复、失败计入用量等逐项在案;候选上限未超904已收紧值"
],
"deferred": [
"前端独立复跑(同锁npm安装、tsc/vitest/build、浏览器两主题×电脑/手机、录音释放/字幕/取消迟到/演示零外发):web/node_modules缺失,本轮禁止安装,列为待主卡批准项;作者148/148与90/90仅为作者证据",
"真实登记/试听/应用、真实worker读时点、打断停声帧级量测、真机麦克风:待主卡集中请批新窗口后执行,本轮真实cap=0、未部署、未触旧服务与8090/4080",
"挂断失败转欠账与TTL到期释放持有时,远端房间删除落地前的极端未入场worker仍可能读到后续写透值:与重叠通话同类残余、每worker仍整通话单一音色,建议下轮在合同§2.5残余段一并补写明(非阻塞)",
"连续ASR分钟级时长/连接重试的程序计数手段:仍为待补阻塞,窗口启动前须由总管在.12验证LiveKit/worker侧指标可执行性",
"CG-009/R6删除与完整备份恢复、R5真机与整体验收、R1-R7完整功能继续承接;本卡通过≠自定义音色用户验收,更≠完整产品完成;旧HEL-879窗口封账永不重开"
],
"requirement_changes": [],
"verification": [
"git:7cb73429为origin/hel-905-unified-review head,merge-base核088ac62/07dda2b均为先祖、基点b09a907未回退、母线与PR#11未改写",
"SHA256:docs/rework/hel903/preservation/author-manifest.json全部11项与统一head工作树一致(独立脚本核对match 11/11)",
"venv311(Python3.11.16)独立复跑:pytest tests 380/380、verify_hel899_custom_voice.py 12/12、ruff check src tests scripts 通过",
"需求检查4/4:--base-ref origin/main 与 --base-ref b09a907 各带/不带 docs/requirements/handoff-HEL-905.json 全部通过(60项)",
"未验证(如实):前端构建/单测/浏览器未独立复跑(缺node_modules,不安装);真实语音链路全部未动;补测包材料审读基于文档与源码,实际量测留获批窗口"
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"baseline_version": "1.12",
"read_commit": "0b804a49ef53eb3d251347c147d96a3f6aa5fe19",
"task": "HEL-906:补齐连续语音实际用量计数与测试准备(施工;2026-10-28 总管定点复核返工)。只补 HEL-905 复审明确列出的连续通话 ASR 实际用量/连接重试计数缺口:在 CyberGril 专属 worker/适配层复用现有 LiveKit/FunASR 接口,落地每通话与全窗口的实际送入 ASR 音频量与 连接尝试/重连/失败计数;达到有限窗口上限时在发送前/建连前阻止并结束本通;上限为待用户批准的新一次性窗口,默认未布防=真实 cap=0(fail-closed,与 think/budget.py 同一先例)。定点返工只修总管复核出的两个缺口:①账本写入失败不得使预算倒退(持久化读/写失败或损坏时停止本窗出站并保全已知消耗,禁止默认零预算/继续放行);②发送失败/取消后的不确定出站不得漏计(发送前保守预记整帧、失败/取消不退还,确证送达量另列,不得宣称预记皆确证)。旧 HEL-879 封账保持原样。零模型/嵌入/真实 ASR/TTS、零新增安装下载、不启动服务、不改共享 /opt/voice-agent、不改视觉、不合并 PR。",
"requirement_ids": [
"CG-010",
"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": "独立分支 hel-906-asr-usage-counting(基于 origin/hel-905-unified-review@0b804a4;首轮源码 ecec9df+材料 ccfc04e,定点返工源码 cf395d4+本材料提交):①src/cybergril/voice/asr_usage.py(仅标准库)——追加写 JSONL 账本、每通/全窗计数、fail-closed 默认(无 CYBERGRIL_ASR_WINDOW_ID 即拒绝一切连接/音频)、触线在发送前/建连前拒绝(AsrWindowClosed)、账本重放恢复(同窗重启不重置预算、跨窗口互不影响)、只读汇总 CLI;返工新增——保守预记 reserve_audio(发送前计入,失败/取消不退还)+确证送达 note_audio_delivered 双口径(confirmed_seconds 单独累计与落账、重放恢复)、账本写失败/读失败/行损坏/中途消失 fail-closed:停止本窗一切出站、内存已知消耗只增不减(禁止零预算回退)、error 日志与停机原因、guard_stop 落账且同窗重启重放仍停;②stt_funasr.py 真实送数接缝——建连前 check_connect、建连结果落账、发送前 reserve_audio(越限帧/账本故障在此拒绝,零字节出站)、await ws.send 成功返回后 note_audio_delivered、utterance 边界/流结束聚合落账、接收侧死亡标记;usage=None 时行为与本卡之前完全一致;③worker.py entrypoint 按 job 构造 guard(房间名为每通标识)、on_exhausted→ctx.shutdown 结束本通(触线与账本故障同一接线)、启动日志输出窗口摘要(含停机状态);④tests/test_voice_asr_usage.py 24 项确定性离线测试;⑤docs/rework/hel902/r5-followup-package.md §4 更新为返工后如实口径+里程碑表登记返工提交;本 handoff JSON",
"acceptance": [
"计数口径:音频=发送前按整帧保守预记的样本数/采样率(48k 重采样后按 16k 输出帧计,重发按重复计);发送失败/取消不退还(帧可能已出站);确证送达量(await 成功返回)单独累计,只作核对下界,不得宣称预记皆确证送达;墙钟与事后成功流水不参与",
"账本 fail-closed(总管复现回归):注入 Path.open 追加失败后已送音频仍在内存(1.3s 不回退 0.8s)、重放不得拉回旧账本值、后续 0.6s 在发送前被拒、停机回调恰好一次带原因;读失败(read_text 注入 OSError)、行损坏(坏 JSON 行)、账本中途消失(unlink)同样 fail-closed 停出站且保全已知消耗;guard_stop 落账后同窗重启仍停",
"连接口径:每次建连尝试(含失败)逐条落账;正常说完一轮后的下一次建连不是 retry,只有失败/接收侧死亡/流异常之后的建连计 retry;每通 retry 上限建连前拒绝(测试驱动真实接缝验证拒绝始于建连之前、假 provider 未被触碰)",
"到线停机:全窗口音频上限触线时越限帧在发送前拒绝(出站字节恰至上限,一个字节不越线,触线帧不碰 provider)、on_exhausted 恰好一次、worker 接线断言 ctx.shutdown 真被调用结束本通;未布防默认拒绝一切 ASR(真实 cap=0 的代码级落实)",
"历史与恢复:删房成功后 asr-usage.jsonl 逐通历史原样保留(测试用真实 VoiceSessionService 成功删房对照);同窗口重启 guard 重放恢复预算/逐通计数/确证量,不得重置;不同 window id 互不误停",
"确定性离线测试(24 项):静音/取消、建连失败/重连、反复结束、不同采样率、触线前拒绝、落盘恢复、崩溃少计有界(≤flush 如实声明)、假 ws 先记 payload 再 raise/取消后预算仍含该帧而确证量不含、worker entrypoint 注入断言",
"补测包 §4 更新为返工后口径:保守/确证双口径、账本故障 fail-closed、程序保证限定为布防进程内(跨进程 ≤5s 有界过冲、崩溃 ≤5s 少计仍为监看边界,不承诺全窗口跨进程绝不越限)、窗口执行约定 ≤3 通顺序不重叠+参数收紧预留余量;候选上限不放宽(≤2h/3通话/6开房/LLM20/60000tokens/嵌入0/登记3/预听10/通话TTS40段/ASR45分钟/每通重连6次/新增付费0)"
],
"deferred": [
"窗口前 .12 真实栈联验:用 scripts/gen_hel877_sdk_check.py 同法在真实 agent venv 加载本模块,确认真 SDK 帧率/重采样与 ctx.shutdown 真实行为(离线假件无法替代,未验证前不启动含连续通话步骤)",
"新一次性窗口仍待白栖知批准:未批前真实 cap=0,不启动、不预留出站;获批后由窗口负责人显式布防 CYBERGRIL_ASR_WINDOW_* 环境变量",
"跨进程并行通话的有界过冲(≤5s resync/flush 间隔)与崩溃少计(≤5s)属如实声明的监看边界,不冒充零误差硬闸;窗口执行约定 ≤3 通顺序执行不重叠、实操上限与 flush/resync 收紧预留余量(已写入 §4)",
"真机麦克风/文字打断帧级量测、LiveKit/worker 侧房间指标交叉核对:并入获批窗口执行,本卡不自行采麦/摄像头",
"前端浏览器/构建独立复跑(同锁 npm ci)仍为单独待批项;CG-009/R6 删除与完整备份恢复、R5 整体验收、R1–R7 完整功能继续承接;本卡通过≠自定义音色或连续语音用户验收"
],
"requirement_changes": [],
"verification": [
"pytest tests/test_voice_asr_usage.py -q:24/24 通过(venv311 复用,零安装;含总管复现的预算回退回归路径)",
"受影响语音区回归:test_voice_asr_usage+test_voice_session+test_voice_custom+test_voice_rework_hel904+test_voice_review_hel902+test_voice_chat_adapter+test_voice_rework_hel879 共 105/105 通过;按卡约定未重跑全量 380/12(作者证据保留)",
"ruff check src/ tests/ 通过",
"需求检查:python3 scripts/check_requirements.py 对 origin/main 与 0b804a4 各带/不带本 handoff 四组通过",
"未验证(如实):真实 FunASR/LiveKit/CosyVoice 栈、真实麦克风、浏览器/手机画面——零真实调用,全部待获批窗口"
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"baseline_version": "1.12",
"read_commit": "038f1eeae71a9f67362d4694548d7d8116e0e35f",
"task": "HEL-907:验证网页自定义声音和三通真实交谈。白栖知批准(主卡评论 01a18246-672a-7a19-9e8a-69c6f547a232 同意这轮测试)后,在统一源码 038f1ee 上执行既有锁定依赖构建与浏览器脚本独立复跑;隔离部署(8360/8361 双入口同进程、8363 WSS 桥、8082 专属 worker、共享 TTS 9880 与面板 8090 窗口内临时恢复);真实网页链路(参考录音上传→cg- 登记→真实 TTS 试听→应用);三通真实通话验证 worker 实用音色与会话 voice 字段一致、通话中改选不换声下一通生效;并入 HEL-879 遗留的文字打断在播语音与打断停声时延量测;全程按批准上限执行并在结束后回收 cg- 条目、恢复共享服务、封账交付。",
"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": "docs/rework/hel907/(window-record.md 窗口记录、build/ 构建与浏览器独立复跑日志、web/ 真网页链路截图与试听音频、window/ 逐通字幕/帧时间轴/回流音频/账本/快照);web/tests/hel900_shots.mjs 一处最小脚本适配(HEL-903 后 503 登记进入先刷新状态,检查意图不变,产品代码零改动);服务器证据包 /opt/cybergril-hel907/hel907-evidence-20261028.tar.gz(sha256 37ee1881…2e07e)。实测结果:网页自定义声音全链真实完成(cg-f2ec321d 登记 1 次出站、真实试听 2 次含音频存证、应用后 call1/call2 会话 voice=cg-f2ec321d 且 tts.log 合成音色逐一对应);call2 通话中改选 rain 本通不换声(worker 状态文件钉住)、call3 voice=rain 下一通生效;文字打断在播语音发现真实缺陷(文字轮被 worker 静默重试链取消、语音播完约 32 秒、文字无回复,DB/字幕/api.log 三层证据,源码机制定位至 llm_chat_api.py 重试守卫失效,本卡按边界不修);回放打断 turn_cancel 真实发生但帧级停声时延未取得(打断源过轻+旧轮无可闻声,如实不冒充);全部上限内完成(LLM 8/20 请求、23958/60000 tokens、嵌入 0、建房 4/6、通话 3/3、试听 2/10、通话 TTS 11/40、连续 ASR 195.16/2400 秒、重连 0、新增付费 0,时窗 18 分 32 秒);cg- 条目经 8090 删除入口回收、共享注册表恢复 11 支内置原样、9880/8090 按窗口前状态停回、8360/8361/8363/8082 全停、LiveKit 房间清空。",
"acceptance": [
"构建独立复跑:npm ci(既有锁文件);typecheck 0 错、lint 0 错 1 既有警告、vitest 148/148、build 成功;hel900 浏览器 83/83(1 处最小脚本适配已声明)、hel903 浏览器 90/90(零改动);本机既有 Chromium 156 运行时库解压到用户目录加载,未下载浏览器、未装系统包",
"真网页链路(真实浏览器经 HTTPS 8361):female_soft.wav 上传前 sha256 只读核对一致;登记成功 cg-f2ec321d(8090 唯一写者,参考文字手填、转写 0);真实 TTS 试听 24kHz/3.72s 音频存证且页面真实播放;应用后 /api/voice/selection 一致;逐步截图与网络事件留证",
"通话验证:call1/call2 会话响应 voice=cg-f2ec321d 与 worker 实际合成音色(tts.log)一致;字幕 v2 seq 单调、turn_end/turn_cancel 生命周期正常;挂断 DELETE 200、房间真实删除、删后仅约 20ms 在途帧",
"通话窗口持有:call2 通话中改选 rain 返回 200 而本通 worker 状态文件保持 cg-f2ec321d;call3 会话 voice=rain 下一通生效;会话响应 voice 与 worker 实读一致",
"文字打断在播语音:缺陷如实记录并可复现核对(chat_message id=6 cancelled / id=5、id=8 sent;call1.subtitles.jsonl t0=1793189878442 前后;api.log 第 122-129 行五次 POST 时序),不冒充通过",
"打断停声:回放打断 turn_cancel 真实发生(FunASR 真实识别);帧级旧声停止时延明确未取得(打断源 RMS 过轻致 VAD 15 秒后才判、旧轮 LLM 卡死无可闻声),同钟帧时间轴方法已备(call1/call3.frames.json),不捏造数字",
"上限与封账:全部类别含失败口径计数并留实时账本快照;触线为零;封账后服务全停、cg- 回收前后快照比对、11 支内置与全局默认零变化、共享服务按前快照恢复(9880/8090 窗口前停机→停回)、保护端口零接触、新增付费 0"
],
"deferred": [
"文字打断在播语音缺陷修复与复测:worker 适配器 llm_chat_api.py 重试守卫(_retry_on_chunk_sent 从未置真)与文字/语音互打断语义修复,交总管安排专项卡;修好前不得声称文字可打断语音",
"帧级打断停声时延:方法已验证,待响亮短促打断源+她正在可闻说话时机复测;真实用户麦克风起声时延(D3 用户动作:局域网设备浏览器开 https://192.168.200.12:8361,证书两处高级→继续,允许麦克风后对她说话,建议每次 ≤2 分钟)与 iOS Safari/安卓 Chrome 真机路径未测,交总管向用户交付入口",
"CG-009/R6 自定义音色删除入口与完整备份恢复、R5 整体验收、R1–R7 完整功能继续按获批依赖承接;本卡通过不等于自定义音色用户验收或 R5 完成",
"本轮窗口已封账:LLM 8/20、tokens 23958/60000 等余额不自动授权新窗口;后续窗口须另行获批并显式布防"
],
"requirement_changes": [],
"verification": [
"构建/浏览器:docs/rework/hel907/build/*.log(typecheck/lint/vitest/build/hel900-browser/hel903-browser)与 hel900-shots/hel903-shots 截图",
"网页链路:docs/rework/hel907/web/*.png 逐步截图、preview-cg-f2ec321d.wav 真实合成试听音频、webflow-result.json 网络事件与最终 selection",
"三通通话:docs/rework/hel907/window/call{1,2,3}.json + subtitles.jsonl + frames.json + agent.wav(回流音频逐通落盘)",
"用量与闸门:window/asr-usage.jsonl 与 asr-usage-summary.json(hel907-20261028-a 逐通 95.18/41.34/58.64 秒,confirmed=conservative);budget 快照在 call*.json;tts 窗口段数以 tts-window-tail.log 核对",
"缺陷证据链:call1.subtitles.jsonl + api.log + chat_message 行(id5/6/8)+ 源码定位(src/cybergril/voice/llm_chat_api.py:87-90 与 api/app.py:577-605 取消路径)",
"回收与恢复:voices-before.json / voices-before-recycle.json / panel-list-after-recycle.json 前后比对;pre/post-window-state.txt 端口与 GPU 快照;post 状态 8360/8361/8363/8082/9880/8090 无监听、8300/8310/8320/8350 原样、8340 停机",
"需求检查:check_requirements.py 对 origin/main 与 038f1ee 各带 --handoff 本文件通过(日志随交付);未验证项(真实用户麦克风、真机 Safari/Chrome、帧级停声时延)已如实标注"
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"baseline_version": "1.12",
"read_commit": "5625de6606a607a9d4fd583fe8a52d93d6d2b4ea",
"task": "HEL-908 两轮定点返修(第二轮为 HEL-909 审核不通过后的集中返修一次)。第一轮修复文字打断丢字/旧轮复活/旧语音32秒(chat.cancelled 终态帧+cancelled 轮拒绝重新激活+worker 499 不重试+SpeechHandle.interrupt 停播,HEL-909 复核通过)。第二轮按总管裁定修两个核心缺口:R1 思考分流改显式整轮缓冲模式(原流式 hold 在中文句号/超128字符形状下整段泄漏且 closer 被适配层提前删除、轮级 split 无法兜底,第一轮『落库与 receipt 始终干净』表述不实已删除);R2 补上 LLM 已 final、TTS 仍在播时的权威打断通道(网页既有 hush() 入口经房间数据通道发 cg.interrupt,worker 订阅校验后 session.interrupt(force=True))。全部离线、零真实请求、零部署、零新依赖。",
"requirement_ids": [
"CG-006",
"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": "同分支追加提交(agent/2/15e79a2458bb,第一轮 b8ec832 之上)。R1:providers/llm/reasoning.py 重做为双显式模式——TurnReasoningGate(buffer_turn,整轮缓冲零中途外发,流结束 split_reasoning 后一次性下发干净正文;取消/超时行为不变)与无启发式 ReasoningSeparator(stream 默认:仅滤显式 <think> 块跨chunk安全,stray closer 原样透传保住轮级兜底信号);openai_compat 显式 reasoning 模式参数;LLMConfig.reasoning 字面量校验+models.yaml 三档显式 buffer_turn(未配置=既有语义,向后兼容)。R2:web/src/voice/session.ts+livekit.ts 纯传输接线(VoiceRoom.interrupt(),既有 hush() 附带 publishData reliable topic=cg.interrupt,fire-and-forget 不阻塞文字发送,零视觉改动);worker.py bind_interrupt_channel(本房间 data_received 订阅,topic/发送者/载荷三重校验,session.interrupt(force=True),失败大声留痕,add_shutdown_callback 卸载);llm_chat_api._interrupt_owner 改 force=True+留痕。文档:docs/rework/hel908/summary.md 重写(删除不实表述、保留原始证据与纠偏、记录等待代价与边界),tests-remote-r2.log、web-r2.log、check-requirements.log。",
"acceptance": [
"R1 评审复现串逐字/小块经真实 OpenAICompatLLM→真实 app→持久化/receipt:'private reasoning '*20+'</think>回答。' 与 '我先想一下。内部思考</think>回答。' 的 chat.delta、落库行、chat.final、receipt text_hash 全部只含干净正文(buffer_turn 生成中零外发,修复前同输入已被本卡离线复现整段泄漏);记录流 id=12 按 1~1000 任意分块同样全干净;正常中英文正文逐字节保留;未闭合显式思考→空正文→诚实 chat.error;stream 模式显式块/推理字段/跨 chunk 语义保持,stray closer 透传可被 app 轮级 split 清理",
"R1 等待代价如实记录并测试固定:buffer_turn 首个可见正文等整轮生成完成(生成中零 chat.delta);取消轮照常 chat.cancelled 零泄漏、超时照常 chat.error;无 closer 无字段的推理不可识别边界已声明,不虚称全好",
"R2 真实 SDK 证明 8/8(agent venv 1.8.8):真实 SpeechHandle(allow_interruptions=False 覆盖 force 语义)+真实 pending 播放等待(wait_if_not_interrupted)——完整轮 chat.final 干净结束后(评审 evidence C 形状)注入 cg.interrupt 数据消息,播放等待被真实 interrupt 释放、句柄 interrupted、force=True 断言成立;非 force 在该句柄上 RuntimeError 复核;校验矩阵(错 topic/服务端来源/自身/坏载荷/错版本)零触发;卸载后零触发;session 未运行大声失败不崩溃",
"R2 app 集成记录:完整语音轮后的文字轮为普通轮(无任何行被取消、无 phantom 行);web 侧 hush() 在 speaking/thinking 才发消息且本地静音反馈保持,下一回复恢复正常收听路径未动;ChatPanel/视觉零改动",
"回归:.venv311 全量 480 passed/8 skipped(2 个 test_r3 失败在基准 5625de6 同样失败,预先存在);第一轮 5 项 SDK 检查全保持(取消终态/不复活/不重试/真实连接错误仍有限重试/完成轮流式+receipt 不变);web npm ci(既有锁文件)typecheck 0 错、lint 0 错 1 既有警告、vitest 148/148、build 成功;ruff 全绿;models.yaml 新键经 ModelsConfig 实际解析"
],
"deferred": [
"真实端到端复测(真实房间/真人打断时延/真机/真实端点思考出现频率与长回复起播延迟)须按授权窗口安排;R2 离线证明是真实 SDK 句柄/等待语义+实装源码核对,不冒充端到端毫秒指标",
"无 closer、无 reasoning 字段、无开头标签的推理任何客户端模式都不可识别,彻底解决需上游自行分流(字段或服务端 parser),零真实请求边界内无法探测——最小缺口如实声明",
"chat.cancelled 的前端可见呈现与 ChatPanel 取消判定 limit 扩大(HEL-909 建议 #3/#4,非阻塞)未动:前者属界面核心交视觉总监,后者纯展示层既有行为",
"gen_hel877_sdk_check harness 在基准上即因 HEL-906 导入失效(前轮已留证),交总管决定维护安排;本卡 SDK 验证由 test_sdk_livekit_hel908.py 覆盖(8 项,agent venv 可独立复跑)",
"R1–R7 完整功能、真实用户麦克风、视频/口型、记忆长跑继续按获批依赖承接;本卡不构成整产品完成"
],
"requirement_changes": [],
"verification": [
"docs/rework/hel908/summary.md:两轮根因/纠偏/方案/等待代价/边界全记录;第一轮不实表述已删除,原始证据与纠偏保留",
"docs/rework/hel908/tests-remote-r2.log:.venv311 全量 480 passed/8 skipped/2 pre-existing + agent venv SDK harness 8/8 实况",
"docs/rework/hel908/web-r2.log:typecheck/lint/vitest 148/148/build",
"tests/test_sdk_livekit_hel908.py 可在任何装有 livekit-agents 1.8.8 的环境复跑(pytest 或 --standalone),命令写在文件头",
"需求检查:check_requirements.py 对 origin/main 与 b8ec832 各带 --handoff 本文件通过(日志 docs/rework/hel908/check-requirements.log)"
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"baseline_version": "1.12",
"read_commit": "958c6962fb1f2a4462a6df794d893aee1fcb0667",
"task": "HEL-910:按白栖知批准的《HEL-797-交谈修复复测安排》(主卡评论 01a18753-8c70-7181-9758-5bab4685ba9f「继续测」)对候选 958c696 做两次真实通话复测:真实网页原发送入口的文字打断(在播时机+final待播间隙)、python 同钟时间轴的等待链与回放打断、思考隔离真实端点检查;隔离部署 8360/8361/8363/8082 + 共享 TTS 9880 按快照临时恢复,全程批准上限内执行并清理恢复。",
"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": "docs/rework/hel910/(window-record.md 窗口记录、window/ 逐通证据含 call2 同钟字幕/帧时间轴/回流音频/DB终态/ASR账本/前后快照/日志、web/ 真网页逐步截图、tools/ 本窗部署与测量脚本及配置);服务器证据包 /opt/cybergril-hel910/hel910-evidence-20261029.tar.gz(sha256 83363576…d7a3f0)。实测结论:真实网页发文字(真 Enter 键)在她语音在播时发送——旧声停止(浏览器能量 ≤~1.4s 持续静默、本地 hush 静音即时)、文字获完整回复并落库、旧轮不复活无幽灵行、被打断轮回执正确缺席、后续语音轮正常出声并完整回执(两次确认恢复);输入结束→实际第一声实测 9.53s(user_final 后 7.10s、生成开始后 3.44s,>5s 关注线,如实交回);全部真实回复零思考泄漏但真实端点未出现带 </think> 形状(覆盖范围如实限定,离线证据在 HEL-909);时机A(生成中被文字取消)因网页侧检测失误与 API 直发竞速反向未获正面样本(文字轮被更新语音轮正常取消:行 cancelled+SSE 幂等 chat.cancelled 终态帧,恰为 HEL-908 R1 修复路径的真实表现),随后语音轮撞 10/10 预算硬闸诚实 φ 失败;可闻旧声期间的回放打断样本因时机错过未取得(如实,与 HEL-907 同项均未取得)。用量:LLM 10/10 触线(30,398/40,000 tokens)、建房 4/4 触线、通话 2/2、TTS 6/20、ASR 350.63/720s(保守=确证、重试 0)、窗口 23.6/45 分、登记/上传/转写/试听/嵌入/付费全 0。清理:窗口端口与专属进程全停(精确 PID)、LiveKit 房间空、voices.json 哈希前后一致 cg- 零残留、共享 TTS 按快照停回、面板 8090 全程未启用、8300/8310/8320/8350/7880/10095 未动、GPU 回 747MiB。",
"acceptance": [
"部署核验:服务器源码树与 git archive 958c696 逐文件 sha256 一致(仅多 REVISION 标记);web/dist 含 cg.interrupt 接线;models.yaml 三档 reasoning: buffer_turn 与候选一致仅预算块不同;cap=0 fail-closed 预检(persona 拒绝、chat 诚实 error 零出站、voice 503 不建房)通过后才布防",
"真实网页链路:自签证书按用户姿态逐 origin 接受;语音通话从真实 UI 开启;文字从真实输入框+真实 Enter 键发送(hush→cg.interrupt 真实链路,未绕开网页修复);逐步截图 9 张留证",
"时机B(在播时发文字):旧声停止(浏览器能量探测 ~1.4s 内持续静默+本地静音即时)、T1 完整回复落库 sent、R1 不复活、后续语音轮正常出声并完整回执",
"final待播间隙(网页入口):U3 回复文本完整落库(音频截断、回执正确缺席)、T2 完整回复、再次语音轮正常出声回执",
"等待链(同钟毫秒):input_end→user_final 2.43s→生成开始 4.10s→实际第一声 9.53s→字幕全文/落库 12.10s→回执 ack;数字与 wav 能量对齐方法留证",
"思考隔离:18 条 companion 行/字幕/TTS 文本零思考;如实声明真实端点未出现带 closer 形状",
"上限与封账:全部类别在上限内或触线即停并如实记录;失败/取消均计入(call1 挂断后 U4 轮、call2 竞速取消轮与预算 φ 轮)",
"清理恢复:端口/房间/PID/声音列表/哈希/GPU 前后核对留证;隔离目录与证据包保留;未触碰任何既有实例与真实档案"
],
"deferred": [
"真实用户麦克风打断时延与可闻旧声期间的语音打断停声样本(两轮工程窗口均未取得,需真人同轴或下轮工程窗口以她实际有声为门控重试)",
"时机A(生成中文字取消)正面样本(需文字在语音轮服务端激活后、生成完成前到达;本轮竞速反向+预算封顶)",
"真实端点带 </think> 形状的分离复现(本轮真实回复未出现该形状;离线证据 HEL-909)",
"首声等待是否经常>5s 需多样本(本轮 1 精确样本+1 量级相近);停声时延的帧级精测(本轮为浏览器能量粗粒度 ≤~1.4s)",
"真机/手机麦克风、Safari(D3 用户动作)",
"R1–R7 完整功能未完成项继续按主卡承接;本轮不改变任何需求与验收状态"
],
"requirement_changes": [],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main(及 --base-ref 958c696)+ --handoff docs/requirements/handoff-HEL-910.json 全部通过(结构检查,不代替原话核对与体验验收)",
"证据:docs/rework/hel910/window/(call2.subtitles.jsonl 逐事件毫秒时间戳、call2.frames.json 帧到达时间轴、call2.agent.wav 回流音频、db-final.json 全部 20 行含状态与回执账、asr-usage.jsonl、tts-window.log 6 段、api/worker 日志、前后快照);web/ 截图;tools/ 全套部署与测量脚本",
"未验证:真人麦克风、手机真机、真实端点思考形状、多样本首声统计——见 deferred,不冒充"
]
}
+46
View File
@@ -0,0 +1,46 @@
{
"baseline_version": "1.12",
"read_commit": "407fc8158ce392e20ee51c05420c8b74aa545a92",
"task": "HEL-911:修好 HEL-910 实证的两个 livekit-agents 1.8.8 SDK 接缝错误(worker _on_item 对 AgentHandoff 取 role 的 AttributeError;add_shutdown_callback 对同步卸载回调 await None 的 TypeError),并用已有原始证据+实际 SDK 源码+离线复现查清首声 ~9.5s 等待的分段:识别终稿→生成开始的 3.66s 系 1.8.8 默认补入的英文 EOU TurnDetector 对中文终稿的 unlikely 判定把 endpointing 顶到 max_delay=6.0,显式 turn_detection=None 修复(离线 A/B 3.704s→0.004s);同时纠正 HEL-910 交付说明四处事实错误并追加纠偏记录、为下次测量修工具可靠性(分段落盘+服务端活动门控,仅离线验证)。全程真实 cap=0:零 LLM/ASR/TTS/嵌入/登记/试听调用、不建房不开窗不恢复服务,仅只读复用 .12 agent venv 实物 SDK 与既有 wav 做离线证明。",
"requirement_ids": [
"CG-006",
"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/worker.py:_on_item 按 1.8.8 ConversationItemAddedEvent.item 判别字段过滤(仅 type==message 且 role==assistant 的 str 部件正文进回声过滤);bind_interrupt_channel 卸载改协程(SDK 关停回调一律被 await)、幂等、断连不抛,interrupt(force=True)/回执/预算路径不动;TURN_HANDLING 显式 turn_detection=None(endpointing 0.8/6.0 逐字保留,不降 VAD 阈值、不动 buffer_turn)。scripts/gen_hel911_wait_repro.py:真 SDK+真 silero VAD+实测时间轴的离线复现(BASELINE 3.704s 复现实测 3.667/3.654s,修复态 0.004s)。tests/test_sdk_livekit_hel908.py +3 项真 SDK 检查(共 11,.12 agent venv 11/11;既有 8 项回归全保持,unload 调用点随协议改 await)。docs/rework/hel910/window-record.md §9 追加纠偏记录(原文未回改);docs/rework/hel910/tools/hel910_call1.mjs 可靠性修正(探针每 5s 分段落盘 call1-probe-live.jsonl+录音分片即存,防超时杀进程丢证;时机A 触发改为服务端已把语音轮落成 chat 行+生成中相位双条件,不再以页面相位/己方发送为 API 已激活代理;node --check 通过)。docs/rework/hel911/summary.md 完整分段归因与验证记录。产品测试套 .12 规范环境 480 passed/11 skipped/2 failed(两个 test_r3 预先存在与 HEL-908 记录一致);本地 py3.10 与基准 407fc81 干树失败集合完全相同(环境差异非本卡引入)。",
"acceptance": [
"真 SDK(.12 /opt/voice-agent/agent/venv livekit-agents 1.8.8)驱动:AgentHandoff/_TypeDiscriminator/user 消息不触发崩溃且不污染回声过滤,assistant 混合 content 只取 str 正文;真 JobContext.add_shutdown_callback 包装协议下协程卸载通过、旧同步形状经同一包装复现生产 TypeError(反向证据)、重复 await 幂等、卸载后监听真摘除;TURN_HANDLING 下 _turn_detection 为 None 且默认探测器存在性前置断言",
"等待链离线复现(复用 HEL-910 实际回放 wav、实测时延脚本化):BASELINE 终稿+2.443→llm.chat+6.142(3.699s,对上实测 3.667/3.654),修复态 +2.439→+2.444(0.005s);VAD 端点 ~0.7s、FunASR 链+0.6s 去抖 2.43s、服务端整轮 ~1.4s、TTS 首包 1.614s 各段归因落在源码行与实测日志",
"既有回归保持:SDK harness 11/11(文字取消不重试/force 停声/通道校验/未启动存活等);产品套 .12 规范环境 480/2 预存失败与基准一致;web 未动未重建",
"纠偏记录入 window-record.md §9:companion 9 条非 18、生成起点 +6.099s 非 +4.10、assistant_final 是播放收尾/回执门非首次全文生成/落库、post-window.txt ListRooms 为 invalid authorization token 不能证明空房(总管独立核实空房成立);原快照/录音/日志一字未改",
"用量:真实 cap=0 全程保持——零模型/识别/合成/嵌入/登记/试听调用、零建房、零窗口、未恢复 9880/8090 或任何本窗服务、未访问 4080、未改绑定;.12 上仅只读使用 agent venv 与既有 wav,临时副本 /tmp/hel911 已清"
],
"deferred": [
"修复后首声真实总时延(离线推算 9.5→约 5.9s,不承诺):需下轮真实工程窗口复测(工具门控与分段落盘已备好)",
"FunASR 适配器 0.6s 去抖与 2pass 离线纠错往返是否可再压:识别链参数,本卡不动语义,与真实复测同窗评估",
"真实用户麦克风打断时延/可闻旧声回放打断正面样本/生成中文字取消正面样本:两轮窗口均未取得,需按新门控重试",
"真实端点带 </think> 形状分离(离线证据 HEL-909);首声是否经常>5s 需多样本",
"真机/手机麦克风、Safari(D3 用户动作);R1–R7 完整功能继续主卡承接,不宣布整产品完成"
],
"requirement_changes": [],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 407fc81,均带 --handoff docs/requirements/handoff-HEL-911.json 通过(结构检查,不代替原话核对与体验验收)",
"定向验证:tests/test_sdk_livekit_hel908.py --standalone 于 .12 agent venv 11/11;scripts/gen_hel911_wait_repro.py BASELINE/FIXED 两模式;产品套 .12 .venv311 480 passed/2 预存失败;hel910_call1.mjs node --check",
"未验证(如实):修复后真实通话首声数字、真实麦克风、手机/真机、思考带 closer 真实端点形状——见 deferred,不冒充"
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"baseline_version": "1.12",
"read_commit": "4a38106eef21c43ba24ab7d07992b1bfe539137d",
"task": "HEL-913(含 2026-10-29 总管取件后同卡集中收口):把通过 HEL-912 审核的统一候选(产品基线 agent/2/8e99cc2cc592 @ 4a38106)装成可交接的统一体验运行包并在原候选分支一次收口五项核心缺项:①整包版本/目录可执行(产品基线与运行包提交区分、部署照抄入口=运行包提交、目录/配置/dist 复用/空档案守门完整准备步骤、不跳旧 HEL895 通用步骤);②启用建议收敛为内部一致的一份待批方案(更正「总管权限内新批 A」为全部待批、语音改 3 通×≤10 分钟=≤1800 秒 ASR 硬闸并逐项交代碰顶后果、B/C 边界消除矛盾、T0 计时点/当地日顶/团队准备与用户参与/可执行到期停服);③真实记忆最少安装/接线补全为完整可审命令(隔离 venv、逐项哈希留证、失败即停、co-import、权威账 498/冻结 2 续账不搬测试聊天);④启停实际接线/失败收尾(ASR 布防三变量入模板默认拒绝、wss 桥必需、TTS 限段/房间/总时限唯一接线、启停脚本身份核验版 42 项离线替身验证);⑤事实表只修明确错误(生成中取消未获样本、打断停声非精确口径、首声不承诺推算值、34/34 口径限制如实)。本卡不部署、不监听、不下载、不安装、cap=0、不合 PR、不动现有实例与资料、不向受保护 venv 增改依赖。",
"requirement_ids": [
"CG-006",
"CG-007",
"CG-008",
"CG-009",
"CG-010",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-023",
"AM-001",
"AM-002",
"AM-003",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-006",
"DEL-001"
],
"deliverable": "deploy/hel913-unified/ 收口版:README(唯一照抄入口:§0 产品基线 vs 运行包提交与自校验、§4 首次准备+启停、§5 语音窗口唯一接线表、§7 离线验证口径如实、§8 Mem0 完整可审安装 W0–W6 与权威账续账)、manifest.json、dist-manifest.json、models/voice/worker.env/voice-window-arm 五份脱敏模板(预算全 0/未布防)、api_ctl.sh/worker_ctl.sh 身份核验版、bridge/tts/force_end 三个窗口模板(默认不生效)、verify_offline.py、test_ctl_scripts.sh、mem0-cp311-wheels.txt;docs/rework/hel913/{verify-offline-run.txt,ctl-checks-run.txt};docs/hel913-capability-and-enablement.md(能力表纠错 + §2 单份一致的待批启用方案)。产品代码(src/web/config/tools/tests)自 4a38106 起零改动(git diff --stat 为空可验证)。 总管取件勘误见运行包README §10:语义配置与离线缓存环境、真实会话列表解析、临时语音服务失败收尾、传输校验与固定解释器、TTL600秒、定时服务及人工监督边界。",
"acceptance": [
"整包可执行:部署入口=运行包提交(含本目录);checkout 4a38106 会没有运行包——README §0 明确两者区别并给 merge-base 与 diff 空两条自校验;首次准备含 mkdir 骨架/模板落位/dist 哈希核验后只读复用/verify_offline 空档案守门复验,单一入口、不跳 HEL895 通用总装步骤",
"启停安全(42/42 离线替身验证,docs/rework/hel913/ctl-checks-run.txt):非法子命令拒绝、重复 start 拒绝、PID 复用拒杀(身份核验=cmdline+cwd)、启动失败清自己留日志、worker .env 占位/解析守门、arm 模板默认全 0 可解析",
"语音窗口唯一接线(README §5):ASR 三变量硬闸默认拒绝一切 ASR;session_ttl 600s+挂断删房;TTS 段数监控口径+tts.log 起始字节;force_end now|watch 到期可执行停服(各按专属句柄);wss 桥必需并给出默认 8363 接线",
"Mem0 安装完整可审(README §8):固定源双哈希核对→获批窗口下载 19+40 轮逐项 sha256→传输复核→隔离 venv /opt/cybergril-hel913/venv311 --no-index 安装→co-import→92MB 缓存落位→暂存清理;绝不向 /opt/cybergril-hel879/.venv311 增改;权威账只复制账本不搬测试聊天,authority 冻结前缀+embed_cap=680 累计算式;本卡零安装零下载零嵌入",
"启用方案单份一致且全部待批(capability §2):A 55 次/50 万 tokens 为建议顶、总管不自行放行、不并入用户实例;B 语音 3 通×≤10 分钟=≤1800 秒 ASR 硬闸(含重连≤2、TTS 60 段监控、上传/试听 ≤10 次监督口径)、语音 LLM 计入 B 对话日账、T0=交付时刻/本地自然日/团队准备不占额度、碰顶后果逐项、到期=force_end+seal 可执行且封账不解除;B 窗口内生活/主动计入 B 日账、窗口后全停,与 C(暂不批)无重叠口径",
"事实表纠错:生成中文字取消=未取得有效样本(未验证);打断停声=作者粗略观察非精确口径;首声修复后未实测、不承诺推算值;34/34 的 deps 只打印 4 包版本、空库裁剪未做种子对照——均在 README §7 与能力表如实标注;天气/素材/备份/摄像头/真实口型仍 ❌ 并追到承接",
"边界保持:未部署未监听、8300/8310/8320/8350 与资料未动、8340/ComfyUI 停机、4080 未触、受保护 venv 未增改、需求与用户原话零改动"
],
"deferred": [
"实际部署与启用:待总管向白栖知说明获批后按 deploy/hel913-unified/README.md §4 执行(四步:安装→独立部署→团队检查→用户首用,见 capability §2 B 前置)",
"Mem0 cp311 安装(README §8.3 W0–W6:19+40 轮获批下载窗口 + 隔离 venv --no-index 安装 + co-import + 缓存落位 + 权威账续账):待一次性批准,本卡零安装零下载",
"语音窗口执行:布防值(voice-window-arm 模板)与到期 watchdog/systemd timer 待批后由窗口负责人接线",
"persona 自主诠释、生活事件、主动留言的真实生成:从未在真实窗口发生(预算 0),A 团队检查与 B 用户首体验窗口补证",
"语音遗留实测:修复后首声、真人麦克风打断、生成中文字取消正面样本、真机/手机/Safari(不承诺任何推算值)",
"R5–R7 完整功能(每日自动备份/天气/素材/摄像头/真实口型)继续主卡承接,不因运行包完成而关闭"
],
"requirement_changes": [],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 4a38106eef21c43ba24ab7d07992b1bfe539137d,均带 --handoff docs/requirements/handoff-HEL-913.json 通过(结构检查,不代替原话核对与体验验收)",
"docs/rework/hel913/verify-offline-run.txt:34/34(.12 venv311、产品基线树、零监听零出站;总管同环境独立复跑亦 34/34、dist 哈希吻合——已过项保留不重跑)",
"docs/rework/hel913/ctl-checks-run.txt:42/42 启停离线替身检查(工作站、stub、零真实服务/零监听/零出站)",
"产品代码零改动:git diff --stat 4a38106 <运行包提交> -- src web config tools tests 为空",
"未验证(如实):运行包尚未在任何现网端口实际起服(cap=0 未部署);修复后语音首声、真机、Safari——见 deferred,不冒充",
"总管机械收口:docs/rework/hel913/coordinator-close-checks.txt 17/17定向离线检查;真实sessions列表生成DELETE、环境变量传入、非法命令拒绝、失败句柄清理与shell语法;未安装/监听/调用真实服务。"
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"baseline_version": "1.12",
"read_commit": "8cd4265182492e34d0321d26698419d7c7c3907e",
"task": "HEL-914 装好独立体验版并检查真实功能(HEL-797 统一体验首次启用执行)",
"requirement_ids": [
"CG-001",
"CG-006",
"CG-007",
"CG-008",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-022",
"CG-023",
"CG-024",
"CG-029",
"CG-030",
"CG-031",
"AM-001",
"AM-002",
"AM-003",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "在 .12 完成统一体验运行包的批准内安装与部署:固定运行包提交 8cd4265(产品基线 4a38106 不变)+ 两个 HEL-914 接线修正提交(persona 生成超时/租约,236fe3d、ffbc8c2,仅 src/cybergril/life/persona.py);隔离 venv311(431MB,产品锁 14 项 + 记忆栈 41 项,--no-index 全离线安装,固定源 mem0ai 轮 sha 698284b6 核对一致);web dist 8 文件哈希核对;离线验证 34/34(修正后复跑仍 34/34);co-import 通过;92MB fastembed 缓存落位零下载。团队独立临时实例(/opt/cybergril-hel914-team,封账归档)完成真实功能链检查:亲选关系/起名/生日→真实 persona 诠释与初始习惯→文字流式对话/落库→真实 Mem0 提取(11 次)/本地嵌入(45 条)/检索→真实生活事件(自然 11:00 时段窗)+未分享服务端裁剪(404)+叙述判定+分享回执→真实后天成长(life/chat 两源)→同数据重开保留(epoch 5)。用户统一实例(/opt/cybergril-hel913)备妥:真正空档案(数据目录仅含唯一活动记账文件 543/680+冻结2、模型缓存)、HTTPS8361/静态/空档案守门已核、激活脚本 activate.sh(原子放行预算+R2+3 自然日 systemd 截止 timer)与 stop.sh 已备而未执行。语音本卡零启用(无 ASR/TTS/建房,LiveKit SDK 只读核空)。",
"acceptance": [
"运行包两段校验通过:merge-base 含 4a38106 且 src/web/config/tools/tests diff 为空(服务器实测)",
"verify_offline 34/34(hel879 venv 口径)+ 修正提交后复跑 34/34;隔离 venv 下 31/34(mem0/qdrant/fastembed 三项『栈未装』旧断言按已批准安装反转,属预期)",
"真实链可观察结果:persona state=ready(370 字第一人称诠释+3 条自主习惯);9 轮对话 chat.delta/final 流式+落库;mem0 llm_ops 11 次提取、embed_ops 45 条真实本地嵌入、检索在下一轮回复中如实引用;life_run slot1 done、事件 told 0→裁剪 404→叙述判定 selected=[1]→回执 ack→told=1→/api/life 可见;growth_event life/chat 两源真实提炼;重开 epoch5 保留身份/20 条消息/told 事件/persona",
"团队账封存:budget_total sealed 49 请求/467,758 tokens(真实用量保留、无解除路径);一致性快照 hel914-team-sealed-20261030-110951.tar.gz(sha 15c02b58)归档",
"用户实例空档案守门:/api/companion、/api/life、/api/memories 均 404;数据目录无 companion.db/测试人物/测试向量;唯一活动记账文件只含历史计数+本卡 A 先用",
"边界不破坏:8300/8310/8320/8350 保留原样,hel879 .venv311 零改动(0 记忆栈包),8081/7880/7881 未动,LiveKit rooms EMPTY,无 836x/8082/9880/8090 监听残留"
],
"deferred": [
"真实主动留言(reach_out):门电路验证到最后一闸——小时/冷却/唤醒 0.597/亲密 0.400 全过,唯一窗口内事件 salience 0.42<0.6(R4 批准参数)未放行真实发送;聊天记忆 salience 固定 0.5 结构性不可达;首次真实发送预计在用户期自然发生,不以伪造状态强测",
"语音全链(通话/ASR/TTS/自定义声音登记试听):本卡按批准零启用;8363/8082/9880/8090 未启动;布防方法在运行包 README §5 与服务器 OPS-hel914.md 备好",
"A 实际用量超出监督子额:SDK 提取 11>10、新嵌入 45>20(每轮对话 mem0 消耗约 4 嵌入/1 提取,规划数低估);用户期余量 49 次提取/135 条嵌入,低于承诺下限 50/160——总盘 60/180 未破(543≤680-2、11≤60),是否补足由总管决定",
"mem0 SDK 提取 token 量级观察:semantic 类 393,466 tokens/11 次≈35.8k/次(提取输入含累积历史);用户期 2M 日池与 llm_cap 60 下需关注,不擅自改实现",
"growth_job 一次性无重试(失败即弃该条):设计取舍记录,未改",
"天气/外观素材/备份导出/摄像头/真实口型(CG-023/005/030/009/021/022/029):仍未接入,承接不变",
"首声延迟/真人打断/生成中文字取消/手机:未验证,承接不变"
],
"requirement_changes": [],
"verification": [
"服务器实测记录:/opt/cybergril-hel913/logs/verify-offline-34-hel914.log、verify-offline-34-afterfix.log;/opt/cybergril-hel914-team/logs/api.log、force_end.log、probe_persona.py、probe_growth.py、probe_reachout.py;团队库与账本 SQLite 逐表可查",
"预算:团队 49/55 请求、467,758/500,000 tokens 封账;A 先用 SDK 提取 11/60、新嵌入 45/180;用户期余 49 提取/135 嵌入;对话 60/日等用户日账由 activate.sh 放行,无一次性总量",
"团队窗起止:2026-10-30 10:14:21 CST 计时器先设、10:16 首个真实出站、11:09:26 force_end 停服、11:09:26 封账;systemd 一次性 timer 机制实测(cybergril-hel914-team-expiry 触发路径验证后停止)",
"未验证设备/场景:真实浏览器端到端(本卡经 API 驱动)、Safari/手机、语音、多日连续运行与主动留言质量——如实未验证,用户期观察",
"总管独立核对与启用见docs/rework/hel914/coordinator-handoff.txt:真实计数/19项锁/旧基线失败边界如实记录;纯替身90s租约验证;持久服务+截止timer+4/4离线guard;真实浏览器仅空创建入口,不触用户建档/付费出站。"
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"baseline_version": "1.12",
"read_commit": "e74531cf98540dd35198753fa8db87ac75d57a94",
"task": "HEL-916 让对话、生活和情绪连贯起来(HEL-915 集中工程修正)",
"requirement_ids": [
"CG-001",
"CG-006",
"CG-007",
"CG-008",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-024",
"AM-001",
"AM-002",
"AM-003",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-005"
],
"deliverable": "两轮交付。第一轮(adf4d5c,总工已核过输入结构/活动接线/分享失败分流/情绪接线框架):api/app.py 对话输入重构为可审分段(事实/此刻状态/指令),出生事实与既有 persona 诠释原文保留;世界边界改自然承接表述;生活活动状态接线为'此刻'唯一权威、分享候选带发生时间标为过去;sharing.py 判定失败分流(无效终态 fail-closed、瞬态经 judge_attempts 恰好重排一次);普通聊天经既有 PATTERN_DETECT+dyad 接线接入情绪(账本化 chat_affect,幂等/租约/恢复终态,salience 0.5 减半,复用既有敏感度与衰减方程)。第二轮(本轮返工,总工 HEL-917 两项阻塞):1) dyad/runtime.py 情绪分类 severity 只接受有限非 bool 数值——True/False(bool 是 int 子类会被当 1.0/0.0)、NaN/正负 Infinity(json.loads 可产出、原 min/max 钳制会放行 NaN 或放大 Infinity)一律按 none 处理:job 终态 done、不写情绪/关系冲击、不重复分类;正常有限 hurt/repair 原有有界算法不变。2) life/runtime.py 聊天情绪补一次性历史切断点(life_control 键 chat_affect_cutoff=初始化时 MAX(message_id)):app lifespan 在开始接收任何聊天前调用 init_chat_affect_cutoff 固化切断点,INSERT…ON CONFLICT/NOT EXISTS 幂等,任何 tick/重启/并发都不会重置;分类只处理切断点之后新完成轮次,旧聊天(迁移库)永不补算为当下伤害;历史回溯未获授权不做。",
"acceptance": [
"返工专项测试(tests/test_hel916.py,本机 Python 3.10+测试 shim,隔离临时库+替身 LLM)16/16 通过:含 True/False/NaN/+Inf/-Inf 五参数均无冲击无重复分类且 dyad 账本不变、正常 hurt 仍走通;迁移旧库首次启动零分类零调用且切断点=MAX(message_id)、新轮只处理一次、重启切断点不变新轮不丢、repair 正向有界冲击走通",
"受影响既有测试通过:tests/test_r2.py -k 'old_profile or disclosure or judgment or receipt or expired' 25 passed;tests/test_r4_api.py + tests/test_mood_runtime.py 11 passed",
"python3 -m ruff check src 通过(0 错误)",
"双基线需求检查通过:--base-ref origin/main 与 --base-ref e74531c 各含本 handoff",
"全量口径如实分开:原施工本机 pytest 全量 482 passed/11 skipped/3 failed(3 项在未改动基线 ffbc8c2 同环境同败,属本机 3.10 与产品 3.11 环境差异);总工全套记录 483 过/11 跳/3 败(总工环境口径),本轮未全库复测、未将总工数字冒充新实测",
"出生底色/人格不重算:persona.prompt() 内容未改一字(测试断言既有 INTERP 原文逐字出现)"
],
"deferred": [
"真实模型下的语气自然度、边界承接与情绪体验:本卡零真实调用,无法以离线替身证明语言自然;建议下一轮最低必要验证:由总管在隔离副本放行小额度(建议 ≤30 次对话/1 次分享判定闭环),操作人总管,约 1 小时,重点观察亲近表达承接、追问'为什么'、隔屏说明是否消失、聊天后情绪词变化与恢复",
"聊天情绪的新语义裁定(如 repair/hurt 之外的正面共享喜悦类别、salience 减半常数标定):本卡仅接线既有已批语义,未新增情绪类别;如需扩展交最小可审方案另行请示",
"mood 提示仍是一个词注入(CG/AM 既有约定),八字敏感度公式沿用 core.bazi 既有连续规则,未改方程",
"语音/天气/素材/备份/摄像头/口型等未完项承接不变(HEL-915 正文清单继续有效)"
],
"requirement_changes": [],
"verification": [
"离线测试证据:tests/test_hel916.py 16/16 通过(隔离临时库+替身 LLM+受控断言);受影响既有子集 25+11 通过;命令与口径见 acceptance,未全库复测",
"返工修前/修后对照:修前(adf4d5c)severity=True/NaN/Infinity 会进入 min/max 钳制(True→1.0 最大冲击、NaN 经 max/min 原样穿透),且首次启动会把库中所有旧聊天逐条分类;修后五类无效值均无冲击无重复分类、迁移旧库首次启动零调用、切断点重启不变",
"每项证据的边界:以上仅证明服务器输入结构、状态接线与数值防护正确;不证明真实模型语气自然、不证明用户满意,真实验收保留;真实体验方案仅备稿,待两项复核通过后由总管集中请用户批准",
"未测到:真实 LLM/ASR/TTS 行为、多日连续运行、浏览器端到端、Safari/手机;均如实未验证"
]
}
+228 -36
View File
@@ -1,55 +1,247 @@
# HEL-902 补测包:自定义声音真机窗口申请材料(交总管集中请批)
状态:**材料备齐,未获批前真实 cap=0**。所有上限为**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、42717/500000 tokens、12/200 TTS 段保持封存;剩余数≠新许可)。工程参数由工程定,用户只批准真实用量与设备操作。
状态:**材料备齐(连续通话 ASR 计数与到线停机已由 HEL-906 落地为可执行
程序闸,2026-10-28 总管定点返工后含账本故障 fail-closed 与保守/确证双
口径,见 §4;剩余仅窗口前 .12 真实栈联验);未获批前真实 cap=0;
本包只写材料,绝不执行**。所有上限为
**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、
42717/500000 tokens、12/200 TTS 段保持封存,永不重开;剩余数≠新许可)。
工程参数由工程定(只能收紧、不能放宽),用户只批准真实用量与设备操作。
## 1. 统一 head 与部署范围
## 0. 统一 head 与复审状态(2026-10-28 HEL-904 后更新,旧报告保持历史可追溯)
- 候选整合分支 `hel-902-integration` @ `d167ba9`(hel-900-preserved 701442d + hel-879-assembly be149cd,无冲突)。离线证据见 `docs/rework/hel902/REVIEW.md`:后端 376/376、全链 12/12、ruff/需求检查 4/4。
- 部署形态沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属 worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回);`config/voice.yaml` 按合同 §4 增补。**不触碰** 8300–8350 演示/真实档案、8090(只读调用其 API)、8081、7880、旧资料与冻结账、4080;ComfyUI 保持暂停;不合 PR、不替换正式界面。
- 回滚:不部署即无痕;已部署时 git 还原 + 停窗口进程 + 移除 voice.yaml 增补即回 HEL-877 行为;资料空间三个新文件留存无害。
- `d167ba9` 是 **hel-902-integration 的整合合并提交**(历史事实,见
`docs/rework/hel902/REVIEW.md`,其 376/376 等证据属于当时 head)。
- HEL-902 统一审核通过时的分支 head 为 **`b09a907`**(README 勘误与补测包
所在提交);此后该分支未再作为交付 head 使用。
- **后台返修(HEL-904,本卡)**:独立分支 `agent/2/f337aae1f551`
(自 `b09a907` 起),登记失败恢复映射、worker 状态损坏实况、capture 竞态
收口三项修复与确定性时序测试;其 head 提交号见本卡交付评论,**该 head
在统一复审通过前不得当作最终已验证 head**。
- **前端返修(另卡,原作者)**:外接服务文案等;head 以该卡交付为准。
- 后端本轮与前端另卡**还需原总工一次统一复审**;通过后由复审在下方登记
「最终已验证 head」,再由主卡向白栖知申请批准本窗口。此前任何 head 都
不是执行依据。
| 里程碑 | head | 状态 |
| --- | --- | --- |
| 整合合并(历史) | `d167ba9` | 已被后续提交覆盖,仅作历史 |
| HEL-902 统一审核 | `b09a907` | 审核通过+一轮有限返修(已启动) |
| 后台返修 HEL-904 | `07dda2b`(分支 `agent/2/f337aae1f551`) | 已并入统一复审分支 |
| 前端返修 HEL-903 | `088ac62`(分支 `hel-903-preserved`,源码 `51669aa` 11 文件逐字节一致) | 已并入统一复审分支 |
| **最终已验证 head** | **`03cec26`**(分支 `origin/hel-905-unified-review`;复审验证基线=03cec26,其后仅本表的登记提交,执行以该分支最新 head 为准。HEL-905 总工统一复审通过:11 文件 SHA256 一致、后台 380/380+12/12+ruff 独立复跑、需求检查 4/4;前端构建/单测/浏览器未独立复跑,如实见 HEL-905 结论) | **复审通过,待主卡请批新窗口** |
| 连续 ASR 计数落地(HEL-906) | `ecec9df`(分支 `hel-906-asr-usage-counting`,基于 `0b804a4`;执行以该分支最新 head 为准,候选 PR 不合并) | 已落地并通过 17 项确定性离线测试+语音区回归 98 项+ruff;待总管复核后与统一 head 合并为窗口执行依据 |
| HEL-906 定点返工(总管复核算 gaps 修复) | `cf395d4`(同分支追加,基于 `ccfc04e`) | 账本写/读失败、损坏、中途丢失 fail-closed 停机+保全已知消耗;发送前保守预记(失败/取消不退还)+确证送达单列;24 项离线测试+语音区回归 105 项+ruff 通过 |
## 1. 部署形态与回滚
- 沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属
worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回原状态);
`config/voice.yaml` 按合同 §4 增补。
- **不触碰** 8300–8350 演示/真实档案、8081、7880、旧资料与冻结账、4080;
ComfyUI 保持暂停;不合 PR、不替换正式界面。
- 8090 语音面板:窗口内**只读调用其查询 API**;获批登记步骤**经其上传
API 写入本窗口 `cg-` 条目**(面板仍是共享注册表唯一写者)。两句话分开
说清,不用「8090 只读」一笔带过获准后的写入。
- 回滚:不部署即无痕;已部署时停止窗口进程+移除 voice.yaml 增补即回
HEL-877 行为;资料空间三个新文件留存无害。**共享注册表/测试数据的回收
只能按 §3 清单经 8090 自有删除入口逐条完成,不得拿 git 回退或重启代替**。
## 2. 测试目标与逐步动作(全部可观察、可中止)
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV 参考音频 → `cg-` 登记 → 列表出现。
2. **可选 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR 转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机→503。
4. **应用与下一通验证**(≤3 次通话):试听后应用 → 开通话核对 worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、下一通生效;从未选择→不代写 worker 文件。
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV
参考音频(来源见 §3)→ `cg-` 登记 → 列表出现。
2. **可选登记转写 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR
转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机
→503。另做一次「未试听直接应用→409」负例(守门保持)。
4. **应用与下一通验证**(≤3 次实际通话):试听后应用 → 开通话核对
worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、
下一通生效(通话窗口持有语义,合同 §2.5);从未选择→不代写 worker
文件。
5. **HEL-879 遗留量测**(并入同一窗口,不另开窗):
- 打断后约 1 秒停声:真实通话开口打断,以 worker 日志 `turn_cancel` 时间戳与最后 TTS 段发送时间差为准(毫秒级,附日志行);辅以浏览器侧听到停声即停表的人工秒表复核。
- 文字打断语音方向:她语音输出中发送文字→声即停、文字被处理;与既有「语音取消文字」方向互补。
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风(非 fake device)完成第 4 项;如用户愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败不影响其余结论)。
6. **窗口内快检**(可选,若获批一次性依赖安装授权):本机 `npm ci`(同锁文件)→ 复跑 vitest 137/tsc/eslint/build + `hel900_mock.mjs`+`hel900_shots.mjs`(Chromium headless,两主题×电脑/手机 + 演示入口零外发断言)。不获批则此项留待后续。
- **打断停声(约 1 秒)**:量测点必须从**用户实际开口/有效打断输入的
时点**起,到**浏览器实际播放的旧音停止**为止。
- 主测法(最小、单一时间基准):LAN 机上一次连续的「屏幕+标签页
输出音频」采集(Chrome 标签页音频采集/OBS,全程单录音文件)。
该采集只录浏览器播放出的声音(**不混入本地麦克风**),打断操作
的时点在画面上可见(说话动作/发送文字的动作),T0=输入起始帧、
T1=旧音最后一帧可闻帧,停声=T1−T0;同一段录音里再标出新回复
起播点与提示音位置(提示音按固定音色/波形辨认并排除,不并入
停声),帧级分析即毫秒级时基,不需要跨机对钟。
- worker 日志 `turn_cancel`→最后 TTS 段发送时间差**只是内部子段
证据**(附日志行),不能单独证明用户听到了约 1 秒停声;人工秒表
仅作辅助复核。
- **文字打断语音**与**真实设备麦克风打断**两个方向都做:文字方向=
她语音输出中发送文字→声即停;麦克风方向必须用 LAN 机真实麦克风
(非 fake device,不把假设备当真机)。**麦克风方向的 T0 必须是
同一录制时间轴上本地麦克风音轨的实际起声帧**(如 OBS 单文件多
音轨:一轨浏览器输出、一轨本地麦克风,同一时钟可分离分析);
仅画面中的说话动作不能直接代替实际音频起点。
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风完成第 4 项;如用户
愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败
不影响其余结论)。
6. **窗口内快检**(可选,**单独待批项**:一次性 npm 同锁文件安装授权,
本轮与后端返修都不执行):本机 `npm ci` → 复跑 vitest/tsc/eslint/
build + `hel900_mock.mjs`+`hel900_shots.mjs`。不获批则留待后续。
## 3. 参考音频来源与共享注册表新增/回收
- 来源:优先用总管自备的一段真实人声 WAV(用户已授权「音色你自己决定」适用窗口测试音色);或复用 D1 试听包中已只读取回的既有参考录音之一作克隆源(不新增采集、不新录用户声音、不申请摄像头)。
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上不可能碰既有 11 支;面板仍是唯一写者)。**回收**:窗口结束按登记清单逐条经 8090 面板自有删除入口删除并截图/列表复核;保留一条经用户后续确认去留。元数据/流水在实例资料空间,随实例清理。
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰(测试断言已含)。
- **参考音频(已明确到段)**:首选 D1 试听包中 `female_soft` 的 zero-shot
参考录音——来源服务器路径 `/opt/voice-agent/cosyvoice/voice_samples/
female_soft.wav`,本地清单 `docs/rework/hel879/d1-audition/manifest.json`
记录 sha256 `4a65d17099239711601cb17e28b7aa7b6149acd153b92fd0366c23902bd4f687`、
341192 字节、10.7 秒、16k/mono(中文女声、时长在 5–20s 建议区间)。
备选 `xiaoyun.wav`(`6ad3ca7a…dc87e`、297004 字节、4.6 秒、32k/mono)。
窗口执行前只读重新取回并**先核对 sha256 与清单一致**再用;不新增采集、
不新录用户声音、不申请摄像头。**授权测试音色由总管决定**;无需为此
再问用户算法或端口。
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上
不可能碰既有 11 支;面板仍是唯一写者);不覆盖原 11 支与全局默认。
- **回收(窗口结束逐项执行)**:按本窗口登记清单(含孤儿条目)逐条经
8090 面板自有删除入口删除,删除前后各留一次列表快照比对;**回收失败
的条目按「编号/名称/登记时间/失败原因」留精确清单交接**,不删不属于
本窗口的孤儿,也**不未经用户确认保留任何一条测试垃圾**——默认全部
回收,确需保留的逐条写明理由交总管向用户确认。元数据/流水在实例资料
空间,随实例清理。
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰
(测试断言已含)。
## 4. 时窗与逐类上限(一次性,超出即停)
## 4. 时窗与逐类上限(一次性,超出即停;计数口径含失败)
| 类别 | 上限 | 计数口径 |
| --- | --- | --- |
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 |
| 语音通话 | ≤3 次 | POST /api/voice/session 成功开房 |
| 开房(含空会话) | ≤6 次 | 同上,含失败重试 |
| LLM 请求 | ≤20 | 统一账本 acquire(失败/打断/后台都计) |
| LLM tokens | ≤60000 | 同上 |
| 嵌入 | 0 | 硬闸 |
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` register_asr 行 |
| 试听合成 | ≤10 | preview_tts 行 |
| 通话 TTS 段 | ≤40 | worker 流水 |
| 音色登记 | ≤3 | 8090 upload 成功数 |
所有上限为**候选申报值**,工程执行时只能收紧不能放宽。**一切失败、取消、
重试、已出站但无成功响应的调用都计入相应口径**,不只统计成功流水。
停机方法:任一上限达到即停(LLM/嵌入为代码硬闸,其余为工程守则:实时盯 `voice-usage.jsonl` 与统一账本页面);窗口结束停 8360/8361/8082/8363/9880(9880 停回原状态),`ss -ltn` 复核端口、房间经 API 挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 cg- 条目按 §3 回收。
| 类别 | 候选上限 | 计数口径(含失败/取消/重试/出站无响应) | 闸门性质 |
| --- | --- | --- | --- |
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 | 工程守则(负责人盯表) |
| 实际语音通话 | ≤3 次 | 通话真实发生数 | 工程守则 |
| 开房(含空会话) | ≤6 次 | `POST /api/voice/session` 每次调用(成功/失败/取消都算) | 程序可数(API 日志/会话账本) |
| LLM 请求 | ≤20 | 统一账本 acquire | **程序硬闸**(`think/budget.py`) |
| LLM tokens | ≤60000 | 同上 | **程序硬闸** |
| 嵌入 | 0 | acquire 拒绝 | **程序硬闸** |
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` `register_asr` 行(含本地元数据落盘失败者)+**面板侧转写失败/断连的如实口径见下** | 工程监看(程序计数、非硬闸) |
| 试听合成 | ≤10 | `preview_tts` 行(含 TTS 出站无成功响应者) | 工程监看(同上) |
| 通话 TTS 段 | ≤40 | worker 侧流水/日志计数 | 工程监看(见下,需窗口前验证命令) |
| 音色登记(8090 upload) | ≤3 | 上传出站次数(含断连后已生效者) | 工程监看(面板上传账) |
| **连续通话 ASR 音频时长** | ≤45 分钟(3 通 × ≤15 分钟实操上限) | 实际送入帧计时(含重发;**含发送失败/取消的不确定出站**——发送前保守预记、不退还,确证送达量另列核对),见下:**HEL-906 已落地(返工后双口径)** | **程序硬闸**(worker 侧发送前拒绝;未布防=拒绝) |
| **连续通话 ASR 连接重试** | ≤6 次/通 | 失败后重试计数(正常一句一连不计 retry),见下 | **程序硬闸**(建连前拒绝;未布防=拒绝) |
| 新增付费支出 | 0 | 全程零付费 API | 工程守则 |
## 5. 旧服务与资料保护清单(窗口内不变式)
**硬闸与监看的界线(如实,HEL-906 返工后更新)**:LLM 请求/tokens、嵌入
(统一账本 acquire)与**连续通话 ASR 音频/连接重试(worker 侧
AsrUsageGuard,布防后发送前/建连前拒绝;账本故障 fail-closed 停机)**
都是代码级硬闸;其余上限是**工程监看或程序计数**,靠实时看数+负责人
停机,**不拿事后流水冒充硬闸**。ASR 闸的保证范围必须如实:**硬停保证
只在布防进程内逐帧成立**;跨进程/崩溃的边界见下段「程序能保证」。
8300/8310 演示与真实档案进程不动;8090 只读调用+授权后的 cg- 写入;7880/8081 不碰;既有数据库、身份、聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
**登记转写 ASR 口径的如实边界(HEL-905 复审核实)**:`register_asr` 流水
只在**登记成功(含本地元数据落盘失败)且参考文字来自转写**时记录;面板侧
转写**失败或上传断连**时 API 只见到面板拒绝/异常,不产生 `register_asr`
行——这部分用量不靠该流水,而由「音色登记(8090 upload)≤3(含断连后已
生效者)」上界覆盖:**每次转写尝试必伴随一次 upload 出站**,两项上限须
一起监看,不得只看 `register_asr` 行宣称 ASR 用量。
**连续通话 ASR(worker 内 FunASR 连续识别,区别于登记转写 ASR)的计数
手段与停机**:
- 墙钟代理(可程序数,命令可执行;**如实声明其边界**。HEL-906 后降为
**下界交叉核对**:执行手段以上方实际帧计数硬闸为准):实例旁会话账本
`<db>-voice-sessions.json` 只保留**当前** `.sessions`(在途)与 `.debts`
(删除未落定的欠账)——**删除成功的通话会从账本里移除**,因此
`jq '[.debts[] | (.first_debt_at - .session.created_at)] | add // 0'
<db>-voice-sessions.json` 只合计**残存欠账**,是全窗用量的下界核对,
**不能冒充整个窗口的通话墙钟合计**。逐通墙钟以开房/挂断当时记录为准:
每通 `POST /api/voice/session` 与 `DELETE` 的时刻由窗口负责人当场记录
逐通归档(≤3 通,人工可数),账本 jq 只作残存欠账与在途通话的核对
(在途看 `.sessions[]` 的 `created_at`)。通话墙钟总时长
≥麦克风推流时长的上界,再加 §2 每通 ≤15 分钟操作掐表即 ≤45 分钟总上限
的执行手段;窗口负责人:总管。到线即停:停开新会话并挂断在途会话
(`DELETE /api/voice/session/{id}`)。
- **已落地(HEL-906,修掉本节前置缺口;2026-10-28 总管定点返工后为下述
口径)**:逐通精确音频时长与 ASR 连接重试次数现在有本仓库可执行的程序
计数与到线停机——`src/cybergril/voice/asr_usage.py`(仅标准库)+专属
worker 真实送数接缝(`stt_funasr.py` 建连前检查、**发送前保守预记并
拒绝越限帧**、`await ws.send` 成功返回后记确证送达;`worker.py` 按 job
注入并以房间名标识每通,触线/账本故障回调 `ctx.shutdown` 结束本通)。
计数口径:
- **音频(保守计费,硬闸依据)**=发送**前**按整帧预记的
`样本数/采样率` 之和(重采样后的输出帧按其自身 16k 计,重发按重复
计);**发送失败或被取消不退还**——帧可能已出站;墙钟与事后成功
流水不参与。**确证送达量**(`confirmed_seconds`,仅 `await` 成功
返回的帧)单独累计、单独落账,只作更低的核对下界,**不得把预记量
宣称全部确证送达**。
- **连接**=每次 websocket 建连尝试(含失败)逐条落账;FunASR 一句一
连的正常重建**不是** retry,只有上次尝试失败/接收侧死亡/流异常结束
之后的建连才计 retry(≤6 次/通在建连前拒绝)。
- **账本**=追加写 JSONL(默认 `/opt/voice-agent-cybergril/asr-usage.jsonl`,
`CYBERGRIL_ASR_USAGE_LEDGER` 可改),逐通 call_start/audio/
connect_attempt/connection_died/call_end/block/guard_stop 永不改写:
删房成功会从会话账本移除该通,但这里的逐通历史保留,窗口总量由
重放账本恢复,同窗口重启进程不能重置预算。
- **账本故障 fail-closed(返工新增)**=账本**写失败、读失败、行损坏
或中途消失**时:guard 停止本窗口一切出站(新连接/新音频一律拒绝)、
保全内存已知消耗(窗口计数只增不减,禁止默认零预算或按旧账回退)、
记 error 日志与停机原因,并把 `guard_stop` 落账——同窗重启重放该
记录仍保持停机。故障下不继续通话(不要求支持)。
- **布防**=窗口启动时由负责人显式设 `CYBERGRIL_ASR_WINDOW_ID`+上限
(`..._MAX_AUDIO_SECONDS=2700`、`..._MAX_CONNECT_RETRIES_PER_CALL=6`);
未设即 fail-closed 拒绝一切 ASR 连接/音频(真实 cap=0,含未获批的
现在)。旧 HEL-879 封账永不重开,新窗口用新 window id 独立预算。
- **程序能保证(如实,不扩大)**:布防**进程内**逐帧发送前硬停(越限
帧一个字节不出站;保守预记含发送失败/取消的不确定出站)、每通重试
上限建连前拒绝、逐通/全窗计数不因删房/重启丢失、**账本读/写/损坏
故障即停且不回退预算**。**剩余只能监看**:两 worker 进程并行通话时
对端最近 ≤ max(resync, flush)=5s 的音频可能未计入本轮判定(有界
过冲,每次建连检查会重放账本收敛)——**不承诺全窗口跨进程绝不
越限**;进程崩溃前 ≤5s 的已发音频可能少计(确证量同样只在 flush
后落账);真实 FunASR/LiveKit 行为仍须窗口前在 .12 用
`scripts/gen_hel877_sdk_check.py` 同法加载本模块联验。为此**窗口执行
约定**:≤3 通一律**顺序执行、不重叠**,通话实操上限与 flush/resync
参数按需收紧并预留余量(例如每通按 ≤13 分钟执行、总量按 ≤40 分钟
监看),把跨进程/崩溃边界排除出真实越限路径。
- **可复现验证命令**(离线,零真实调用):
`python -m pytest tests/test_voice_asr_usage.py -q`(24 项:不同采样
率、静音/取消/失败/重连、反复结束、触线前拒绝、落盘恢复、删房不丢
历史、跨窗口不误停、worker 接线与默认 fail-closed;返工新增——
**总管复现的「写失败→重放回退」回归路径、读失败/损坏/中途丢失
fail-closed、guard_stop 跨重启仍停、假 ws 先记 payload 再 raise/
取消后预算仍含该帧(保守计费)而确证量不含**);
`python -m cybergril.voice.asr_usage <ledger.jsonl> [--window W]`
(窗口中实时核对逐通/全窗合计与停机状态的只读汇总)。
**停机方法**:任一上限达到即停(LLM/嵌入自动拒;其余实时盯
`voice-usage.jsonl`、统一账本与会话账本);窗口结束停 8360/8361/8082/
8363,9880 停回原状态(见 §5 快照),`ss -ltn` 复核端口、房间经 API
挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 `cg-`
条目按 §3 回收。
## 5. 旧服务与资料保护清单(窗口内不变式,逐项核对)
- 8300/8310 演示与真实档案进程不动;7880/8081 不碰;既有数据库、身份、
聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务
(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
- **共享 TTS 9880 启停保护**:窗口开始前快照其当前状态(是否在运行、
进程/容器 id、启动参数:`systemctl status`/`docker ps` + `ps` 输出
存档);窗口内临时恢复;窗口结束**按快照恢复原状**(原本停机则停回
停机),恢复后再次 `ss -ltn | grep 9880` + 进程比对留证。
- **端口/房间/临时声音/日志逐项核对表**(窗口结束逐项打勾并存证):
端口 8360/8361/8363/8082 全停;9880 恢复原状;LiveKit 房间清空
(`DELETE /api/voice/session/*` 或 TTL);`voice_samples/` 无本窗口
残留 wav(对照 `cg-` 登记清单);窗口专属日志与账本快照归档
`docs/rework/hel902/`;共享 `voices.json` 与 11 支内置前后 diff 仅
本窗口 `cg-` 且已按 §3 回收。
- **不能用 git 回退代替共享注册表/测试数据回收**(git 只管仓库文件,
不管 8090 面板与服务器状态)。
## 6. 通过判据
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志对得上;通话中改选不中途换声。
- 打断停声时延有毫秒级日志证据;文字打断语音方向有观察记录。
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成;11 支内置与全局默认零变化(前后快照比对)。
- 全部上限内完成;结束后无残留窗口进程/端口/房间。
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志
对得上;通话中改选不中途换声(会话响应 `voice`=worker 实际音色)。
- 打断停声时延有**同一时间基准的帧级录音证据**(输入时点→旧音停止,
排除提示音/新回复/麦克风混音);文字打断语音方向有观察记录;真实
麦克风路径用的是真机不是假设备。
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成(或留精确失败交接清单);
11 支内置与全局默认零变化(前后快照比对)。
- 全部上限内完成(含失败与重试口径);结束后无残留窗口进程/端口/房间;
付费支出为 0。
@@ -0,0 +1 @@
author-logs/* -text
+38
View File
@@ -0,0 +1,38 @@
# HEL-903 原作者返修保全与入库
本记录只证明原样保全及需求结构检查,不代替总工统一复审、浏览器独立复跑或用户体验验收。
- 基准:需求 v1.12 / 60 项;读取与保全基点 `b09a907747f9836c4aea04016757321d28b7cb6b`(`origin/hel-902-integration`)。
- 原作者:视觉总监;HEL-903 交付评论 `01a17e55-ad02-72b5-a087-0ccb15e28dd4`。
- 原 ZIP 附件 `01a17e55-418d-7cf8-bf09-7fd21893a2e0`,SHA256 `b5435308cee0318a04eb892ca0b2a6c49f7cb2dd9c9f8644403acdb49960ea75`。
- 原补丁附件 `01a17e55-4943-7889-a6aa-2ad6154cb888`,SHA256 `e9b803d4b3aa66879637d766ec9d8b7eafa8de71e7451bb362c09a4a749ceefa`。
- 源码入库提交 `51669aa`,只含原作者 11 个文件,无总管实现改动;HEL-879 与 HEL-900 既有成果完整继承。
## 实际核验
1. `author-manifest.json` 中全部 11 个改前哈希与 Git 基点一致(新增文件对应不存在)。ZIP 中 11 个完整改后文件、工作树、暂存 Git blob 的 SHA256 与改后清单逐一一致。
2. 原补丁 `git apply --check` **失败**:`components.css:1035` 的原文件没有末尾换行,补丁没有对应的无末尾换行标记。不能采信作者所称的严格 Git 回放成功。保留 `author-original.patch`,按 ZIP 完整源码原样写入,未调整源码末尾换行或任何实现。
3. 从确切的 11 个暂存文件用 `git diff --cached --binary --full-index` 导出 `canonical.patch`。以独立 `GIT_INDEX_FILE` 读取 b09a907 后,`git apply --cached --check` 和严格应用均成功;重放后 11/11 Git blob 哈希与作者改后清单一致。
4. `git diff --cached --check` 通过。
5. 四组需求检查实际执行均通过(每组 60 项):
- `python3 scripts/check_requirements.py --base-ref origin/main`
- 同上加 `--handoff docs/requirements/handoff-HEL-903.json`
- `python3 scripts/check_requirements.py --base-ref b09a907747f9836c4aea04016757321d28b7cb6b`
- 同上加 `--handoff docs/requirements/handoff-HEL-903.json`
需求基准及历史来源未改动,关联要求与承接详见原作者 handoff。未变更需求。
## 自测证据的归属与限制
`author-logs/` 为 ZIP 原始日志字节,记录作者 tsc 通过、eslint 0 错/1 条既有警告、vitest 148/148、Vite build 通过、离线假媒体浏览器 90/90。本环境 `web/node_modules` 不存在,依本轮禁止下载安装约定,**总管没有独立复跑前端构建、单测或浏览器**,不能把作者日志称作独立复测。截图留原 ZIP,未批量重新上传主卡。
本轮真实调用 0,未部署、未合并 PR、未写共享音色、未访问 4080、未改变队员配置。
## 交原总工统一复审
HEL-904 已交后台分支 `agent/2/f337aae1f551` / `07dda2b`,应与本卡原作者源码在独立候选整合,一次复审前后台合同及真实补测材料,不改写母线。
需特别核对:后台新登记错误与前端恢复提示一致;延迟 worker 读音色、通话取消/失败/TTL、重叠会话残余;连续 ASR 用量可实际计数并到线停机;用户实际输入至浏览器旧音停止的量测。作者另发现 Chromium 在连接发出任何字节前被重置时可能重发登记 POST,交总工结合后台核查请求重复风险,未确认前不称已消除。
原 HEL-879 窗口已封账且永不重开。新窗口真实 cap=0;仅主卡明确新授权后执行。CG-009/R6 删除与完整备份恢复、R5 真机与 R1–R7 均继续承接;HEL-901 停放。
@@ -0,0 +1 @@
PASS mock is this hel903 mock
@@ -0,0 +1,92 @@
PASS mock is this hel903 mock
PASS moon-desktop: services — TTS row says configured + health probe reachable
PASS moon-desktop: services — ASR row claims no probe it does not have
PASS moon-desktop: services — no voice row says "尚未接通" or "已接通"
PASS moon-desktop: services — reading status opens no microphone
PASS moon-desktop: a voice is applied through the existing flow
PASS moon-desktop: settings still says a change takes effect at the next call
PASS moon-desktop: call line shows 本次通话 voice from the session answer
PASS moon-desktop: subtitles still show beside it
PASS moon-desktop: a choice made during the call does not change this call
PASS moon-desktop: ending the call releases the microphone and drops the pin
PASS moon-desktop: the next call shows the new voice
PASS moon-desktop: 500 after filing — says it may be registered and to refresh first
PASS moon-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS moon-desktop: the list is not re-read or re-registered behind your back
PASS moon-desktop: refreshing names the new numbered entry
PASS moon-desktop: the numbered entry is marked as missing local metadata
PASS moon-desktop: after it is found the cautious button goes away
PASS fog-desktop: services — TTS row says configured + health probe reachable
PASS fog-desktop: services — ASR row claims no probe it does not have
PASS fog-desktop: services — no voice row says "尚未接通" or "已接通"
PASS fog-desktop: services — reading status opens no microphone
PASS fog-desktop: a voice is applied through the existing flow
PASS fog-desktop: settings still says a change takes effect at the next call
PASS fog-desktop: call line shows 本次通话 voice from the session answer
PASS fog-desktop: subtitles still show beside it
PASS fog-desktop: a choice made during the call does not change this call
PASS fog-desktop: ending the call releases the microphone and drops the pin
PASS fog-desktop: the next call shows the new voice
PASS fog-desktop: 500 after filing — says it may be registered and to refresh first
PASS fog-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS fog-desktop: the list is not re-read or re-registered behind your back
PASS fog-desktop: refreshing names the new numbered entry
PASS fog-desktop: the numbered entry is marked as missing local metadata
PASS fog-desktop: after it is found the cautious button goes away
PASS moon-phone: services — TTS row says configured + health probe reachable
PASS moon-phone: services — ASR row claims no probe it does not have
PASS moon-phone: services — no voice row says "尚未接通" or "已接通"
PASS moon-phone: services — reading status opens no microphone
PASS moon-phone: a voice is applied through the existing flow
PASS moon-phone: settings still says a change takes effect at the next call
PASS moon-phone: call line shows 本次通话 voice from the session answer
PASS moon-phone: subtitles still show beside it
PASS moon-phone: a choice made during the call does not change this call
PASS moon-phone: ending the call releases the microphone and drops the pin
PASS moon-phone: the next call shows the new voice
PASS moon-phone: 500 after filing — says it may be registered and to refresh first
PASS moon-phone: offers 刷新列表 and words a second registration as a checked choice
PASS moon-phone: the list is not re-read or re-registered behind your back
PASS moon-phone: refreshing names the new numbered entry
PASS moon-phone: the numbered entry is marked as missing local metadata
PASS moon-phone: after it is found the cautious button goes away
PASS fog-phone: services — TTS row says configured + health probe reachable
PASS fog-phone: services — ASR row claims no probe it does not have
PASS fog-phone: services — no voice row says "尚未接通" or "已接通"
PASS fog-phone: services — reading status opens no microphone
PASS fog-phone: a voice is applied through the existing flow
PASS fog-phone: settings still says a change takes effect at the next call
PASS fog-phone: call line shows 本次通话 voice from the session answer
PASS fog-phone: subtitles still show beside it
PASS fog-phone: a choice made during the call does not change this call
PASS fog-phone: ending the call releases the microphone and drops the pin
PASS fog-phone: the next call shows the new voice
PASS fog-phone: 500 after filing — says it may be registered and to refresh first
PASS fog-phone: offers 刷新列表 and words a second registration as a checked choice
PASS fog-phone: the list is not re-read or re-registered behind your back
PASS fog-phone: refreshing names the new numbered entry
PASS fog-phone: the numbered entry is marked as missing local metadata
PASS fog-phone: after it is found the cautious button goes away
PASS status down: TTS "已配置 · 合成服务这会儿暂不可用"
PASS status down: ASR row honest
PASS status off: TTS "未配置 · 这台服务没有语音配置"
PASS status off: ASR row honest
PASS status no-route: TTS "这台服务没有语音状态接口"
PASS status no-route: ASR row honest
PASS status unknown-500: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-500: ASR row honest
PASS status unknown-net: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-net: ASR row honest
PASS session without voice: "声音未说明", not the settings choice
PASS session voice "default": shown as 默认声音
PASS dropped answer: may be registered, refresh first
PASS dropped answer: refresh finds the numbered entry
PASS 503: keeps the server words and adds the refresh advice
PASS 503: refresh finds nothing new and says to wait before registering again
PASS 503: the checked retry registers once and the take is gone
PASS recording: microphone live while recording
PASS recording: stop keeps the take and releases the microphone
PASS recording: cancel releases the microphone
PASS demo: zero /api requests from settings and the conversation
90/90 passed
@@ -0,0 +1,9 @@
vite v6.3.6 building for production...
transforming...
✓ 59 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-B8TdHoQm.css 88.58 kB │ gzip: 18.37 kB
dist/assets/index-ba4PURHH.js 342.44 kB │ gzip: 114.48 kB
✓ built in 654ms
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,6 @@
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-903-112f9b6e687e\workdir\src\cybergril\web\src\chat\ChatPanel.tsx
252: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 @@
hel903 mock on 8913 pid 12880
@@ -0,0 +1 @@
hel903 mock on 8903 pid 16260
@@ -0,0 +1,22 @@
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-903-112f9b6e687e/workdir/src/cybergril/web
✓ tests/hel879_frames.test.ts (10 tests) 16ms
✓ tests/inbox.test.ts (13 tests) 20ms
✓ tests/voice.test.ts (45 tests) 478ms
✓ tests/hel898_agent_left.test.ts (7 tests) 119ms
✓ tests/hel900_custom_voice.test.ts (19 tests) 571ms
✓ reference audio becomes the WAV the server accepts > refuses what the server would refuse, before sending anything 512ms
✓ tests/hel903_voice_rework.test.ts (11 tests) 11ms
✓ tests/reading.test.ts (11 tests) 36ms
✓ tests/r2wiring.test.ts (11 tests) 30ms
✓ tests/r3memory.test.ts (5 tests) 8ms
✓ tests/domain.test.ts (12 tests) 9ms
✓ tests/real.test.ts (3 tests) 10ms
✓ tests/review878_late_cleanup.test.ts (1 test) 11ms
Test Files 12 passed (12)
Tests 148 passed (148)
Start at 13:00:27
Duration 1.48s (transform 315ms, setup 0ms, collect 856ms, tests 1.32s, environment 1ms, prepare 670ms)
@@ -0,0 +1,856 @@
{
"base_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"changed": {
"docs/requirements/handoff-HEL-903.json": {
"before": null,
"after": "bd4b385eb0b6633ad1a801a5c67eee8b14699116d9865d4d603e7e0d0b2e2b1b"
},
"web/src/chat/ChatPanel.tsx": {
"before": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900",
"after": "9d458bd826e505300e8054565fddcc196fce93214f4f240e9b44fb2728520b63"
},
"web/src/manage/ManagePanel.tsx": {
"before": "a0e6a4a9028277b9adbed280d8958890fe6cd56a2375fd23072bd7719d3af878",
"after": "d32bb459231fdc5ec262c162483dc29d669ec2c3a4c4127c86beb23cc271a12e"
},
"web/src/manage/VoiceSection.tsx": {
"before": "de0cf6fa86e85a8b34ed79fccd2cdab4e9a2e66bea298ba2f03de2e7c4f797ec",
"after": "68e8e15442dc8d3e758eb938b4fb202af0a40ccae2e3c0af8b4e32abd4216535"
},
"web/src/styles/components.css": {
"before": "653cbdafe876966a1fb320a438460837b10aa5f1cf204fbabb26e33810e3b848",
"after": "c0a535c24db36d767ba571394176a3b5f722c534844016afb4903d4811a953b0"
},
"web/src/voice/custom.ts": {
"before": "46bc0b6291e2a60cbd90704fade5c0f45aef009b817e388c7651495b372f2112",
"after": "bf6690e33b49e79a9d65af112f429a65f0da3e35ceeb35efd552abb36f3f2f27"
},
"web/src/voice/session.ts": {
"before": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5",
"after": "76eca20191124b1b402a91e2da9c4ec04330986085a6146244ac544f7e57d888"
},
"web/tests/hel898_agent_left.test.ts": {
"before": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b",
"after": "8026851f378686fc56b4e1f143b0550c536e9d76eaadd59522d096b182f31bec"
},
"web/tests/hel903_mock.mjs": {
"before": null,
"after": "60935d78508e0d03325c22833af160d2be767e87690c965c5ecf676dd9800cc6"
},
"web/tests/hel903_shots.mjs": {
"before": null,
"after": "8bd4042a1958a51eeabaeb03a2ab7bb78bf6379e440b114b85d64b07a5dbf112"
},
"web/tests/hel903_voice_rework.test.ts": {
"before": null,
"after": "804a62b3c55f5cd3e95b734761c60628795568cee326e372cff550a57874771c"
}
},
"files": {
".env.example": "c6551011203737a875feb87a24d267994cc17e2b2e576bb39bc9d9295fd8738a",
".gitattributes": "aeef7e1984486c112d00695aa73a4338cf4fbd81d618522c0f05e43441188066",
".gitignore": "b0c08e3eea2b01c059e6ff75846df875875627dd44a579851a0e371b49282bea",
"AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7",
"README.md": "1ee70d262a155a7832b12e4339a95a916dd9ac1d20589ddf002ee53c7e325c13",
"SOURCE-BUNDLE-PROVENANCE.json": "829ed6ba821286b07033401c00a943b82b6e97f85b21e2b23a609ff6d7aaac51",
"config/character.yaml": "7b0279bfb0c9ac90e09fb58a482ebd15562b768d99d676976a128cbece7371c6",
"config/models.yaml": "baed2a8042c330823c08aba0ebcd95899e8d3a1b8446655854b24e2b62289eb7",
"config/semantic.example.yaml": "32173b89234abab76df0d4334fc25d4f2ad285fde001294a666ef4f6299b0199",
"config/voice.example.yaml": "c52848b27b5de230038149bd22e2b2b122b6bb598f114333c7667474960b1a42",
"deploy/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"deploy/cybergril-api.service.example": "afcac96b347e1ad6c9bb6f201c20ba2843d75e5612af9874d14c8a50f4f636d9",
"deploy/cybergril-hel874-8360.service.example": "bbd9abf6ac66bf50f4f9b96903ef7bb14331cfb1357a1968b298bbe1ddd0dc71",
"deploy/hel835_guard.py": "ee6c533d6b31643354f1e165d6315d7d8515560cf1509e05ec161ad0968c35ad",
"deploy/hel874-release.md": "8273e82ad6bae999e1c95201a4ae97765acbc1883d9881b2cc68844e6e6833a1",
"deploy/hel895-release-candidate.md": "40cf119e330e11b40e288edef6d03d36b0af92a5d1775736134ea7599441b189",
"deploy/mem0-stack.lock": "784bb70fdae8b31b1671a401f498bc726498873d1296151dc593ad730f64c484",
"deploy/voice/restart_cybergril.sh.example": "881327010f9c78bb258fa513a33900cb64274778af7e6818de7268fa8e497b3e",
"deploy/voice/serve_dual_8360_8361.py": "6cc4830df0d3a29347f943735a54855de6b2ae4e0501e7fc099680b56e7bb430",
"deploy/voice/worker.env.example": "7eae6da5539ae096d71e8e7c87cde2b9faeb236d6deae7e56c8295d7c1eb7657",
"deploy/voice/wss_bridge.example.py": "fa08f17bd828e016a24f7e16fab01b844dc0757b6436dea1443d551754baf4cf",
"docs/01-membrane.png": "ac3ea97a3a0237edd64752f8910eb91fa8fcf495d7e7e7985191d11ad628b993",
"docs/02-heartbeat.png": "57a2deaf723c878a7d6fc919fc8c3c73c35c08437f7ea996bc4b122c2e07240d",
"docs/03-memory.png": "624ed9b917471c5a6096246339995ae5682a472439504fe5c64e40098dc6c8b3",
"docs/04-title.png": "be2f63ec008f445ac056adcab3543d5e3a844d36553ba2adb2fe69534b847c97",
"docs/HEL-878-preservation-manifest.json": "0c5e9db95268452ffc95c4fd1a15705bca0a8c4711d77b7106316b0bdd689865",
"docs/HEL-878-preservation-review.md": "d5f28f1d57b792dc436f7ddb3c4b2640119d907ab11b53eb79aaa40776880b53",
"docs/SPEC.md": "a2506bd15ae3265ad5049bd750f5f17d88c0ae63a681073a4cf7f81b9d0fe24e",
"docs/budget-package-hel895.md": "ad8f01148a11cd7e97562bf2fca01623e481bbbb37b5b762a6a46dac5a76f071",
"docs/budget-plan-hel874.md": "799cb9569b60c64968b2bc676bb31749c06b7ea423ca32399585e1be36c27dde",
"docs/frontend/.gitignore": "405c7e9a52883194cd5b95a435948eda03961e5c399cb81bc67939d1681d3ff5",
"docs/frontend/IMPLEMENTATION.md": "60e4cb3e194e51e2e408a2546ca6ab30f6940c0c069e4d51d3a99523a2988ba9",
"docs/frontend/PR.md": "80edd7042d7032493e7adeb69dd106af8ab2db5beeb9377b2a7c80b44b68feae",
"docs/frontend/TEST-RECORD.md": "923610921e8a157949ccc78c992fa66b87b1bfd892d920898a2ceb1cb5a8836c",
"docs/frontend/browser-results-1440.json": "e52842e5979161a238f4912713a677fb7e0c4c6e73a4d8e13c5de945ce8a33b1",
"docs/frontend/browser-results-390.json": "95b81baab1bfee9aff8ac8cac553d80d9addb6c5cdd754104f4e81e2a61d91ff",
"docs/frontend/browser-results.json": "773c3dafb2a53a7450a40e8f577b094dd0529074e9783261492551c41e7804da",
"docs/frontend/import-results.json": "fc28b1b9d1b36e1fbeeb326e35689d6482ad793a55e0b7eae25b038af9f46714",
"docs/frontend/recordings/hel825-phone-nav-esc.webm": "9a3e1086a50e60f51d270a44f93f2dca02a605efa91057a7d738f8f7c34ebc77",
"docs/frontend/recordings/hel825-wide-nav-dialog-esc.webm": "6945fba64c0a674b646ab32f771d8430f4e1bb6994a1a42704dd29e4f82fce6d",
"docs/intro.html": "1b2784fa3b38f63307c9cc1b75a091784121eac3d1ffd92dba7b4a48eb888b3c",
"docs/preview.webp": "e36368b5a7753057f2382925831b55addf5c78d415079b9eb9de4fed6f1d92f4",
"docs/r1/DEPLOY-ROLLBACK.md": "f381ac058fb79211aaf4b8655701de8f2473db88735d943e5f4b659738837e5c",
"docs/r1/IMPLEMENTATION.md": "acdaa3f1c1dc1c6f3b1c1a049c0bb4fe2279e91af1acb9b831581c49e8710521",
"docs/r1/PR.md": "f08cfff4531eaf9c89ff944993468d19f9cb1b1f2b02c18b6947addbb417abe9",
"docs/r1/TEST-RECORD.md": "83b1ba9136f1ee1f48516575cb2f9f2a0e32d0595e4c51918726e7ad3fd0e542",
"docs/r1/browser-results.json": "710c04ec4d15559ad64baa5c2e0a29f6c03884b5f83b85cd09e536cdfcc7b9d7",
"docs/r1/controlled/browser-results.json": "5b3e1e53589bcdb969b93d20bae9f6ffacf7819334d585048d620a24c17e7e6a",
"docs/r1/desktop-history-moon.png": "1cb74ca298499abde7a0832e371849e29565e4532b5c0b2b2ebb8ddc981a17b9",
"docs/r1/desktop-model-error.png": "839b9b05f757e39421e8d3b15c192a1b1f213a879ff35ac81c6ce7178631048a",
"docs/r1/desktop-preview-moon.png": "ca8115ec27e70471585df2a5ce4d3f1e4b89e162f4cc4c351ae6f608c073e089",
"docs/r1/desktop-real-chat-moon.png": "cd2422457dcac56af1149f8d6bc3a0590bd5e37adbf3ad8015cc212e6dd6e05e",
"docs/r1/mobile-chat-fog.png": "7ac20f654c78fdf11fe5bb11e4731dbaf551e01227e284b855bd13cbf8832696",
"docs/r1/mobile-history-moon.png": "9168aa143c67469bfc00cdccaafd5ff59dd183f9526544cd4d0cb4f366517cda",
"docs/r1/mobile-relation-fog.png": "c1002c20e65a0967eca6ef1c2559755d0de9d1f04f66406f34ca86a302410557",
"docs/r2/CONTENT-REVIEW-HEL-830.md": "32ad98545aa8791998979120f3710cd5b0691fb8b85fd755c993586a7829a2e3",
"docs/r2/HEL-827-HANDOFF.md": "fcde52d27bbb77495f9b47f572b03042725a4885127f2b7ba36ed39529ab907d",
"docs/r2/HEL-830-HANDOFF.md": "dd392fdfca8d764be9db115689e16c3d87cd3710ecadd442c0b4d6ab5792a8a4",
"docs/r2/VALIDATION-HEL-830.md": "b80ee2d62dbde635fd1cd64b6660649055e36f7893c29308b9eae43367e0d1a2",
"docs/r2/VALIDATION.md": "ad7c6243b2208476d7ee8b740fa43e28606b8f7ac2419718a350d17d3555192a",
"docs/r2/model-smoke-HEL-827.json": "09f2242f1a8e4f637967c7ac3e232407e40b0fe5707f8cc57a76ac1e8a93ff84",
"docs/r2/model-smoke-HEL-830.json": "4c83baff50ee2038925f50d41ff4ea8ddaf68e3883c49931a79c3c44621d3a21",
"docs/r2/pytest-HEL-827.txt": "8e8a03f39e1a8b3062766de3d746dd8e63144a617231dfe1a7c33c4ebc21bdea",
"docs/r2/pytest-HEL-830.txt": "c93e4c1a44bf30fd51b325e5510cbb1247bc4811408c99793cfbf8d499f3f43d",
"docs/r3/CONSTRUCTION-CARDS-HEL-832.md": "fd0f1f930a95f6500700107381dfa29e6f68cc2df0e305a200c9d1568c6a2b3d",
"docs/r3/DEPLOY-HEL-835.md": "2baf25cd13729a7583d9ef07b45ef1ed0a64db08358c359494509a78b94c001c",
"docs/r3/MANIFEST-HEL-834-source.md": "b98f1c7aa7c6924a3c6d3d86be6a6fdce447db3c60b944813493cd5fe122a45e",
"docs/r3/PLAN-real-followup-HEL-835.md": "39676418d5841f88c2bcc562300e1d108bcb1a3cda8801b82f80fb5a4cf1d993",
"docs/r3/PLAN-synthetic-retrieval-HEL-835.md": "c026477f1848253f6899e701b4bb6ab067c186791eccd66193a473b67ddd7c01",
"docs/r3/PROBE-runbook-HEL-835.md": "acdea88bb6bee26321519eebe596cc036231c0b793ddf8fb7bcb8de82b534a2d",
"docs/r3/REPORT-HEL-832.md": "4503f9e54ef09914616a5a84202169277f558aa4435e1a6f5fc967d50d9623ea",
"docs/r3/REPORT-HEL-834.md": "77740766ccfc3d0858e3f4af7660b54b36eef8c2e0841b38251baf8b0eb3f189",
"docs/r3/STATUS-HEL-833.md": "4c4c028d0c79906510c3a28f14b4bd81c21f990fa7f745294d983a453db5f45c",
"docs/r3/browser-HEL-834.txt": "fa2640e476c7ad754ee3d5132b4e830945cb909e3ae2c9140ec87517fbf9508d",
"docs/r3/browser-HEL-835.txt": "70741b740ef4e73f42c695c82f824488a777d41cdd43b47164a50dc6a6b46e3c",
"docs/r3/correction-proof-HEL-835.py": "0ed3da3f6ba132127decb2cfb97a7e7d05b9fbc81b7c9bc6e19cf11385eceabb",
"docs/r3/correction-proof-HEL-835.txt": "7dea0a5735a89a461fac48f72561188282f9a023dd6017cbf99d6ffe747c260e",
"docs/r3/e2e-HEL-835.py": "381fa13555786adf9ca542f11224b6939fe20f91760eaaa70788fefa11543923",
"docs/r3/e2e-HEL-835.txt": "5574437c36fb01e8baf167379c52e87444554b77b606ec19e9200b2ca5012474",
"docs/r3/fts-probe-HEL-832.txt": "8240648088d661575e5e4b8fb3eac2e0b359a1ad461614f9d3a792729b8cb9e7",
"docs/r3/guard-test-HEL-835.py": "e012f31edf597b69fab631c791faf4c21aa8e038705f76e1709c198305ec33fe",
"docs/r3/guard-test-HEL-835.txt": "298ff142518231ced38d63b49313554da72ccad81248ee4151233081e4001e5a",
"docs/r3/handoff-HEL-832.json": "e391ed66273bae849067f0d000469bc7e62c8559d349e2d99fab018162c8d6c8",
"docs/r3/handoff-HEL-833.json": "01a1df4de10d4b453ab1a45e334efba1554a8f59bffd9c58b77c419820184be1",
"docs/r3/handoff-HEL-834.json": "cde71b060455bdc5ebf0971dfc92f875182ae7e1aadf429439f5888e849a79a7",
"docs/r3/handoff-HEL-835.json": "881a35a7efaeeff1cf37bedab2002a43b0568c914ae99cbfe361460a653b5b6b",
"docs/r3/lint-HEL-833.txt": "82b3e6a6c090a57601d22943bd23fca9218d1031dbe5a7b754092f9a156b4f18",
"docs/r3/probe-seed-HEL-835.py": "52af2bce1a1745bc0c52e23c18ca43af2ce492fd4339bbbee0bdbddfe2264260",
"docs/r3/pytest-baseline-integration-HEL-833.txt": "9d0001187df1efa22add8cad53334914153ca78641f327aedd20a9ac11e10068",
"docs/r3/pytest-factory-fix-HEL-833.txt": "4023bff69febb0dafd1b4b50684b7fa6bd110bea1008934c1f86be0a45608454",
"docs/r3/pytest-factory-fixed-HEL-833.txt": "a9633c21c64d119b38d38318bafc6f01a0e2981700cbacf477ac3094bf0bfdd5",
"docs/r3/pytest-final-HEL-833.txt": "6d854a95805385d3ea0bf71799b6c0755d99eecece242b5d060336c50905f55a",
"docs/r3/pytest-r3-HEL-833.txt": "43c3af9e809b8242f8b3f7218db9a1857ac5b0c852e68eb7eb1238e95fa83dd3",
"docs/r3/pytest-r3-complete-HEL-833.txt": "e572da11882f4e474a135ecfe382ddbdf81b3ce0498ab0352e903a7d5bf18902",
"docs/r3/pytest-r3-final-HEL-833.txt": "5c5c3ab408f4a7facc22b878b2a5df2f5524f00fd39bbf26cc3e248351d130d1",
"docs/r3/pytest-r3-first-HEL-833.txt": "92b40ed9a75ccd1b150ac53e1f74952e15ae9be89c4ef58c2aa80262dbd7e70e",
"docs/r3/pytest-r3-second-HEL-833.txt": "5ed4e8608f1537f81a46a18453b092851c44c5ca8125f69a0c32155f15fbfa37",
"docs/r3/pytest-rerun-HEL-835.txt": "b6664da3662203e0a83a60ed86d011a1e8f04c5b919bbd3dda0281d6a4663d14",
"docs/r3/pytest-restored-HEL-833.txt": "f01bfba7ca8899a889f67218dec8718aaf13dd50c029071239a5d6c45dd0b842",
"docs/r3/pytest-restored-fixed-HEL-833.txt": "1c30ecd3b900aa1b62f3dcb76ca7154e0395e357119559a294f1f8a5c77122cf",
"docs/r3/requirements-HEL-833.txt": "6f91ccbac356f222d7b5f776f50f830da3de37196651d0c04162ecf8b590ba05",
"docs/r3/requirements-HEL-835.txt": "18c86cac189e8e5b3a8718c57d075aa823124afb9337b71fb577c089283a38b3",
"docs/r3/web-build-HEL-833.txt": "7dff1478eb043cad384184db8b1a638e1e6d1b7faefd6b109427cf8c2e22110e",
"docs/r3/web-build-HEL-834.txt": "a85d8e2d9253f115ca9045f43c4c55bde3fa8e3b3797fef84b264fbbe695103e",
"docs/r3/web-build-HEL-835.txt": "c754dde832fc925848e644bac96beafa77d4a9f75aa4ae0e2e360a75feae8765",
"docs/r3/web-lint-HEL-833.txt": "1c2b79600d1b8317501cbd26dcb3c4a931f1e9faa25d1a79017907bf88688d12",
"docs/r3/web-lint-HEL-834.txt": "2813d6a1d44e721a2365b7208e3a02dfc2bac27c7c6d187493c7312926cefffe",
"docs/r3/web-lint-HEL-835.txt": "ee0f22dc391a5a537aed68e83127c9dbd432ac6e4015b9e6e4ad31da00f98ab7",
"docs/r3/web-test-HEL-833.txt": "407ba2dd1bf999497aa2195d25f879404f90c48b8248ee53c08f2df1fbffdeef",
"docs/r3/web-test-HEL-834.txt": "8f5ec1193c72425c02dedd90d23ad23bed133b43020e38b92d0d5f76d1e2ef21",
"docs/r3/web-test-HEL-835.txt": "f51c9ab4f7558471822326dc6d4874a04123abd892c43eefc39c44aaf3609c82",
"docs/requirements/README.md": "64ed5afa15edfb9364cf2b9a2af757035899b99e1bc62c2ead0c584a05784036",
"docs/requirements/baseline.json": "6cf523a70929c30f13605492df06dfe56b8cd92af23345412a98affd18aaa0c6",
"docs/requirements/changes.json": "a38222d0c4018abab44f1a7aa47028b6e5446376a6483b4de7f031b3e2f926b5",
"docs/requirements/changes.md": "eb967e418e608a42491b2df4f9ff52cd7c98316b7eb1b7e42874c9b5eec673de",
"docs/requirements/coverage.json": "05e17f85781fb9c01e6cf60786f26236e436a7c3730de864e0d77ceb9b79965b",
"docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658",
"docs/requirements/current.md": "7bbc122a24d2964f12cb557aff8d33997199267fb8142cbf9047254491c11a10",
"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": "ce8aad74a3abebefb1f4b4b0d72b9bcd93a9286f8ebb446fc9359957df5896f6",
"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": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31",
"docs/requirements/handoff-HEL-899.json": "580ad2fa309d1bfeb78a4e295205f9d2e270654933a09d8a939be291e2a0408c",
"docs/requirements/handoff-HEL-900.json": "297b5c257b5bf55b2e09c49ef8a9646f01060dd74b92433d8bd3e32c9ffce218",
"docs/requirements/handoff-HEL-902.json": "3f734a1a40b401dc30e0411f9cc1ba43b1ef8d4633d30ea20b4f6cc5a0860a33",
"docs/requirements/handoff-HEL-903.json": "bd4b385eb0b6633ad1a801a5c67eee8b14699116d9865d4d603e7e0d0b2e2b1b",
"docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819",
"docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989",
"docs/requirements/source-manifest.json": "6b3e5cc6836c881a915b812164e7abcc1092b8f19f0f791686ab9324f5578aa6",
"docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b",
"docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"docs/requirements/sources/comment-evidence-custom-voice-20261027.json": "ed72bd78218e1c0b4ca644bf933d6bdb2e3aa8cd2c0333f3410f6b60845bd3cb",
"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/reuse-hel847/mature-reuse-plan-v3.md": "584d92cfe67e064df958d8cf8cd66ab49bf54b7426a3abbc208f279b2690256c",
"docs/reuse-hel847/mature-reuse-plan-v4.md": "351e210133783bc5431414ed887b78a897cc09dd6a3cbf4869046417d0f1c795",
"docs/reuse-hel847/openclaw-reuse-plan.md": "5df74412afdff07ee1ab65a96ccb39c4c06f1df7b59651ab8d5ab6470d77546a",
"docs/reuse-hel847/probe-result.json": "de08b408dd632ce672aff2c090d8afc8b125e462ff26e5564df674f784f7db59",
"docs/reuse-hel847/probe_recall.py": "12a7ce7898e3e2541910ce68eb01ef5de6bb2a654b8c762fc76f7c6c64efb98d",
"docs/rework/REVIEW.md": "2cb3425b9df96c62fd81daf0ab22a5d7016de6b74cffdc6bef49a0658d40f464",
"docs/rework/appearance-fog.png": "0e4ca6e32ec31bc63910924b9cc34850caf8dc831b63255bc58d2cd2f493db19",
"docs/rework/backup-fog.png": "590b6206e4ab837f02ae524e726748d3e11dc8c62506ffcf642fd971dd1ba28e",
"docs/rework/birth-calendar-help-moon.png": "765648e39fb33966117f255772c492bb4a510f00279c1a4e2e81173eeae78305",
"docs/rework/birth-preview-moon.png": "de9264431b010e3c856e8436bf228e016d289c4151b62972d49316045bf7c899",
"docs/rework/browser-results.json": "6018d335c81995f7f3a2226183f52a558e9984fe67d25ad63c331d676ca2ea27",
"docs/rework/city-fog.png": "fbab999af93a61a35da2b994d5f78e4a3129289fe749390d3d3e71083f72093d",
"docs/rework/compact-first-press-390-420-moon.png": "a3e84420b022984ed2af6e34c220b649bcd0408f6dd704093144b5e20cf44b86",
"docs/rework/compact-first-press-390-430-moon.png": "8e6b217c1734cf9aaaee616ff9d6c6e7c503ccaa11ad001f5469643321c139bc",
"docs/rework/demo-voice-390.png": "115a5c6af987609469f860ec111c59d64892cf4e618693cfc8367d19f716f2cd",
"docs/rework/existing-birth-fog.png": "e05f816d01a29642457e2f9c38dddbe7f4d4e2c2cf241b94ac96dfd5ba705694",
"docs/rework/existing-growth-fog.png": "65a30346ee685b8249e1dbed5217b15b5422ac9e0453a64417fd68c991cb0e24",
"docs/rework/full-history-fog.png": "d95eb4e69c98450d0b20e982eee49eababa4f998ef3b51bd76549e14d3f6d02b",
"docs/rework/full-history-moon.png": "db0fe19658abff84ced08744fa4a3e443dcaf8ddbb495862077d552e2484ac3b",
"docs/rework/hel874/README.md": "cf65d13af9c8d6967fa815fafcd1a289ebd3a866018d925b549b64c697973d93",
"docs/rework/hel874/erratum-hel882.json": "7914d30ddcfe8f32e58ec181b78a9c0b60674f6b3b0d1975d0cc21a235cb52c1",
"docs/rework/hel874/real-sdk-after.json": "5f342b3eea44339eeaf6d90b001ef39575899ca5e3ef854054cad2a6c68eb9e1",
"docs/rework/hel874/real-sdk-before.json": "277f57bc4dd9ba556e65f25b1a042a6f5cbc940ad1e7170316885e3e752ae2d2",
"docs/rework/hel879/browser/real-boot.png": "5610d244e72ef0e5e675a6fe48fc36bda5e797657a49ea70db79355b563a612f",
"docs/rework/hel879/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel879/browser/real-card.png": "366c80949935b1de5dc16df718e8e4126c873f172dd2f0f0bfdd6893fbd608bc",
"docs/rework/hel879/browser/real-gap.png": "c1a7709ad49b82db1fdb86e814f61abf2c573c6ff79f8fe16c98d779613ab8f0",
"docs/rework/hel879/browser/real-record.png": "7dfd5a631c2f9916c1ae35d2a7d60c280c4814132becea69e80af6c98059bd1c",
"docs/rework/hel879/browser/real-stage.png": "e2e5bac215e47a46e088cc5ed518e1f12ca7a70acc61df4397ae342680066b3f",
"docs/rework/hel879/d1-audition/README.md": "0da72f608956457ec67624c6d79bfc5c0d1cdb1ae7c918a0781abc5e0de371ca",
"docs/rework/hel879/d1-audition/manifest.json": "66605e35df3ea8f73c8fde6f6efa3163cbb1359d65f48810b1c88be556b2f9fd",
"docs/rework/hel879/r5-window-20261027.md": "c095529a2841156cc42a2b95c371dea01e00da263a4be1fd52937c2baef91ce8",
"docs/rework/hel879/r5-window/call1.json": "ec8d3ef7c7405573117e8d55091a8aca591b73f4253f492f72f9415505f9685b",
"docs/rework/hel879/r5-window/call1.subtitles.jsonl": "69f41191150f40db49eeb948d0412c0da6bd9734b7d3384598616cc7d506717e",
"docs/rework/hel879/r5-window/call1b.json": "1a110cafd9c2755682b82f4f1b3025eb9105475d0cc85be32f73019b869f5fc1",
"docs/rework/hel879/r5-window/call1b.subtitles.jsonl": "15f0cac2f5af3a97c1e36115f4cb14761f0ee352a62d1ed7386ceaefab2214b3",
"docs/rework/hel879/r5-window/call3.json": "a7a4a2897da9713871cfaee2efb21daeb4df370795fa87d7eb3006d1178aea35",
"docs/rework/hel879/r5-window/call3.subtitles.jsonl": "6ac5e7a330b387a44af7bfe8a202eca513517983d15641f19cdef536143edc5c",
"docs/rework/hel879/r5-window/evidence-window.txt": "7447b6aa49ec8937ae7648d95b4cc423a4d6a28f31f071e1e218b8bc418e8dbf",
"docs/rework/hel879/r5-window/worker.log": "c5d7ea664f8b0d14dbf0a786dcf10ec6be7dde581dc9754681752f9e8d5406a8",
"docs/rework/hel879/regression-close-ownership.json": "4fe1dcdb6aeaa514251b9fd8c72efe6d4654eeadcdfb0001786c1d6dee3a3a1b",
"docs/rework/hel879/regression-inflight.json": "bda736af33860f827df6024dfd45da1bc089c8afbf2756a0920d9592728d3ecf",
"docs/rework/hel879/regression-mem0-real.json": "da2d6bb865ec2abc8456c7749bcdd3ef3dbdce944f2aa6da2d4641128f28a4d0",
"docs/rework/hel879/regression-semantic-recovery.json": "e78976c1e2856988accf0df0727e52a7880f425e31d9f8984b1454f1d68a44cf",
"docs/rework/hel879/regression-shutdown-queued.json": "b069772ad232b1d8b4f29875c7e7bec9d133a061835e86db3986f7dc00ea7344",
"docs/rework/hel879/release-artifacts.json": "3212d6200d57bb5c1b43c584c8eda5ee25f37444b2b13bf4e02174f739f7bcde",
"docs/rework/hel879/release-check/hel882-release-check.json": "5ba134d5cced47431bb631803c5617a84236f099e06c90153a3d27d3e503d1ad",
"docs/rework/hel879/release-snapshot/selftest.json": "afa3fcfc8a76f4eb5ef8cad2e6578a49c89f39e1b83cacd4feb2284f831f0ec7",
"docs/rework/hel879/sdk-audit/sdk-audit.json": "5fc52280ba998f4d57ef8d4a6d5db8bfcc12c4b3e3ecef2aba79172082afaf67",
"docs/rework/hel879/sdk-audit/sdk-check-hel879-rework.log": "99f0c00dcfd7366c352780542bb169c5ce9df3e2883dc2a776cb5aec10a9042b",
"docs/rework/hel879/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel879/sdk-check-hel879-remaining.log": "95e0b29d63f32adb4551e7195dad44926f741ae3b8dae7c0f15600fd6242c9e0",
"docs/rework/hel879/sdk-check-hel879-timing.log": "32158bd3ead2d05e76c7b6d43ed9342a9b4df4bfe5be115f7065d6de312b5f5e",
"docs/rework/hel879/timing-fix/delayed-dispatch-after.json": "368771cc63b83f7a5cd202afc57eaa639856e69a2e32414432031b5aaa846040",
"docs/rework/hel879/timing-fix/delayed-dispatch-before.json": "16942e6d43535fb175c2c69283a680df2c139f86b820b1e3093fdcbca7bedce2",
"docs/rework/hel879/timing-fix/sdk-probe-after.json": "8dce4cafc9a463149c644e83eb7fc2e239d8fb95c4a9ba1885bdcfb0af7dc4cd",
"docs/rework/hel879/timing-fix/sdk-probe-before.json": "42ed372966e41ebf1bfdcddfea22c4193f4bcf24a35c09accdd1db1cea1119d4",
"docs/rework/hel879/timing-fix/summary.md": "3b888256ce8bbd8b1994a3f9eeae59d0d35d3d6d5121664141ac2559e4b667e0",
"docs/rework/hel879/unified-review/final-review-20261027.md": "7c73a5d00a8d12e875f6e6b280cdc0264971d7f0b11e3b2a588da324f2f49690",
"docs/rework/hel879/unified-review/frames/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"docs/rework/hel879/unified-review/frames/worker-frames-raw.json": "c2bb153888d5f94957a0f6dd1cc4efcc6e12c658c75f5a1029ce141207e606b5",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/r5-facts-20261027.md": "5bf2b54c45d2c3730801afd0db3d0246362278dc49a3274266b8760451371c69",
"docs/rework/hel879/unified-review/r5-test-package.md": "82b1f7fadab74ab1aea1f336af84c2f96f03c22c9abf5faabcd68d4216dfa7c8",
"docs/rework/hel879/unified-review/seq-job-analysis.md": "cf621542cf609083f8ce4597d98727c1eecc5b6d3442a8dfafa44bdf0d0ed7d3",
"docs/rework/hel879/unified-review/summary.md": "0b6f576351099185d74bc294c35343643d41a38f75fb6c2cedd8cf23cfbcd2a7",
"docs/rework/hel879/unified-review/wss-bridge-smoke.log": "c2c6f0fefa84be22b06bd140a34916563995cb803bfd199767241f1beaa76f38",
"docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293",
"docs/rework/hel882/HEL-882-sdk-addon-stub-switch.json": "1fa6407d9cb9fd39ae9b576cf530c77891bf0e1c799033cb5a94b33d0ef86ec5",
"docs/rework/hel882/README.md": "a87fd204e50c56531700f176472d005f7e89868c420b5f7620fdefd84205eaad",
"docs/rework/hel882/addon_stub_switch_hel882.py": "eb8e3016c517abfc2ebd4e4b5fba5f7b4fbeade178dbae3f4c0bc09c3a48dcbb",
"docs/rework/hel882/manager-review-api-rerun.json": "d3c9cc125a6d4494dda7ab98bd0a7b6a049338ed4f8491f51cb7c59b168c9699",
"docs/rework/hel882/manager-review-sdk-rerun.json": "f30a0f95e21c306f948bf748e799e67b9f449bfa0ce575eff7868492f92febec",
"docs/rework/hel882/real-sdk-hel882.json": "8cce7b2e9574089e235961594cd7335863dafcaa2383f47143e5a2a4ee82f9c8",
"docs/rework/hel882/release-offline-hel882.json": "854f11fa19d78ebfe8bbf7e03f378ae4097dd244de0d91b38152ae0f3d543316",
"docs/rework/hel882/review_hel882_api.py": "bc58a1faad731dfbc637e78bfd9b5e8dcf8b1b6c088121b1254bc94631f23970",
"docs/rework/hel882/review_hel882_sdk.py": "0d27a64ca971006747ded0749bd156bbc5900bb327c3bf447b797cc005f8534c",
"docs/rework/hel885/README.md": "a6b90140b73a59ee0b019d7602e7da2e77313fe2b2af9af47527384ae1d5dfc6",
"docs/rework/hel885/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel885/manager-review-partial-rerun.json": "b7fb2ff659bc701ee3cad88aff49cde96f043eddbe53724624e97b3a4bbdcc22",
"docs/rework/hel885/real-sdk-contract.json": "e6850945a4b44cec7c3b31486995521ab4a82b4c0156f41d103efba350c98b56",
"docs/rework/hel885/recovery-real-sdk.json": "6e870e0356162293687ee68bfb50afa6d11043e03e7028d018b2087c82eba1f2",
"docs/rework/hel885/release-offline-hel885.json": "c31cb1119541a70df93f1342d9d8ad12e196a8a5ffed768904ba03bc4770c5fd",
"docs/rework/hel885/review_hel882_extra.py": "8153c198a2a926916c593fa5369abda9eb8f6f7ccac7dc8e26b273cdaa6497a2",
"docs/rework/hel885/review_hel882_partial.py": "1f8f72e55e13218b92857f8a89bac95c7a83c139b844f1ebb8cefbc22b27de03",
"docs/rework/hel886/HEL-885-inflight-baseline-78b6267.json": "1f8c0edfbc88482fcd2c6186b70fb482d537ed3951ffb795f5e885d5c69a51fc",
"docs/rework/hel886/HEL-886-inflight-rerun.json": "f0fdd0056c7c569efb8103f2c82170ba2ecd6c2d608c1e6a5ad0065b5f8d59d6",
"docs/rework/hel886/contract-hel874-rerun.json": "e4c66881f0c19d39edbc16ede18f53151bec333bf3e6aa6b773d3f9a3d706f8f",
"docs/rework/hel886/inflight-real-sdk.json": "5803ad65587489b6d2382902b70922537c47a8d233a041ac043c7060f1fa3741",
"docs/rework/hel886/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel886/manager-review-partial-rerun.log": "3531914e4218a4d7528f7b50a845b778d0bdf41c1266b210f44428e80de494be",
"docs/rework/hel886/original-verbatim-after-fix.log": "753944017e39033874b2a026b31ef2e9c4e0137502847b29da1c0f08d8817a21",
"docs/rework/hel886/recovery-hel885-rerun.json": "db162f62e3cce19404045028a65fe1ef1b4f6823e2590a3898d36dba8d00af0d",
"docs/rework/hel886/release-offline-hel886.json": "671e73775e313264a31bb369ca4ce023130f800e74dcc56561c59a00abc4e3d9",
"docs/rework/hel886/rerun-inflight-fixed.log": "8727ba7711137e6b57e7e1baff6cb9cd5e7b531a1860fdd96afabc4fca6f15d5",
"docs/rework/hel886/review_hel886_inflight_rerun.py": "d806c9a884314a0646d7d263f533f33daba6707cc040ba59fd7f73c91534c785",
"docs/rework/hel889/HEL-889-edges-rerun-fixed.json": "1e2da91b0f5af7e0e9f20000be9e0510d776dcbe1189775ded59c9a69e8a84b2",
"docs/rework/hel889/README.md": "0c71977bb34d7687ea53269edce597827bb550a136d6ba6b6961989c4b354ab0",
"docs/rework/hel889/contract-hel874-rerun.json": "7d535f3128fb06915f38f6b00244b2faeaaa368ab04e6e8d2b2b967fe92052c3",
"docs/rework/hel889/contract-hel874-rerun.log": "63a94f494dfb25f15f834a8927b7897c9dd5cd4647b12485dd78bfb318cb609b",
"docs/rework/hel889/edges-real-sdk-hel889.json": "24ed3a2be6aca29ab6e9a9cccc5b9f468745237ed9d45105e4259df7094dd25a",
"docs/rework/hel889/edges-real-sdk-hel889.log": "4f6ac192eb7bd952d5560ce1ff6ef879940bb6d25bb42aa0e9a6d8ab1d0dbf41",
"docs/rework/hel889/edges-rerun-fixed.log": "35da33b2f898a177b0bb781532e4b8fc6da7c5a5d57d87649f5a85d8d697319a",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.json": "622db8e329e710c56949a6e938b0eaab926f22a5377ac1496c718e268734d63b",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.log": "d561d2a1e617e9837354bb9a0617c710e279ef83d386b401bc4751c8fcdaa238",
"docs/rework/hel889/inflight-hel886-rerun.json": "429a419b4329fda60f249a83e30eb8820ba8681b769c8e11ef910d0fe34a0d9c",
"docs/rework/hel889/inflight-hel886-rerun.log": "51132b0edfa1707d32a926fc1d76cb467c6aebcab82f0de23211aa4fae757bfa",
"docs/rework/hel889/inflight885-verbatim-after-fix.log": "1bc0a7063fe271c670a945121cdf307ec5ea5e96d8dc9bf1ca211895ff7c50c6",
"docs/rework/hel889/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel889/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel889/manager-review-partial-rerun.json": "193040328c630fed808a42507714a214e2db6e5a88042ed68b8fcbb655a50560",
"docs/rework/hel889/manager-review-partial-rerun.log": "6948e3ff7e268d3b940fed813030ad0b3e1f488b0374957e2d6bce00d0c3ca3f",
"docs/rework/hel889/original-verbatim-after-fix.log": "2b2bf6d1a53b31803f9274b6c83bf18a80be13d04c071336822f36e8ee935b7d",
"docs/rework/hel889/recovery-hel885-rerun.json": "e2d603b777bb949aa9cbc1ffab027b004ed9ebcdc292c705c0b74c9c6767597b",
"docs/rework/hel889/recovery-hel885-rerun.log": "193512e7acc2df77a4cc1649515ca5fa0b963c165b733babbcafe50fbc4ff349",
"docs/rework/hel889/release-offline-hel889.json": "a91e67b5492b9aa196fbc99a1a18605396441a2534cddf7ea14b22dd22bf14b5",
"docs/rework/hel889/release-offline-hel889.log": "6adda1a9d16e458c02d6a9b6e9cd65b9aebfdc81d826aeff55f8c1df837580d7",
"docs/rework/hel889/review_hel886_edges_rerun_hel889.py": "2d9805f4f2bb210877ea03106012068d657e3367f4ca5769439beef53a3dfc69",
"docs/rework/hel894/README.md": "f7f6ffc21313507a1ca3d8c45f330f4229f15d50c274ae9252e5b4e28226c39a",
"docs/rework/hel894/before-ceb0f35.json": "6bdd1f12de0b74bb281ab47c969945bc384671fa2ef92d6e7c548967079ec824",
"docs/rework/hel894/before-ceb0f35.log": "cc5aa75523def5975476fcd45825d518914e886b46e75748b871bdddfc9adfb7",
"docs/rework/hel894/close-ownership-real-sdk.json": "35edac5f922f8c1bc556ce109e0cb0dd44aabcb7864728b14c4f6c14ba1bc03c",
"docs/rework/hel894/close-ownership-real-sdk.log": "d152d5d69231a05eb73620316db9493528eaac474451126a0e7696a4f2fdf432",
"docs/rework/hel894/contract-hel874-rerun.json": "c1ebe639712ccb76c54f53e531d7fb5689a2bfb94eb2a0220e0a9b2bc3fc8b76",
"docs/rework/hel894/edges-rerun-hel889.json": "2b5fd2dd6aa60c9b1e3a0b1b4ebb62776a006dae1b89b2099f8b99b65e30034b",
"docs/rework/hel894/inflight-hel886-rerun.json": "16fd3f06e38c55d16cd49eee36d505950c4d6df42c5d88f7fddfdf08e468966d",
"docs/rework/hel894/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel894/manager-review-partial-rerun.json": "b0ffe7b2a765ce230641780a11be32ac933e3079ca981db8c32fccb3d273a761",
"docs/rework/hel894/recovery-hel885-rerun.json": "072838353bf06aa0eac3c8cfa9a653aa1aba2c6e40cf4fa78250f35eed848b35",
"docs/rework/hel894/release-offline-hel894.json": "a01c5dcb054981cd2e966d809ab04861b678109eb901e7c627dd3417649c5885",
"docs/rework/hel894/shutdown-queued-hel889-rerun.json": "2dc2e61073fba8f128ecedba8bf5c6040580a936ccfab00677d78352def33007",
"docs/rework/hel894/verbatim-after-fix.log": "562eb581b196c64b3d379f733f2bca500042d5b39d84027620afe9e0e61f95f2",
"docs/rework/hel895/http-pagination.json": "6d7a24e89fca3ae0dcb4b3ed4ccdbad05315c428467c286273c5a443f9ee1811",
"docs/rework/hel895/real-boot.png": "71f15746f8e2c9086980b1b0937a88b14ea6f409d67ea2e87ca3f89bb34f7788",
"docs/rework/hel895/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel895/real-card.png": "d29907914917ba499138c8aed562c5ce14e7b8819b37d168f65ac05e959d09b6",
"docs/rework/hel895/real-gap.png": "4ac193acaaf2cc4f6b84e5b6c3ceb83b95ad69dade965fc1b1237373fa092409",
"docs/rework/hel895/real-stage.png": "0007a60053b154c2c4b7754b0389e69566174df22e50b66cdbdcd4e73d953f1f",
"docs/rework/hel896/README.md": "2e80276baee4bf1bc4836293b74aa3c2803a39bbb4d3a1fd0651bb6a3c5c7f7c",
"docs/rework/hel896/browser-before/hel895_real.mjs.original": "d7f682aa268f78576974b2ab515ca87aa630a27aa88ccf954c5c85da67abe777",
"docs/rework/hel896/browser-before/real-boot.png": "9e8f192db66acb7e12fdbb68e197878b4fe355076adf5d68c7c4f66182ce8f60",
"docs/rework/hel896/browser-before/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel896/browser-before/real-card.png": "51475111fce910ce7df91a45274c8522249013bb8a5f0691070b3ea31e89bb74",
"docs/rework/hel896/browser-before/real-gap.png": "045c0f025b17780828d12f7f742521151cdf2a26eb88a1f26a2221f9ea59d1bb",
"docs/rework/hel896/browser-before/real-stage.png": "c7c4996d36e4542f0d6075a1faa999f06dac96e2c8baabd9bff5fa1bcb248ef2",
"docs/rework/hel896/browser/real-boot.png": "574bdef19cb49b9767d760e17806e9b4be4c74531c9165b1f10108ef6a23bc2e",
"docs/rework/hel896/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel896/browser/real-card.png": "eeb3b7713ca9950e42fd7dad33ea7322fe31fa4ff2457c8278055403fa571a70",
"docs/rework/hel896/browser/real-gap.png": "623ddc39640623356fdd3db5b1fbec633bacb55d6264866fc12df9d3859f3ebe",
"docs/rework/hel896/browser/real-record.png": "8925684798fcb879058d15b25cd681920ffb3bf2f51f680f4f057e86beaec8f6",
"docs/rework/hel896/browser/real-stage.png": "e73e8bc79d03c2dfa8d446031c661e78597c6626ff3845cd0ca7a9ce3fbd4846",
"docs/rework/hel896/browser/run-commands.txt": "fe9554e7bc876041b9aac92a434cd48a98746a4f54aed29cbc4e60a73d12708a",
"docs/rework/hel896/budget-split/budget-split.json": "2306bf31bf46c0b7ef69589dcd33bef67988789ec7866c938244140f5597986e",
"docs/rework/hel896/http-pagination-rerun.json": "936c07b50d7487e7121c88c8c64e0b440ffd406966e705acc46504c801f253af",
"docs/rework/hel896/regression/verify_close_ownership_hel894.json": "1cf311a752222c9f9e8247e207abcd54a02ec74f1f3aaa267de1e7071519dc6a",
"docs/rework/hel896/regression/verify_inflight_lifecycle_hel886.json": "0306d9aa599bd21c71e942522323285dd7fca7f975cc876b8d58ae183730ae36",
"docs/rework/hel896/regression/verify_mem0_real_hel874.json": "4b0a3f75848d017c9ecc9e8b15d080fad40320280a7af41bfc4f5909ec05c519",
"docs/rework/hel896/regression/verify_semantic_recovery_hel885.json": "2ca8109bb8e18d2e42341c9f0002c99f2a18bebaae55e106c87440e5946d056a",
"docs/rework/hel896/regression/verify_shutdown_queued_hel889.json": "51ce6516daf0f1625bc41be587e28c2f7c7ece7918083ad3ab32e61be19aa156",
"docs/rework/hel896/release-snapshot/selftest.json": "8cd9e006c26c24307b58707bd83bba785a4f6438e9f3d922fbb7318d3b0f4121",
"docs/rework/hel896/sdk-audit/sdk-audit.json": "16447ddd5a0fdd5d42c60c55544666ea9d729c66d3b2d72d088d845c92aaf947",
"docs/rework/hel896/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel897/author-MANIFEST.md": "b77c014ba496ce1d0d25d7af7166fdf5d2b6ee9f88dd55bef0b8dc5d399faaeb",
"docs/rework/hel897/preservation-review.md": "da576648238dfac4b656976f8ddab0a73dca9b835f8f8df3c2a331c2265b8365",
"docs/rework/hel897/preservation.json": "f4b42d482dcc075112e49775b75dd42d98dba587db9caf499cc4bbc1ce4b853a",
"docs/rework/hel897/verification/baseline-voice.log": "bdac37ef3f11862f97e4203e5f89398cc3733f94614e038628d2764fff118ec9",
"docs/rework/hel897/verification/build.log": "ae7afd2c0039c63fae3a98d1e6f9e2297f6571daa5db6e9dc5ede1b39b29bb5a",
"docs/rework/hel897/verification/lint.log": "cd386c675832086bd4e971d9013795802169abdc5f17ed5685bec83cd3e62a17",
"docs/rework/hel897/verification/requirements-0-878.log": "390db0ca70a614ce52762e0c58f761ff9c352d3aa7f33d33e44220473a91b43f",
"docs/rework/hel897/verification/requirements-0-897.log": "a28d30af3b4008b7dcf00a8368c56be4fe9ca90a5dca597665dedb31ede63130",
"docs/rework/hel897/verification/requirements-0-plain.log": "bdc7c0adceb6a4167ed86405cca65372102be43fe14aadfffef649d00d3bccb5",
"docs/rework/hel897/verification/requirements-1-878.log": "d047c9640b88fc2bae9935b1edc673fa78b1f395e2cd7273a970f64559260328",
"docs/rework/hel897/verification/requirements-1-897.log": "e7850ddeba1c4599986f0baa681c03a05483b50242366c24929d77b9903671e6",
"docs/rework/hel897/verification/requirements-1-plain.log": "23ce0f8507fea249ca04266ff2446e1ddc6cd548507cbc5a593b5cbee195ccc9",
"docs/rework/hel897/verification/requirements-2-878.log": "1841b81cea75ee5c9c1f7805f15a9097064803a415f34baf487cc636dd3e1247",
"docs/rework/hel897/verification/requirements-2-897.log": "fd6c1d457a39b7dd139e904013b1294ca1fba3963873957c99e59065e0c181e0",
"docs/rework/hel897/verification/requirements-2-plain.log": "dfcfb121a77a5e61f8c86bf85ceb3f552599a2d2208dae7e03d142747289d4ec",
"docs/rework/hel897/verification/requirements-3-878.log": "4b74e9de0e9c3bf5c677b6190886f225ee5e030e08e46b75c317bdcfc3c8daaa",
"docs/rework/hel897/verification/requirements-3-897.log": "83329b71f5a62662dccf6f55a8818ab4c756a6608fc57f345cf66f2eddad325f",
"docs/rework/hel897/verification/requirements-3-plain.log": "5ef5db73083d4072020ea7f77a06977266263cefb46aa3a49488f5e8792390b3",
"docs/rework/hel897/verification/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel897/verification/vitest-all.log": "718c853381bf572be1dd716eb747adf095ffefc9a97d847c06946ee4a09a17a4",
"docs/rework/hel898/HEL-898.patch": "9e653a45bcee4e1927c797bb94869e259d98298188485c03b5bfb42a221fea37",
"docs/rework/hel898/MANIFEST-HEL-898.json": "f9744e3513129df9c23a86c3dfa934ade47e3d4b3e53ad7adbc0ef706121811b",
"docs/rework/hel898/author-logs/before-fix-voice.log": "1d19bb00239c89466a1e7fd893ee002a492e0c666ef211042f17968107cecd32",
"docs/rework/hel898/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/author-logs/browser-hel898.log": "438e5a87615ec3005040fa2a0946244fa6c4d083a9629160d8243a3e0c09dbe8",
"docs/rework/hel898/author-logs/build.log": "f2e1d0c89c8a81d808dab307f891221d99277960a55b9ef96c0577b43bf692ad",
"docs/rework/hel898/author-logs/lint.log": "1f1b4b564f7342fa50ed2a44b0c1fb110476efc42b04e1ec9880053b3c5fd940",
"docs/rework/hel898/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/author-logs/vitest.log": "9979ce6e9e813c8953ae6ce5440e9934f497848ffc51ee865f729bb643498a46",
"docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch": "ba1aa445ff7e3be6c4f1ae60866c38a793f06d564f2edc39c21d1fa1142b4c14",
"docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json": "082fc1b0c655c4a24f9a8cbb2307dbc2a28decccc47169fdfecd9357b13c7e08",
"docs/rework/hel898/f92a86f-rework/README.md": "ababbfd5f2809feba7b075083bc89ecdfa487d737db4050e0c951b8ef7eb75de",
"docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log": "1b38231602e232a10661b1da4abbb08071a0c67aa0d33a224212ae2e2c9429dc",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log": "9c24c70b57e87478a45cc28f98f1bcd360259d5c4f8108559cc54d2a6e184bdd",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log": "f6d27faa14e3cb5fb7d4e6726476d1e1b6cede8da7953b8d4a8915677a676943",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log": "be9e6e9b1095c42c593b7227479e8b865fd2f3f69ea5044ed41e1396749a9a53",
"docs/rework/hel898/f92a86f-rework/author-logs/build.log": "ea00bf64f258dee412fa16c3f91f2f8eda0f7d508d9ff4cccc4c699373341ffd",
"docs/rework/hel898/f92a86f-rework/author-logs/lint.log": "b91a312f6a7093ab71938a5000bd7e74d495dced85b4cca806d1c07cf5d0322b",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out": "aa3283544f6aaf179ec8889b8e5b4f40140d9d931734f68f4fcda4236fe431b7",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out": "8f5c505be5b4a53a1004146453981eaca882443b9ee5493f6ef75b316ec166ca",
"docs/rework/hel898/f92a86f-rework/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/vitest.log": "8e8956c2eaa5efeb3bb98f54df75fc7d0e620551bbc3989ff58bf558516e8320",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log": "ce337e1d74ce2a15a29e4fd13a5ba55be2dbc306f7e4ac98f65183c1d6c8730c",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser.log": "212a347da8189c405357caac49551848dd742c68f969f96cdae89f39302f98c6",
"docs/rework/hel898/f92a86f-rework/manager-checks/build.log": "f99c37ce20446d893da9034b6c94657fe1a780ae53c94278aeb02c20da98f4c0",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/lint.log": "a6d2f20be6af0732befc1f4253f56785ddce8d4efa8fef542d12d229e715567f",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json": "7fd2bbe038be6cff322782819af1123b1f12aadf99fcfd2d676f2bacf1c5152b",
"docs/rework/hel898/f92a86f-rework/manager-checks/test.log": "2a4061596e1c77ce27669bbf90a70edd5995b2fae18f056c55dbd9f261a731f0",
"docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/f92a86f-rework/preservation-review.md": "36219963433b4f286e46dcf831da9c1df88f4d940ecc4b4f5a7d15b44e15ce46",
"docs/rework/hel898/f92a86f-rework/preservation.json": "c1b07b77d250d034bfe72e806b8a85f20eb4d6c4a0e1b08f3fd98afb8e0e92cb",
"docs/rework/hel898/manager-checks/build.log": "606ca426f708ce742d8febb2610a455a179e1b22038fd4f322fbbeb882785450",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/lint.log": "bde4108ccd7f12e3e499d9aa3489710f250169aa5cb709d446dffaf15e35d54e",
"docs/rework/hel898/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/test.log": "955c26e7260e552317f01c95ed40984ff460ba06cd710f9a12f231144d3ea760",
"docs/rework/hel898/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/preservation-review.md": "7b30452cc64fd7468c6be941e6a42ea4411d73678787d81059f93e7eb2d88654",
"docs/rework/hel898/preservation.json": "9b34b4520de79e5473a171c7789c506219a432e6f4fcb6e305816fb830f59f0e",
"docs/rework/hel900/preservation/HEL-900-author.patch": "994d510231b4820baa4009a90ec4c8b5a560c41f44b88a6cef1a35b590094590",
"docs/rework/hel900/preservation/HEL-900-git.patch": "33f7afe059fa9a9684ba545ba7818b718ac0e7a58751b9bcfe142292bf50a1e3",
"docs/rework/hel900/preservation/MANIFEST-HEL-900.json": "d83b1530d9815fcd531cee8a496491eea668e75d007b402ee10c734ac23d3df9",
"docs/rework/hel900/preservation/README.md": "6ab08385da75377f7decdbd07335e2f970f08d501aad9faf420257da90f063c4",
"docs/rework/hel900/preservation/author-logs/browser-hel900.log": "c75130714cbb1f865f944732e3049d646a093bf76a48210c938a5ba0257e9239",
"docs/rework/hel900/preservation/author-logs/build.log": "d80a8cc7620f16b21864f5e08ecf4b049fe13c64b479c147ed59f5472d8eea17",
"docs/rework/hel900/preservation/author-logs/check-requirements-handoff.log": "ad23bae99ef3cc51e5b98b6018b22ee4ecb570fdcf78ada981a4614f55a88aef",
"docs/rework/hel900/preservation/author-logs/check-requirements.log": "ad23bae99ef3cc51e5b98b6018b22ee4ecb570fdcf78ada981a4614f55a88aef",
"docs/rework/hel900/preservation/author-logs/lint.log": "975339138b6edca80852f5285338d2e6ba56189959e61ba54185152006b9fa86",
"docs/rework/hel900/preservation/author-logs/mock.out": "68d43ad9fa5c8faac342b628b3282473cd2c133e0405e3f86ffec0ed430437de",
"docs/rework/hel900/preservation/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel900/preservation/author-logs/vitest.log": "40416149588d3ff63c1e440bcc3c903895edcdc2c96a6629bb396d8d9216fc73",
"docs/rework/hel900/preservation/ce7279e68f64319c080f-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/ce7279e68f64319c080f.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/preservation.json": "1f710c2ed8b73cfc414199c06e2b640966bd50ffd5f01ada895b52986a8e4e90",
"docs/rework/hel902/REVIEW.md": "76507fc70913a1476724f5143e76d2f1d363cf53c4eb3e33acc9aa3d3215543b",
"docs/rework/hel902/r5-followup-package.md": "5b7667dcccbb726aa4628211e3d4fd3bf10d14123a18db6359df2787f01105a4",
"docs/rework/life-empty-fog.png": "1bbf2570e4a8800f7608dc6640f7b9e44821f399823ce9443294b26a0b28427d",
"docs/rework/live-gateway/browser-results.json": "30be349c389d080901de6a8670eb46782db3fd49db523a44649c6d40fcddfe36",
"docs/rework/live-gateway/desktop-history-moon.png": "e3659bca0e1022477921eda7f68be797b6478b3aabee66b49ea10535cacd7728",
"docs/rework/live-gateway/desktop-model-error.png": "1b379403df9aa951d63bafc93d6e5672aba24d210a1141fcbd7b0328558f3929",
"docs/rework/live-gateway/desktop-preview-moon.png": "d5019e895c42e30598b41419920e2ab6d21963135abe14ee0d168445f6771fff",
"docs/rework/live-gateway/desktop-real-chat-moon.png": "19b7ff408d00db160acf85f8219795e506041e5c6d6bd617395296241eabb316",
"docs/rework/live-gateway/mobile-chat-fog.png": "9bf5e16b0a9627ff6f083a6037a1eb28c33ea096e3704f8a4cb099140d212587",
"docs/rework/live-gateway/mobile-history-moon.png": "5378c9acba14c1f0f355679f46be44e767a9b6ae72fc3715b4f3dc33e28028a4",
"docs/rework/live-gateway/mobile-relation-fog.png": "19dc0bd378732568a0d3e4c48ed11f26bff7e10184f4e27949636d2b11ccc8bd",
"docs/rework/long-end-360-640-fog.png": "4c837b6743263ede73fe47ee73ab74d9545fbb2a7628cbea135ca7a2f1ebec2a",
"docs/rework/long-end-360-640-moon.png": "486266d4ae75bb1750f265c014b6564a9db29403595a2384d80426f2c8574cc5",
"docs/rework/long-end-390-420-fog.png": "8e86a38399aeb4099886ca3a5d44fe1d36bb64292259cc32da4f64aa5f5fa50c",
"docs/rework/long-end-390-420-moon.png": "eb71bbf0ecc3c0bfea52946e220142f91cc79b542534bc1d0fbdbf17953dfda0",
"docs/rework/long-end-390-844-fog.png": "89b9d157d0a3cb357c50d58f38ecfc692b9c356eeeaef30b081aec34f9f0b43f",
"docs/rework/long-end-390-844-moon.png": "3f9415a261915cee3f16e88f66e49142a1cc39fecb05a282677d61661b559b7f",
"docs/rework/long-line-360-640-fog.png": "75cf566114a26e4df2faa12970f9677f075b04c0865629cc99116aa1841718e9",
"docs/rework/long-line-360-640-moon.png": "3b49df960565dff19b46240144381d3891afd8d8e6d358c827600b7a4c357e92",
"docs/rework/long-line-390-420-fog.png": "9771dc93ca1387918a82ebf214130925ec4dcbf80459a63a7694c37e991c67d6",
"docs/rework/long-line-390-420-moon.png": "63442ed8846d4d7ba4e4c0e7e9b65397916982fd6c028010e90482969f34a072",
"docs/rework/long-line-390-844-fog.png": "af30bbde4ad69a07cd74c16c1f042fea3aeb21e496dda6fd90436f2a2c3eba26",
"docs/rework/long-line-390-844-moon.png": "c4573c80e40f9eabcec85232d5d3b8a4f48c64341fbf7d6609f6bef723f4d09c",
"docs/rework/long-start-360-640-fog.png": "fda738f17b21e708a22742c1378cd598d1705cdb71abad3c1128e56791a85ea9",
"docs/rework/long-start-360-640-moon.png": "9d89286b79fe1b8a8ab811701b3997b15d613c9ae1af2a0c1c19ae1072db6cb1",
"docs/rework/long-start-390-420-fog.png": "4ac8dc957c6479d5d41632ef954ba18d1e211ececa4572a04af1a25379d1fa9d",
"docs/rework/long-start-390-420-moon.png": "19d72e5aa42fe4144bc616181e5cb63535f0853580cbf9a9df1d617335cd1256",
"docs/rework/long-start-390-844-fog.png": "566a267f4680bf9e0c6a5bf8a31567266870b5dfdc289149cb17746982ee870b",
"docs/rework/long-start-390-844-moon.png": "3e7feca3c35a6a4e93730a98495c350545ecde343b0fb8f2d5c6d4392678d5f3",
"docs/rework/memory-groups-empty-fog.png": "0f090db810e1dce89380331d5a4373849b1f8432bd065f7e96aabcf42a8098b4",
"docs/rework/motion-enter-0.png": "84cd16febbfef374e2b1337343c0877234e964a6748e8a446d0448496d50f3b5",
"docs/rework/motion-enter-200.png": "0f8785c46e25cf716166f5631aa317401c29a239894e8ac8c06ba07815027ca2",
"docs/rework/motion-enter-80.png": "05dd14acb0a9b9fcea7910a94540895c2dbdcf3798969a6c416d22f46537765b",
"docs/rework/r1-regression/browser-results.json": "c5d47431753028968dfaf767ae324ffa38f13df898fc4ddb3e0931fa5614e8fa",
"docs/rework/services-fog.png": "3d97b02f0098f91e5d7d32c973bbc2fbc2c15287874bc18579bc2c2a0e912e67",
"docs/runbook-hel870.md": "149f57b77b9bba00d7b38c1359540d4adca3cedf313602a2da96159aafe3b006",
"docs/runbook-hel877-voice.md": "1ca96ccf4df50b90b338443d2cdca0181d5ac316216e95611625e5172763eaed",
"docs/voice-custom-contract-HEL899.md": "bf81216b2742e5948ccc4d2184b1a2f2f61303908a128dd7bbc4f6a1c35b03b3",
"docs/voice-r5-readiness-HEL876.md": "0ba8e2bbf51659221762235fc7766e247291186b8ebda3c28f94bfc8780cade0",
"docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4",
"prototypes/hel-799/PROVENANCE.md": "0d03344a0b4fe7324ee5b931b4c1062aeda4c35caa8380c753c20730ccb1ff86",
"prototypes/hel-799/README.md": "8269d688ffbda2dd34608778204f5074079c254dbf69072e7a2fe78e793f3093",
"prototypes/hel-799/build.ps1": "8a5d8cba2c60eb0b4999a482635c317cf8f1df54bb295eb5fca4b5f9464b43a1",
"prototypes/hel-799/handoff-hel-799.json": "812d4eab8f1b4f57b8378ff0a1f810e8b2dce5342cbfc686cf430c03bd2e1452",
"prototypes/hel-799/index.html": "826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e",
"prototypes/hel-799/records/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"prototypes/hel-799/records/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"prototypes/hel-799/reference/hel-798-static/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"prototypes/hel-799/reference/hel-798-static/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"prototypes/hel-799/reference/hel-798-static/hel-798-compare-pr8.png": "6ec7b3d454af4404eafb0b0fa86c17baa10e6aa622456c2ef8689d9011bec764",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-annotated.png": "8d5092b722d653d0217ed758635d0be61b70a5ebdc79da449a280a8bed43d52d",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-moon.png": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a",
"prototypes/hel-799/reference/hel-798-static/index.html": "8ee3fba1e62409f06d0ee8b42cee735a1d5eb64965ea3a027b997352bcce3d7c",
"prototypes/hel-799/src/ambient.js": "6a9b5edf204364a31e855fa3554643715b91d1b2ef88175a0bc2be2cb0792e27",
"prototypes/hel-799/src/room.css": "a3daf8ac189969e8dab1de8f8139bf6fc2426b5c15d14d332f40532b3fe50515",
"prototypes/hel-799/src/scene.js": "d51302195996d6ec75c2044dfa01ec81ad2f17b11676f143ede965f0d282e2f6",
"prototypes/hel-799/src/shell.html": "53b468896e6b8d722fc8ded1d7a3f83b8f8e6ab0b027d7d0b0a2ba4bec70b292",
"prototypes/hel-799/src/talk.js": "55af813358f83c5e0f99948056489b4387138c1464c51bafaa1359ec6072f73a",
"prototypes/hel-799/tools/cdp.ps1": "e7b872ee6915bbfd4c6bb528c7b5593f25e60bde0bc66c79f3c63f0efddd22fb",
"prototypes/hel-799/tools/contact.ps1": "f91761ec80dc44d1746238667da47df291f43f7bb14d6137002d73777612fd0c",
"prototypes/hel-799/tools/record.ps1": "b6ed8344b125e739664c2a31b735d18238577f5d591ec9ad3e011bb20be99d37",
"prototypes/hel-799/tools/snap.ps1": "ecb8dc017bfcb3883942ac7b01e160037c8261749ddc5759b4048d85ad6020e6",
"prototypes/hel-799/tools/strip.ps1": "fe37c21270feeb64f810aef8f6aead21bdc3b79fc57ed99cff8435e35896817d",
"prototypes/hel-799/tools/verify.ps1": "796739f6d079cc500d6221a6155cc1fb4545f246c3cb753007b8d4b0b969d557",
"pyproject.toml": "7b803e2f08b61f4cf40e5bfd83138d5de883ef2101a20094ae74052213dab45b",
"requirements.lock": "fb0218790cc3208ea7695afb7ff98b186f169e13d01f3c97d482ed7edcee8cb6",
"scripts/check_requirements.py": "6fd1143aafa7ec146b8f33396af6ec83566ba678c66c50987887b4c929a62b1e",
"scripts/gen_hel877_sdk_check.py": "707d2b508662fe90681957118f99930c6b50d56d0d30d9cc15d95dcd7a5d98fe",
"scripts/gen_hel879_timing_probe.py": "a5d22ed39cff0cf7c0ae3670f081871a7eb739be08f3d39a9aa5d6e543251927",
"scripts/gen_hel879_unified_frames.py": "4ad231ec3ca8a5e87b6500102dd634df71101737b2a0399ff20b4ad8946f8112",
"scripts/gen_subtitle_samples_hel879.py": "a6c391a7ef18e61335d1dce029ff8e98c852b8634ade0cdaaed27437ff46c391",
"scripts/r2_backfill_persona.py": "48d75f4b1ab7d43628df0449074f1063874756996c4a4bf6aefd19a237dc7411",
"scripts/r2_model_smoke.py": "e5eb2bc7b428c021d87466c71eee381f634537188b9b94fb25a84f93e1eb2e5c",
"scripts/verify_hel876_voice_seam.py": "f6db5aa96bdf000331efcec07beb4f9b74c592fbf4a2eb29190f49f527a2a197",
"scripts/verify_hel877_voice_chain.py": "c7c060006f0e9ab0916cf5489a5ed845930a6cedd75cfd4806916a0799a4f29b",
"scripts/verify_hel899_custom_voice.py": "45f53dfb31fb41461b342c79a31fcbed3ae3af34e9f4ad28876f6756ec0dcc03",
"spike/hel857/README.md": "39083ad6365f0f3d59f67ed67c1917b9fdef82ea8a8dba2d9c645b4c572ac7d8",
"spike/hel857/__init__.py": "ad18d8bb81710d642d592b60cffeaf73edba5a825fff55015f4bf9a4ecf85cc8",
"spike/hel857/__main__.py": "0c067efd5fcc616f2b7d81f06a65f4c92eddd07c8eefb98fce250fc5efdc7243",
"spike/hel857/ab_child.py": "38bab11fb4cb5bbc515cb79a3e457dbe42ae579ebdea84ff49791e957263f280",
"spike/hel857/ab_preflight.py": "4b153e9bb7faa1cd1a92bba978ad8b29960656e7999e5a26e858ad5534e923ca",
"spike/hel857/ab_scenarios.py": "0f9d7865a3a9bf78334b567d30979f4e4e0e638d4adb309087834843b1555aa8",
"spike/hel857/audit859.py": "f6bb5123b19ba8d4ef751be2c86c8f0aa5715371223609bae58708344c73b6c6",
"spike/hel857/audit860.py": "ce17214437abac7eb4d7e76a44d40482c7ba17285cdae507cb97f4754c4efb89",
"spike/hel857/audit861.py": "e6db2c4110ddf01105b048753a416b98aea175ff6602e4121a018a6010c56164",
"spike/hel857/audit862.py": "5cd66d49cafa8a40e8b78a5cf9a64d538bc5352688149f1a8712cf791fd6324c",
"spike/hel857/audit863.py": "4696fa67a1ba5abd7be7873442187cfeed47ea20ad821c82b66eb2bab3265c94",
"spike/hel857/audit864.py": "c25449c609385124b51fd3d3b8406ee32c898c18505b5aef58049a140ee89159",
"spike/hel857/audit865.py": "63a0c0fab527536c7ee51a2f54b1c892152710a1d4e9ea66ca1583dcbbda25ff",
"spike/hel857/audit866.py": "d106ee648589ff67e6a3735041e4a7c4acbfc7704b95d88f3233d2b8df7bab53",
"spike/hel857/audit867.py": "f8aeb2f3c3eb5589aefb0111fea2a8feda272ee62e530a53e509eb78259c0471",
"spike/hel857/common.py": "6e7450b506bd13e8bba2744e0d93557c6679a207d4639eeeca3d9eda1a479da5",
"spike/hel857/fixtures.py": "b8d680c1fc818da469015e2354f7910f1472a4413e111c6dcc3ddce081ab180e",
"spike/hel857/ledger.py": "70dfd3fad1ee912463659f6b3ce0baf4ce78c618baf6efb09c4fa644ad63e08a",
"spike/hel857/ledger_child.py": "9eeb977aa3fd2e747edf5592f712541bdbbec015d5a0ea814c258a65c613eeef",
"spike/hel857/ledger_fake_test.py": "b6166499506f5bd75ab1d5c9bcc937e0fdd571ea1d7b0b8a21d6bebfd4c47210",
"spike/hel857/plan-next-HEL-869.md": "10ae07336a30929d86a5bbee2974f354a8d6147a4e0b00b000989874d4dfe3f1",
"spike/hel857/recheck869.py": "6142cc4aff0a8001da85c6864558eac744a2bed9984a4e623fad25a10add586b",
"spike/hel857/resource_check.py": "98644760d80e97c4154c37f8da841e6154d540783182c191bdefb3c6926beb67",
"spike/hel857/results/ab_scenarios.json": "6745088341baafd14f73ce88ba7d8de3550b48122a1c90f439d192c055a5eef8",
"spike/hel857/results/ab_scenarios_run1.json": "c473cbee15e9d243cb0475cae36ae3077ccfdc660be615dd84e5da62a2c4f4b3",
"spike/hel857/results/audit859_postfix.json": "5a6a57191fd904c7226892fb1e69f4ea4dd0394169dbe8e1ed13922297f559cf",
"spike/hel857/results/audit859_prefix.json": "d651383754d036cc4a5f52a943d58e4acce9e7bc3558fd0bdd38de098997442c",
"spike/hel857/results/audit859_reuse860.json": "d6f0ddee989c67fa8922f6c2b81272039f08fac4e3369625dd36fdfb00a3a12f",
"spike/hel857/results/audit860_postfix.json": "31b3127d1f5e57738ecabc32fb7f8cd004af2d6cbd9d8ab8c262ff0f406349ee",
"spike/hel857/results/audit860_prefix.json": "5f4a7fa5a23548231cc022a01836f7f07bcf68cb385c98db38ed2f457d5efbe4",
"spike/hel857/results/audit861_postfix.json": "e6a346a54ab23c68e18aff18014577c53910c321dcf8ac6dd81fe0556933d826",
"spike/hel857/results/audit861_prefix.json": "57fff753d876a7f35589ce1e112a8aeea658d8eb5831050c2ba49155a63fe9b6",
"spike/hel857/results/audit861_reuse862.json": "5252430ca76d8ead326e86106cd7f7d336dde3644e16ba3157e78516929424e2",
"spike/hel857/results/audit861_reuse863.json": "a70a394efc27a59c62ba9774988838e001871037fd009705098ffb4eeda15834",
"spike/hel857/results/audit861_reuse864.json": "8d92cc3b187271ea7c2071b452f08da844bc6772e3952e121e4c40c6ab806305",
"spike/hel857/results/audit861_reuse865.json": "661f59ddcaedcba89aad251ee44eac960726bd40e1ee00c54daddc56b1fbbc07",
"spike/hel857/results/audit861_reuse866.json": "77f1dda2e2423e36196d60bf39a1fb925ef3635d3a2510ed69a49cd4b4f7b405",
"spike/hel857/results/audit861_reuse867.json": "72957a8a0420538930fe551579fdd4702195f134c28080aca05ecb5927a7d4bc",
"spike/hel857/results/audit861_reuse868.json": "789add169e08d6f1ea9826f1f8176cab451edb88adbe3b58d1054e176bddf836",
"spike/hel857/results/audit862_postfix.json": "7b735a07a705416a9f1ae85f7c801307a8b66a63cbad57c6e1e86bd4bfdac9e6",
"spike/hel857/results/audit862_prefix.json": "0f35e645bed678822d0d21e334e1fac1000a2b0d243a31b652ee4ba4b6c5365f",
"spike/hel857/results/audit863_postfix.json": "a89446b732321cec324f8da57bc3175ad87042b16de519c5b33198497e4e5fc4",
"spike/hel857/results/audit863_prefix.json": "c75f93e93933c218d539f4e1e0ce567f72f423dbd96537d68100ab28b3b3437d",
"spike/hel857/results/audit864_postfix.json": "d7d8374e5e7df46a52c32329b8fd10844011e37cb460515973bb7ad00a8c5e59",
"spike/hel857/results/audit864_prefix.json": "64d2f75dc13935e1b67a3d884c04d4c7efde69e004f4e331f689f972bd37d15f",
"spike/hel857/results/audit865_postfix.json": "3197245db1fd41a7fb6b5cf41441c0f22abc90ad489fe722805fa905a7c4d3a6",
"spike/hel857/results/audit865_prefix.json": "d72e3ac932ce2181ed9d37fd4987bd70dc2aede3853c79033a6485e5f8e9f4fb",
"spike/hel857/results/audit866_postfix.json": "05257d7d3fd1c486100ad1af650e92c84cfabca3614a488ab9db342136c56920",
"spike/hel857/results/audit866_prefix.json": "3cebddab4dab5c0f2ba4016da224908e6e840c1fbd2f586de253d208b230f7df",
"spike/hel857/results/audit867_postfix.json": "1ff0480bc1dfd3ccb014e111c7e98d0e0b62107b856a50f9c14e0b2ffd17a3fd",
"spike/hel857/results/audit867_prefix.json": "91ae2b39f8bd9456834adbfd6e10120123205bccec077f4014584d567af69711",
"spike/hel857/results/audit867_reuse868.json": "e65a98408f9b54a33bfcf22f749a17f2bb880729163f085049251b6c92ac65a2",
"spike/hel857/results/erratum_HEL-869.json": "8b1fe85be0e5b6291d4fc4c72f5f99afef63b270e719416995e6a3e3d457ea23",
"spike/hel857/results/gate0_ledger.json": "e2a7224a4a29ffcf0e54006d93db71ec14400d7a548a67167f023339cac02faf",
"spike/hel857/results/gate1_backend.json": "ceb0def6253609b0fd610386a5ccd67cb15233bd9aa021c6a42d72bbc7a1f94a",
"spike/hel857/results/gate2_privacy.json": "579ad8c1944598f92ca90ec622c578b9b8cd242f50e1337608f45114b4a91d77",
"spike/hel857/results/lock-notes.md": "44e89b7af34ef5db1d25d3916d2d254c10097e49e3d8940bfcf8e7ee69cc967c",
"spike/hel857/results/preflight.json": "4bae4229f2a6a3d6c46964dc76594831c90405cc7e8c12bd6d5edd2dc482c1a5",
"spike/hel857/results/recheck_HEL-869_rawretention.json": "0872c3abf7f40e6a0776d8b8603f7078f54444389963738048215ed74f512d41",
"spike/hel857/results/resource_manifest_pre.json": "08675786695fd7a32fc21a38e72f0b8708a0edb55d4efb02ba2438b6851a362d",
"spike/hel857/results/retest_HEL-868_S1.json": "b613d1e6a8cf3386c0a963692e9d40143b28820fad1b96ece92efc6656e504d8",
"spike/hel857/results/retest_HEL-868_S2.json": "0e5f9be91b88582cc2044b1fb56860a34be1be6ef6f9fff13268a512302ffb2a",
"spike/hel857/results/retest_HEL-868_S3.json": "2fe60d0c4a290c1f7e1027953f0898b0d2bba009196cf91a98857cf9d4947386",
"spike/hel857/results/retest_HEL-868_S4.json": "fc9138388defeefa28706573ce89b05ee3788efe6835caebdaba09680c36e14e",
"spike/hel857/results/retest_HEL-868_S5.json": "fd162411ad19de84b3a4254202be130cf3f9af18c419baa8334fbe550541414c",
"spike/hel857/results/retest_HEL-868_S6.json": "b418f4f1535682110f9ad6ec733ee5cdfbb16e383dce603f43965b7953f0d431",
"spike/hel857/results/retest_HEL-868_S7.json": "9d5f0fa41fef1088183f73c03003dcd1e1c75bfd36aa1eb64d0650763928cf63",
"spike/hel857/results/retest_HEL-868_S8.json": "374fce83614d304911e5b7b833d55bc128b0df3617b3d841f7d1ca8ea3be2405",
"spike/hel857/results/retest_HEL-868_S9.json": "7255bef509190578e529523cb7f680a09460d170e885f04bc7f009269b9021d4",
"spike/hel857/results/retest_HEL-868_calls.json": "afd039315333e85fe2b831018a894643f3cf5ea112e15a37f87cf9fb4c5dccea",
"spike/hel857/results/retest_HEL-868_calls_stream.jsonl": "e80062f83cab4268e680bb45cbaa36f49e8f5bbcb99da9bed1c58633966a3bf4",
"spike/hel857/results/rework858_offline.json": "01b9d8fb9757605ed528bb9cc559b22098b4b7e599f4890240459d1bad877ef4",
"spike/hel857/results/rework859_offline.json": "0785228e2c0964bace4b95617dea6b1261c2cf5bd3923a0c61cfb8e7d5e754c5",
"spike/hel857/results/rework860_offline.json": "788f4090233d228f54ceeab99a3517870022e926dda160d7f5a9ae8fdc35c203",
"spike/hel857/retest868.py": "bbcb3bd6dfd0f560f25301d667e12a08793236df27ea985838e027cabb2f7022",
"spike/hel857/rework_suite.py": "4a249249344fe76088b5e11df30721d4354e21dea3ca6e544cdd59205600e44c",
"spike/hel857/step0_backend_check.py": "0d8bbc9e356e026f87b1800e877901d1dfa7e0db58b52f559d8f83b0b667d98b",
"spike/hel857/step0_child.py": "d43b2fc56e49578fcf75d038ed3140e679a7190561b180686b4c9d53fdb72d35",
"spike/hel857/stub_gate.py": "1c63195f95af9682750fe7a708e789e71402d919f2a33d31ce27e5a7d4ebcd45",
"src/cybergril/__init__.py": "28b475ebffa3d3a8e23c310ac95109d4806190d72f621100a3a8d026157553f7",
"src/cybergril/api/__init__.py": "cf114cbe3885a318da0c95d8d52720c7acad2f378c8c34d82d1ff3f985dec6dd",
"src/cybergril/api/__main__.py": "e0fb1851ae8f3e5c65397b9bbd81f011230bdab70b14720feaaa854a545513cb",
"src/cybergril/api/app.py": "54a7ed03fe0976996f4e6b68c4ee6b5db79a712301b5a275a13477fb718defcf",
"src/cybergril/config.py": "fef3da0512f91dbfe5d0ba0b72df512d33bae4482b63410631ed0fb934095893",
"src/cybergril/contracts/__init__.py": "de6de43999429d64c016b242e8b1c3a64dc67359a0694b86e87ea1783360b486",
"src/cybergril/contracts/audio.py": "fc2a4d783b921d4f0ce6b73d71a3c2742ab0b66112312c1016bedbb93e7a30ba",
"src/cybergril/contracts/capabilities.py": "cc0584d223dc8c035b412cfb6381e95d2c7d6accb07b06c14d918bfaa31d405f",
"src/cybergril/contracts/context.py": "95122b4f9e1be718d5f3ffb012c273e5acd33bf6a8f53aa9a5dc0b7a69bef000",
"src/cybergril/contracts/percept.py": "5076a620ac8e83697c34ea3142f02040e645642233cd713106a20dd516e6e2bd",
"src/cybergril/contracts/render.py": "6c3072da720e1dd57ac3245b8aa84da89d17bb6e024cd0ad2e85a2a179e942ab",
"src/cybergril/contracts/state.py": "bf67347a42f70a6b65576a137364765a2b9b59131f569e8ee0635d3f7d3c579b",
"src/cybergril/contracts/text.py": "566f61221a2059f7809c7c362f6d326f4553448a53d64b851813e39959db485f",
"src/cybergril/contracts/utterance.py": "271ac41a21e792b66a90db5380ca9452d46f92cb8c586d105570e9819fe005c8",
"src/cybergril/core/__init__.py": "b20a1f351aa4861eacdabfc85c40f40627ee114f50f80101c6f106ccf72ab2ea",
"src/cybergril/core/bazi.py": "ab6a827b19a70ea24d655cfd6ac8c5e541795c241390a8d5ad3d3e04bc1678d7",
"src/cybergril/core/birth_view.py": "8b9e67ceb42ad26df9b79ee120818bb51659bebaff9710fab496058cc5df5950",
"src/cybergril/core/mood.py": "20e6b3f8cf79b333d660197a83bb5af826cd12b6d5371c7eb4702ccac6d7a1fb",
"src/cybergril/core/mood_runtime.py": "bcb8a6818d738ddea60198b22b2295c1519e81cd269b53ffc10f0767151ad2cc",
"src/cybergril/core/projection.py": "574df3cb060699867396043e86d222a12ec0eab962f7c04b597ed1996cfb0243",
"src/cybergril/dyad/__init__.py": "fb4ee7bcd35ec72d4e9c32d4be1e013e708b988c2fa1abbdb59d695bd974ef25",
"src/cybergril/dyad/ledger.py": "63a0adef8ffef0c5e1b0c2c8aca23235cb618f1cde0ff4e4cc1fb0d5f7badcbe",
"src/cybergril/dyad/runtime.py": "7e26adea61db256c9d8c2f377e1b539e3452f668e20963442405c94af22149cc",
"src/cybergril/life/__init__.py": "f9e61e40ddd4cdbc520dc582e9197ba37ea5ac734ec6ad6eea3a7eea4eee0693",
"src/cybergril/life/activity.py": "3a0d0fce9e49f537f55d8db50af1d860c7a7c922bbac0be0f67d1723c3f671c5",
"src/cybergril/life/model.py": "2ce2360bcdabbe9de52851324ab04fa672ff644264982d8760812e7176f208ba",
"src/cybergril/life/persona.py": "b58999b1a9a50f0a0d222d549de5f6d97ae62dc243374444143b64feacaedecd",
"src/cybergril/life/public.py": "c3e85cf86c8acbbce2c5cea70f14f7f9cf0342378b3751da3495b6f29052e81d",
"src/cybergril/life/reach_out.py": "c430d0d0b855b5c2d3252fa75ed93988eac5f5cc1cd95044e27f89dc45bc4890",
"src/cybergril/life/runtime.py": "aa28cc8d7ea7bbec6107dd09f53ff62e1310e2b2e337cc86c0635bf653c94f2a",
"src/cybergril/life/sharing.py": "7fd16d4492aa270040a12f28905bf45848895a931104a487b152a24c672239b8",
"src/cybergril/memory/__init__.py": "4fd64b3bd074c9877d4b3feea7f970e6d5c5fc76ca3c2147b852fafeb4265fc8",
"src/cybergril/memory/episodic.py": "e680b3dc95d6e320a0690936f63f7814ecf334381608f7e2afb3d47c0b80b9e4",
"src/cybergril/memory/r2.sql": "e199d34a0f7c0c8737b59bebe9d7a566e4764b9b783096e5e8c5f595217ff4b5",
"src/cybergril/memory/r3.sql": "c9c7e59131b601e0532c8105eff505cc1365de156e50fbcace68ff620ed2ff95",
"src/cybergril/memory/r4.sql": "1bfb63fed14c01c33d499f49185992ca1c295b7bc8d4a4c8488753d93a9611b5",
"src/cybergril/memory/schema.sql": "3b420fc7f581f43f0409f93a4ba1f3c72296da74abbde8d82248bbc3c531c5ce",
"src/cybergril/memory/semantic.py": "b112bf309c52ab0b1182c10ae0cf4c342b8b52a1f3a0a30503f17119aa61f5de",
"src/cybergril/memory/store.py": "ba34f446709c298e544946a2a130a92e229ed0ecefcb3f38c6007d2075bff291",
"src/cybergril/providers/__init__.py": "27f8fc8fa1fa73b5516b97b52ecab1ea927f2cfa20dc221ca872198b2d7dda5b",
"src/cybergril/providers/asr/__init__.py": "56c89ede6111df2185f3d365890f4818f4e77f58d9b925d7f8c3ad19cc64ba2f",
"src/cybergril/providers/asr/base.py": "747c935d30fbc50552c8af2698cda4b96ab403e6983f1eb662c90b844fe33c91",
"src/cybergril/providers/asr/scripted.py": "4674aea0882c0dd54ddafe7ec501e09364668ac69074383abe25611a28c8176c",
"src/cybergril/providers/avatar/__init__.py": "e9c5fa66f32d5edec861779ca7376d98f80e1b22e23080f72f6b68c4577a839b",
"src/cybergril/providers/avatar/base.py": "bb6ed5de7e608cccfccbef8027a57ce419d3d244ee222ab40bf329e91423dffa",
"src/cybergril/providers/base.py": "2c70cc5ab6c59b51d8de6c49c298d09815ef00b18a967d3fd36fc193dde3a72c",
"src/cybergril/providers/embedding/__init__.py": "69cb8d4462f727b11180ba66e66ff9993335e55cb1e9a0c238e252c8fb26f6dc",
"src/cybergril/providers/embedding/base.py": "49bfd7614d5f703408aef4064464e2d6400fec116f9f6b5e82b486c58a2af083",
"src/cybergril/providers/embedding/openai_compat.py": "78ca1d03facdb79aaa37a54dbfd163fb192c9c88ffa25a244a2d82bf880602cd",
"src/cybergril/providers/llm/__init__.py": "641e66a647bf9dc01252d769b67596a0252b857a65954dd71662f8f1de7faf88",
"src/cybergril/providers/llm/base.py": "40eccf124f7cb4b360e9b6d9df89b0ada069cdc056a43572a3836e40d5c83f6b",
"src/cybergril/providers/llm/echo.py": "1d68e2e5a51f0639920f56626f42e2f19a1e0e9f5c6d0fad7c74ba53de2e6779",
"src/cybergril/providers/llm/openai_compat.py": "f809ca9cd160815c518bd7bd4c616c28b8ae7ff90c0818d0accaf99f24c62daa",
"src/cybergril/providers/tts/__init__.py": "5432b91bacff0beee5d11d4001c6e79aa20daefb9793450f87474931d7bfadb8",
"src/cybergril/providers/tts/base.py": "6fc19f9536801efeebac96cd689e156e0862b80b1a4ff1cf6559f61030c2da55",
"src/cybergril/providers/tts/silence.py": "04fd210f541bcafef2763053ed3e29dc2c28740dc092499aebfeb08114b845ba",
"src/cybergril/providers/turn/__init__.py": "326995f0240211df3bee0d74e83bd30593b2dd39142af1763ee8d04d18daca82",
"src/cybergril/providers/turn/base.py": "18077523dce912b65b2d8096f5e9d0d75241c0b5e8cc699701c614a162c77086",
"src/cybergril/providers/turn/heuristic.py": "15745e1c059361e5e860acf9dbe3e0a45cb242ba35abaecac7f1a9e447e8ead5",
"src/cybergril/providers/vad/__init__.py": "d2a0fb7fb4e6f751dc2c44894344b719d6b4622cf3417167e80109c6e64fae96",
"src/cybergril/providers/vad/base.py": "0265a10832a29baae349a105310775db6ea2b8421e99ee9d5d5d99ea9d50335f",
"src/cybergril/providers/vad/energy.py": "1962ad3ed0ff65387e93d68d3ab9ef98bc859f1cd755df5770ea503fae4fc468",
"src/cybergril/think/__init__.py": "47b1a45a41c16441621cda9df7499ee8c64128a35e4bcd897de7d6fa04fe84f3",
"src/cybergril/think/budget.py": "84c62821631bdf9802d483abf3bc8e041d52b9653ae216ba4a2ae1beb82c6bce",
"src/cybergril/think/router.py": "4a05601e5e587764708da17f41c617d0f1433f42794860ce5b09810fe4e41204",
"src/cybergril/think/scenes.py": "5a22383a13a52d34c767b8345ba170900e68b9d14fd5f68f0efb1b28f33e4aa0",
"src/cybergril/think/wiring.py": "3154c973ac2ba4c342bf547a93b37756f6519d9f24ab29a1c92b98fe97998c70",
"src/cybergril/voice/__init__.py": "7003c103ce0995806ec392939dd6db52f12b3e36f16560af2881b6bf70e1a786",
"src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd",
"src/cybergril/voice/custom.py": "cb01ef929911e4e206b56f7141952365a7fbcbeadb03dc183b36fea1ffc93daa",
"src/cybergril/voice/livekit_wire.py": "ffc477cd96d48c510905ce9fb887ba5b00edd6941ee59cf1b960f51f18500941",
"src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007",
"src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9",
"src/cybergril/voice/router.py": "b1db42d4a6979e92b239be5e318e75e14661d07847bccbf0230bba2cb6a2f2df",
"src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa",
"src/cybergril/voice/session.py": "63727cfec316bb5f8f086c458ae0bac82220ff70b515ccd70a1a9d7114c45483",
"src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2",
"src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba",
"src/cybergril/voice/worker.py": "dee6994fce5fef70f6ea0fb4659cda2e7d46d6b889485d5d2697426dddd949ae",
"tests/conftest.py": "5e94b5e9cb5804ab7f4e683e30f68cb7d38e2405b781e9cc790c9be023ddebd2",
"tests/r3_baseline_probe.py": "944c3a559023ed08adb68994714bea43865994c762d83e420767412a5874a720",
"tests/test_api.py": "cdddc3a657cd0595a1cdfeb9b40ddab3169c12db51e2dd8282cdbcb9ee8e3d23",
"tests/test_bazi.py": "0c6dac1649f8cdff102f0595a33915ea674cd98d5268bd80bdf1aa8133cce240",
"tests/test_birth_view.py": "90331ce8ba1cfbf319c5b9de38867c597233bbe30132a07f169791b92337c954",
"tests/test_contracts.py": "d65d467c4ea097068e60336703b7a04df5e2690d63bc6440837025ddd53ac35a",
"tests/test_hard_gates_hel882.py": "b031c8b586d3add4c329604c33774da33e411b641c9cfe12df485f3e16b6e3af",
"tests/test_mem0_backend.py": "220efe649a1a65e7a33767c99494c8bbc50a77cf0a0ac74871db57f73b2f8ceb",
"tests/test_mood.py": "ad057857584e1062369a30842c8eb2efe7bec699f32699db40090bd4d10404a6",
"tests/test_mood_runtime.py": "bcc6952037a54a0b3b323fa3df94c3d893811475043d5c9e83f2faed7c9a8d5c",
"tests/test_projection.py": "3acddc73bec5dc031db3821006b4fed0d1144cdb457d8ccb9dfca177d3e18a30",
"tests/test_providers.py": "de875f8990029bb800139ccd8d5e16adac74a7de40ecd42af5b98a2f2f6bf76d",
"tests/test_r2.py": "60c7bcb696332991546e61d0104010dd7f7441b7ac3dd8282706f50b5b8fc353",
"tests/test_r2_model_smoke.py": "91084082080b32dbce60cbbc8a752c8ada46c0eec71e5b85207a9d490c4a52e5",
"tests/test_r3.py": "4019c091936f4f5e013b90639da853e47f24fd76911f01eaf0018e51c5b62471",
"tests/test_r4_api.py": "94c149ddb31f07fe14b7422cb09b866358c8ecb3fb3e20284f5fb5d62abd3bcb",
"tests/test_reach_out.py": "31c86e68c2f29c2495ec9fdf5428cab953609430d452b047ccf6a6c76aee1bee",
"tests/test_readback_hel874.py": "100df58a193506d36106b33abed79e0c0f910b91591be3c147582266c9f952ce",
"tests/test_rework_hel885.py": "1b82624d4eb97a44edf7e5e5dd248622e821794d8adc010c7b684d2590584de0",
"tests/test_rework_hel886.py": "445230c3d578f3d0f5b01a8acc3f4463664c88f1e71320fb3be1a1c1bdb3bba8",
"tests/test_rework_hel889.py": "c2fda1a800100ee74b29aad66f0c9d10bfad54553981caa2fdae3503736c5861",
"tests/test_rework_hel894.py": "e7788a19d17b686bdab6f5e9aa960fafc52e3550c4d2bcfb8c3b870f822a8f81",
"tests/test_router.py": "56f2b3ecd29866db534db1731831861be00759fbe563f6e6e3ab76772449514c",
"tests/test_semantic.py": "4afcd8a2bc46b273423a113ec2166cbe0a56df7ba9afc967370a00a97de4be2f",
"tests/test_semantic_recovery_hel882.py": "fb08dc41fcacc3159109d7b68f5acc47ac80a14829aae76821dd6ade71a795c4",
"tests/test_store.py": "c2726ad5b00ec14d15aa379cf32d5379784b1daac649456427bc50ab79413a6d",
"tests/test_voice_chat_adapter.py": "ed0aaec68c96a191ea2c3f5e9f72dd79325191534df419e834ea9884babee9be",
"tests/test_voice_custom.py": "de78ac24fde7edeb3bf8120ffe1926f3298b8def21884ab60c9f6d5a2cb49236",
"tests/test_voice_review_hel902.py": "d710126a2e225a789f8876654e048f95f9d47030664fde5e60aab5283ea00115",
"tests/test_voice_rework_hel879.py": "a13c2db2c879ffc6554d15d3587ff593a0d3e132d618106449bb57cf5a595cc8",
"tests/test_voice_session.py": "8bedf05edb52662200e37a5ca24f3ae2303e492f84ff88aaed23771cc4778481",
"tools/audit_sdk_offline_hel896.py": "f88e9eaf3985f2dd4d72ca1b453fb2c1b822ba57197cf507e829c57a2beba0f1",
"tools/check_release_offline_hel882.py": "4818caedf2cde26bf5999d5836adfb227009b94b4b5fd59da53374123060cd32",
"tools/release_snapshot_hel896.py": "f1129015492fad4075c0fc798a999910537223aac5884f66e27b3495d0d0020c",
"tools/render_preview.py": "506a04919db24226477ebbc44e0ab344a6ce6ca75b2f3b81b566d43370c053ed",
"tools/seal_budget_hel879.py": "85b904899ccf725b38165bcbc6416da0ee13ac3f7e91d8203e53853648a5aa59",
"tools/verify_budget_split_hel896.py": "8c5bdee07eeba1a1fe2206621356e6153d84e16bd5e961ba91a532b6e64beba7",
"tools/verify_close_ownership_hel894.py": "84c5fa2b8f9dd7314d16c9dbae7cebb3d9b8b37c7f69bb3e2cebac08c83f41cd",
"tools/verify_http_pagination_hel895.py": "a73c81fb8381cde3e13f7d69ff17b81a96cc510a84c592cd4d1fd1f39cb028f4",
"tools/verify_inflight_lifecycle_hel886.py": "55d24905efefb293286f2a9a77f8d1c687af37927135de14483e5ff7c20e6408",
"tools/verify_mem0_real_hel874.py": "2c46b6ef306f96e1cc1791bb851cd316fbd3da472bd45e62bfaa84272be2bb84",
"tools/verify_semantic_recovery_hel885.py": "b63895afe1f211f566aab5441f8c5cf54a92e3758a3c7b1e7f0334dc2c9a0f28",
"tools/verify_shutdown_queued_hel889.py": "488fac844ee3b66a9cdadae6cb8ccff7e67271749777ae0b5a3404dab7c93f90",
"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": "9d458bd826e505300e8054565fddcc196fce93214f4f240e9b44fb2728520b63",
"web/src/chat/lineStage.ts": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2",
"web/src/chat/reading.ts": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb",
"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": "d32bb459231fdc5ec262c162483dc29d669ec2c3a4c4127c86beb23cc271a12e",
"web/src/manage/VoiceSection.tsx": "68e8e15442dc8d3e758eb938b4fb202af0a40ccae2e3c0af8b4e32abd4216535",
"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": "c0a535c24db36d767ba571394176a3b5f722c534844016afb4903d4811a953b0",
"web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475",
"web/src/voice/custom.ts": "bf6690e33b49e79a9d65af112f429a65f0da3e35ceeb35efd552abb36f3f2f27",
"web/src/voice/livekit.ts": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e",
"web/src/voice/session.ts": "76eca20191124b1b402a91e2da9c4ec04330986085a6146244ac544f7e57d888",
"web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656",
"web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45",
"web/tests/fake-livekit.js": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917",
"web/tests/fixtures/hel879/adversarial-gates.jsonl": "febd098b07af47fed78b94cd69e7bcf5d6f173c84c2cb4b241dee25bbb8f8671",
"web/tests/fixtures/hel879/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"web/tests/fixtures/hel879/worker-interrupt.jsonl": "4263617779fb7b8467a8cda9eec17e021591edfa7606a7bcf739e4f004940e84",
"web/tests/fixtures/hel879/worker-no_receipt.jsonl": "2acd5b70da10062f6ddceb02f6648e16b4676f15fed3a5a3f0902291fdb7634c",
"web/tests/fixtures/hel879/worker-normal.jsonl": "bcba7ad2a07a63d3b444cc6cec2d0a5371d96c8567e3e5b9d5c290efe07757a1",
"web/tests/fixtures/hel879/worker-phi.jsonl": "6716f9b8a8556b5b8ff9fac024760838595314d5f00c786fb54862e59136b008",
"web/tests/fixtures/hel879/worker-proactive.jsonl": "9f4b5bfaf9ad76dce8546c470f80dd6fbe9d36248079e822e5323a1d30079a85",
"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": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76",
"web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
"web/tests/hel879_frames.test.ts": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8",
"web/tests/hel879_unified_shots.mjs": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f",
"web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b",
"web/tests/hel898_agent_left.test.ts": "8026851f378686fc56b4e1f143b0550c536e9d76eaadd59522d096b182f31bec",
"web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
"web/tests/hel900_custom_voice.test.ts": "54be8b0437262528b034844ad66275e79ce53ef68d5dc679c53f3e6d2094ecd9",
"web/tests/hel900_mock.mjs": "f120fe076d4b5ca6f1970f0f853bc3148e0fd668d8b53177b5c6d08a8771db30",
"web/tests/hel900_shots.mjs": "d49905fd5475e4e0022456e75490a15ad0ed7fad2717ed290e539a1bfcf85ab3",
"web/tests/hel903_mock.mjs": "60935d78508e0d03325c22833af160d2be767e87690c965c5ecf676dd9800cc6",
"web/tests/hel903_shots.mjs": "8bd4042a1958a51eeabaeb03a2ab7bb78bf6379e440b114b85d64b07a5dbf112",
"web/tests/hel903_voice_rework.test.ts": "804a62b3c55f5cd3e95b734761c60628795568cee326e372cff550a57874771c",
"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": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5",
"web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157"
}
}
@@ -0,0 +1,919 @@
diff --git a/docs/requirements/handoff-HEL-903.json b/docs/requirements/handoff-HEL-903.json
new file mode 100644
--- /dev/null
+++ b/docs/requirements/handoff-HEL-903.json
@@ -0,0 +1,57 @@
+{
+ "baseline_version": "1.12",
+ "read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
+ "task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
+ "requirement_ids": [
+ "CG-006",
+ "CG-009",
+ "CG-010",
+ "CG-013",
+ "CG-014",
+ "CG-015",
+ "CG-016",
+ "CG-018",
+ "CG-019",
+ "CG-020",
+ "CG-021",
+ "VIS-003",
+ "VIS-004",
+ "VIS-008",
+ "VIS-010",
+ "VIS-013",
+ "UX-001",
+ "UX-003",
+ "UX-005",
+ "UX-006",
+ "AM-001",
+ "GOV-001",
+ "GOV-002",
+ "GOV-003",
+ "GOV-004",
+ "GOV-005",
+ "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
+ "acceptance": [
+ "外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
+ "通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
+ "登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
+ "回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
+ ],
+ "deferred": [
+ "后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
+ "语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
+ "编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
+ "_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
+ "真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
+ "浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
+ "对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
+ "需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
+ "未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
+ ]
+}
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
@@ -11,6 +11,7 @@
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 { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
+ // The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
+ // hold now, which is the next call's — and an answer for another id (an older call) is never shown.
+ const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
+ useEffect(() => {
+ const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
+ const c = new AbortController();
+ listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
+ return () => c.abort();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
+ }, [pinned?.id]);
+ const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
+ {pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx
--- a/web/src/manage/ManagePanel.tsx
+++ b/web/src/manage/ManagePanel.tsx
@@ -5,12 +5,21 @@
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
import { VoiceSection, type VoiceCap } from './VoiceSection';
+import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
+// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
+// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
+type VoiceSvc = VoiceStatusKind | 'loading';
+const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
+ tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
+ asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
+ config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
+};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -33,6 +42,14 @@
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
+ // Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
+ const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
+ useEffect(() => {
+ if (!real || !open || section !== 'services') return;
+ const c = new AbortController(); setVoiceSvc('loading');
+ readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
+ return () => c.abort();
+ }, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
@@ -56,7 +73,7 @@
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
+ try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
- try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -187,7 +204,7 @@
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
+ {real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
- {real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
diff --git a/web/src/manage/VoiceSection.tsx b/web/src/manage/VoiceSection.tsx
--- a/web/src/manage/VoiceSection.tsx
+++ b/web/src/manage/VoiceSection.tsx
@@ -4,7 +4,7 @@
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
+import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
-import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
@@ -37,6 +37,11 @@
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
+ // HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
+ // listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
+ const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
+ const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
+ const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
@@ -69,6 +74,12 @@
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
+ const before = unsureBefore.current;
+ if (before) {
+ const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
+ if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
+ else setRegNote({ text: SAY.refreshNone, error: false });
+ }
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
@@ -86,6 +97,7 @@
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
+ settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
@@ -99,7 +111,7 @@
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
+ function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
- function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
@@ -111,6 +123,7 @@
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
+ settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
@@ -119,8 +132,15 @@
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
+ // Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
+ // have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
+ if (e instanceof VoiceError && e.uncertain) {
+ const l = loadNow.current;
+ if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
+ setUnsure(true);
+ // The server's own words when it gave any; a bare "try again later" would contradict "look first".
+ setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
+ } else setRegNote({ text: message(e), error: true });
- // Nothing is lost on failure: the take, the label and the text all stay for another try.
- setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
@@ -174,7 +194,7 @@
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
+ const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
- const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
@@ -197,7 +217,7 @@
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
+ <div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
- <div className="take-acts"><Button primary disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
@@ -205,6 +225,7 @@
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
+ {mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
--- a/web/src/styles/components.css
+++ b/web/src/styles/components.css
@@ -1035,3 +1035,15 @@
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
}
+/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
+.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
+.voice-row.real .voice-pin.unknown{color:var(--text-3)}
+.svc[data-state="ok"] .nm small{color:var(--ok)}
+.svc[data-state="down"] .nm small{color:var(--warn)}
+.voice-idonly{margin:8px 0 0;font-size:12.5px}
+@media (max-width:720px){
+ .voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
+ .voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
+ .voice-row.real .voice-pin .vp-wide{display:none}
+}
diff --git a/web/src/voice/custom.ts b/web/src/voice/custom.ts
--- a/web/src/voice/custom.ts
+++ b/web/src/voice/custom.ts
@@ -37,9 +37,16 @@
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
+ // HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
+ // dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
+ maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
+ refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
+ refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
+ idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
+// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
+export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
-export class VoiceError extends Error { constructor(message: string, readonly status = 0) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
@@ -111,7 +118,8 @@
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
+ // Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
+ catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
- catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
@@ -136,11 +144,22 @@
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
+ // A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
+ // panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
+ if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
- if (!r.ok) throw await refusal(r);
const data = await r.json().catch(() => null) as Partial<Registered> | null;
+ if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
- if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
+// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
+// this server never recorded it — typically a registration whose answer was lost.
+export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
+// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
+export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
+ if (!id) return '声音未说明';
+ if (id === 'default') return '默认声音';
+ return named?.id === id ? named.label : id;
+}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
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
@@ -24,12 +24,16 @@
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
+ // The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
+ // `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
+ voice: CallVoice | null;
}
+export interface CallVoice { id: string | null }
// 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, voice: null };
-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;
@@ -69,7 +73,9 @@
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
+export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
+// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
+export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
-export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
+// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
+// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
+// never that a whole call works end to end (`voice` stays false until real-device acceptance).
+export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
+export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
-export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
let response: Response;
+ try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
- try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
// A server from before R5 has no such route: that is "not wired", not a failure.
+ if (response.status === 404 || response.status === 405) return 'none';
- if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
+ if (!response.ok || !data) return 'unknown';
+ if (data.configured !== true) return 'off';
+ return data.tts_ok === true ? 'ok' : 'down';
+}
+const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
+export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+ const kind = await readVoiceStatus(signal, f);
+ return { available: kind === 'ok', reason: REASON[kind] };
- if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
- if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
- if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
- return { available: true, reason: '' };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
+ // 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
+ // of an older call never gets here (stale), and every end or failure resets the view.
+ this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
- // 4. Join and speak.
- this.show({ phase: 'joining' }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
diff --git a/web/tests/hel898_agent_left.test.ts b/web/tests/hel898_agent_left.test.ts
--- a/web/tests/hel898_agent_left.test.ts
+++ b/web/tests/hel898_agent_left.test.ts
@@ -62,7 +62,7 @@
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
+ expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
- expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
diff --git a/web/tests/hel903_mock.mjs b/web/tests/hel903_mock.mjs
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_mock.mjs
@@ -0,0 +1,130 @@
+// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
+// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
+// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
+// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
+// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
+// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
+// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
+// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
+const port = Number(process.argv[2] || 8903);
+const T0 = Date.UTC(2026, 9, 27, 12, 0);
+const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
+const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
+const log = [], uploads = [], sessions = new Map();
+let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
+const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
+const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
+function wavInfo(b) {
+ if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
+ let at = 12, fmt = null;
+ while (at + 8 <= b.length) {
+ const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
+ if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
+ if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
+ at += 8 + size + (size & 1);
+ }
+ return null;
+}
+function tone(seconds = 1.6, rate = 24000) {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
+ return b;
+}
+const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
+const known = id => listed().includes(id);
+// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
+const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
+const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
+const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
+
+const handle = async (req, res) => {
+ const url = new URL(req.url, 'http://x'), p = url.pathname;
+ if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
+ if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
+ if (p === '/__log') return send(res, 200, log.splice(0));
+ if (p === '/__uploads') return send(res, 200, uploads);
+ if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
+ if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
+ if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
+ if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
+ if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
+ if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
+ if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
+
+ if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
+ if (p === '/api/companion') return send(res, 200, identity);
+ if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
+ if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
+ if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
+
+ if (p === '/api/voice/status') {
+ if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
+ if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (statusMode === 'net') { res.destroy(); return; }
+ if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
+ return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
+ }
+ if (p === '/api/voice/session' && req.method === 'POST') {
+ if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
+ await sleep(120);
+ // As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
+ const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
+ const id = `sess-${++rooms}`; sessions.set(id, captured);
+ const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
+ if (sessionVoice === 'omit') delete grant.voice;
+ return send(res, 201, grant);
+ }
+ if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
+
+ if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
+ if (p === '/api/voice/voices' && req.method === 'POST') {
+ const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
+ uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
+ // The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
+ // `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
+ // reused socket, which is not the case this card covers).
+ if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
+ if (f === 'net') { res.destroy(); return; }
+ if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
+ if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
+ const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
+ if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
+ const id = newId(), label = (q.get('label') || '').trim() || id;
+ const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
+ custom.set(id, record);
+ return send(res, 201, record);
+ }
+ if (p === '/api/voice/preview' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ previewed.add(voice);
+ return send(res, 200, tone(), 'audio/wav');
+ }
+ if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
+ if (p === '/api/voice/selection' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
+ selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
+ return send(res, 200, { ...selection });
+ }
+ if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
+ const file = normalize(join(dist, p === '/' ? 'index.html' : p));
+ if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
+ try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
+ catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
+};
+createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
+ .on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
+ .listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
diff --git a/web/tests/hel903_shots.mjs b/web/tests/hel903_shots.mjs
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_shots.mjs
@@ -0,0 +1,226 @@
+// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
+// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
+// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
+// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
+// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
+// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
+import { spawn } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const [edge, out, portArg, mode] = process.argv.slice(2);
+const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
+mkdirSync(out, { recursive: true });
+const profile = join(tmpdir(), `hel903-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 && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
+const ctl = async path => (await fetch(`${base}${path}`)).json();
+const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
+const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
+const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
+const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
+const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
+const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
+const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
+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 }); }
+const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
+ md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
+ window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
+const live = () => js('window.__live ? window.__live() : 0');
+async function boot(theme, path = '/') {
+ await ctl('/__reset');
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
+}
+async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
+async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
+const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
+const wav = (seconds, rate = 22050) => {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
+ return b;
+};
+const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
+writeFileSync(join(files, 'reference.wav'), wav(6));
+async function upload() {
+ const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
+ await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
+ return until(() => exists('.voice-sec .take .pill'));
+}
+async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
+async function applyVoice(label) {
+ await takeReady(label);
+ await click('.voice-sec .take button', '登记这段声音');
+ await until(async () => (await text('.voice-sec .voice-list')).includes(label));
+ await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
+ await click('.voice-sec .audition button', '给栖用这个声音');
+ return until(async () => (await text('.voice-sec .kv')).includes(label));
+}
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
+const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
+async function startCall() {
+ await click('.voice-button'); await until(() => exists('.modal.open'));
+ await click('.modal.open .btn-primary', '开始语音');
+ await until(async () => (await phase()) === 'joining');
+ await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
+ await js('window.__lk.join()'); await sleep(300);
+}
+async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
+async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
+
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
+await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
+check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
+
+if (mode === 'before') {
+ // The same three places before this card: services rows, the call line, a registration that failed after filing.
+ await view('desktop'); await boot('moon');
+ await settings('外接服务'); await sleep(600); await shot('before-01-services');
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+ await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
+ browser.kill(); process.exit(0);
+}
+
+// ── Main path, both themes × desktop/phone ──
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
+ const tag = `${theme}-${v}`;
+ await view(v); await boot(theme);
+ // 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
+ await settings('外接服务');
+ await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
+ const tts = await svc('语音合成'), asr = await svc('语音识别');
+ check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
+ check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
+ check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
+ check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
+ await scrollTo('.svc'); await shot(`${tag}-01-services`);
+
+ // 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
+ check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
+ await closeSettings(); await talk();
+ await startCall();
+ check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
+ // 3. Another device applies a different voice mid-call: this call keeps its own.
+ await ctl('/__select?voice=rain');
+ await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
+ check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
+ check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
+ await shot(`${tag}-02-call-pinned`);
+ await endCall();
+ check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
+ // 4. The next call takes the new choice.
+ await startCall();
+ check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
+ await shot(`${tag}-03-next-call`);
+ await endCall();
+
+ // 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个');
+ await ctl('/__fail?register=commit500');
+ await click('.voice-sec .take button', '登记这段声音');
+ check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+ check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
+ check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
+ await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
+ await click('.voice-sec .take button', '刷新列表');
+ check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
+ check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
+ check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
+ await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
+ await closeSettings();
+}
+
+// ── Branches, desktop moon unless noted ──
+await view('desktop'); await boot('moon');
+for (const [m, tts, asr, name] of [
+ ['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
+ ['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
+ ['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
+ ['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
+ ['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
+]) {
+ await ctl(`/__status?mode=${m}`);
+ await settings('外接服务'); await click('.svc button', '重新读取状态');
+ await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
+ const t = await svc('语音合成'), a = await svc('语音识别');
+ check(`status ${name}: TTS "${tts}"`, t === tts, t);
+ check(`status ${name}: ASR row honest`, a.includes(asr), a);
+ if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
+}
+await ctl('/__status?mode=ok');
+await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
+await view('desktop'); await boot('moon');
+
+// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
+check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
+await shot('branch-call-voice-unknown');
+await endCall();
+await ctl('/__session-voice?v=default'); await startCall();
+check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
+await endCall(); await ctl('/__session-voice');
+
+// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
+await click('.voice-sec .take button', '登记这段声音');
+check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
+await click('.voice-sec .take button', '换一段');
+await takeReady('面板不在'); await ctl('/__fail?register=503');
+await click('.voice-sec .take button', '登记这段声音');
+check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
+await shot('branch-register-503-none');
+const before = (await ctl('/__uploads')).length;
+await click('.voice-sec .take button', '确认没有,再登记');
+check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
+// Recording regression: the microphone opens on a tap and closes on stop / cancel.
+await click('.voice-sec .seg button', '现在录一段');
+await click('.voice-sec .rec button', '开始录音');
+await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await sleep(2600);
+check('recording: microphone live while recording', (await live()) === 1);
+await click('.voice-sec .rec button', '停止');
+check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
+await click('.voice-sec .take button', '换一段');
+await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await click('.voice-sec .rec button', '取消');
+check('recording: cancel releases the microphone', (await live()) === 0);
+await closeSettings();
+
+// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
+await boot('moon', '/?demo=1'); await ctl('/__log');
+await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
+await click('.nav-item', '交谈'); await sleep(1200);
+const demoLog = await ctl('/__log');
+check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
+await settings('外接服务'); await shot('demo-services');
+
+const passed = results.filter(Boolean).length;
+console.log(`\n${passed}/${results.length} passed`);
+browser.kill();
+process.exit(passed === results.length ? 0 : 1);
diff --git a/web/tests/hel903_voice_rework.test.ts b/web/tests/hel903_voice_rework.test.ts
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_voice_rework.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
+import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
+
+// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
+const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
+const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
+const signal = () => new AbortController().signal;
+const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe('外接服务 reads only what /api/voice/status can tell', () => {
+ const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
+ it('tells not configured, reachable, down, no route and unreadable apart', async () => {
+ expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
+ expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
+ expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
+ expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
+ expect(await kind(() => reply('oops', 500))).toBe('unknown');
+ expect(await kind(() => reply('not json'))).toBe('unknown');
+ expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
+ });
+ it('a configured flag alone is never "reachable"', async () => {
+ expect(await kind(() => reply({ configured: true }))).toBe('down');
+ });
+});
+
+// ── The call's own voice ──
+class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
+class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
+function rig() {
+ const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
+ let made = 0;
+ const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
+ if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
+ if (url === '/api/voice/session' && init.method === 'POST') {
+ const n = made++, g = gates[n]; if (g) await g.promise;
+ const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
+ if (voices[n] !== undefined) grant.voice = voices[n];
+ return reply(grant, 201);
+ }
+ return reply({ ended: true });
+ });
+ const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
+ const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
+ return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
+}
+
+describe('本次通话声音 comes from the session answer and stays for the call', () => {
+ it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
+ const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
+ await r.c.start();
+ expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
+ r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
+ expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
+ await r.c.end();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('a missing or odd field is "not said", never a guess', () => {
+ expect(callVoiceOf({})).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
+ });
+ it('an older call answering late never overwrites the newer call', async () => {
+ const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
+ const first = r.c.start(); await flush();
+ await r.c.start();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ r.gates[0]!.open(); await first; await flush();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
+ });
+ it('a failure after the room clears the voice with the call', async () => {
+ const r = rig(); r.voices[0] = 'rain';
+ await r.c.start(); r.rooms[0].on.agentJoined();
+ r.rooms[0].on.dropped(); await flush();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
+ expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
+ expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
+ expect(callVoiceName('default', null)).toBe('默认声音');
+ expect(callVoiceName(null, null)).toBe('声音未说明');
+ });
+});
+
+describe('a registration with no clear answer says to look before registering again', () => {
+ const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
+ const attempt = async (answer: () => Response | Promise<Response>) => {
+ try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
+ throw new Error('expected a failure');
+ };
+ it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
+ for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
+ expect(e).toBeInstanceOf(VoiceError);
+ expect(e.uncertain).toBe(true);
+ }
+ expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
+ });
+ it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
+ expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
+ expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
+ vi.stubGlobal('navigator', { onLine: false });
+ const offline = await attempt(() => reply({}, 201));
+ expect(offline.message).toBe(SAY.offline);
+ expect(offline.uncertain).toBe(false);
+ });
+ it('the advice points at refreshing the list and the numbered entries', () => {
+ expect(SAY.maybeRegistered).toContain('刷新列表');
+ expect(SAY.maybeRegistered).toContain('cg-');
+ expect(SAY.maybeRegistered).toContain('不用再登记');
+ });
+ it('an entry without local metadata is recognised as id-only', () => {
+ const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
+ expect(idOnly(v({}))).toBe(true);
+ expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
+ expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
+ });
+});
@@ -0,0 +1,932 @@
diff --git a/docs/requirements/handoff-HEL-903.json b/docs/requirements/handoff-HEL-903.json
new file mode 100644
index 0000000000000000000000000000000000000000..a99a4c9329cb34030c48cd9b2138b8b4a0fd25d5
--- /dev/null
+++ b/docs/requirements/handoff-HEL-903.json
@@ -0,0 +1,57 @@
+{
+ "baseline_version": "1.12",
+ "read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
+ "task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
+ "requirement_ids": [
+ "CG-006",
+ "CG-009",
+ "CG-010",
+ "CG-013",
+ "CG-014",
+ "CG-015",
+ "CG-016",
+ "CG-018",
+ "CG-019",
+ "CG-020",
+ "CG-021",
+ "VIS-003",
+ "VIS-004",
+ "VIS-008",
+ "VIS-010",
+ "VIS-013",
+ "UX-001",
+ "UX-003",
+ "UX-005",
+ "UX-006",
+ "AM-001",
+ "GOV-001",
+ "GOV-002",
+ "GOV-003",
+ "GOV-004",
+ "GOV-005",
+ "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
+ "acceptance": [
+ "外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
+ "通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
+ "登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
+ "回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
+ ],
+ "deferred": [
+ "后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
+ "语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
+ "编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
+ "_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
+ "真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
+ "浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
+ "对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
+ "需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
+ "未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
+ ]
+}
diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx
index 8267924ec7860eb410bc52b16744f0fc16599ed5..84659d15d1e0bd8452815806bc38e7d0492673d3 100644
--- a/web/src/chat/ChatPanel.tsx
+++ b/web/src/chat/ChatPanel.tsx
@@ -11,6 +11,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 { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
+ // The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
+ // hold now, which is the next call's — and an answer for another id (an older call) is never shown.
+ const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
+ useEffect(() => {
+ const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
+ const c = new AbortController();
+ listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
+ return () => c.abort();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
+ }, [pinned?.id]);
+ const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
+ {pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx
index 4f7b0092b35ec2c6b30ced395c684a9fc1cc0f24..78aee2f15f8d2b990f64926092b66a41e15adc68 100644
--- a/web/src/manage/ManagePanel.tsx
+++ b/web/src/manage/ManagePanel.tsx
@@ -5,12 +5,21 @@ import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
import { VoiceSection, type VoiceCap } from './VoiceSection';
+import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
+// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
+// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
+type VoiceSvc = VoiceStatusKind | 'loading';
+const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
+ tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
+ asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
+ config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
+};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -33,6 +42,14 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
+ // Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
+ const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
+ useEffect(() => {
+ if (!real || !open || section !== 'services') return;
+ const c = new AbortController(); setVoiceSvc('loading');
+ readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
+ return () => c.abort();
+ }, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
@@ -56,7 +73,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
- try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
+ try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -187,7 +204,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
- {real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
+ {real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
diff --git a/web/src/manage/VoiceSection.tsx b/web/src/manage/VoiceSection.tsx
index 36a88c1a631c24e429842da23d975a4082a4f813..9075a83c04d0699a97c01bf049d79e828addad04 100644
--- a/web/src/manage/VoiceSection.tsx
+++ b/web/src/manage/VoiceSection.tsx
@@ -4,7 +4,7 @@
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
-import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
+import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
@@ -37,6 +37,11 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
+ // HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
+ // listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
+ const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
+ const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
+ const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
@@ -69,6 +74,12 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
+ const before = unsureBefore.current;
+ if (before) {
+ const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
+ if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
+ else setRegNote({ text: SAY.refreshNone, error: false });
+ }
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
@@ -86,6 +97,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
+ settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
@@ -99,7 +111,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
- function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
+ function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
@@ -111,6 +123,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
+ settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
@@ -119,8 +132,15 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
- // Nothing is lost on failure: the take, the label and the text all stay for another try.
- setRegNote({ text: message(e), error: true });
+ // Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
+ // have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
+ if (e instanceof VoiceError && e.uncertain) {
+ const l = loadNow.current;
+ if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
+ setUnsure(true);
+ // The server's own words when it gave any; a bare "try again later" would contradict "look first".
+ setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
+ } else setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
@@ -174,7 +194,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
- const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
+ const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
@@ -197,7 +217,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
- <div className="take-acts"><Button primary disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
+ <div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
@@ -205,6 +225,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
+ {mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
index 6e8412c0d346d5cbae90df14f4d20e5555c9079e..0988bdf2aa8ee8cdc35f5d7328910885e3d794e7 100644
--- a/web/src/styles/components.css
+++ b/web/src/styles/components.css
@@ -1034,4 +1034,16 @@ body.creating .now-chip{display:none}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
+}
+/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
+.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
+.voice-row.real .voice-pin.unknown{color:var(--text-3)}
+.svc[data-state="ok"] .nm small{color:var(--ok)}
+.svc[data-state="down"] .nm small{color:var(--warn)}
+.voice-idonly{margin:8px 0 0;font-size:12.5px}
+@media (max-width:720px){
+ .voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
+ .voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
+ .voice-row.real .voice-pin .vp-wide{display:none}
}
\ No newline at end of file
diff --git a/web/src/voice/custom.ts b/web/src/voice/custom.ts
index 6a815694a5c1e4fc00816c46cf53ebae5104ff21..afc2b7bee7b0d02f34b1f445456fbce3cb4f2113 100644
--- a/web/src/voice/custom.ts
+++ b/web/src/voice/custom.ts
@@ -37,9 +37,16 @@ export const SAY = {
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
+ // HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
+ // dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
+ maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
+ refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
+ refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
+ idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
-export class VoiceError extends Error { constructor(message: string, readonly status = 0) { super(message); } }
+// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
+export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
@@ -111,7 +118,8 @@ const online = () => typeof navigator === 'undefined' || navigator.onLine !== fa
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
- catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline); }
+ // Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
+ catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
@@ -136,11 +144,22 @@ export async function listVoices(signal: AbortSignal, f: Fetch = fetch): Promise
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
- if (!r.ok) throw await refusal(r);
+ // A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
+ // panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
+ if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
const data = await r.json().catch(() => null) as Partial<Registered> | null;
- if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd);
+ if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
+// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
+// this server never recorded it — typically a registration whose answer was lost.
+export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
+// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
+export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
+ if (!id) return '声音未说明';
+ if (id === 'default') return '默认声音';
+ return named?.id === id ? named.label : id;
+}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts
index 5b34bc68625914c00c002e85b88f945de35bbfa4..7328b8b3a7f300a3abdee158bae7cce09024808d 100644
--- a/web/src/voice/session.ts
+++ b/web/src/voice/session.ts
@@ -24,12 +24,16 @@ export interface VoiceView {
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
+ // The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
+ // `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
+ voice: CallVoice | null;
}
+export interface CallVoice { id: string | null }
// 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 };
+export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false, voice: null };
// ── 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;
@@ -69,7 +73,9 @@ export interface VoiceRoom { publish(mic: MicTrack): Promise<void>; hush(on: boo
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
-export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
+export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
+// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
+export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@ interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: Voi
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
-export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
+// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
+// never that a whole call works end to end (`voice` stays false until real-device acceptance).
+export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
+export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
let response: Response;
- try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
+ try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
// A server from before R5 has no such route: that is "not wired", not a failure.
- if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
+ if (response.status === 404 || response.status === 405) return 'none';
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
- if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
- if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
- if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
- return { available: true, reason: '' };
+ if (!response.ok || !data) return 'unknown';
+ if (data.configured !== true) return 'off';
+ return data.tts_ok === true ? 'ok' : 'down';
+}
+const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
+export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+ const kind = await readVoiceStatus(signal, f);
+ return { available: kind === 'ok', reason: REASON[kind] };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@ export class VoiceController {
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
- // 4. Join and speak.
- this.show({ phase: 'joining' }, gen);
+ // 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
+ // of an older call never gets here (stale), and every end or failure resets the view.
+ this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
diff --git a/web/tests/hel898_agent_left.test.ts b/web/tests/hel898_agent_left.test.ts
index daacaa069de2781403d9fafdbc823e60b8199270..092c8c98aa02d10f86bb18c3afa7de85cb9188e1 100644
--- a/web/tests/hel898_agent_left.test.ts
+++ b/web/tests/hel898_agent_left.test.ts
@@ -62,7 +62,7 @@ describe('her worker leaves while the room stays open (real adapter, SDK event n
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
- expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
+ expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
diff --git a/web/tests/hel903_mock.mjs b/web/tests/hel903_mock.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..b8d2a117482b0c7dee656f7c84508963e48f4b81
--- /dev/null
+++ b/web/tests/hel903_mock.mjs
@@ -0,0 +1,130 @@
+// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
+// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
+// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
+// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
+// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
+// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
+// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
+// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
+const port = Number(process.argv[2] || 8903);
+const T0 = Date.UTC(2026, 9, 27, 12, 0);
+const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
+const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
+const log = [], uploads = [], sessions = new Map();
+let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
+const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
+const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
+function wavInfo(b) {
+ if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
+ let at = 12, fmt = null;
+ while (at + 8 <= b.length) {
+ const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
+ if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
+ if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
+ at += 8 + size + (size & 1);
+ }
+ return null;
+}
+function tone(seconds = 1.6, rate = 24000) {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
+ return b;
+}
+const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
+const known = id => listed().includes(id);
+// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
+const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
+const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
+const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
+
+const handle = async (req, res) => {
+ const url = new URL(req.url, 'http://x'), p = url.pathname;
+ if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
+ if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
+ if (p === '/__log') return send(res, 200, log.splice(0));
+ if (p === '/__uploads') return send(res, 200, uploads);
+ if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
+ if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
+ if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
+ if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
+ if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
+ if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
+ if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
+
+ if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
+ if (p === '/api/companion') return send(res, 200, identity);
+ if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
+ if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
+ if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
+
+ if (p === '/api/voice/status') {
+ if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
+ if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (statusMode === 'net') { res.destroy(); return; }
+ if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
+ return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
+ }
+ if (p === '/api/voice/session' && req.method === 'POST') {
+ if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
+ await sleep(120);
+ // As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
+ const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
+ const id = `sess-${++rooms}`; sessions.set(id, captured);
+ const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
+ if (sessionVoice === 'omit') delete grant.voice;
+ return send(res, 201, grant);
+ }
+ if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
+
+ if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
+ if (p === '/api/voice/voices' && req.method === 'POST') {
+ const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
+ uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
+ // The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
+ // `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
+ // reused socket, which is not the case this card covers).
+ if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
+ if (f === 'net') { res.destroy(); return; }
+ if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
+ if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
+ const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
+ if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
+ const id = newId(), label = (q.get('label') || '').trim() || id;
+ const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
+ custom.set(id, record);
+ return send(res, 201, record);
+ }
+ if (p === '/api/voice/preview' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ previewed.add(voice);
+ return send(res, 200, tone(), 'audio/wav');
+ }
+ if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
+ if (p === '/api/voice/selection' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
+ selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
+ return send(res, 200, { ...selection });
+ }
+ if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
+ const file = normalize(join(dist, p === '/' ? 'index.html' : p));
+ if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
+ try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
+ catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
+};
+createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
+ .on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
+ .listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
diff --git a/web/tests/hel903_shots.mjs b/web/tests/hel903_shots.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..4a4f16d7133c3dc56fda93729383bb1746c480e3
--- /dev/null
+++ b/web/tests/hel903_shots.mjs
@@ -0,0 +1,226 @@
+// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
+// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
+// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
+// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
+// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
+// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
+import { spawn } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const [edge, out, portArg, mode] = process.argv.slice(2);
+const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
+mkdirSync(out, { recursive: true });
+const profile = join(tmpdir(), `hel903-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 && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
+const ctl = async path => (await fetch(`${base}${path}`)).json();
+const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
+const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
+const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
+const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
+const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
+const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
+const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
+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 }); }
+const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
+ md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
+ window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
+const live = () => js('window.__live ? window.__live() : 0');
+async function boot(theme, path = '/') {
+ await ctl('/__reset');
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
+}
+async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
+async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
+const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
+const wav = (seconds, rate = 22050) => {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
+ return b;
+};
+const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
+writeFileSync(join(files, 'reference.wav'), wav(6));
+async function upload() {
+ const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
+ await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
+ return until(() => exists('.voice-sec .take .pill'));
+}
+async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
+async function applyVoice(label) {
+ await takeReady(label);
+ await click('.voice-sec .take button', '登记这段声音');
+ await until(async () => (await text('.voice-sec .voice-list')).includes(label));
+ await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
+ await click('.voice-sec .audition button', '给栖用这个声音');
+ return until(async () => (await text('.voice-sec .kv')).includes(label));
+}
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
+const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
+async function startCall() {
+ await click('.voice-button'); await until(() => exists('.modal.open'));
+ await click('.modal.open .btn-primary', '开始语音');
+ await until(async () => (await phase()) === 'joining');
+ await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
+ await js('window.__lk.join()'); await sleep(300);
+}
+async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
+async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
+
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
+await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
+check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
+
+if (mode === 'before') {
+ // The same three places before this card: services rows, the call line, a registration that failed after filing.
+ await view('desktop'); await boot('moon');
+ await settings('外接服务'); await sleep(600); await shot('before-01-services');
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+ await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
+ browser.kill(); process.exit(0);
+}
+
+// ── Main path, both themes × desktop/phone ──
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
+ const tag = `${theme}-${v}`;
+ await view(v); await boot(theme);
+ // 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
+ await settings('外接服务');
+ await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
+ const tts = await svc('语音合成'), asr = await svc('语音识别');
+ check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
+ check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
+ check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
+ check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
+ await scrollTo('.svc'); await shot(`${tag}-01-services`);
+
+ // 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
+ check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
+ await closeSettings(); await talk();
+ await startCall();
+ check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
+ // 3. Another device applies a different voice mid-call: this call keeps its own.
+ await ctl('/__select?voice=rain');
+ await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
+ check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
+ check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
+ await shot(`${tag}-02-call-pinned`);
+ await endCall();
+ check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
+ // 4. The next call takes the new choice.
+ await startCall();
+ check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
+ await shot(`${tag}-03-next-call`);
+ await endCall();
+
+ // 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个');
+ await ctl('/__fail?register=commit500');
+ await click('.voice-sec .take button', '登记这段声音');
+ check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+ check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
+ check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
+ await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
+ await click('.voice-sec .take button', '刷新列表');
+ check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
+ check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
+ check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
+ await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
+ await closeSettings();
+}
+
+// ── Branches, desktop moon unless noted ──
+await view('desktop'); await boot('moon');
+for (const [m, tts, asr, name] of [
+ ['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
+ ['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
+ ['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
+ ['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
+ ['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
+]) {
+ await ctl(`/__status?mode=${m}`);
+ await settings('外接服务'); await click('.svc button', '重新读取状态');
+ await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
+ const t = await svc('语音合成'), a = await svc('语音识别');
+ check(`status ${name}: TTS "${tts}"`, t === tts, t);
+ check(`status ${name}: ASR row honest`, a.includes(asr), a);
+ if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
+}
+await ctl('/__status?mode=ok');
+await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
+await view('desktop'); await boot('moon');
+
+// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
+check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
+await shot('branch-call-voice-unknown');
+await endCall();
+await ctl('/__session-voice?v=default'); await startCall();
+check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
+await endCall(); await ctl('/__session-voice');
+
+// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
+await click('.voice-sec .take button', '登记这段声音');
+check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
+await click('.voice-sec .take button', '换一段');
+await takeReady('面板不在'); await ctl('/__fail?register=503');
+await click('.voice-sec .take button', '登记这段声音');
+check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
+await shot('branch-register-503-none');
+const before = (await ctl('/__uploads')).length;
+await click('.voice-sec .take button', '确认没有,再登记');
+check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
+// Recording regression: the microphone opens on a tap and closes on stop / cancel.
+await click('.voice-sec .seg button', '现在录一段');
+await click('.voice-sec .rec button', '开始录音');
+await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await sleep(2600);
+check('recording: microphone live while recording', (await live()) === 1);
+await click('.voice-sec .rec button', '停止');
+check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
+await click('.voice-sec .take button', '换一段');
+await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await click('.voice-sec .rec button', '取消');
+check('recording: cancel releases the microphone', (await live()) === 0);
+await closeSettings();
+
+// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
+await boot('moon', '/?demo=1'); await ctl('/__log');
+await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
+await click('.nav-item', '交谈'); await sleep(1200);
+const demoLog = await ctl('/__log');
+check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
+await settings('外接服务'); await shot('demo-services');
+
+const passed = results.filter(Boolean).length;
+console.log(`\n${passed}/${results.length} passed`);
+browser.kill();
+process.exit(passed === results.length ? 0 : 1);
diff --git a/web/tests/hel903_voice_rework.test.ts b/web/tests/hel903_voice_rework.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a8783db6a3b1f4796480cd3bbe96bef8bf61760
--- /dev/null
+++ b/web/tests/hel903_voice_rework.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
+import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
+
+// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
+const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
+const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
+const signal = () => new AbortController().signal;
+const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe('外接服务 reads only what /api/voice/status can tell', () => {
+ const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
+ it('tells not configured, reachable, down, no route and unreadable apart', async () => {
+ expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
+ expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
+ expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
+ expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
+ expect(await kind(() => reply('oops', 500))).toBe('unknown');
+ expect(await kind(() => reply('not json'))).toBe('unknown');
+ expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
+ });
+ it('a configured flag alone is never "reachable"', async () => {
+ expect(await kind(() => reply({ configured: true }))).toBe('down');
+ });
+});
+
+// ── The call's own voice ──
+class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
+class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
+function rig() {
+ const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
+ let made = 0;
+ const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
+ if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
+ if (url === '/api/voice/session' && init.method === 'POST') {
+ const n = made++, g = gates[n]; if (g) await g.promise;
+ const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
+ if (voices[n] !== undefined) grant.voice = voices[n];
+ return reply(grant, 201);
+ }
+ return reply({ ended: true });
+ });
+ const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
+ const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
+ return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
+}
+
+describe('本次通话声音 comes from the session answer and stays for the call', () => {
+ it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
+ const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
+ await r.c.start();
+ expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
+ r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
+ expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
+ await r.c.end();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('a missing or odd field is "not said", never a guess', () => {
+ expect(callVoiceOf({})).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
+ });
+ it('an older call answering late never overwrites the newer call', async () => {
+ const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
+ const first = r.c.start(); await flush();
+ await r.c.start();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ r.gates[0]!.open(); await first; await flush();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
+ });
+ it('a failure after the room clears the voice with the call', async () => {
+ const r = rig(); r.voices[0] = 'rain';
+ await r.c.start(); r.rooms[0].on.agentJoined();
+ r.rooms[0].on.dropped(); await flush();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
+ expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
+ expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
+ expect(callVoiceName('default', null)).toBe('默认声音');
+ expect(callVoiceName(null, null)).toBe('声音未说明');
+ });
+});
+
+describe('a registration with no clear answer says to look before registering again', () => {
+ const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
+ const attempt = async (answer: () => Response | Promise<Response>) => {
+ try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
+ throw new Error('expected a failure');
+ };
+ it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
+ for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
+ expect(e).toBeInstanceOf(VoiceError);
+ expect(e.uncertain).toBe(true);
+ }
+ expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
+ });
+ it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
+ expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
+ expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
+ vi.stubGlobal('navigator', { onLine: false });
+ const offline = await attempt(() => reply({}, 201));
+ expect(offline.message).toBe(SAY.offline);
+ expect(offline.uncertain).toBe(false);
+ });
+ it('the advice points at refreshing the list and the numbered entries', () => {
+ expect(SAY.maybeRegistered).toContain('刷新列表');
+ expect(SAY.maybeRegistered).toContain('cg-');
+ expect(SAY.maybeRegistered).toContain('不用再登记');
+ });
+ it('an entry without local metadata is recognised as id-only', () => {
+ const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
+ expect(idOnly(v({}))).toBe(true);
+ expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
+ expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
+ });
+});
+58
View File
@@ -0,0 +1,58 @@
# HEL-904 后台有限返修记录(登记失败恢复 / worker 状态实况 / capture 竞态)
作者:施工员2号(HEL-899 原作者)。2026-10-28。基准 v1.12 @ `b09a907`。
零真实 LLM/嵌入/ASR/TTS 调用、零共享音色写入、零部署、零依赖下载安装、
未动 web/、未改共享 worker、未新增依赖。不重做架构、不改网页。
对应 `docs/rework/hel902/REVIEW.md` §5 的第 2、3、4、6 项(后台部分)。
## 1. 改动清单
| 文件 | 改动 |
| --- | --- |
| `src/cybergril/voice/custom.py` | ① `register()`:本地元数据落盘失败从裸 OSError 改为 `LocalRecordFailed`(共享登记不回滚、编号写进中文提示、刷新可找回继续试听/应用、不逼重复登记);`register_asr` 流水改在本地落盘**之前**记(转写真实发生,资源口径必计)。② `write_worker_voice()` docstring 补损坏实况:文件读不出时整体重写仅剩 voice,不再宣称任何情况下保留其他键;正常可读文件行为不变。③ 新增通话持有 `hold/release_call_voice`/`call_voice_held`;`apply_selection()` 在有活跃通话时只记选择、暂缓写 worker 文件(整通话窗口按「worker 尚未读」对待),无活跃通话行为与原先一致(含回滚)。 |
| `src/cybergril/voice/router.py` | `_capture_voice()` 捕获后登记通话持有(无 await 间隙);`open_session` 失败/取消即释放;`register_voice` 映射 `LocalRecordFailed`→503 中文文案;挂载 `on_session_settled`→释放持有;头部契约注释更新。 |
| `src/cybergril/voice/session.py` | 新增可选 `on_session_settled` 回调;会话离开活跃表(显式挂断成功、挂断失败转欠账、TTL 到期 `_prune`)即结算。回调异常只影响释放时机(安全方向),不拖垮会话流程。 |
| `tests/test_voice_review_hel902.py` | 第 1 例按新契约改写:503 中文映射+编号在文案里+不误称回滚+进程内可试听可应用+重启按编号(label=编号)继续用+新音色未试听仍 409+11 支/通用助手零改写。 |
| `tests/test_voice_rework_hel904.py`(新增 4 例) | 确定性时序用例(asyncio.Event 门控,无 sleep 判定):worker 读时点延迟到 dispatch 返回之后+并发应用→本通话保持旧音色(响应=实读)、挂断结算、下一通用新音色;挂断失败转欠账与 TTL 到期两条结算路径;dispatch 失败即释放(应用立即写透);开房请求在途取消即释放。 |
| `docs/voice-custom-contract-HEL899.md` | §2.2 补共享登记成功/本地写失败与断连孤儿行为;§2.5 补通话窗口持有语义(含重叠通话如实保留的残余);§3 worker 文件损坏实况。 |
| `docs/rework/hel902/r5-followup-package.md` | 补测材料收紧(仅材料):head 里程碑表(d167ba9=整合提交、b09a907=审核 head、返修 head 待统一复审登记)、登记/连续通话 ASR 区分、连续通话 ASR 计数手段与待补阻塞如实声明、停声量测改为单一时间基准帧级录音法、参考音频明确到 D1 包具体段(含 sha256/长度)、回收清单与「不未经确认保留测试垃圾」、共享 TTS 启停快照恢复与逐项核对表、git 回退不代替共享回收、上限表补含失败口径与硬闸/监看界线。 |
## 2. 竞态收口的事实依据与设计
- 事实入口(不猜):专属 worker 是**另一进程**,在**它自己的会话入口**读
一次 `voice_state_cybergril.json`(HEL-877 既有行为,合同 §2.5;本仓库
不含 worker 代码,不改共享 worker)。API 只知道 dispatch 返回时刻,
**dispatch 返回 ≠ worker 已读**。
- 设计=等价「会话不可变快照」:capture 时(写文件后、任何 await 前)登记
按音色计数的通话持有;活跃通话期间 `apply` 只更新选择(本地选择仍原子
落盘),worker 文件定在本通话捕获值上;挂断/到期/失败/取消结算持有。
暂缓的写不需要补写:下一次通话 capture 总会按当前选择重写文件(从未
选择仍不代写,维持 D1 边界)。
- 保证:**会话响应 `voice` = worker 本通话实际读到并使用的声音**;通话中
改选只影响下一通,绝不半句换人。
- 如实保留的残余(不宣称消除):两通**重叠**(单用户产品退化场景)且选了
不同声音时,较新通话的 capture 会重写文件;较早通话的 worker 若迟迟
未读,可能读到较新捕获值。每个 worker 仍整通话单一音色。确定性用例
覆盖的是正常单通话流。
## 3. 自测(全部离线,零真实调用;py3.11.16)
- `pytest tests -q`:**380 通过**(基线 376+新增 4;改写的第 1 例保持
原名)。含既有 18 项 HEL-899 与总工 6 项故障注入全数通过。
- `python -I scripts/verify_hel899_custom_voice.py`:**12/12**(含通话中
改选定住本通话、下一通生效)。
- `ruff check src tests scripts`:通过。
- 需求检查 4 组(origin/main 与 b09a907,各带/不带本卡 handoff):通过
(证据见交付评论)。
## 4. 未验证 / 承接(如实)
- 真实 worker 读时点、真实 dispatch/房间行为、打断停声与真机麦克风路径:
属获批补测窗口(材料已收紧,见 `docs/rework/hel902/r5-followup-package.md`)。
- 本卡修复(后台)与前端另卡仍需原总工**一次统一复审**;通过后由复审
登记最终已验证 head,再由主卡向白栖知申请批准新窗口。复审前不得把本
head 当作最终已验证 head。
- 前端「会话页显示本次通话声音」(审核建议第 5 项)属 web/,归前端另卡。
- CG-009/R6 删除与完整备份恢复、R5 整体验收、R1–R7 完整功能持续承接;
本卡通过≠自定义音色用户验收,更≠完整产品完成。
+13
View File
@@ -0,0 +1,13 @@
> cybergril-web@0.1.0 build
> tsc --noEmit && vite build
vite v6.3.6 building for production...
transforming...
✓ 59 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-B8TdHoQm.css 88.58 kB │ gzip: 18.37 kB
dist/assets/index-ba4PURHH.js 342.44 kB │ gzip: 114.48 kB
✓ built in 680ms
@@ -0,0 +1,85 @@
PASS mock is this hel900 mock
PASS moon-desktop: no microphone before anything is tapped
PASS moon-desktop: opening the section only reads the list
PASS moon-desktop: says when a change takes effect
PASS moon-desktop: recording state is explicit and the microphone is live
PASS moon-desktop: audio only, never the camera
PASS moon-desktop: stopping keeps the take and releases the microphone
PASS moon-desktop: the take is labelled as your reference, not her voice
PASS moon-desktop: register needs reference text or an explicit transcription opt-in
PASS moon-desktop: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS moon-desktop: the new voice is chosen for a preview; apply waits for it
PASS moon-desktop: a fresh preview plays and only then can be applied
PASS moon-desktop: the preview is labelled as her synthesized voice, not your reference
PASS moon-desktop: the preview actually plays in the page
PASS moon-desktop: applied — current voice shown, from the next call
PASS moon-desktop: no object URL or microphone left after closing settings
PASS fog-desktop: no microphone before anything is tapped
PASS fog-desktop: opening the section only reads the list
PASS fog-desktop: says when a change takes effect
PASS fog-desktop: recording state is explicit and the microphone is live
PASS fog-desktop: audio only, never the camera
PASS fog-desktop: stopping keeps the take and releases the microphone
PASS fog-desktop: the take is labelled as your reference, not her voice
PASS fog-desktop: register needs reference text or an explicit transcription opt-in
PASS fog-desktop: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS fog-desktop: the new voice is chosen for a preview; apply waits for it
PASS fog-desktop: a fresh preview plays and only then can be applied
PASS fog-desktop: the preview is labelled as her synthesized voice, not your reference
PASS fog-desktop: the preview actually plays in the page
PASS fog-desktop: applied — current voice shown, from the next call
PASS fog-desktop: no object URL or microphone left after closing settings
PASS moon-phone: no microphone before anything is tapped
PASS moon-phone: opening the section only reads the list
PASS moon-phone: says when a change takes effect
PASS moon-phone: recording state is explicit and the microphone is live
PASS moon-phone: audio only, never the camera
PASS moon-phone: stopping keeps the take and releases the microphone
PASS moon-phone: the take is labelled as your reference, not her voice
PASS moon-phone: register needs reference text or an explicit transcription opt-in
PASS moon-phone: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS moon-phone: the new voice is chosen for a preview; apply waits for it
PASS moon-phone: a fresh preview plays and only then can be applied
PASS moon-phone: the preview is labelled as her synthesized voice, not your reference
PASS moon-phone: the preview actually plays in the page
PASS moon-phone: applied — current voice shown, from the next call
PASS moon-phone: no object URL or microphone left after closing settings
PASS fog-phone: no microphone before anything is tapped
PASS fog-phone: opening the section only reads the list
PASS fog-phone: says when a change takes effect
PASS fog-phone: recording state is explicit and the microphone is live
PASS fog-phone: audio only, never the camera
PASS fog-phone: stopping keeps the take and releases the microphone
PASS fog-phone: the take is labelled as your reference, not her voice
PASS fog-phone: register needs reference text or an explicit transcription opt-in
PASS fog-phone: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS fog-phone: the new voice is chosen for a preview; apply waits for it
PASS fog-phone: a fresh preview plays and only then can be applied
PASS fog-phone: the preview is labelled as her synthesized voice, not your reference
PASS fog-phone: the preview actually plays in the page
PASS fog-phone: applied — current voice shown, from the next call
PASS fog-phone: no object URL or microphone left after closing settings
PASS file: a renamed non-WAV is refused in the browser, nothing uploaded
PASS file: a 22 kHz stereo WAV becomes 16 kHz mono
PASS asr: opting in enables register with empty text
PASS offline: says so, keeps the take, sends nothing
PASS 503: honest message, take still there for a retry
PASS double tap registers once; transcription only on explicit opt-in
PASS asr: the transcribed text is shown back
PASS late: an older preview arriving after switching is dropped
PASS bad preview: refused, nothing playable shown
PASS preview 503: honest message
PASS apply 409: message, selection unchanged
PASS cancel: microphone released, take discarded
PASS leaving the section mid-take releases the microphone
PASS coming back says the take was dropped
PASS denied: says why, nothing recording
PASS an unregistered take guards leaving
PASS after leaving, nothing of the section remains
PASS list unconfigured: honest card, no microphone, no 'adjustable' tag
PASS list no-route: honest card, no microphone, no 'adjustable' tag
PASS list panel-down: honest card, no microphone, no 'adjustable' tag
PASS panel back: retry shows the list
PASS no registration or apply was ever cut off by the page itself
83/83 passed
+1
View File
@@ -0,0 +1 @@
hel900 mock on 8900 pid 2311407
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -0,0 +1,92 @@
PASS mock is this hel903 mock
PASS moon-desktop: services — TTS row says configured + health probe reachable
PASS moon-desktop: services — ASR row claims no probe it does not have
PASS moon-desktop: services — no voice row says "尚未接通" or "已接通"
PASS moon-desktop: services — reading status opens no microphone
PASS moon-desktop: a voice is applied through the existing flow
PASS moon-desktop: settings still says a change takes effect at the next call
PASS moon-desktop: call line shows 本次通话 voice from the session answer
PASS moon-desktop: subtitles still show beside it
PASS moon-desktop: a choice made during the call does not change this call
PASS moon-desktop: ending the call releases the microphone and drops the pin
PASS moon-desktop: the next call shows the new voice
PASS moon-desktop: 500 after filing — says it may be registered and to refresh first
PASS moon-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS moon-desktop: the list is not re-read or re-registered behind your back
PASS moon-desktop: refreshing names the new numbered entry
PASS moon-desktop: the numbered entry is marked as missing local metadata
PASS moon-desktop: after it is found the cautious button goes away
PASS fog-desktop: services — TTS row says configured + health probe reachable
PASS fog-desktop: services — ASR row claims no probe it does not have
PASS fog-desktop: services — no voice row says "尚未接通" or "已接通"
PASS fog-desktop: services — reading status opens no microphone
PASS fog-desktop: a voice is applied through the existing flow
PASS fog-desktop: settings still says a change takes effect at the next call
PASS fog-desktop: call line shows 本次通话 voice from the session answer
PASS fog-desktop: subtitles still show beside it
PASS fog-desktop: a choice made during the call does not change this call
PASS fog-desktop: ending the call releases the microphone and drops the pin
PASS fog-desktop: the next call shows the new voice
PASS fog-desktop: 500 after filing — says it may be registered and to refresh first
PASS fog-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS fog-desktop: the list is not re-read or re-registered behind your back
PASS fog-desktop: refreshing names the new numbered entry
PASS fog-desktop: the numbered entry is marked as missing local metadata
PASS fog-desktop: after it is found the cautious button goes away
PASS moon-phone: services — TTS row says configured + health probe reachable
PASS moon-phone: services — ASR row claims no probe it does not have
PASS moon-phone: services — no voice row says "尚未接通" or "已接通"
PASS moon-phone: services — reading status opens no microphone
PASS moon-phone: a voice is applied through the existing flow
PASS moon-phone: settings still says a change takes effect at the next call
PASS moon-phone: call line shows 本次通话 voice from the session answer
PASS moon-phone: subtitles still show beside it
PASS moon-phone: a choice made during the call does not change this call
PASS moon-phone: ending the call releases the microphone and drops the pin
PASS moon-phone: the next call shows the new voice
PASS moon-phone: 500 after filing — says it may be registered and to refresh first
PASS moon-phone: offers 刷新列表 and words a second registration as a checked choice
PASS moon-phone: the list is not re-read or re-registered behind your back
PASS moon-phone: refreshing names the new numbered entry
PASS moon-phone: the numbered entry is marked as missing local metadata
PASS moon-phone: after it is found the cautious button goes away
PASS fog-phone: services — TTS row says configured + health probe reachable
PASS fog-phone: services — ASR row claims no probe it does not have
PASS fog-phone: services — no voice row says "尚未接通" or "已接通"
PASS fog-phone: services — reading status opens no microphone
PASS fog-phone: a voice is applied through the existing flow
PASS fog-phone: settings still says a change takes effect at the next call
PASS fog-phone: call line shows 本次通话 voice from the session answer
PASS fog-phone: subtitles still show beside it
PASS fog-phone: a choice made during the call does not change this call
PASS fog-phone: ending the call releases the microphone and drops the pin
PASS fog-phone: the next call shows the new voice
PASS fog-phone: 500 after filing — says it may be registered and to refresh first
PASS fog-phone: offers 刷新列表 and words a second registration as a checked choice
PASS fog-phone: the list is not re-read or re-registered behind your back
PASS fog-phone: refreshing names the new numbered entry
PASS fog-phone: the numbered entry is marked as missing local metadata
PASS fog-phone: after it is found the cautious button goes away
PASS status down: TTS "已配置 · 合成服务这会儿暂不可用"
PASS status down: ASR row honest
PASS status off: TTS "未配置 · 这台服务没有语音配置"
PASS status off: ASR row honest
PASS status no-route: TTS "这台服务没有语音状态接口"
PASS status no-route: ASR row honest
PASS status unknown-500: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-500: ASR row honest
PASS status unknown-net: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-net: ASR row honest
PASS session without voice: "声音未说明", not the settings choice
PASS session voice "default": shown as 默认声音
PASS dropped answer: may be registered, refresh first
PASS dropped answer: refresh finds the numbered entry
PASS 503: keeps the server words and adds the refresh advice
PASS 503: refresh finds nothing new and says to wait before registering again
PASS 503: the checked retry registers once and the take is gone
PASS recording: microphone live while recording
PASS recording: stop keeps the take and releases the microphone
PASS recording: cancel releases the microphone
PASS demo: zero /api requests from settings and the conversation
90/90 passed
+1
View File
@@ -0,0 +1 @@
hel903 mock on 8901 pid 2314388
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Some files were not shown because too many files have changed in this diff Show More