HEL-898: record exact source preservation and independent offline checks

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总管
2026-10-27 05:54:01 +08:00
co-authored by multica-agent
parent 46ac0a7a20
commit fb7ea846ff
19 changed files with 1922 additions and 0 deletions
+863
View File
@@ -0,0 +1,863 @@
diff --git a/docs/requirements/handoff-HEL-898.json b/docs/requirements/handoff-HEL-898.json
new file mode 100644
--- /dev/null
+++ b/docs/requirements/handoff-HEL-898.json
@@ -0,0 +1,39 @@
+{
+ "baseline_version": "1.11",
+ "read_commit": "3228b1e0d7f822b7030f945e285eac74126771aa",
+ "task": "HEL-898(HEL-879 接线卡,视觉总监本人编码):在已认可的交谈界面接上她的增量字幕(cg.subtitle 契约 v2)与语音内主动开口呈现,处理打断/文字接管/错误/挂断收尾;不另做聊天页、不改主题与整体动效",
+ "requirement_ids": [
+ "CG-004", "CG-006", "CG-010", "CG-011", "CG-018", "CG-020",
+ "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013",
+ "UX-001", "UX-002", "UX-005", "UX-006",
+ "AM-001",
+ "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "自总管输入包 3228b1e(INPUT-MANIFEST 逐文件 SHA-256)起的前端补丁,只动 web/src/voice/session.ts、web/src/chat/ChatPanel.tsx 与测试:1) session.ts 严格解析 v2 信封(v===2、正整数 seq、kind 白名单、角色与种类对应、她的事件必带 speech_id、assistant_final 必带 turn_id、proactive_* 必带正整数 message_id;不合格整条丢弃且不推进序号);每次通话(控制器代次)独立 seq 高水位,seq<=已应用即丢;按 speech 生命周期只接受当前打开的一轮,已关闭/被取代的 speech 不再显示,旧 speech 的高 seq 事件不能关闭新轮。VoiceView 新增 her(speech/text/state open|final|done/turnId/messageId/proactive),与用户 ASR 草稿 heard 分槽。2) turn_start 开轮,assistant_interim 整句替换,assistant_final 原子替换为落库文本并请页面回读 /api/messages,turn_end 关轮(无 final 的关轮按未成为正式回复即清),turn_cancel/error(φ)/user_final/文字接管 hush/结束/掉线/卸载/重开即时清;φ 原句仍在语音行以“未记入聊天”显示。3) proactive_start 以落库原文、同源 message_id 显示在她的位置,proactive_end 保留至记录或下一轮接替,proactive_cancel 即清;不发任何请求、不标已读、不开麦、不重 TTS、不进入正式记录。4) ChatPanel:她的语音行进入与文字回复相同的舞台槽位(当前一两句、自动续读、暂停/回看、全文),生长中沿用文字流的“最新一句先压住”规则,逐分片不重启动画;落库行到来时同一 turn 同一位置接替,行键不变不重放;被撤回的语音行以 instant 方式立即离场(不淡出);减少动态设置沿用既有逻辑。你用语音开始新一轮时与发送文字一致恢复自动呈现(原先暂停状态下她的回答不会进来)。5) 测试:voice.test.ts 改为 v2 帧并新增 11 项(录制样例三场景回放、信封负例、乱序/重放/迟到/双 handle、φ、主动开口及打断、文字接管/挂断/重开、舞台行键接替);fake-livekit.js 未带信封的帧自动套 v2;hel878_mock.mjs 增 /__proactive;hel878_shots.mjs 的她的 final 与 φ 改为 v2 生命周期;新增 hel898_shots.mjs(两主题×电脑/手机)",
+ "acceptance": [
+ "假 LiveKit 有序回放 normal-turn.jsonl:用户草稿→用户终稿→turn_start→两次增量(整句替换)→final(带 turn_id)→turn_end;她的字在舞台出现时 /__rows 尚无该回复;落库后记录中只出现一次(页面只回读,不写)",
+ "interrupt.jsonl 与负例:turn_cancel 后她的未完成行立即离场;被取消轮之后的高 seq 增量/final/重复 turn_start 均不显示;整段录制重放不改变界面;双 handle 时旧 speech 的 turn_end/turn_cancel 不关闭新轮;无 final 的 turn_end 视为未成为正式回复并清除",
+ "φ:error 原句在语音行显示并标“未记入聊天”,她的增量行立即清除,随后 turn_cancel(error_phi) 不抹掉 φ 原句;不落正式记录",
+ "proactive.jsonl:原文一字不差显示在她的位置并保留落库行身份(id/turn),可被你的语音打断(proactive_cancel 即清);全程页面无任何非 GET 请求(无回执、无已读、无消息写入),不调用 onSpoke/onRecord",
+ "文字接管、结束语音、掉线、新通话:她的行即时清;旧房间迟到帧被代次挡住;新通话序号从 1 重新开始并正常显示",
+ "两主题(月光/雾光)电脑 1440×900 与手机 390×844 截图与连续帧:沿用已认可位置与组件,无新增组件或样式;减少动态设置下她的字照常出现、无入场动画",
+ "工程:tsc 0 错;eslint 0 错 1 条既有警告(ChatPanel fullButton ref,HEL-897 起即有);vitest 8 文件 99/99;vite build 通过;浏览器 hel898 61/61,HEL-878 回归 49/49;修前对照:新 voice.test.ts 在原 session.ts/ChatPanel 上 12 项失败"
+ ],
+ "deferred": [
+ "真实通话未测:真实 LiveKit/WebRTC、真实 worker 与 SDK 回调时序、真机/Safari/iOS、可信 HTTPS+WSS、R5 连续三轮/1 秒打断/停麦无残音/有限预算,均仍由 HEL-797 主卡承接;本卡全部为假 LiveKit、假音频设备与 Chromium 无头 Edge",
+ "CG-011 语音内主动开口仅是“会话开时念一条已落库旧留言”的最小接缝(后台默认关,本卡不启用);会话中持续主动联系、激活节奏、未读联动策略未完成,不得记为 CG-011 完成",
+ "后台并行小修(建房延迟后的清理唤醒、主动 say 的 start 发得过晚、无完整 receipt 错误发 final)由原总工在 HEL-879 处理;本卡按 v2 正确语义实现,最终后台 commit 出来后需按其统一复核(特别是 proactive_start 晚于音频开始时的呈现)",
+ "已知边界:seq 按“一次 worker job 一个序列”实现;若同一房间内 worker 重连且序号归零,前端会按契约丢弃其帧直到超过旧高水位——需后台确认 job 与房间一一对应",
+ "原版 Python 需求检查(scripts/check_requirements.py --base-ref origin/main,含本交接)未在本运行时执行(无 Python、无 git),须总管对实际目标分支复跑",
+ "D1 音色、cap=0、R6/R7、整体沉浸体验由主卡与白栖知决定;工程自测不是用户体验验收"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "读取:HEL-898 卡(启动交接与原说明)、输入包 3228b1e:docs/voice-subtitle-contract-v2.md、docs/rework/hel879/voice-seam-interfaces.md、subtitle-samples/*.jsonl、src/cybergril/voice/seam.py 与 worker.py(事件发出点)、AGENTS.md、docs/requirements/README.md/current.md(v1.11)/coverage.md/changes.md、sources/integration-plan-v1.md R5",
+ "工具:沿用本机既有离线工具链(HEL-834 留存 node v22.11.0;HEL-897 留存 web/node_modules,package-lock 哈希与本包一致,目录联接使用);未下载、未安装",
+ "web:tsc --noEmit 0;eslint src 0 错 1 既有警告;vitest run 8 文件 99/99(原 88 + 新增);vite build 通过;日志随交付包",
+ "浏览器:node tests/hel878_mock.mjs + node tests/hel898_shots.mjs(无头 Edge、脚本化 LiveKit 替身、Chromium 假音频设备)61/61;node tests/hel878_shots.mjs 回归 49/49;截图与连续帧随交付包",
+ "无模型、嵌入、ASR、TTS 请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台、总装分支或 PR#11"
+ ]
+}
diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx
--- a/web/src/chat/ChatPanel.tsx
+++ b/web/src/chat/ChatPanel.tsx
@@ -10,7 +10,7 @@
import { motionOff } from '../stage/roomScene';
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
+import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
-import { mergeTurns, OFF, recentRows, VoiceController, voiceStatus, WORDS, type VoiceView } from '../voice/session';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -19,6 +19,8 @@
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
const NO_IDS: string[] = [], NO_MESSAGES: Message[] = [];
+// Marks "your next turn is one you said by voice" until its row is read back and its turn key is known.
+const SPOKEN = '\u0000spoken';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt, incoming = NO_MESSAGES, onAdmit }: {
real?: boolean;
// Real only: the server accepted a complete-receipt confirmation (the life it may unlock is decided later).
@@ -68,7 +70,8 @@
};
const spoke = useRef(() => {});
// Speaking cancels a text reply still streaming (the server's turn gate already did); it is kept as a cut fragment.
+ // Like sending, it starts a new turn that comes in by itself, even if you had paused reading.
+ spoke.current = () => { if (busyNow.current) stop(true); sentTurn.current = SPOKEN; if (!flow) setFlash('新的一轮会自动呈现,随时可以暂停'); };
- spoke.current = () => { if (busyNow.current) stop(true); };
useEffect(() => {
if (!real) return;
const sync = recordSync.current;
@@ -99,17 +102,27 @@
// Real messages are trimmed by the server before transmission; their lifeIds point at records this page has not
// necessarily loaded, so the demo's local filter would wrongly hide them.
const view = useMemo(() => real ? state : publicView(state), [state, real]);
+ // What she says by voice takes the place her text reply would take (HEL-898). It is shown, never stored: once the
+ // record holds it (same turn, or the same stored reach-out as the newest line) the row takes over under the same
+ // line keys; a finished line also gives way as soon as anything else comes into the record after it.
+ const her = real ? voiceView.her : null, herBase = useRef({ speech: '', at: 0 });
+ if (her && herBase.current.speech !== her.speech) herBase.current = { speech: her.speech, at: view.messages.length };
+ const herLine = useMemo(() => reply ? null : voiceLine(her, view.messages, herBase.current.at, incoming), [her, reply, view.messages, incoming]);
+ const herOpen = !!herLine && her?.state === 'open', talking = busy || herOpen;
// The cut-off part of a stopped reply stays with its own turn (it is shown, never stored).
const shown = useMemo<ShownMessage[]>(() => {
const list: ShownMessage[] = [...view.messages];
if (reply) list.push({ id: 'live-reply', role: 'companion', text: reply, lifeIds: [], turnId: activeTurn, live: 'generating' });
+ else {
+ if (interrupted) {
+ const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
+ list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
+ }
+ if (herLine) list.push(herLine);
- else if (interrupted) {
- const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
- list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
}
return list;
+ }, [view.messages, reply, activeTurn, interrupted, herLine]);
+ const turn = useMemo(() => groupTurns(shown).at(-1), [shown]), opener = useRef(''); opener.current = turn?.items[0]?.role || '';
- }, [view.messages, reply, activeTurn, interrupted]);
- const turn = useMemo(() => groupTurns(shown).at(-1), [shown]);
const stageRef = useRef<HTMLDivElement>(null), lines = useRef<LineStage | null>(null), meter = useRef<HTMLElement>(null);
const [metrics, setMetrics] = useState<Metrics>(() => metricsFor(Math.min(900, innerWidth * .88)));
const pages = useMemo(() => pagesOf(turn, metrics), [turn, metrics]);
@@ -126,7 +139,7 @@
// Her own message that arrives while you are here waits its turn: never while she is answering you, a sheet is
// open, you paused or went back, or the line on screen has not had its time. Then it comes in as a new turn.
const waiting = useRef({ count: 0, busy: false, sheet: false }), admit = useRef(() => {});
+ waiting.current = { count: incoming.length, busy: talking || voiceView.phase === 'thinking' || voiceView.phase === 'speaking', sheet: log || full };
- waiting.current = { count: incoming.length, busy: busy || voiceView.phase === 'thinking' || voiceView.phase === 'speaking', sheet: log || full };
admit.current = () => { const add = incoming.filter(m => !live.current.some(k => k.id === m.id)); onAdmit?.(incoming.map(m => m.id)); if (add.length) change([...live.current, ...add]); };
function stop(quiet = false) { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); if (!quiet) onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
@@ -154,8 +167,12 @@
const key = turn?.key || '';
if (key === turnKey.current) return;
turnKey.current = key; how.current = 'jump'; head.current = START; setAnchor(START);
+ // Your own new turn (sent, or said by voice: its server turn id is only known once the row is read back).
+ if (key && (key === sentTurn.current || (sentTurn.current === SPOKEN && opener.current === 'user'))) { sentTurn.current = key; setFlowState(true); }
- if (key && key === sentTurn.current) setFlowState(true);
}, [turn?.key]);
+ // Her voice line withdrawn (cut, φ, text took over, you spoke, the call ended): it leaves at once, no fade.
+ const hadLine = useRef(false);
+ useLayoutEffect(() => { if (hadLine.current && !her) how.current = 'instant'; hadLine.current = !!herLine; }, [her, herLine]);
const signature = pages.map(p => p.key).join('|');
useLayoutEffect(() => {
lines.current?.present(pages, cursor, how.current, name, !motionOff());
@@ -165,8 +182,8 @@
useLayoutEffect(() => {
if (!page) return;
const mine = page.message, cancelled = mine.live === 'cancelled' || (mine.status === 'cancelled' && mine.role === 'companion');
+ const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && talking && mine.role === 'companion' ? '还在说' : '';
+ lines.current?.mark({ text, waiting: atEnd && talking && mine.role === 'companion' });
- const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && busy && mine.role === 'companion' ? '还在说' : '';
- lines.current?.mark({ text, waiting: atEnd && busy && mine.role === 'companion' });
});
useEffect(() => { lines.current?.rename(name); }, [name]);
@@ -306,7 +323,7 @@
useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]);
const typing = (on: boolean) => document.documentElement.classList.toggle('typing', on);
useEffect(() => () => { document.documentElement.classList.remove('typing'); }, []);
+ const record = shown.filter(m => m !== herLine && (!reply || !m.live));
- const record = reply ? shown.filter(m => !m.live) : shown;
// While a reading sheet is open the table words are out of reach (the sheet holds them all); layout effect so the
// focus hand-back after closing never lands on an inert button.
const conversation = useRef<HTMLElement>(null);
@@ -327,7 +344,7 @@
<div className="reader-bar">
<button className="flow" aria-pressed={flow} onClick={() => setFlow(!flow)}><Icon name={flow ? 'pause' : 'play'}/><span>{flow ? '暂停' : '继续'}</span></button>
<button className="icon" aria-label="上一句" disabled={cursor <= 0} onClick={() => back()}><Icon name="back"/></button>
+ <span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${talking ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${talking ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
- <span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${busy ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${busy ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
<button className="icon" aria-label="下一句" disabled={atEnd} onClick={() => forward()}><Icon name="next"/></button>
<button className="read-all" ref={fullButton} disabled={!page} onClick={() => setFull(true)}>全文</button>
{busy && <button className="stop" onClick={() => stop()}>停止回复</button>}
@@ -341,7 +358,7 @@
</div>
</section>
<aside className="record log-panel glass read" hidden={!log} role="dialog" aria-modal="false" aria-labelledby="record-title"><header><div><p className="eyebrow">{real ? '服务器聊天记录' : '完整记录 · 本机演示'}</p><h2 id="record-title">你们说过的话</h2></div><button className="btn-round sm" ref={logClose} aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header>
+ <div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-mid={m.id} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${(focusLife && m.lifeIds.includes(focusLife)) || focusIds.includes(m.id) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}{herLine && !herLine.proactive && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{herLine.text}</p><span className="incomplete">{herOpen ? '还在说…' : '语音刚说完 · 正在读回聊天记录'}</span></article>}</div></aside>
- <div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-mid={m.id} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${(focusLife && m.lifeIds.includes(focusLife)) || focusIds.includes(m.id) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
{full && fullMessage && <aside className="record full glass read" role="dialog" aria-modal="false" aria-labelledby="full-title"><header><div><p className="eyebrow">{fullMessage.role === 'user' ? '你' : name} · 全文</p><h2 id="full-title">这一段,慢慢看</h2></div><button className="btn-round sm" ref={fullClose} aria-label="关闭全文" onClick={() => setFull(false)}><Icon name="x"/></button></header>
<div className="scroll"><p className="guide">整段完整可读。关闭后回到刚才那一句。</p><article className="message" data-role={fullMessage.role}><div className="author"><i/>{fullMessage.role === 'user' ? '你' : name}</div><p>{fullMessage.text}</p>{fullMessage.live === 'generating' && <span className="incomplete">还在说…</span>}{fullMessage.live === 'cancelled' && <span className="incomplete">这段回复已停止,内容未完成,未存入聊天记录。</span>}</article></div></aside>}
{real && permission === 'mic' && <Modal title={`开始和${name}语音?`} onClose={() => { setPermission(''); setNotice('没有开启语音,文字仍可使用。'); }} confirm="开始语音" onConfirm={() => { setPermission(''); setNotice(''); void voiceCtl.current?.start(); }}>浏览器会请求麦克风权限。你的声音只送到这台服务器上{name}的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。<br/><small className="voice-note">语音通路还没有经过真机验收,效果可能不稳定。</small></Modal>}
diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts
--- a/web/src/voice/session.ts
+++ b/web/src/voice/session.ts
@@ -7,6 +7,7 @@
// One rule holds the whole lifecycle together: every call starts a new generation, and anything that answers for an
// older generation (a late token, a late join, a late microphone, a late room event) is cleaned up, never shown.
import type { Message } from '../domain/model';
+import type { ShownMessage } from '../chat/reading';
import { api, toMessage, within, type MessagePage } from '../adapters/real';
import { serverId } from '../adapters/inbox';
@@ -17,17 +18,38 @@
heard: string;
// Her line spoken after a failure (φ): heard, not part of the record.
spoken: string;
+ // What she is saying right now, shown on the stage where her text replies appear; never stored by the page.
+ her: HerLine | null;
// Why voice is off or what went wrong; empty while it simply runs.
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
}
+// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
+// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
+// stays only until the record (or the next turn) takes its place.
+export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
+export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
+
+// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
+const USER_KINDS = ['interim', 'user_final'] as const;
+const HER_KINDS = ['turn_start', 'assistant_interim', 'assistant_final', 'turn_end', 'turn_cancel', 'error', 'proactive_start', 'proactive_end', 'proactive_cancel'] as const;
+export type SubtitleKind = typeof USER_KINDS[number] | typeof HER_KINDS[number];
+export interface Subtitle { seq: number; kind: SubtitleKind; role: 'user' | 'assistant'; text: string; speech: string; turnId?: string; messageId?: number; reason?: string }
+const id = (v: unknown) => typeof v === 'string' && v.length > 0 && v.length <= 200 ? v : '';
-export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', notice: '', blocked: false };
-export interface Subtitle { kind: 'interim' | 'user_final' | 'assistant_final' | 'error'; role: 'user' | 'assistant'; text: string }
export function subtitle(v: unknown): Subtitle | null {
const s = v as Record<string, unknown> | null;
+ if (!s || typeof s !== 'object' || Array.isArray(s) || s.v !== 2 || !Number.isSafeInteger(s.seq) || (s.seq as number) <= 0 || typeof s.text !== 'string') return null;
+ const kind = s.kind as SubtitleKind, user = (USER_KINDS as readonly string[]).includes(kind);
+ if (!user && !(HER_KINDS as readonly string[]).includes(kind)) return null;
+ if (s.role !== (user ? 'user' : 'assistant')) return null;
+ const out: Subtitle = { seq: s.seq as number, kind, role: user ? 'user' : 'assistant', text: s.text, speech: user ? '' : id(s.speech_id) };
+ if (!user && !out.speech) return null;
+ if (s.turn_id !== undefined && s.turn_id !== null) { if (!id(s.turn_id)) return null; out.turnId = s.turn_id as string; }
+ if (kind === 'assistant_final' && !out.turnId) return null;
+ if (kind.startsWith('proactive_')) { if (!Number.isSafeInteger(s.message_id) || (s.message_id as number) <= 0) return null; out.messageId = s.message_id as number; }
+ if (typeof s.reason === 'string') out.reason = s.reason;
+ return out;
- if (!s || typeof s !== 'object' || typeof s.text !== 'string' || !['interim', 'user_final', 'assistant_final', 'error'].includes(s.kind as string)) return null;
- return { kind: s.kind as Subtitle['kind'], role: s.role === 'assistant' ? 'assistant' : 'user', text: s.text };
}
// ── What the controller needs from a real-time client. livekit.ts implements it with livekit-client; tests use fakes. ──
@@ -77,7 +99,10 @@
secure?: () => boolean;
}
+// seq: the highest subtitle sequence applied in this call (one worker job, one sequence). speech: her speech open now;
+// closed: speeches that ended, were cancelled or were superseded here; nothing for them is shown again.
+interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType<typeof setTimeout>; seq: number; speech: string; pro: boolean; message?: number; closed: string[] }
+const CLOSED_KEPT = 64;
-interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType<typeof setTimeout> }
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
@@ -127,7 +152,7 @@
// The generation is taken before anything is awaited, so a start that wakes up after a newer one never takes
// the call over; the call it replaces is released first, as end() would.
const prev = this.live;
+ const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false, seq: 0, speech: '', pro: false, closed: [] };
- const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false };
this.live = live;
this.view = { ...OFF, phase: 'opening' }; this.deps.onView(this.view);
const stale = () => gen !== this.gen;
@@ -189,11 +214,11 @@
},
subtitle: payload => {
if (stale()) return;
+ // Replays, late frames of an older turn and frames that overtook each other all sit at or below the
+ // highest sequence already applied in this call.
+ const s = subtitle(payload); if (!s || s.seq <= live.seq) return;
+ live.seq = s.seq;
+ this.hear(live, s);
- const s = subtitle(payload); if (!s) return;
- if (s.kind === 'interim') { this.show({ heard: s.text, spoken: '', ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); if (live.hushed && !live.stated) { live.hushed = false; live.room?.hush(false); } return; }
- if (s.kind === 'user_final') { this.show({ heard: s.text, spoken: '', ...(live.stated ? {} : { phase: 'thinking' as const }) }, gen); this.deps.onSpoke(); this.deps.onRecord(); return; }
- if (s.kind === 'assistant_final') { this.show({ heard: '', spoken: '', ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); this.deps.onRecord(); return; }
- this.show({ heard: '', spoken: s.text, ...(live.stated ? {} : { phase: 'listening' as const }) }, gen); this.deps.onRecord();
},
dropped: () => { if (!stale()) void fail(WORDS.dropped); },
audioBlocked: blocked => this.show({ blocked }, gen),
@@ -224,10 +249,73 @@
if (live) void this.teardown(live, true);
}
+ // One subtitle that passed the sequence gate. Your draft and her line never share a slot: `heard` is yours, `her`
+ // is hers. Her frames act only on the speech open now; a speech once closed here is never shown again.
+ private hear(live: Live, s: Subtitle) {
+ const gen = live.gen, cur = this.view.her, honest = (phase: VoicePhase) => live.stated ? {} : { phase };
+ if (s.kind === 'interim') {
+ // Your draft. Her open line is left to the server's cancel (an SDK barge-in decides, not every sound).
+ this.show({ heard: s.text, spoken: '', ...honest('listening') }, gen);
+ if (live.hushed && !live.stated) { live.hushed = false; live.room?.hush(false); }
+ return;
+ }
+ if (s.kind === 'user_final') {
+ // A new turn of yours: nothing of hers from before may stand after it.
+ if (live.speech) this.close(live, live.speech);
+ this.show({ heard: s.text, spoken: '', her: null, ...honest('thinking') }, gen); this.deps.onSpoke(); this.deps.onRecord();
+ return;
+ }
+ const pro = s.kind.startsWith('proactive_');
+ if (s.kind === 'turn_start' || s.kind === 'proactive_start') {
+ if (live.closed.includes(s.speech) || live.speech === s.speech) return;
+ if (live.speech) this.close(live, live.speech);
+ live.speech = s.speech; live.pro = pro; live.message = s.messageId;
+ // A reach-out arrives whole (its stored text); a reply starts empty and grows.
+ this.show({ spoken: '', ...(pro ? { heard: '' } : {}), her: pro ? { speech: s.speech, text: s.text, state: 'open', messageId: s.messageId, proactive: true } : { speech: s.speech, text: '', state: 'open' }, ...honest(pro ? 'speaking' : 'thinking') }, gen);
+ return;
+ }
+ if (s.speech !== live.speech || pro !== live.pro || (pro && s.messageId !== live.message)) return;
+ const mine = cur?.speech === s.speech ? cur : null;
+ switch (s.kind) {
+ case 'assistant_interim':
+ // Replace, never append: each interim is the whole reply so far.
+ if (mine?.state === 'open' && mine.text !== s.text) this.show({ her: { ...mine, text: s.text } }, gen);
+ return;
+ case 'assistant_final':
+ if (mine?.state !== 'open') return;
+ // Her stored answer: your draft it answered has done its part (as before v2).
+ this.show({ heard: '', her: { ...mine, text: s.text, state: 'final', turnId: s.turnId }, ...honest('listening') }, gen); this.deps.onRecord();
+ return;
+ case 'error':
+ // φ: heard, not stored; her unfinished line goes at once. The cancel that follows closes the speech.
+ this.show({ heard: '', spoken: s.text, ...(mine ? { her: null } : {}), ...honest('listening') }, gen); this.deps.onRecord();
+ return;
+ case 'turn_end': case 'proactive_end':
+ this.close(live, s.speech);
+ // A reply closed without its final never became her reply; a reach-out read to the end stays until the
+ // record or the next turn takes its place.
+ this.show({ ...(mine ? { her: mine.state === 'final' || pro ? { ...mine, state: 'done' as const } : null } : {}), ...honest('listening') }, gen);
+ if (!pro) this.deps.onRecord();
+ return;
+ case 'turn_cancel': case 'proactive_cancel':
+ this.close(live, s.speech);
+ this.show({ ...(mine ? { her: null } : {}), ...honest('listening') }, gen);
+ if (!pro) this.deps.onRecord();
+ return;
+ }
+ }
+ private close(live: Live, speech: string) {
+ if (live.speech === speech) { live.speech = ''; live.pro = false; live.message = undefined; }
+ if (!live.closed.includes(speech)) { live.closed.push(speech); if (live.closed.length > CLOSED_KEPT) live.closed.shift(); }
+ }
+
// You sent text while she was answering by voice: the server cancels her voice turn; keep its last audio silent.
+ // Her line goes at once, whatever state it was in: your words come next on the stage.
hush() {
+ const live = this.live; if (!live) return;
+ if (live.speech) this.close(live, live.speech);
+ if (this.view.her) this.show({ her: null }, live.gen);
+ if (!live.room || (this.view.phase !== 'speaking' && this.view.phase !== 'thinking')) return;
- const live = this.live;
- if (!live?.room || (this.view.phase !== 'speaking' && this.view.phase !== 'thinking')) return;
live.hushed = true; live.room.hush(true);
this.show({ spoken: '' }, live.gen);
}
@@ -285,6 +373,21 @@
}
return next;
}
+// Her voice line as the stage shows it, in the place her text reply would take; null once it has nothing to add.
+// `since` is how many record rows there were when this speech was first seen. It gives way to the record: her reply
+// once its own row (same turn) is there, a reach-out once its stored row is the newest line; a finished line also as
+// soon as anything else came into the record after it, so it never stands in front of a newer turn.
+export function voiceLine(her: HerLine | null, messages: Message[], since: number, incoming: Message[] = []): ShownMessage | null {
+ if (!her || !her.text) return null;
+ const own = (m: Message) => her.proactive ? m.id === String(her.messageId) : !!her.turnId && m.turnId === her.turnId;
+ if (her.proactive ? messages.at(-1)?.id === String(her.messageId) : messages.some(m => m.role === 'companion' && own(m))) return null;
+ if (her.state !== 'open' && messages.slice(since).some(m => !own(m))) return null;
+ // A reach-out keeps its stored row's identity (id, turn), so the row taking over keeps the same lines on the stage.
+ const row = her.proactive ? [...messages, ...incoming].find(own) : undefined;
+ // Her reply still growing holds back its newest line, as a streamed text reply does.
+ return { id: row?.id ?? `voice-${her.speech}`, role: 'companion', text: her.text, lifeIds: [], turnId: row ? row.turnId : her.turnId, ...(her.proactive ? { proactive: true } : {}), ...(her.state === 'open' && !her.proactive ? { live: 'generating' as const } : {}) };
+}
+
// Newest rows down to `since` (exclusive), at most `pages` pages of 50. Voice turns are few per read; a longer gap is
// left to the next re-read rather than read without bound.
export async function recentRows(since: number, signal: AbortSignal, pages = 4): Promise<Message[]> {
diff --git a/web/tests/fake-livekit.js b/web/tests/fake-livekit.js
--- a/web/tests/fake-livekit.js
+++ b/web/tests/fake-livekit.js
@@ -26,7 +26,8 @@
return true;
};
lk.state = s => { const room = lk.rooms.at(-1), p = [...room.remoteParticipants.values()][0]; if (!p) return false; p.attributes['lk.agent.state'] = s; room.emit(RoomEvent.ParticipantAttributesChanged, { 'lk.agent.state': s }, p); return true; };
+ // Frames without an envelope get the worker's v2 one (next seq of this room); explicit frames are sent as given.
+ lk.say = obj => { const room = lk.rooms.at(-1), frame = 'v' in obj ? obj : { v: 2, seq: (room.seq || 0) + 1, ...obj }; if (Number.isSafeInteger(frame.seq)) room.seq = Math.max(room.seq || 0, frame.seq); room.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(frame)), undefined, 0, 'cg.subtitle'); return true; };
- lk.say = obj => { const room = lk.rooms.at(-1); room.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(obj)), undefined, 0, 'cg.subtitle'); return true; };
lk.drop = () => { const room = live(); room.state = 'gone'; room.emit(RoomEvent.Disconnected); return true; };
lk.block = () => { const room = live(); room.canPlaybackAudio = false; room.emit(RoomEvent.AudioPlaybackStatusChanged); return true; };
async function createLocalAudioTrack(constraints) {
diff --git a/web/tests/hel878_mock.mjs b/web/tests/hel878_mock.mjs
--- a/web/tests/hel878_mock.mjs
+++ b/web/tests/hel878_mock.mjs
@@ -4,7 +4,7 @@
// answered with tests/fake-livekit.js (a scripted stand-in, no WebRTC); the real vendored build stays reachable at
// /__real-sdk.js for the API-surface check only. Synthetic data only: no model, no LiveKit, no TTS, no network.
// Usage: node tests/hel878_mock.mjs <port>. Control GETs: /__voice?configured=&tts=, /__refuse/<code|0>,
+// /__voice-turn?stage=user|reply|cancel&text=, /__proactive?text=, /__cut, /__sessions, /__log, /__rows.
-// /__voice-turn?stage=user|reply|cancel&text=, /__cut, /__sessions, /__log, /__rows.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
@@ -41,6 +41,8 @@
if (stage === 'cancel') rows.forEach(r => { if (r.turn_id === `voice-${voiceTurn}`) r.status = 'cancelled'; });
return send(res, 200, { turn: voiceTurn });
}
+ // A reach-out the life runtime stored (HEL-898): the worker may read it aloud; the page sees it through the inbox.
+ if (p === '/__proactive') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '', turn_id: `pro-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); }
// A voice turn took the server's turn gate: the text stream in flight ends without a final; its row is cancelled.
if (p === '/__cut') { const s = textStream; textStream = null; if (s) { rows.forEach(r => { if (r.turn_id === s.turn_id && r.role === 'user') r.status = 'cancelled'; }); s.cut = true; s.res.end(); } return send(res, 200, { cut: !!s }); }
if (p === '/__real-sdk.js') return send(res, 200, await readFile(join(dist, 'vendor/livekit-client-2.22.4.umd.min.js')), 'text/javascript');
diff --git a/web/tests/hel878_shots.mjs b/web/tests/hel878_shots.mjs
--- a/web/tests/hel878_shots.mjs
+++ b/web/tests/hel878_shots.mjs
@@ -114,7 +114,7 @@
await frames('m2-voice-speaking', 900);
await shot('08-moon-desktop-voice-speaking');
await ctl('/__voice-turn?stage=reply&text=' + encodeURIComponent('是橘色的那只吗?它最近总在桥下晒太阳。'));
+await lk(`__lk.say({ kind: 'turn_start', role: 'assistant', text: '', speech_id: 'sp-878-1' })`); await lk(`__lk.say({ kind: 'assistant_final', role: 'assistant', text: '是橘色的那只吗?它最近总在桥下晒太阳。', speech_id: 'sp-878-1', turn_id: 'voice-1' })`); await lk(`__lk.say({ kind: 'turn_end', role: 'assistant', text: '', speech_id: 'sp-878-1', turn_id: 'voice-1' })`); await lk(`__lk.state('listening')`);
-await lk(`__lk.say({ kind: 'assistant_final', role: 'assistant', text: '是橘色的那只吗?它最近总在桥下晒太阳。' })`); await lk(`__lk.state('listening')`);
await until(async () => (await said()).includes('晒太阳'), 6000);
check('her spoken answer reaches the stage once it is in the record', (await said()).includes('晒太阳'), await said());
await shot('09-moon-desktop-voice-reply-on-stage');
@@ -125,7 +125,7 @@
await click('.log-panel .btn-round'); await sleep(400);
// ── 6. φ: her failure line is heard and shown, never recorded. ──
+await lk(`__lk.say({ kind: 'turn_start', role: 'assistant', text: '', speech_id: 'sp-878-phi' })`); await lk(`__lk.say({ kind: 'error', role: 'assistant', text: '我刚刚没接上话,你再说一遍好吗?', speech_id: 'sp-878-phi' })`); await lk(`__lk.say({ kind: 'turn_cancel', role: 'assistant', text: '', speech_id: 'sp-878-phi', reason: 'error_phi' })`); await sleep(400);
-await lk(`__lk.say({ kind: 'error', role: 'assistant', text: '我刚刚没接上话,你再说一遍好吗?' })`); await sleep(400);
check('φ line shown as heard, marked not recorded; not in the record', (await row()).includes('没接上话') && (await row()).includes('未记入聊天') && !(await ctl('/__rows')).some(r => r.text.includes('没接上话')));
await shot('11-moon-desktop-voice-phi');
diff --git a/web/tests/hel898_shots.mjs b/web/tests/hel898_shots.mjs
new file mode 100644
--- /dev/null
+++ b/web/tests/hel898_shots.mjs
@@ -0,0 +1,172 @@
+// HEL-898 browser check: her words while she speaks (cg.subtitle contract v2) on the approved conversation. Drives
+// headless Edge over CDP against tests/hel878_mock.mjs, with tests/fake-livekit.js in place of the LiveKit client and
+// Chromium's fake audio device in place of a microphone: no model, no TTS, no real call. Frames are the worker's v2
+// envelopes (docs/voice-subtitle-contract-v2.md). Usage: node tests/hel898_shots.mjs <edge.exe> <outDir> [port].
+import { spawn } from 'node:child_process';
+import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const [edge, out, portArg] = process.argv.slice(2);
+const port = Number(portArg || 8878), base = `http://127.0.0.1:${port}`, cdpPort = 9498;
+mkdirSync(out, { recursive: true });
+const profile = join(tmpdir(), `hel898-edge-${Date.now()}`);
+const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let targets;
+for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
+const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
+await new Promise(r => ws.addEventListener('open', r, { once: true }));
+let id = 0; const waiting = new Map();
+ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
+const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
+const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
+const results = [];
+const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
+const ctl = async path => (await fetch(`${base}${path}`)).json();
+const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
+const frames = async (name, ms) => { const list = []; const end = Date.now() + ms; while (Date.now() < end) { const { data } = await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 }); list.push(data); } list.forEach((d, i) => writeFileSync(join(out, `${name}-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64'))); return list.length; };
+const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.click(); return true; })()`);
+const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].map(e => e.innerText).join('\\n')`);
+const exists = sel => js(`!!document.querySelector(${JSON.stringify(sel)})`);
+// The stage's own text (every line now on it) and the line read out to assistive tech.
+const stage = () => js(`document.querySelector('.conversation .stage')?.innerText || ''`);
+const said = () => js(`document.querySelector('.conversation .sr')?.textContent || ''`);
+const row = () => js(`document.querySelector('.voice-row.real')?.innerText || ''`);
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
+const lk = expr => js(`window.__lk ? (${expr}) : null`);
+const say = frame => lk(`__lk.say(${JSON.stringify(frame)})`);
+const her = (kind, speech, extra = {}) => say({ kind, role: 'assistant', text: '', speech_id: speech, ...extra });
+const type = t => js(`(() => { const el = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, ${JSON.stringify(t)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
+const until = async (fn, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
+const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
+async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
+async function boot(theme) {
+ await cdp('Page.navigate', { url: `${base}/` }); await sleep(500);
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`);
+ await cdp('Page.navigate', { url: `${base}/` }); await sleep(1600);
+}
+async function startVoice() {
+ await click('.voice-button'); await until(() => exists('.modal.open'));
+ await click('.modal.open .btn-primary', '开始语音');
+ await until(async () => (await phase()) === 'joining');
+ await until(async () => (await lk('__lk.rooms.at(-1)?.state')) === 'connected');
+ await lk('__lk.join()'); await sleep(200);
+}
+const calls = async () => ctl('/__log');
+// One voice turn of yours as the worker leaves it: the stored row, then user_final; waits for it on the stage.
+const youSay = async words => {
+ const { turn } = await ctl('/__voice-turn?stage=user&text=' + encodeURIComponent(words));
+ await say({ kind: 'user_final', role: 'user', text: words }); await lk(`__lk.state('thinking')`);
+ await until(async () => (await stage()).includes(words.slice(0, 4)), 6000);
+ return turn;
+};
+const rows = async () => ctl('/__rows');
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable');
+
+const REPLY = '是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。';
+const STEPS = ['是橘色的那只吗?它最近总在桥下晒太阳,', '是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。', `${REPLY}`];
+
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
+ const tag = `${theme}-${v}`;
+ await view(v); await boot(theme); await click('.nav-item', '交谈'); await sleep(1400);
+ await startVoice(); await calls();
+
+ // ── 1. A voice turn: your draft and final, then her words growing on the stage before anything is stored. ──
+ await say({ kind: 'interim', role: 'user', text: '今天下班的路上' }); await sleep(200);
+ const prior = (await rows()).filter(r => r.text === REPLY).length;
+ const turn = await youSay('今天下班的路上看到一只猫');
+ await her('turn_start', `a-${tag}`); await lk(`__lk.state('speaking')`);
+ const shots = [];
+ for (const [i, step] of STEPS.entries()) {
+ await her('assistant_interim', `a-${tag}`, { text: step });
+ shots.push(frames(`${tag}-m1-growing-${i}`, 450));
+ await shots.at(-1);
+ }
+ await until(async () => (await stage()).includes('晒太阳'), 8000);
+ check(`${tag}: her reply shows on the stage while she speaks, before it is stored`, (await stage()).includes('晒太阳') && (await rows()).filter(r => r.text === REPLY).length === prior, await stage());
+ check(`${tag}: your draft stays in the voice line, her words never there`, !(await row()).includes('晒太阳'), await row());
+ await shot(`${tag}-01-her-words-growing`);
+
+ // ── 2. Pause and look back while she is still speaking: reading holds, nothing jumps. ──
+ await click('.reader-bar .flow'); await sleep(300);
+ check(`${tag}: pause while she speaks`, await exists('.conversation.reading-paused'));
+ await click('.reader-bar .icon[aria-label="上一句"]'); await sleep(400);
+ await shot(`${tag}-02-paused-looking-back`);
+ await ctl('/__voice-turn?stage=reply&text=' + encodeURIComponent(REPLY));
+ await her('assistant_final', `a-${tag}`, { text: REPLY, turn_id: `voice-${turn}` }); await her('turn_end', `a-${tag}`, { turn_id: `voice-${turn}` }); await lk(`__lk.state('listening')`);
+ await sleep(900);
+ await click('.reader-bar .latest'); await sleep(2500);
+ await click('.log-btn'); await sleep(700);
+ const rec = await text('.log-panel .scroll');
+ check(`${tag}: once stored, the record holds her reply exactly once (re-read, not written by the page)`, rec.split(REPLY).length - 1 === prior + 1, rec.slice(-160).replace(/\n/g, ' '));
+ await shot(`${tag}-03-record-after-final`);
+ await click('.log-panel .btn-round'); await sleep(400);
+ await click('.read-all'); await sleep(500); await shot(`${tag}-04-full-text`); await click('.record.full .btn-round'); await sleep(300);
+
+ // ── 3. Interrupted: no final; her line goes at once; late frames of the cut turn change nothing. ──
+ // Paused reading, then you speak: like sending, your new turn and her answer come in by themselves.
+ await click('.reader-bar .flow'); await sleep(200);
+ check(`${tag}: paused before speaking`, await exists('.conversation.reading-paused'));
+ await youSay('你今天去哪儿了');
+ check(`${tag}: speaking resumes reading for the new turn`, !(await exists('.conversation.reading-paused')));
+ await her('turn_start', `b-${tag}`); await lk(`__lk.state('speaking')`);
+ await her('assistant_interim', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,' });
+ check(`${tag}: second reply growing`, await until(async () => (await stage()).includes('白鹭'), 8000), await stage());
+ await shot(`${tag}-05-before-cut`);
+ await say({ kind: 'interim', role: 'user', text: '等一下' });
+ await her('turn_cancel', `b-${tag}`, { reason: 'interrupted' }); await lk(`__lk.state('listening')`); await ctl('/__voice-turn?stage=cancel');
+ await sleep(150);
+ check(`${tag}: interrupted: her unfinished line is gone at once`, !(await stage()).includes('白鹭'), await stage());
+ await her('assistant_interim', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,真好看。' });
+ await her('assistant_final', `b-${tag}`, { text: '对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。它们飞得很低,真好看。', turn_id: 'late' });
+ await sleep(400);
+ check(`${tag}: late frames of the cut turn stay out`, !(await stage()).includes('白鹭'));
+ await shot(`${tag}-06-after-cut`);
+
+ // ── 4. Text takes over while she speaks: her line goes, your typed words come next. ──
+ await youSay('我们聊点别的吧');
+ await her('turn_start', `c-${tag}`); await lk(`__lk.state('speaking')`);
+ await her('assistant_interim', `c-${tag}`, { text: '那我们换个话题吧,你今天晚饭吃了什么,好不好吃?我今天' });
+ check(`${tag}: her line before you type`, await until(async () => (await stage()).includes('换个话题'), 8000), await stage());
+ await type('先等等,我打字说'); await js(`document.querySelector('.send').click(), true`); await sleep(400);
+ check(`${tag}: text over voice: her line is gone, your words are on the stage`, !(await stage()).includes('换个话题') && (await stage()).includes('先等等'), await stage());
+ await until(async () => !(await exists('.reader-bar .stop')), 9000); await lk(`__lk.state('listening')`); await sleep(400);
+
+ // ── 5. A stored reach-out read aloud: its own text in her place, no request, no read mark, interruptible. ──
+ const { id: pro } = await ctl('/__proactive?text=' + encodeURIComponent('刚才路过花店,想起你说过喜欢白色的洋桔梗。'));
+ await calls();
+ await say({ kind: 'proactive_start', role: 'assistant', text: '刚才路过花店,想起你说过喜欢白色的洋桔梗。', speech_id: `p-${tag}`, message_id: pro }); await lk(`__lk.state('speaking')`);
+ await sleep(1500);
+ check(`${tag}: reach-out shown in her place, word for word`, (await stage()).includes('洋桔梗'), await stage());
+ await shot(`${tag}-07-reach-out`);
+ await say({ kind: 'interim', role: 'user', text: '是吗' });
+ await say({ kind: 'proactive_cancel', role: 'assistant', text: '', speech_id: `p-${tag}`, message_id: pro, reason: 'interrupted' }); await lk(`__lk.state('listening')`);
+ await sleep(200);
+ const after = await calls();
+ check(`${tag}: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)`, !after.some(l => !l.startsWith('GET ')) , after.join(' | '));
+
+ // ── 6. Hanging up mid-sentence clears her line with the call. ──
+ await youSay('我先去睡了');
+ await her('turn_start', `d-${tag}`); await her('assistant_interim', `d-${tag}`, { text: '晚安,明天见。今天说了好多好多话,你要早点睡哦。我会在窗边等你,' });
+ check(`${tag}: her line before hanging up`, await until(async () => (await stage()).includes('早点睡'), 8000), await stage());
+ await click('.voice-row.real .voice-end'); await sleep(400);
+ check(`${tag}: hung up: her line gone, voice off`, !(await stage()).includes('早点睡') && (await phase()) === 'off', await stage());
+ await shot(`${tag}-08-ended`);
+}
+
+// ── 7. Reduced motion: her words still arrive, without animated entrances. ──
+await view('desktop'); await cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
+await boot('moon'); await click('.nav-item', '交谈'); await sleep(1400); await startVoice();
+await youSay('慢一点说给我听');
+await her('turn_start', 'rm'); await lk(`__lk.state('speaking')`);
+for (const step of STEPS) { await her('assistant_interim', 'rm', { text: step }); await sleep(300); }
+check('reduced motion: her words arrive', await until(async () => (await stage()).includes('晒太阳'), 8000), await stage());
+await frames('reduced-motion-growing', 600);
+await click('.voice-row.real .voice-end'); await sleep(300);
+
+ws.close(); browser.kill();
+try { rmSync(profile, { recursive: true, force: true }); } catch { /* Edge may still hold the profile. */ }
+const failed = results.filter(r => !r).length;
+console.log(`${results.length - failed}/${results.length} passed`);
+process.exit(failed ? 1 : 0);
diff --git a/web/tests/voice.test.ts b/web/tests/voice.test.ts
--- a/web/tests/voice.test.ts
+++ b/web/tests/voice.test.ts
@@ -1,8 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mergeTurns, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type HerLine, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
+import { groupTurns, pagesOf, type ShownMessage } from '../src/chat/reading';
-import { mergeTurns, recentRows, VoiceController, voiceStatus, WORDS, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
import type { ServerMessage } from '../src/adapters/real';
import type { Message } from '../src/domain/model';
+import { readFileSync } from 'node:fs';
+// The backend's recorded cg.subtitle frames (contract v2, docs/rework/hel879/subtitle-samples, generated offline).
+type Frame = Record<string, unknown> & { seq: number };
+const sample = (name: string): Frame[] => readFileSync(new URL(`../../docs/rework/hel879/subtitle-samples/${name}.jsonl`, import.meta.url), 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
+
// Fake transport and media objects: no LiveKit, no microphone, no network. The fake server follows the HEL-877 voice
// router (src/cybergril/voice/router.py): GET /api/voice/status, POST /api/voice/session -> 201 grant, DELETE
// /api/voice/session/{id} -> 200 or 404.
@@ -176,7 +182,7 @@
const r = await connected(), old = r.transport.rooms[0];
await r.c.end(); r.record.mockClear();
const seen = r.views.length;
+ old.on.agentState('speaking'); old.on.subtitle({ v: 2, seq: 1, kind: 'user_final', role: 'user', text: '旧的话' }); old.on.subtitle({ v: 2, seq: 2, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'old' }); old.on.agentJoined(); old.on.dropped(); old.on.audioBlocked(true);
- old.on.agentState('speaking'); old.on.subtitle({ kind: 'user_final', role: 'user', text: '旧的话' }); old.on.agentJoined(); old.on.dropped(); old.on.audioBlocked(true);
r.transport.mics[0].ended?.();
await flush();
expect(r.views.length).toBe(seen);
@@ -243,10 +249,12 @@
});
it('never confirms a receipt or posts chat itself: the worker owns both', async () => {
const r = await connected(), room = r.transport.rooms[0];
+ for (const line of sample('normal-turn')) room.on.subtitle(line);
+ for (const line of sample('proactive')) room.on.subtitle({ ...line, seq: line.seq + 100 });
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '今天好冷' });
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '那多穿一件。' });
await r.c.end();
expect(r.server.calls.some(c => c.url.startsWith('/api/chat'))).toBe(false);
+ // No receipt, no read mark, no message write: the page only talks to the voice routes.
+ expect(r.server.calls.every(c => c.url.startsWith('/api/voice/'))).toBe(true);
});
});
@@ -338,24 +346,32 @@
});
});
+// A v2 frame as the worker's SubtitleBus emits it (src/cybergril/voice/seam.py).
+const frame = (seq: number, kind: string, extra: Record<string, unknown> = {}) => ({ v: 2, seq, kind, role: kind === 'interim' || kind === 'user_final' ? 'user' : 'assistant', text: '', ...extra });
describe('subtitles and the two channels', () => {
it('interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded', async () => {
const r = await connected(), room = r.transport.rooms[0];
+ room.on.subtitle(frame(1, 'interim', { text: '今天' }));
- room.on.subtitle({ kind: 'interim', role: 'user', text: '今天' });
expect(r.view().heard).toBe('今天'); expect(r.record).not.toHaveBeenCalled();
+ room.on.subtitle(frame(2, 'user_final', { text: '今天好冷' }));
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '今天好冷' });
expect(r.spoke).toHaveBeenCalledTimes(1); expect(r.record).toHaveBeenCalledTimes(1);
+ room.on.subtitle(frame(3, 'turn_start', { speech_id: 'a' }));
+ room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'a', text: '我刚' }));
+ room.on.subtitle(frame(5, 'error', { speech_id: 'a', text: '我刚才走神了,你再说一遍?' }));
+ expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her: null });
+ room.on.subtitle(frame(6, 'turn_cancel', { speech_id: 'a', reason: 'error_phi' }));
+ expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her: null });
+ room.on.subtitle(frame(7, 'assistant_interim', { speech_id: 'a', text: '我刚才走神了' })); // after its cancel
+ expect(r.view().her).toBeNull();
- room.on.subtitle({ kind: 'error', role: 'assistant', text: '我刚才走神了,你再说一遍?' });
- expect(r.view().spoken).toBe('我刚才走神了,你再说一遍?');
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '那多穿一件。' });
- expect(r.view()).toMatchObject({ heard: '', spoken: '' });
room.on.subtitle({ kind: 'bogus', text: 1 }); room.on.subtitle(null);
expect(r.record).toHaveBeenCalledTimes(3);
+ expect(r.server.calls.some(c => c.url.startsWith('/api/chat') || c.url.startsWith('/api/messages'))).toBe(false);
});
it('without the agent state attribute, subtitles still move the state honestly', async () => {
const r = await connected(), room = r.transport.rooms[0];
+ room.on.subtitle(frame(1, 'user_final', { text: '在吗' })); expect(r.view().phase).toBe('thinking');
+ room.on.subtitle(frame(2, 'turn_start', { speech_id: 'a' })); expect(r.view().phase).toBe('thinking');
+ room.on.subtitle(frame(3, 'assistant_final', { speech_id: 'a', turn_id: 't', text: '在。' })); expect(r.view().phase).toBe('listening');
- room.on.subtitle({ kind: 'user_final', role: 'user', text: '在吗' }); expect(r.view().phase).toBe('thinking');
- room.on.subtitle({ kind: 'assistant_final', role: 'assistant', text: '在。' }); expect(r.view().phase).toBe('listening');
});
it('typing over her spoken answer silences what is left of it until she is listening again', async () => {
const r = await connected(), room = r.transport.rooms[0];
@@ -367,6 +383,187 @@
});
});
+// HEL-898: her words while she speaks (contract v2). The page shows them; it never stores, confirms or marks them.
+describe('her voice line (cg.subtitle v2)', () => {
+ const play = (room: Room, frames: Frame[], after?: (f: Frame) => void) => { for (const f of frames) { room.on.subtitle(f); after?.(f); } };
+ it('rejects anything that is not exactly a v2 frame', async () => {
+ const r = await connected(), room = r.transport.rooms[0], seen = r.views.length;
+ const bad = [
+ { kind: 'user_final', role: 'user', text: 'v1 没有信封' },
+ { ...frame(1, 'user_final', { text: '版本不对' }), v: 3 },
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: 0 },
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: 1.5 },
+ { ...frame(1, 'user_final', { text: '序号不对' }), seq: '1' },
+ frame(1, 'turn_start'), // no speech_id
+ { ...frame(1, 'turn_start', { speech_id: 'a' }), role: 'user' }, // her kind, your role
+ { ...frame(1, 'interim', { text: '草稿' }), role: 'assistant' }, // your kind, her role
+ frame(1, 'assistant_final', { speech_id: 'a', text: '无归属' }), // final without turn_id
+ frame(1, 'proactive_start', { speech_id: 'p', text: '无行号' }), // reach-out without message_id
+ frame(1, 'proactive_start', { speech_id: 'p', text: '行号不对', message_id: '17' }),
+ frame(1, 'assistant_text', { speech_id: 'a', text: '未知种类' }),
+ [frame(1, 'user_final', { text: '数组' })],
+ ];
+ for (const b of bad) room.on.subtitle(b);
+ expect(r.views.length).toBe(seen); expect(r.record).not.toHaveBeenCalled(); expect(r.spoke).not.toHaveBeenCalled();
+ // A rejected frame does not move the sequence: the first good one still lands.
+ room.on.subtitle(frame(1, 'interim', { text: '好的' })); expect(r.view().heard).toBe('好的');
+ });
+ it('normal turn: your draft and final, her growing reply replaced in place, then her stored final', async () => {
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('normal-turn');
+ const steps: (VoiceView['her'] | string)[] = [];
+ play(room, frames, () => steps.push(r.view().her ? { ...r.view().her! } : r.view().heard));
+ expect(steps).toEqual([
+ '你在听的', '你在听吗?',
+ { speech: 'speech_normal', text: '', state: 'open' },
+ { speech: 'speech_normal', text: '在听呢,', state: 'open' },
+ { speech: 'speech_normal', text: '在听呢,在听呢,你刚才说到一半的那件事我还在想。', state: 'open' },
+ { speech: 'speech_normal', text: '在听呢,你刚才说到一半的那件事我还在想。', state: 'final', turnId: '0e2f-demo-turn' },
+ { speech: 'speech_normal', text: '在听呢,你刚才说到一半的那件事我还在想。', state: 'done', turnId: '0e2f-demo-turn' },
+ ]);
+ expect(r.view().heard).toBe(''); // her stored answer retires the draft it answered
+ // Her words never land in your draft slot, and yours never in hers.
+ expect(r.views.every(v => !v.her || !v.heard.includes('在听呢'))).toBe(true);
+ expect(r.spoke).toHaveBeenCalledTimes(1);
+ expect(r.record).toHaveBeenCalledTimes(3); // your final, her final, her turn's end: re-read, never written
+ // The whole recording replayed (same seq) changes nothing.
+ const seen = r.views.length; play(room, frames);
+ expect(r.views.length).toBe(seen);
+ // Your next turn: her finished line does not stay in front of it.
+ room.on.subtitle(frame(8, 'interim', { text: '那' })); expect(r.view().her?.state).toBe('done');
+ room.on.subtitle(frame(9, 'user_final', { text: '那你想到了吗' })); expect(r.view().her).toBeNull();
+ });
+ it('interrupted: no final, her line goes at once, its late frames never reach the new turn', async () => {
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('interrupt');
+ play(room, frames.slice(0, 2));
+ expect(r.view().her).toMatchObject({ speech: 'speech_cut', text: '我正想跟你说,今天下午', state: 'open' });
+ play(room, frames.slice(2, 3));
+ expect(r.view().her).toBeNull();
+ // Late increments of the cut turn, even with a higher seq, and a replay of its start.
+ room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'speech_cut', text: '我正想跟你说,今天下午去了' }));
+ room.on.subtitle(frame(5, 'assistant_final', { speech_id: 'speech_cut', turn_id: 'old', text: '我正想跟你说,今天下午去了河边。' }));
+ room.on.subtitle(frame(6, 'turn_start', { speech_id: 'speech_cut' }));
+ expect(r.view().her).toBeNull();
+ play(room, frames.slice(3).map(f => ({ ...f, seq: f.seq + 10 })));
+ expect(r.view()).toMatchObject({ heard: '', her: { speech: 'speech_next', text: '好,你先说。', state: 'done', turnId: '0e2f-demo-next' } });
+ });
+ it('frames that overtook each other: a lower seq is stale; an older speech never touches or closes the new one', async () => {
+ const r = await connected(), room = r.transport.rooms[0];
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
+ room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a', text: '一二三' }));
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '一二' })); // overtaken: dropped
+ expect(r.view().her?.text).toBe('一二三');
+ // Two handles: b starts while a is still open (queued then generated). a is closed here by b.
+ room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
+ room.on.subtitle(frame(5, 'assistant_interim', { speech_id: 'a', text: '一二三四' }));
+ room.on.subtitle(frame(6, 'turn_end', { speech_id: 'a', turn_id: 'ta' }));
+ room.on.subtitle(frame(7, 'turn_cancel', { speech_id: 'a', reason: 'interrupted' }));
+ room.on.subtitle(frame(8, 'proactive_cancel', { speech_id: 'b', message_id: 3, reason: 'interrupted' })); // wrong family
+ expect(r.view().her).toEqual({ speech: 'b', text: '', state: 'open' });
+ room.on.subtitle(frame(9, 'assistant_interim', { speech_id: 'b', text: '好' }));
+ room.on.subtitle(frame(10, 'turn_end', { speech_id: 'b', turn_id: 'tb' })); // closed without final
+ expect(r.view().her).toBeNull();
+ room.on.subtitle(frame(11, 'assistant_final', { speech_id: 'b', turn_id: 'tb', text: '好。' }));
+ expect(r.view().her).toBeNull();
+ });
+ it('reach-out read aloud: shown whole with its stored message id, interruptible, nothing marked or stored', async () => {
+ const r = await connected(), room = r.transport.rooms[0], frames = sample('proactive');
+ play(room, frames.slice(0, 1));
+ expect(r.view()).toMatchObject({ phase: 'speaking', her: { speech: 'speech_pro1', text: '我看到一句很适合现在的话,先念给你听。', state: 'open', messageId: 17, proactive: true } });
+ room.on.subtitle(frame(2, 'proactive_end', { speech_id: 'speech_pro1', message_id: 99 })); // another row's end
+ expect(r.view().her?.state).toBe('open');
+ play(room, frames.slice(1, 2).map(f => ({ ...f, seq: 3 })));
+ expect(r.view().her).toMatchObject({ messageId: 17, state: 'done' });
+ play(room, frames.slice(2).map(f => ({ ...f, seq: f.seq + 10 })));
+ expect(r.view().her).toBeNull();
+ // Reaching out is not a turn of yours: no "you spoke", no record write, no request at all.
+ expect(r.spoke).not.toHaveBeenCalled(); expect(r.record).not.toHaveBeenCalled();
+ expect(r.server.calls.filter(c => !c.url.startsWith('/api/voice/'))).toEqual([]);
+ });
+ it('a reach-out cut by your voice: the cancel clears it, your words follow', async () => {
+ const r = await connected(), room = r.transport.rooms[0];
+ room.on.subtitle(frame(1, 'proactive_start', { speech_id: 'p', message_id: 18, text: '还有一件事想告诉你——' }));
+ room.on.subtitle(frame(2, 'interim', { text: '等等' }));
+ expect(r.view()).toMatchObject({ heard: '等等', her: { speech: 'p', state: 'open' } });
+ room.on.subtitle(frame(3, 'proactive_cancel', { speech_id: 'p', message_id: 18, reason: 'interrupted' }));
+ expect(r.view()).toMatchObject({ heard: '等等', her: null });
+ });
+ it('text taking over, hanging up and starting again each clear her line at once', async () => {
+ const r = await connected(), room = r.transport.rooms[0];
+ room.on.agentState('speaking');
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '那我们' }));
+ r.c.hush();
+ expect(r.view().her).toBeNull(); expect(room.hushed).toEqual([true]);
+ room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a', text: '那我们明天' }));
+ expect(r.view().her).toBeNull();
+ // A finished line is cleared by text too: your typed line comes next on the stage.
+ room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
+ room.on.subtitle(frame(5, 'assistant_interim', { speech_id: 'b', text: '好。' }));
+ room.on.subtitle(frame(6, 'assistant_final', { speech_id: 'b', turn_id: 'tb', text: '好。' }));
+ room.on.subtitle(frame(7, 'turn_end', { speech_id: 'b', turn_id: 'tb' }));
+ expect(r.view().her?.state).toBe('done');
+ r.c.hush(); expect(r.view().her).toBeNull();
+ // Hanging up mid-sentence.
+ room.on.subtitle(frame(8, 'turn_start', { speech_id: 'c' }));
+ room.on.subtitle(frame(9, 'assistant_interim', { speech_id: 'c', text: '嗯' }));
+ await r.c.end(); expect(r.view().her).toBeNull();
+ room.on.subtitle(frame(10, 'assistant_interim', { speech_id: 'c', text: '嗯,晚安' }));
+ expect(r.view().her).toBeNull();
+ // A new call is a new worker job: its sequence starts again; the old room's frames stay dead.
+ await r.c.start(); const next = r.transport.rooms[1]; next.on.agentJoined();
+ next.on.subtitle(frame(1, 'turn_start', { speech_id: 'n' }));
+ next.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'n', text: '又见面了' }));
+ room.on.subtitle(frame(11, 'turn_start', { speech_id: 'z' }));
+ room.on.subtitle(frame(12, 'assistant_interim', { speech_id: 'n', text: '旧房间' }));
+ expect(r.view().her).toEqual({ speech: 'n', text: '又见面了', state: 'open' });
+ await r.c.end();
+ });
+ it('the room dropping mid-sentence clears her line with the call', async () => {
+ const r = await connected(), room = r.transport.rooms[0];
+ room.on.subtitle(frame(1, 'turn_start', { speech_id: 'a' }));
+ room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a', text: '我' }));
+ room.on.dropped(); await flush();
+ expect(r.view()).toMatchObject({ phase: 'off', her: null, notice: WORDS.dropped });
+ });
+});
+
+describe('her voice line on the stage', () => {
+ const M = (id: number, role: Message['role'], text: string, turnId?: string, extra: Partial<Message> = {}): Message => ({ id: String(id), role, text, lifeIds: [], turnId, status: 'sent', ...extra });
+ const metrics = { width: 900, font: 23, compact: false };
+ const stage = (list: ShownMessage[]) => pagesOf(groupTurns(list).at(-1), metrics).map(p => `${p.key}=${p.text}`);
+ const you = M(4, 'user', '你在听吗?', 'T');
+ it('grows in her slot after your voice turn; the stored row takes over under the same line keys', () => {
+ const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直还在心里想着。后来呢?', state: 'open' };
+ const growing = voiceLine(open, [you], 1)!;
+ expect(growing).toMatchObject({ role: 'companion', live: 'generating' });
+ // The newest line of a reply still growing is held back, as in a streamed text reply.
+ expect(stage([you, growing])).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。']);
+ const done: HerLine = { ...open, state: 'done', turnId: 'T' };
+ const shown = stage([you, voiceLine(done, [you], 1)!]);
+ expect(shown).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。', 'T:1:1=后来呢?']);
+ const stored = [you, M(5, 'companion', done.text, 'T')];
+ expect(voiceLine(done, stored, 1)).toBeNull();
+ expect(stage(stored)).toEqual(shown);
+ });
+ it('a finished line never stands in front of a newer turn; an open one waits for its own end', () => {
+ const done: HerLine = { speech: 's', text: '好。', state: 'done', turnId: 'T' };
+ expect(voiceLine(done, [you, M(6, 'user', '还有', 'U')], 1)).toBeNull();
+ expect(voiceLine({ ...done, state: 'open' }, [you, M(6, 'user', '还有', 'U')], 1)).not.toBeNull();
+ expect(voiceLine({ ...done, text: '' }, [you], 1)).toBeNull();
+ });
+ it('a reach-out read aloud keeps its stored row identity and gives way when that row is the newest line', () => {
+ const line: HerLine = { speech: 'p', text: '我看到一句很适合现在的话,先念给你听。', state: 'open', messageId: 17, proactive: true };
+ const row = M(17, 'companion', line.text, 'R', { proactive: true });
+ const before = [you, M(5, 'companion', '在。', 'T')];
+ const waiting = voiceLine(line, before, 2, [row])!;
+ expect(waiting).toMatchObject({ id: '17', turnId: 'R', proactive: true }); expect(waiting.live).toBeUndefined();
+ expect(stage([...before, waiting])).toEqual(stage([...before, row]));
+ expect(voiceLine({ ...line, state: 'done' }, [...before, row], 2)).toBeNull();
+ // Not on the page at all: shown under its own key; nothing is created for the record.
+ expect(voiceLine(line, before, 2)).toMatchObject({ id: 'voice-p', proactive: true });
+ });
+});
+
describe('record re-read', () => {
const msg = (id: number, role: Message['role'], turnId: string, extra: Partial<Message> = {}): Message => ({ id: String(id), role, text: `${role}${id}`, lifeIds: [], turnId, status: 'sent', ...extra });
it('adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream', () => {
+199
View File
@@ -0,0 +1,199 @@
{
"base_commit": "3228b1e0d7f822b7030f945e285eac74126771aa",
"changed": {
"docs/requirements/handoff-HEL-898.json": {
"before": null,
"after": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d"
},
"web/src/chat/ChatPanel.tsx": {
"before": "419c2f4243d7a147ea8ebcf377d74c97dc558049a3bf76dee39bba0746d2c9e6",
"after": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669"
},
"web/src/voice/session.ts": {
"before": "b13ba87bf3cc860da6d89b16bd0d23284f83ecb338487a03ac6f44aa82cea2c1",
"after": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14"
},
"web/tests/fake-livekit.js": {
"before": "d16e794308073e967566f3ebda16e72d0f5eb0132b657338baa6a29115b02508",
"after": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc"
},
"web/tests/hel878_mock.mjs": {
"before": "1b1608d5e0858842e9a0cf51faaf9baece5e52b546757464b046df0e66a4b815",
"after": "f87e61ffce879ec7e7c9287321e9d73a6b276b71633e68d84716205fd36380f0"
},
"web/tests/hel878_shots.mjs": {
"before": "e5706c5a4459315ecdb991cbd8d3776db3efe035fe520930ffb09e726eb639c7",
"after": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7"
},
"web/tests/hel898_shots.mjs": {
"before": null,
"after": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393"
},
"web/tests/voice.test.ts": {
"before": "4c120b9449ce3af0b66b3b8a18668d5012d286d6c18c0dee1347a880f7780471",
"after": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73"
}
},
"files": {
"AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7",
"INPUT-MANIFEST.json": "099bf7575295fc6ecf376d3d805463f761f65e0452bd7cc73f87f1f3f61fa983",
"docs/requirements/README.md": "9b1fe8e94c74950ed671c381ebd0b9e254673b4f4cc226978153a8cd16549e73",
"docs/requirements/baseline.json": "d36bd3d4e2187925645790637468ee116d5701a6182ef1fc78db77a994ce1bdf",
"docs/requirements/changes.json": "8b815a75ae6978e8d62929b8708a235acd90f7d2be1037e2fb4adaadd82e9dd1",
"docs/requirements/changes.md": "c2a50b361bed40238c5d64e78485c08b4055d40eb3d7427c9100371c6999410c",
"docs/requirements/coverage.json": "0f631bf848c4331b3281086a2265c29d8d381dda3ca93164a05ca0d71bd99254",
"docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658",
"docs/requirements/current.md": "3ecae2bf182e19d990cf5030b1fe585adb514fd1415260e3da9e766a6fc8fafa",
"docs/requirements/discussion-takeaways-20261025.md": "e2ee666e5193b237a739a8d022a82bbb868cd144f8f4e1fe34f0e3981f94cf7d",
"docs/requirements/handoff-HEL-697.json": "2ddfca357e170e864d482ca469da0129c1cf26ef6b437ee24b8a3d34947b4510",
"docs/requirements/handoff-HEL-811.json": "07a596b9328224dd621c9d1d4b428f2d76ea5dc81c173d9d7a55c0b70ee8d8be",
"docs/requirements/handoff-HEL-816.json": "4577524f25db9b301a3bc68fb970d31e4320cae11e4354d23881bb134223f642",
"docs/requirements/handoff-HEL-821.json": "7f3be159f017a9600708c7d0ff2809c67bd2811498f0c232d040a9601b78862f",
"docs/requirements/handoff-HEL-822.json": "8753bd145ae4ed0d045d0d4f6160101e200b4f1071601228ef2c0b43db1a5303",
"docs/requirements/handoff-HEL-823.json": "5d6337ad15e82538b8b284b4aa7889579a1b3fc89029af221ffdf35d8f0a0fef",
"docs/requirements/handoff-HEL-824.json": "302fd163a35f296d64cecbd521d84b2de525d5bddf27bc1a9dbb9f7e969f6f1d",
"docs/requirements/handoff-HEL-825.json": "60e2c4f87ee1c2a814e934337a2a48801fcef7750cfcc9134578f6c77238d898",
"docs/requirements/handoff-HEL-827.json": "b1e625dac3163d09967c7c8d43de2f0927becefac535f58844ee6045fa2b1f6a",
"docs/requirements/handoff-HEL-828.json": "89f05d82366835a425d0fc5db9bce9a7b600dd0a782608f2b0a494f1d9ffe054",
"docs/requirements/handoff-HEL-830.json": "16fa3048878b08dafc2a8ca02e8d3db417530a1678c5cb31a20987ad5b684de6",
"docs/requirements/handoff-HEL-857.json": "382392235666d735655e241cb4bd3c8208e15b6fdc2427b86de30b9d472064a9",
"docs/requirements/handoff-HEL-858.json": "68c1975f89a926822321c4159d7fe60d6a8569cf382321290aa23fc05b47b538",
"docs/requirements/handoff-HEL-859.json": "388c620e99367a24e499efb01361a04c8dfdcafd36cf575f0a69d28eb79cd09f",
"docs/requirements/handoff-HEL-860.json": "1222921c61630b072f58ee1c643a4da02721d592199b34bd0806d8c5be4e58d7",
"docs/requirements/handoff-HEL-861.json": "d604581abc60ff553fe62ccfe4d98c8b8532da948f8f33ea2bd53333fa7961ff",
"docs/requirements/handoff-HEL-862.json": "d08fda4d2c4f2ef0ca9fd8c4e265cef85b5d520e40da6a322a2d099648b94acc",
"docs/requirements/handoff-HEL-863.json": "30d5322a2673d6d4d71bb07153d3d974a636af1f04f96f31e85a7c082b568c86",
"docs/requirements/handoff-HEL-864.json": "909213044fa7a75f8043c95bb071636ff376200a124e4d1300f8ff65bbec802e",
"docs/requirements/handoff-HEL-865.json": "1e361d2b7b2a66f56f88da021fda5a8432308c855975b0613cf35c9da525852d",
"docs/requirements/handoff-HEL-866.json": "539759490fb6f1795b5a325632e3720a7332c06640038fbbc958eca11df59324",
"docs/requirements/handoff-HEL-867.json": "3eb2fd69e5231e257967160195bcc3ee93975f27c339d3eefe4f295e0efd818e",
"docs/requirements/handoff-HEL-868.json": "dd609e7828b8ff20c13a6b938fce40d77f38aa30b978bd451b468b2169d5db0c",
"docs/requirements/handoff-HEL-869.json": "8954ea038f375934732adc2cfbe60e75eaa23969b4fb163bca29512d6520b7b4",
"docs/requirements/handoff-HEL-870.json": "71baea0fb2cec30b7ff40d3382fa749b0c46213ad34ac64d02456ac1c0527bdc",
"docs/requirements/handoff-HEL-874.json": "73cd0a585b86c2f3b03d7b4bdca65f933065efc0c55e82fdef550d1fed253a9f",
"docs/requirements/handoff-HEL-875.json": "984f362ba3cb42434c53194d479c1dfe4b03113261a74b46e7e6ff599e8ce5ae",
"docs/requirements/handoff-HEL-876.json": "df18ad67b29ab72b9ac9a2928d88b2ab25d4ae0975b3fc2c41802f97ddd5e8cd",
"docs/requirements/handoff-HEL-877.json": "238dc7f98fe7617051a7d121175f450cf9fbe8aaeb2153e86fc9ea5daa93fc1e",
"docs/requirements/handoff-HEL-878.json": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516",
"docs/requirements/handoff-HEL-879.json": "45740707a1d5711de2d98e78150c8fee4682829c8589069ec348993ffd288bbf",
"docs/requirements/handoff-HEL-880.json": "9506c3c5c1278b090d1e3eaf0f6191290af6114031e1f0e1ec514efeb74fb275",
"docs/requirements/handoff-HEL-882.json": "8901262dc1714b4f6264c2f375e7b12e977653ed5ad80a5efacf48754c1c1089",
"docs/requirements/handoff-HEL-885.json": "caf97d45ef8c23777942ed504d5ca6d3411ab5b86fda58c5ba308e31ffb80d32",
"docs/requirements/handoff-HEL-886.json": "5e965ee60fba01a4e7ebde534c76177aa509421aea31e33beb83d01387b7413b",
"docs/requirements/handoff-HEL-889.json": "bc28ee707a433d28f4efef9c33088781f98e1fe323f0c228f9444789d37363d3",
"docs/requirements/handoff-HEL-894.json": "932251b76b952705a97e5b4259c0bb5596d8b51347c4c91ee082146a4a3fa3fa",
"docs/requirements/handoff-HEL-895.json": "afb68a2db07dbf791a59e7878a646bd6cc30e26c8d94943551eb769024a79bbf",
"docs/requirements/handoff-HEL-896.json": "9b3f7533e4ead1ffbca2b502c3be35223648314e74aac907f29c38cb5b8b4955",
"docs/requirements/handoff-HEL-897.json": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9",
"docs/requirements/handoff-HEL-898.json": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d",
"docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819",
"docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989",
"docs/requirements/source-manifest.json": "9dc69b281f7a7b05b481d0604cde80756d9d499a60d9eb49e138d551f5e076f3",
"docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b",
"docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d",
"docs/requirements/sources/comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
"docs/requirements/sources/comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
"docs/requirements/sources/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"docs/requirements/sources/comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
"docs/requirements/sources/comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"docs/requirements/sources/design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"docs/requirements/sources/dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e",
"docs/requirements/sources/frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"docs/requirements/sources/governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"docs/requirements/sources/integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"docs/requirements/sources/mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
"docs/requirements/sources/other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
"docs/requirements/sources/product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"docs/requirements/sources/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"docs/requirements/sources/visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"docs/requirements/task-handoff-template.md": "8ee4d6d4c237e8a2c134ba751426975b0e24f03bfd6ac048b80642c69b59e445",
"docs/requirements/task-migration.json": "a5131adbdf14a79df01992d2cbbc4b7aea5b8b377c7115a80cedb04073f9bc7d",
"docs/requirements/verification.json": "7da6c2732cf28d46f8deaafc7ec571b8fc37bbd7efec0126ba339e51bbe12ace",
"docs/rework/hel879/subtitle-samples/interrupt.jsonl": "611f58303849588c6c80efb73d0cf254c5968e1ae4b9dab191756d00356335bc",
"docs/rework/hel879/subtitle-samples/normal-turn.jsonl": "5f503ff0efe3c677f787d287fc29b17616fa9b6294eb3bfe2f7be1b5742a7c31",
"docs/rework/hel879/subtitle-samples/proactive.jsonl": "3f7f058cc137b7128b0815f9fd28ec96b752a472cb551a303183685798da6024",
"docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293",
"docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4",
"src/cybergril/voice/__init__.py": "d550b2c9008681594edd34fd25ae05b31387332e3d4960f2323b6b21ec9cea9e",
"src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd",
"src/cybergril/voice/livekit_wire.py": "8aead2faa0b50677a7d8bccfea52fe17f55a8c53b090a2bb9bd868e450ce9d32",
"src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007",
"src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9",
"src/cybergril/voice/router.py": "6d2afadeed807b442bbfda9f894097fda7df691539afad9cdfb2f8620039107e",
"src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa",
"src/cybergril/voice/session.py": "f1c0b84a0b0aea0a7a1c66cf35113e4f052b835146f7f608334716c82638951a",
"src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2",
"src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba",
"src/cybergril/voice/worker.py": "95616f077cba302e2051b5b76ad1b24359d5e9da2554a82d9bcd7510f89f3f80",
"web/.gitignore": "f99ead27b34cbc6756d707e7e7ee7340eb9a71e02dfec2b40d5fb848dd3f4d4f",
"web/README.md": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726",
"web/VENDOR.md": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6",
"web/eslint.config.js": "c59a6a7e9f0dbeb5c8b90a66d1405606a5b0f909fafae47ca8e6737a9a386982",
"web/index.html": "ecf71d805fa2d20a6e244ac1f6fc729a55a44dc24c4a7ac75f966179a51cf61d",
"web/package-lock.json": "f950de58b5e86380705d979029894e62b39aba451cdb349922b97b4177677a27",
"web/package.json": "f58c320d8b4d35c586016dc002d5e83a26ceeb05d8729552c831c39d0791336e",
"web/public/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"web/public/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"web/public/assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
"web/public/demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
"web/public/vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626",
"web/src/adapters/demo.ts": "be4593f70337e87dd4b3c0cb499cd73cb4dda1ee921d579d8bafe0b6cea53083",
"web/src/adapters/inbox.ts": "8e145b914af1247f066ebb3e99574d43303a915173e6206950657e462461c85c",
"web/src/adapters/real.ts": "5aa12ec2367c8505f663e52885138adf33ceeef4cfe2ae02d7d6402ff42864ee",
"web/src/adapters/receipt.ts": "cdd7f41d79c843886167947f05bdbed159919e288c6196c6d94d5080b821876d",
"web/src/adapters/sha256.ts": "c65b58368858ed13bff0fcb0710dc72c9cc5b112fd443dc6820fc66e62e08f22",
"web/src/adapters/storage.ts": "e06afb3f8f801c2a4ff5a5ea109f27f59ad2d3187c915d48d50fba3f68cc08be",
"web/src/app/App.tsx": "6d266a532c832fd8d93f447bf516158c17f517363155a47df7642cbc62ae1b90",
"web/src/app/RealApp.tsx": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e",
"web/src/chat/ChatPanel.tsx": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669",
"web/src/chat/lineStage.ts": "21ddd34964237f537262f557e56dced2fd72a51c8a24577d867bc3933b1d40f7",
"web/src/chat/reading.ts": "c00aa08141d00047bc9097a50197e5223772baa7af8b1c747bb259c9a5ada16b",
"web/src/demo/fixtures.ts": "17e3525a155a4d07f37f4dc4e557a259788e943e39ee7ceca1e6fbf6a272e81c",
"web/src/domain/id.ts": "b03e2c6a7120d1af5065a61103a0aa0679bdfedf3437ce31374d63df1fba8137",
"web/src/domain/model.ts": "88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5",
"web/src/domain/privacy.ts": "3bb318fae0704f09d1bb657bf54c10dea6ce8b0847daaee36a8dbda22d9c00db",
"web/src/life/LifePanel.tsx": "a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192",
"web/src/main.tsx": "4af93f56bbcb00d8d3327ed9f64ab57288234838ed311a1a8c3e825d4ada46b6",
"web/src/manage/CreationWizard.tsx": "7c4e8945b3baf705c9fbc88ec8a50d3b4be289bd892789427061bbe97d396d5c",
"web/src/manage/ManagePanel.tsx": "df0c968ec91c571124f9707c853a17587569448195745b4757d7afd753cfef3c",
"web/src/shared/Viewport.tsx": "2302c18e00019134184a70d038bb0bcaea4e9230ae8c83ee9da0b7c285590bc0",
"web/src/shared/motion.ts": "fb397e28411c59349efd75cb5aac1a2722f047ece2e9b7b305803a523576c9f6",
"web/src/shared/navigation.ts": "f4820f6fad24bcb5e89dc497562aacd52ef264608f0a77718cade3a9efc4cb25",
"web/src/shared/space.ts": "5d32075facc0a4e3a56577a126942cee85f724185afd4a43de5f1f85979a1fca",
"web/src/shared/ui.tsx": "6900ffc625d4db2e8ef4772f9371927f85bd47c7464257a36c78e2c49449183f",
"web/src/stage/Background.tsx": "0e36869e3b0600238fc380941ab355429996966fb1aa8bebfc7c5ef9cf807706",
"web/src/stage/Presenter.tsx": "d4cf57bd990bfcf94ca567a1a681339c31474696fe2a92dbc6af218781522a7c",
"web/src/stage/ambient.ts": "076a02ec6152d245d8be5a40cda364f298518cfd7e4a4c3315b4f245afe3a59f",
"web/src/stage/camera.ts": "f3fd15fc4546d0572e03c2f82ae39bb7d2bcc78f65e1d7a3646e297f28dc8128",
"web/src/stage/roomScene.ts": "f01befa8068d88f0f62b845bd4a3cc4421fd7a28d4f5e5ade9c11d0df95488e3",
"web/src/styles/components.css": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d",
"web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475",
"web/src/voice/livekit.ts": "c42475d37f7eef7abe0903b66656aedc5379bc9de3dcba11c1b88450a470ab04",
"web/src/voice/session.ts": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14",
"web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656",
"web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45",
"web/tests/fake-livekit.js": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc",
"web/tests/hel825_shot.py": "75f6075ae3d3c493360797e7e2f592fa5f4701f99e99981b8b9674ce70e01c92",
"web/tests/hel825_tag_probe.py": "f8c2f51a0403adba71ac4c925700d248efad5b7ad8c3eda14ec81ff98ca3aca0",
"web/tests/hel834_mock.mjs": "303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f",
"web/tests/hel834_shots.mjs": "cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c",
"web/tests/hel875_mock.mjs": "00b17de26604c7ca0999da12139fd4006468ced3e0536b60d239ccae0f949cc6",
"web/tests/hel875_shots.mjs": "41b04fb9b9ee9569f39dd2dc57f694f5efcd2890124fbc6ced14e6d546878aa2",
"web/tests/hel878_mock.mjs": "f87e61ffce879ec7e7c9287321e9d73a6b276b71633e68d84716205fd36380f0",
"web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
"web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b",
"web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
"web/tests/import_check.py": "a7eea222bfdf823a2bcc2fe139780d1d44279b2629e81f2ef8c5c5353abab990",
"web/tests/inbox.test.ts": "649bc6e09c93427369aca63ce214ee3d288197d0f886bfced1696f41228b0ffb",
"web/tests/r2wiring.test.ts": "d497adceb2c5187d980acb8801dcedbb1b5c991148048b9138bcb33820642b6f",
"web/tests/r3memory.test.ts": "166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e",
"web/tests/reading.test.ts": "55573c3cfeb613fb5c2990f8b5274f0c8c75fb25d7f723e05fec95761e6389b4",
"web/tests/real.test.ts": "f63d377c148c901f97d3d5104bc648d72f0301fc2f28652d39693e3ce2836cfd",
"web/tests/real_browser_check.py": "0f716011d3b25d9c9578aba4e2d4e10e5f38ddc572e54d4528fdaa8645e84ed0",
"web/tests/review878_late_cleanup.test.ts": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935",
"web/tests/rework_browser_check.py": "aee2600a4672b6052ab9372a20d9342a47be637a7271af7aed421b73256567d5",
"web/tests/voice.test.ts": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73",
"web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157"
}
}
@@ -0,0 +1,357 @@
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-60677d46f686/workdir/before/web
❯ tests/voice.test.ts (43 tests | 12 failed) 459ms
✓ honest switch > reads status only: no room, no microphone 14ms
✓ honest switch > configured && tts_ok never means "verified": voice:false is not read as connected 1ms
✓ honest switch > refuses honestly before touching the microphone when the server cannot speak 1ms
✓ honest switch > an insecure page is told why, without asking for the microphone 0ms
✓ lifecycle > opens, joins, publishes the microphone, follows her state; ending stops everything 1ms
✓ lifecycle > permission denied: nothing opened on the server, nothing left running 1ms
✓ lifecycle > server refusal shows its own words and releases the microphone 0ms
✓ lifecycle > a failed join releases the microphone and gives the room back 0ms
✓ lifecycle > ending while the microphone prompt is open: the late microphone is stopped, no room is asked for 8ms
✓ lifecycle > ending while the room is being opened: the late grant is given back, never joined 31ms
✓ lifecycle > ending while joining: the join is aborted, a late room is closed, the server room deleted 36ms
✓ lifecycle > rapid on/off/on: only the last call stays, every earlier microphone and room is released 17ms
✓ lifecycle > late events from an ended call never bring it back 14ms
✓ lifecycle > the room dropping on its own (network, server ended it) cleans up and says so 15ms
✓ lifecycle > microphone permission revoked mid-call ends the call 16ms
✓ lifecycle > her worker never joining is a failure, not an endless wait 2ms
✓ lifecycle > offline ends with its own words; unload releases at once and the DELETE survives the page 25ms
✓ lifecycle > a DELETE that cannot get through (offline) is owed, survives a new page, and is sent once back online 1ms
✓ lifecycle > an owed room already gone on the server (404) counts as settled 0ms
✓ lifecycle > never confirms a receipt or posts chat itself: the worker owns both 11ms
✓ a late end of an older call > the room dropped, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 62ms
✓ a late end of an older call > the microphone was taken away, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 58ms
✓ a late end of an older call > her worker never joined, the DELETE is slow, and a new call is started before it answers 2ms
✓ a late end of an older call > a normal end whose DELETE is slow, then a new call, then the old answer arrives 2ms
✓ a late end of an older call > starting again while the previous start still waits for the old room to be given back: only the newest call survives 71ms
✓ a late end of an older call > a microphone granted late to an older start is stopped without touching the new call 31ms
× subtitles and the two channels > interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded 9ms
→ expected { phase: 'listening', heard: '', …(3) } to match object { spoken: '我刚才走神了,你再说一遍?', her: null }
(4 matching properties omitted from actual)
✓ subtitles and the two channels > without the agent state attribute, subtitles still move the state honestly 1ms
✓ subtitles and the two channels > typing over her spoken answer silences what is left of it until she is listening again 1ms
× her voice line (cg.subtitle v2) > rejects anything that is not exactly a v2 frame 1ms
→ expected 10 to be 3 // Object.is equality
× her voice line (cg.subtitle v2) > normal turn: your draft and final, her growing reply replaced in place, then her stored final 2ms
→ expected [ '你在听的', '你在听吗?', '你在听吗?', …(4) ] to deeply equal [ '你在听的', '你在听吗?', { …(3) }, …(4) ]
× her voice line (cg.subtitle v2) > interrupted: no final, her line goes at once, its late frames never reach the new turn 6ms
→ expected undefined to match object { speech: 'speech_cut', …(2) }
× her voice line (cg.subtitle v2) > frames that overtook each other: a lower seq is stale; an older speech never touches or closes the new one 1ms
→ expected undefined to be '一二三' // Object.is equality
× her voice line (cg.subtitle v2) > reach-out read aloud: shown whole with its stored message id, interruptible, nothing marked or stored 1ms
→ expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'speaking', her: { …(5) } }
(4 matching properties omitted from actual)
× her voice line (cg.subtitle v2) > a reach-out cut by your voice: the cancel clears it, your words follow 1ms
→ expected { phase: 'listening', …(4) } to match object { heard: '等等', …(1) }
(4 matching properties omitted from actual)
× her voice line (cg.subtitle v2) > text taking over, hanging up and starting again each clear her line at once 1ms
→ expected undefined to be null
× her voice line (cg.subtitle v2) > the room dropping mid-sentence clears her line with the call 9ms
→ expected { phase: 'off', heard: '', …(3) } to match object { phase: 'off', her: null, …(1) }
(3 matching properties omitted from actual)
× her voice line on the stage > grows in her slot after your voice turn; the stored row takes over under the same line keys 0ms
→ (0 , voiceLine) is not a function
× her voice line on the stage > a finished line never stands in front of a newer turn; an open one waits for its own end 0ms
→ (0 , voiceLine) is not a function
× her voice line on the stage > a reach-out read aloud keeps its stored row identity and gives way when that row is the newest line 0ms
→ (0 , voiceLine) is not a function
✓ record re-read > adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream 0ms
✓ record re-read > a pending voice row settles in place (sent or cancelled), never duplicated 0ms
✓ record re-read > reads newest pages down to what the page holds, bounded 2ms
node :
At line:4 char:24
+ ... efore\web"; node node_modules\vitest\vitest.mjs run tests/voice.test. ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
⎯⎯⎯⎯⎯⎯ Failed Tests 12 ⎯⎯⎯⎯⎯⎯⎯
FAIL tests/voice.test.ts > subtitles and the two channels > interim is a draft; user_final cancels a text reply and
asks for the record; φ is heard but not recorded
AssertionError: expected { phase: 'listening', heard: '', …(3) } to match object { spoken: '我刚才走神了,你再说一遍?', her: null }
(4 matching properties omitted from actual)
- Expected
+ Received
{
- "her": null,
"spoken": "我刚才走神了,你再说一遍?",
}
❯ tests/voice.test.ts:361:26
359| room.on.subtitle(frame(4, 'assistant_interim', { speech_id: 'a…
360| room.on.subtitle(frame(5, 'error', { speech_id: 'a', text: '我刚…
361| expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her:…
| ^
362| room.on.subtitle(frame(6, 'turn_cancel', { speech_id: 'a', rea…
363| expect(r.view()).toMatchObject({ spoken: '我刚才走神了,你再说一遍?', her:…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > rejects anything that is not exactly a v2 frame
AssertionError: expected 10 to be 3 // Object.is equality
- Expected
+ Received
- 3
+ 10
❯ tests/voice.test.ts:407:32
405| ];
406| for (const b of bad) room.on.subtitle(b);
407| expect(r.views.length).toBe(seen); expect(r.record).not.toHave…
| ^
408| // A rejected frame does not move the sequence: the first good…
409| room.on.subtitle(frame(1, 'interim', { text: '好的' })); expect(…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > normal turn: your draft and final, her growing reply
replaced in place, then her stored final
AssertionError: expected [ '你在听的', '你在听吗?', '你在听吗?', …(4) ] to deeply equal [ '你在听的', '你在听吗?', { …(3) }, …(4) ]
- Expected
+ Received
[
"你在听的",
"你在听吗?",
- {
- "speech": "speech_normal",
- "state": "open",
- "text": "",
- },
- {
- "speech": "speech_normal",
- "state": "open",
- "text": "在听呢,",
- },
- {
- "speech": "speech_normal",
- "state": "open",
- "text": "在听呢,在听呢,你刚才说到一半的那件事我还在想。",
- },
- {
- "speech": "speech_normal",
- "state": "final",
- "text": "在听呢,你刚才说到一半的那件事我还在想。",
- "turnId": "0e2f-demo-turn",
- },
- {
- "speech": "speech_normal",
- "state": "done",
- "text": "在听呢,你刚才说到一半的那件事我还在想。",
- "turnId": "0e2f-demo-turn",
- },
+ "你在听吗?",
+ "你在听吗?",
+ "你在听吗?",
+ "",
+ "",
]
❯ tests/voice.test.ts:415:23
413| const steps: (VoiceView['her'] | string)[] = [];
414| play(room, frames, () => steps.push(r.view().her ? { ...r.view…
415| expect(steps).toEqual([
| ^
416| '你在听的', '你在听吗?',
417| { speech: 'speech_normal', text: '', state: 'open' },
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > interrupted: no final, her line goes at once, its late
frames never reach the new turn
AssertionError: expected undefined to match object { speech: 'speech_cut', …(2) }
- Expected:
{
"speech": "speech_cut",
"state": "open",
"text": "我正想跟你说,今天下午",
}
+ Received:
undefined
❯ tests/voice.test.ts:438:30
436| const r = await connected(), room = r.transport.rooms[0], fram…
437| play(room, frames.slice(0, 2));
438| expect(r.view().her).toMatchObject({ speech: 'speech_cut', tex…
| ^
439| play(room, frames.slice(2, 3));
440| expect(r.view().her).toBeNull();
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > frames that overtook each other: a lower seq is stale;
an older speech never touches or closes the new one
AssertionError: expected undefined to be '一二三' // Object.is equality
- Expected:
"一二三"
+ Received:
undefined
❯ tests/voice.test.ts:454:36
452| room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a…
453| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
454| expect(r.view().her?.text).toBe('一二三');
| ^
455| // Two handles: b starts while a is still open (queued then ge…
456| room.on.subtitle(frame(4, 'turn_start', { speech_id: 'b' }));
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > reach-out read aloud: shown whole with its stored
message id, interruptible, nothing marked or stored
AssertionError: expected { phase: 'listening', heard: '', …(3) } to match object { phase: 'speaking', her: { …(5) } }
(4 matching properties omitted from actual)
- Expected
+ Received
{
- "her": {
- "messageId": 17,
- "proactive": true,
- "speech": "speech_pro1",
- "state": "open",
- "text": "我看到一句很适合现在的话,先念给你听。",
- },
- "phase": "speaking",
+ "phase": "listening",
}
❯ tests/voice.test.ts:471:26
469| const r = await connected(), room = r.transport.rooms[0], fram…
470| play(room, frames.slice(0, 1));
471| expect(r.view()).toMatchObject({ phase: 'speaking', her: { spe…
Test Files 1 failed (1)
Tests 12 failed | 31 passed (43)
Start at 17:45:16
Duration 875ms (transform 138ms, setup 0ms, collect 219ms, tests 459ms, environment 0ms, prepare 60ms)
| ^
472| room.on.subtitle(frame(2, 'proactive_end', { speech_id: 'speec…
473| expect(r.view().her?.state).toBe('open');
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > a reach-out cut by your voice: the cancel clears it,
your words follow
AssertionError: expected { phase: 'listening', …(4) } to match object { heard: '等等', …(1) }
(4 matching properties omitted from actual)
- Expected
+ Received
{
"heard": "等等",
- "her": {
- "speech": "p",
- "state": "open",
- },
}
❯ tests/voice.test.ts:486:26
484| room.on.subtitle(frame(1, 'proactive_start', { speech_id: 'p',…
485| room.on.subtitle(frame(2, 'interim', { text: '等等' }));
486| expect(r.view()).toMatchObject({ heard: '等等', her: { speech: '…
| ^
487| room.on.subtitle(frame(3, 'proactive_cancel', { speech_id: 'p'…
488| expect(r.view()).toMatchObject({ heard: '等等', her: null });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > text taking over, hanging up and starting again each
clear her line at once
AssertionError: expected undefined to be null
- Expected:
null
+ Received:
undefined
❯ tests/voice.test.ts:496:30
494| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
495| r.c.hush();
496| expect(r.view().her).toBeNull(); expect(room.hushed).toEqual([…
| ^
497| room.on.subtitle(frame(3, 'assistant_interim', { speech_id: 'a…
498| expect(r.view().her).toBeNull();
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/12]⎯
FAIL tests/voice.test.ts > her voice line (cg.subtitle v2) > the room dropping mid-sentence clears her line with the
call
AssertionError: expected { phase: 'off', heard: '', …(3) } to match object { phase: 'off', her: null, …(1) }
(3 matching properties omitted from actual)
- Expected
+ Received
{
- "her": null,
"notice": "通话断开了,麦克风已经关闭。文字仍可使用。",
"phase": "off",
}
❯ tests/voice.test.ts:526:26
524| room.on.subtitle(frame(2, 'assistant_interim', { speech_id: 'a…
525| room.on.dropped(); await flush();
526| expect(r.view()).toMatchObject({ phase: 'off', her: null, noti…
| ^
527| });
528| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/12]⎯
FAIL tests/voice.test.ts > her voice line on the stage > grows in her slot after your voice turn; the stored row
takes over under the same line keys
TypeError: (0 , voiceLine) is not a function
❯ tests/voice.test.ts:537:25
535| it('grows in her slot after your voice turn; the stored row takes …
536| const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直…
537| const growing = voiceLine(open, [you], 1)!;
| ^
538| expect(growing).toMatchObject({ role: 'companion', live: 'gene…
539| // The newest line of a reply still growing is held back, as i…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/12]⎯
FAIL tests/voice.test.ts > her voice line on the stage > a finished line never stands in front of a newer turn; an
open one waits for its own end
TypeError: (0 , voiceLine) is not a function
❯ tests/voice.test.ts:550:16
548| it('a finished line never stands in front of a newer turn; an open…
549| const done: HerLine = { speech: 's', text: '好。', state: 'done'…
550| expect(voiceLine(done, [you, M(6, 'user', '还有', 'U')], 1)).toB…
| ^
551| expect(voiceLine({ ...done, state: 'open' }, [you, M(6, 'user'…
552| expect(voiceLine({ ...done, text: '' }, [you], 1)).toBeNull();
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/12]⎯
FAIL tests/voice.test.ts > her voice line on the stage > a reach-out read aloud keeps its stored row identity and
gives way when that row is the newest line
TypeError: (0 , voiceLine) is not a function
❯ tests/voice.test.ts:558:25
556| const row = M(17, 'companion', line.text, 'R', { proactive: tr…
557| const before = [you, M(5, 'companion', '在。', 'T')];
558| const waiting = voiceLine(line, before, 2, [row])!;
| ^
559| expect(waiting).toMatchObject({ id: '17', turnId: 'R', proacti…
560| expect(stage([...before, waiting])).toEqual(stage([...before, …
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/12]⎯
@@ -0,0 +1,52 @@
PASS status read on arrival only: no session, no microphone, no LiveKit client — GET /api/voice/status
PASS voice button offers to start, honestly labelled
PASS not configured: refusal in her own words, no dialog, no session, no microphone — 语音还没有在这台服务上配置,文字仍可使用。
PASS TTS down: says her voice service is away — 她的声音服务这会儿不在,稍后再试试。文字仍可使用。
PASS the dialog explains microphone, destination, same memory, how to stop, and the unverified path — 开始和栖语音? 浏览器会请求麦克风权限。你的声音只送到这台服务器上栖的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。 语音通路还没有经过真机验收,效果可能不稳定。 取消 开始语音
PASS cancel: no microphone request, no session
PASS waiting for her is shown as waiting, not as connected — 在等栖接起…
结束语音
PASS one session opened with a server token; one microphone; mic published to that room
PASS her worker joined: listening, wave in your colour, her audio attached — 在听你说
结束语音
PASS interim words show as a draft line beside the state
PASS your spoken words enter the stage from the server record (not from the subtitle) — 你:今天下班的路上看到一只猫
PASS speaking: her name, and that you can cut in by speaking
PASS her spoken answer reaches the stage once it is in the record — 栖:是橘色的那只吗?它最近总在桥下晒太阳。
PASS the record holds the voice turn once, same conversation as text
PASS φ line shown as heard, marked not recorded; not in the record
PASS text over voice: her remaining audio is muted
PASS …and unmuted once she is listening again
PASS text reply streaming
PASS voice over text: no failure notice, no retry, draft not refilled
PASS …the cut text turn is marked cancelled in the record; the voice words are there once — 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳。 你 等等,我先打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 你 再给我讲讲那只猫 已取消 栖 我在。今天的雨停了没有?要是还在 已停止 · 这是未完成片段,未存入聊天记录 你 算了先不说了
PASS blocked audio offers a tap
PASS …and the offer goes away after the tap
PASS 结束语音: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS ended: says the microphone is closed; DELETE sent — 语音已结束,麦克风已关闭。
PASS late events after the end do not revive anything
PASS permission refused: told plainly, no session opened, text still there — 没有拿到麦克风权限,语音没有开始。文字仍可使用。
PASS microphone revoked mid-call: call ends, said so — 麦克风被关掉了,语音已结束。
PASS revoked: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS room dropped: call ends, said so — 通话断开了,麦克风已经关闭。文字仍可使用。
PASS dropped: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS slow DELETE: dropped call says so and its microphone stops before the server answers — 通话断开了,麦克风已经关闭。文字仍可使用。
PASS …a new call is up meanwhile: listening
PASS …the old DELETE answers late: the new call stays listening, no old notice, one live microphone, her audio, only the new room open — phase=listening status= open=sess-5
PASS new call after the late old DELETE: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS server refusal shows its detail; microphone released — 她的声音服务这会儿不在,稍后再试试。
PASS rapid off/on: exactly one open session and one live microphone — {"open":["sess-7"],"made":7}
PASS rapid toggle: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS nav away: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS Esc: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS 结束交谈: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS network lost: call ends with its own words — 网络断开了,语音已结束,麦克风已关闭。
PASS network lost: microphone and her audio stop at once; the server room is owed, not pretended deleted — mics=0 audio=0 open=1
PASS offline, after the network is back: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0
PASS …the owed DELETE is sent once back online and forgotten
PASS page unload: the server room is deleted (keepalive) — DELETE /api/voice/session/sess-12
PASS moon/phone: voice line fits the screen
PASS fog/phone: voice line fits the screen
PASS real livekit-client 2.22.4: every name the adapter uses exists — {"events":true,"values":["participantAttributesChanged","dataReceived","audioPlaybackChanged"],"mic":"microphone","agent":4,"create":"function","methods":true,"playback":"boolean","remote":true,"publish":"function","version":"2.22.4"}
PASS demo entry makes no voice requests
49/49 passed
@@ -0,0 +1,289 @@
PASS moon-desktop: her reply shows on the stage while she speaks, before it is stored — 你
今天下班的路上看到一只猫
你
今天下班的路上看到一只猫
栖
还在说
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
PASS moon-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
「今天下班的路上看到一只猫」
结束语音
PASS moon-desktop: pause while she speaks
PASS moon-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 你 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
PASS moon-desktop: paused before speaking
PASS moon-desktop: speaking resumes reading for the new turn
PASS moon-desktop: second reply growing — 你
你今天去哪儿了
你
你今天去哪儿了
栖
还在说
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
PASS moon-desktop: interrupted: her unfinished line is gone at once — 你
你今天去哪儿了
PASS moon-desktop: late frames of the cut turn stay out
PASS moon-desktop: her line before you type — 你
我们聊点别的吧
你
我们聊点别的吧
栖
还在说
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
PASS moon-desktop: text over voice: her line is gone, your words are on the stage — 你
先等等,我打字说
PASS moon-desktop: reach-out shown in her place, word for word — 栖
还在说
刚才路过花店,想起你说过喜欢白色的洋桔梗。
PASS moon-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
PASS moon-desktop: her line before hanging up — 你
我先去睡了
你
我先去睡了
栖
还在说
晚安,明天见。今天说了好多好多话,你要早点睡哦。
PASS moon-desktop: hung up: her line gone, voice off — 你
我先去睡了
PASS fog-desktop: her reply shows on the stage while she speaks, before it is stored — 你
今天下班的路上看到一只猫
你
今天下班的路上看到一只猫
栖
还在说
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
PASS fog-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
「今天下班的路上看到一只猫」
结束语音
PASS fog-desktop: pause while she speaks
PASS fog-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
PASS fog-desktop: paused before speaking
PASS fog-desktop: speaking resumes reading for the new turn
PASS fog-desktop: second reply growing — 你
你今天去哪儿了
你
你今天去哪儿了
栖
还在说
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
PASS fog-desktop: interrupted: her unfinished line is gone at once — 你
你今天去哪儿了
PASS fog-desktop: late frames of the cut turn stay out
PASS fog-desktop: her line before you type — 你
我们聊点别的吧
你
我们聊点别的吧
栖
还在说
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
PASS fog-desktop: text over voice: her line is gone, your words are on the stage — 你
先等等,我打字说
PASS fog-desktop: reach-out shown in her place, word for word — 栖
还在说
刚才路过花店,想起你说过喜欢白色的洋桔梗。
PASS fog-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
PASS fog-desktop: her line before hanging up — 你
我先去睡了
你
我先去睡了
栖
还在说
晚安,明天见。今天说了好多好多话,你要早点睡哦。
PASS fog-desktop: hung up: her line gone, voice off — 你
我先去睡了
PASS moon-phone: her reply shows on the stage while she speaks, before it is stored — 你
今天下班的路上看到一只猫
你
今天下班的路上看到一只猫
栖
还在说
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
PASS moon-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
「今天下班的路上看到一只猫」
结束语音
PASS moon-phone: pause while she speaks
PASS moon-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
PASS moon-phone: paused before speaking
PASS moon-phone: speaking resumes reading for the new turn
PASS moon-phone: second reply growing — 你
你今天去哪儿了
你
你今天去哪儿了
栖
还在说
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
PASS moon-phone: interrupted: her unfinished line is gone at once — 你
你今天去哪儿了
PASS moon-phone: late frames of the cut turn stay out
PASS moon-phone: her line before you type — 你
我们聊点别的吧
你
我们聊点别的吧
栖
还在说
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
PASS moon-phone: text over voice: her line is gone, your words are on the stage — 你
先等等,我打字说
PASS moon-phone: reach-out shown in her place, word for word — 栖
还在说
刚才路过花店,想起你说过喜欢白色的洋桔梗。
PASS moon-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
PASS moon-phone: her line before hanging up — 你
我先去睡了
你
我先去睡了
栖
还在说
晚安,明天见。今天说了好多好多话,你要早点睡哦。
PASS moon-phone: hung up: her line gone, voice off — 你
我先去睡了
PASS fog-phone: her reply shows on the stage while she speaks, before it is stored — 你
今天下班的路上看到一只猫
你
今天下班的路上看到一只猫
栖
还在说
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
PASS fog-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断
「今天下班的路上看到一只猫」
结束语音
PASS fog-phone: pause while she speaks
PASS fog-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。
PASS fog-phone: paused before speaking
PASS fog-phone: speaking resumes reading for the new turn
PASS fog-phone: second reply growing — 你
你今天去哪儿了
你
你今天去哪儿了
栖
还在说
对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。
PASS fog-phone: interrupted: her unfinished line is gone at once — 你
你今天去哪儿了
PASS fog-phone: late frames of the cut turn stay out
PASS fog-phone: her line before you type — 你
我们聊点别的吧
你
我们聊点别的吧
栖
还在说
那我们换个话题吧,你今天晚饭吃了什么,好不好吃?
PASS fog-phone: text over voice: her line is gone, your words are on the stage — 你
先等等,我打字说
PASS fog-phone: reach-out shown in her place, word for word — 栖
还在说
刚才路过花店,想起你说过喜欢白色的洋桔梗。
PASS fog-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message)
PASS fog-phone: her line before hanging up — 你
我先去睡了
你
我先去睡了
栖
还在说
晚安,明天见。今天说了好多好多话,你要早点睡哦。
PASS fog-phone: hung up: her line gone, voice off — 你
我先去睡了
PASS reduced motion: her words arrive — 你
慢一点说给我听
栖
还在说
是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。
61/61 passed
+9
View File
@@ -0,0 +1,9 @@
vite v6.3.6 building for production...
transforming...
✓ 57 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB
dist/assets/index-8mbVmtoD.js 310.56 kB │ gzip: 103.40 kB
✓ built in 706ms
+6
View File
@@ -0,0 +1,6 @@
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-898-60677d46f686\workdir\src0\web\src\chat\ChatPanel.tsx
239:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
+17
View File
@@ -0,0 +1,17 @@
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-60677d46f686/workdir/src0/web
✓ tests/reading.test.ts (11 tests) 56ms
✓ tests/inbox.test.ts (13 tests) 40ms
✓ tests/domain.test.ts (12 tests) 11ms
✓ tests/r2wiring.test.ts (11 tests) 45ms
✓ tests/voice.test.ts (43 tests) 439ms
✓ tests/r3memory.test.ts (5 tests) 19ms
✓ tests/real.test.ts (3 tests) 23ms
✓ tests/review878_late_cleanup.test.ts (1 test) 15ms
Test Files 8 passed (8)
Tests 99 passed (99)
Start at 17:41:51
Duration 959ms (transform 262ms, setup 0ms, collect 620ms, tests 647ms, environment 1ms, prepare 532ms)
@@ -0,0 +1,13 @@
> cybergril-web@0.1.0 build
> tsc --noEmit && vite build
vite v6.3.6 building for production...
transforming...
✓ 57 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB
dist/assets/index-8mbVmtoD.js 310.56 kB │ gzip: 103.40 kB
✓ built in 647ms
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,10 @@
> cybergril-web@0.1.0 lint
> eslint src
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web/src/chat/ChatPanel.tsx
239:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,21 @@
> cybergril-web@0.1.0 test
> vitest run
RUN v3.1.2 /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web
✓ tests/review878_late_cleanup.test.ts (1 test) 6ms
✓ tests/r3memory.test.ts (5 tests) 11ms
✓ tests/inbox.test.ts (13 tests) 25ms
✓ tests/real.test.ts (3 tests) 13ms
✓ tests/reading.test.ts (11 tests) 35ms
✓ tests/r2wiring.test.ts (11 tests) 27ms
✓ tests/domain.test.ts (12 tests) 13ms
✓ tests/voice.test.ts (43 tests) 96ms
Test Files 8 passed (8)
Tests 99 passed (99)
Start at 05:53:01
Duration 433ms (transform 557ms, setup 0ms, collect 1.22s, tests 225ms, environment 1ms, prepare 489ms)
@@ -0,0 +1,4 @@
> cybergril-web@0.1.0 typecheck
> tsc --noEmit
+13
View File
@@ -0,0 +1,13 @@
HEL-898 原作者保全与交接(总管,2026-10-27)
本轮负责保全和续接统一审核,没有替视觉总监改写业务代码。需求 v1.11 / 60项;实际读取 fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c。已读 AGENTS.md、需求 README/current/coverage/changes、R5/D1/D3、视觉访谈与动态认可、作者分工、成熟复用及完整体验委托原话。关联编号沿 handoff-HEL-898.json,完整要求无变更。
作者来源为 HEL-898 评论 01a17a31-adf4-7999-b23f-174b2d27e1d4,附件 01a17a31-75b6-7b92-8148-299b9f5df9a7。zip SHA256 b8e81f14eb93fb16f2b096ef962784c80aa1655e5b3cb056e2356888f5dab42e;patch SHA256 9e653a45bcee4e1927c797bb94869e259d98298188485c03b5bfb42a221fea37,均与原作者一致。解包规范化 Windows 路径分隔符,未改文件内容。
作者基于3228b1e的8个改前哈希与fc905fe实际对应文件全部相等。git apply --check通过,实际应用后8个文件逐字节等于附件源码及清单。原样代码独立提交46ac0a7a20bdd8f11fa5893818bd7535a771fd11,父fc905fe;仅两个web/src文件、五个测试文件与作者handoff。后续仅追加本保全记录和证据,不改作者历史handoff。hel-879-assembly与PR11未被本轮修改。
本轮独立执行:对origin/main与实际整合目标fc905fe,各不带/带handoff-HEL-898.json的原版需求检查共4/4;vitest 8文件99/99;typecheck、build通过;lint 0错/1条既有fullButton ref警告;git diff --check通过。使用本机现有Node v24.16.0,复制同package-lock的既有node_modules,没有下载或安装。日志在manager-checks。作者的浏览器61/61、HEL-878回归49/49日志另存author-logs;本轮未复跑,不称独立通过。附件截图与连续帧在原卡保留,连续帧不是视频录屏,也不是真机或真实WebRTC。
交原总工在HEL-879接续统一整合:以fc905fe为母线纳入本作者保全提交,保留已过的预算/备份/后台绑定和三处时机修复,不重开审核串。核对实际worker产生的v2帧进入当前前端的生命周期、落库接替和打断残影,复核主动字幕在播放中显示。作者提出同房间新worker job序号归零问题,必须查明实际job与房间/控制器代次关系并给可执行证据;不凭口头假定一一对应。当前前端closed仅保留64个speech,长会话中退休speech的高seq重复start是否可重新进入,也须有限核清。业务代码若须修改由原作者接续,不能借整合重设计。
真实LiveKit/WebRTC、真实模型/ASR/TTS/麦克风、三轮对话、打断停声/停麦、实际掉线/API重启释放、可信HTTPS+WSS、真机/Safari未完成。后台默认关闭的开场念一条旧留言不等于完整CG-011。D1音色、有限逐项预算和部署准备要在统一离线审核完成后集中交主卡,不自行放行;真实cap=0。R6/R7和全部未完成要求继续承接。不部署、不合并、不修改任何旧服务/档案或运行时/模型/认证绑定。
+65
View File
@@ -0,0 +1,65 @@
{
"baseline_version": "1.11",
"baseline_count": 60,
"read_commit": "fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c",
"author_input": "3228b1e0d7f822b7030f945e285eac74126771aa",
"preserved_code_commit": "46ac0a7a20bdd8f11fa5893818bd7535a771fd11",
"source_comment": "01a17a31-adf4-7999-b23f-174b2d27e1d4",
"source_attachment": "01a17a31-75b6-7b92-8148-299b9f5df9a7",
"zip_sha256": "b8e81f14eb93fb16f2b096ef962784c80aa1655e5b3cb056e2356888f5dab42e",
"patch_sha256": "9e653a45bcee4e1927c797bb94869e259d98298188485c03b5bfb42a221fea37",
"files": [
{
"path": "docs/requirements/handoff-HEL-898.json",
"sha256": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d",
"author_source_equal": true
},
{
"path": "web/src/chat/ChatPanel.tsx",
"sha256": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669",
"author_source_equal": true
},
{
"path": "web/src/voice/session.ts",
"sha256": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14",
"author_source_equal": true
},
{
"path": "web/tests/fake-livekit.js",
"sha256": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc",
"author_source_equal": true
},
{
"path": "web/tests/hel878_mock.mjs",
"sha256": "f87e61ffce879ec7e7c9287321e9d73a6b276b71633e68d84716205fd36380f0",
"author_source_equal": true
},
{
"path": "web/tests/hel878_shots.mjs",
"sha256": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
"author_source_equal": true
},
{
"path": "web/tests/hel898_shots.mjs",
"sha256": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
"author_source_equal": true
},
{
"path": "web/tests/voice.test.ts",
"sha256": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73",
"author_source_equal": true
}
],
"checks": {
"requirements": "4/4 main and fc905fe each with/without handoff",
"vitest": "99/99",
"typecheck": "pass",
"lint": "0 errors 1 existing warning",
"build": "pass",
"browser": "author 61/61 and 49/49, not independently rerun"
},
"requirement_changes": [],
"real_calls": 0,
"download_install": false,
"deployed_merged": false
}