From fb7ea846fffa14ba1b71d90554a258626bb99221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E7=AE=A1?= Date: Tue, 27 Oct 2026 05:54:01 +0800 Subject: [PATCH] HEL-898: record exact source preservation and independent offline checks Co-authored-by: multica-agent --- docs/rework/hel898/HEL-898.patch | 863 ++++++++++++++++++ docs/rework/hel898/MANIFEST-HEL-898.json | 199 ++++ .../hel898/author-logs/before-fix-voice.log | 357 ++++++++ .../hel898/author-logs/browser-hel878.log | 52 ++ .../hel898/author-logs/browser-hel898.log | 289 ++++++ docs/rework/hel898/author-logs/build.log | 9 + docs/rework/hel898/author-logs/lint.log | 6 + docs/rework/hel898/author-logs/typecheck.log | 0 docs/rework/hel898/author-logs/vitest.log | 17 + docs/rework/hel898/manager-checks/build.log | 13 + ...1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log | 1 + ...905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log | 1 + docs/rework/hel898/manager-checks/lint.log | 10 + .../manager-checks/origin-main-handoff.log | 1 + .../hel898/manager-checks/origin-main.log | 1 + docs/rework/hel898/manager-checks/test.log | 21 + .../hel898/manager-checks/typecheck.log | 4 + docs/rework/hel898/preservation-review.md | 13 + docs/rework/hel898/preservation.json | 65 ++ 19 files changed, 1922 insertions(+) create mode 100644 docs/rework/hel898/HEL-898.patch create mode 100644 docs/rework/hel898/MANIFEST-HEL-898.json create mode 100644 docs/rework/hel898/author-logs/before-fix-voice.log create mode 100644 docs/rework/hel898/author-logs/browser-hel878.log create mode 100644 docs/rework/hel898/author-logs/browser-hel898.log create mode 100644 docs/rework/hel898/author-logs/build.log create mode 100644 docs/rework/hel898/author-logs/lint.log create mode 100644 docs/rework/hel898/author-logs/typecheck.log create mode 100644 docs/rework/hel898/author-logs/vitest.log create mode 100644 docs/rework/hel898/manager-checks/build.log create mode 100644 docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log create mode 100644 docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log create mode 100644 docs/rework/hel898/manager-checks/lint.log create mode 100644 docs/rework/hel898/manager-checks/origin-main-handoff.log create mode 100644 docs/rework/hel898/manager-checks/origin-main.log create mode 100644 docs/rework/hel898/manager-checks/test.log create mode 100644 docs/rework/hel898/manager-checks/typecheck.log create mode 100644 docs/rework/hel898/preservation-review.md create mode 100644 docs/rework/hel898/preservation.json diff --git a/docs/rework/hel898/HEL-898.patch b/docs/rework/hel898/HEL-898.patch new file mode 100644 index 0000000..e3c54bf --- /dev/null +++ b/docs/rework/hel898/HEL-898.patch @@ -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(() => { + 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(null), lines = useRef(null), meter = useRef(null); + const [metrics, setMetrics] = useState(() => 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(null); +@@ -327,7 +344,7 @@ +
+ + ++ {pages.length ? `${cursor + 1} / ${pages.length}${talking ? '…' : ''}` : '0 / 0'} +- {pages.length ? `${cursor + 1} / ${pages.length}${busy ? '…' : ''}` : '0 / 0'} + + + {busy && } +@@ -341,7 +358,7 @@ +
+ + +-

内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。

{record.map(m =>
{m.role === 'user' ? '你' : name}{m.proactive && 她主动说的}

{m.text}

{m.status === 'failed' && }{m.status === 'cancelled' && 已取消}{m.live === 'cancelled' && 已停止 · 这是未完成片段,未存入聊天记录}
)}{reply &&
{name}

{reply}

还在说…
}
+ {full && fullMessage && } + {real && permission === 'mic' && { setPermission(''); setNotice('没有开启语音,文字仍可使用。'); }} confirm="开始语音" onConfirm={() => { setPermission(''); setNotice(''); void voiceCtl.current?.start(); }}>浏览器会请求麦克风权限。你的声音只送到这台服务器上{name}的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。
语音通路还没有经过真机验收,效果可能不稳定。
} +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 | 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; 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 } + + const AGENT_PHASE: Record = { 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 { +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 . Control GETs: /__voice?configured=&tts=, /__refuse/, ++// /__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 [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 & { 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 = {}) => ({ 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 => ({ 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 => ({ 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', () => { diff --git a/docs/rework/hel898/MANIFEST-HEL-898.json b/docs/rework/hel898/MANIFEST-HEL-898.json new file mode 100644 index 0000000..6f91c1f --- /dev/null +++ b/docs/rework/hel898/MANIFEST-HEL-898.json @@ -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" + } +} diff --git a/docs/rework/hel898/author-logs/before-fix-voice.log b/docs/rework/hel898/author-logs/before-fix-voice.log new file mode 100644 index 0000000..97be490 --- /dev/null +++ b/docs/rework/hel898/author-logs/before-fix-voice.log @@ -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]⎯ + diff --git a/docs/rework/hel898/author-logs/browser-hel878.log b/docs/rework/hel898/author-logs/browser-hel878.log new file mode 100644 index 0000000..cc19376 --- /dev/null +++ b/docs/rework/hel898/author-logs/browser-hel878.log @@ -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 diff --git a/docs/rework/hel898/author-logs/browser-hel898.log b/docs/rework/hel898/author-logs/browser-hel898.log new file mode 100644 index 0000000..039c220 --- /dev/null +++ b/docs/rework/hel898/author-logs/browser-hel898.log @@ -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 diff --git a/docs/rework/hel898/author-logs/build.log b/docs/rework/hel898/author-logs/build.log new file mode 100644 index 0000000..eef31a2 --- /dev/null +++ b/docs/rework/hel898/author-logs/build.log @@ -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 diff --git a/docs/rework/hel898/author-logs/lint.log b/docs/rework/hel898/author-logs/lint.log new file mode 100644 index 0000000..1768cfb --- /dev/null +++ b/docs/rework/hel898/author-logs/lint.log @@ -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) + diff --git a/docs/rework/hel898/author-logs/typecheck.log b/docs/rework/hel898/author-logs/typecheck.log new file mode 100644 index 0000000..e69de29 diff --git a/docs/rework/hel898/author-logs/vitest.log b/docs/rework/hel898/author-logs/vitest.log new file mode 100644 index 0000000..48edab3 --- /dev/null +++ b/docs/rework/hel898/author-logs/vitest.log @@ -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) + diff --git a/docs/rework/hel898/manager-checks/build.log b/docs/rework/hel898/manager-checks/build.log new file mode 100644 index 0000000..caf1986 --- /dev/null +++ b/docs/rework/hel898/manager-checks/build.log @@ -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 diff --git a/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log b/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log b/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/manager-checks/lint.log b/docs/rework/hel898/manager-checks/lint.log new file mode 100644 index 0000000..47ffffe --- /dev/null +++ b/docs/rework/hel898/manager-checks/lint.log @@ -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) + diff --git a/docs/rework/hel898/manager-checks/origin-main-handoff.log b/docs/rework/hel898/manager-checks/origin-main-handoff.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/manager-checks/origin-main-handoff.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/manager-checks/origin-main.log b/docs/rework/hel898/manager-checks/origin-main.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/manager-checks/origin-main.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/manager-checks/test.log b/docs/rework/hel898/manager-checks/test.log new file mode 100644 index 0000000..ba61c01 --- /dev/null +++ b/docs/rework/hel898/manager-checks/test.log @@ -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) + diff --git a/docs/rework/hel898/manager-checks/typecheck.log b/docs/rework/hel898/manager-checks/typecheck.log new file mode 100644 index 0000000..5781d36 --- /dev/null +++ b/docs/rework/hel898/manager-checks/typecheck.log @@ -0,0 +1,4 @@ + +> cybergril-web@0.1.0 typecheck +> tsc --noEmit + diff --git a/docs/rework/hel898/preservation-review.md b/docs/rework/hel898/preservation-review.md new file mode 100644 index 0000000..80df41e --- /dev/null +++ b/docs/rework/hel898/preservation-review.md @@ -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和全部未完成要求继续承接。不部署、不合并、不修改任何旧服务/档案或运行时/模型/认证绑定。 diff --git a/docs/rework/hel898/preservation.json b/docs/rework/hel898/preservation.json new file mode 100644 index 0000000..4573204 --- /dev/null +++ b/docs/rework/hel898/preservation.json @@ -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 +}