From 1ae243f286b14789289e0bdca77eec818074c879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E6=A0=96=E7=9F=A5?= <2298888111@qq.com> Date: Mon, 26 Oct 2026 22:08:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?HEL-875:=20=E6=8E=A5=E4=B8=8A=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E4=B8=BB=E5=8A=A8=E7=95=99=E8=A8=80=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E5=85=A8=E8=A7=86=E8=A7=89=E6=80=BB=E7=9B=91=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the exact eight-file HEL-875 patch from the visual director on 31970c4. Independently reran 49 frontend tests, typecheck/lint/build and original Python requirement checks. Candidate only; intake found pagination overflow dropping older reach-outs after >250 new records, requiring author repair before approval or deployment. Co-authored-by: multica-agent --- docs/requirements/handoff-HEL-875.json | 62 ++++++++++ web/src/adapters/inbox.ts | 60 ++++++++++ web/src/adapters/real.ts | 13 ++- web/src/app/RealApp.tsx | 62 +++++++++- web/src/chat/ChatPanel.tsx | 22 +++- web/tests/hel875_mock.mjs | 63 ++++++++++ web/tests/hel875_shots.mjs | 153 +++++++++++++++++++++++++ web/tests/inbox.test.ts | 98 ++++++++++++++++ 8 files changed, 518 insertions(+), 15 deletions(-) create mode 100644 docs/requirements/handoff-HEL-875.json create mode 100644 web/src/adapters/inbox.ts create mode 100644 web/tests/hel875_mock.mjs create mode 100644 web/tests/hel875_shots.mjs create mode 100644 web/tests/inbox.test.ts diff --git a/docs/requirements/handoff-HEL-875.json b/docs/requirements/handoff-HEL-875.json new file mode 100644 index 0000000..e275e12 --- /dev/null +++ b/docs/requirements/handoff-HEL-875.json @@ -0,0 +1,62 @@ +{ + "baseline_version": "1.11", + "read_commit": "31970c4dd3851aae67a922b03264743d504b8e97", + "task": "HEL-875 接线:沿已认可界面把真实主动留言(R4 reach-out,chat_message proactive=1)接进普通入口——打开页面期间接收、去重、未读提示与清除、不抢断阅读与回复;只改视觉总监负责的前端,不重定视觉方向,不改后端", + "requirement_ids": [ + "CG-008", + "CG-011", + "CG-018", + "CG-031", + "VIS-001", + "VIS-002", + "VIS-003", + "VIS-004", + "VIS-005", + "VIS-006", + "VIS-007", + "VIS-008", + "VIS-009", + "VIS-010", + "VIS-011", + "VIS-012", + "VIS-013", + "UX-001", + "UX-002", + "UX-005", + "UX-006", + "AM-001", + "GOV-001", + "GOV-002", + "GOV-003", + "GOV-004", + "GOV-005", + "GOV-006", + "DEL-001" + ], + "deliverable": "自统一母线 origin/hel-870-assembly @ 31970c4 起的前端补丁(仅 web/):1) 接口核实:后端没有 /api/sync;现有只读 GET /api/messages(按行 id 倒序分页、limit+1 探测、next_before,主动行带 proactive:true,见 src/cybergril/api/app.py:399-414 与 life/public.py:26-34)已足够,未改后端、未造假服务。2) web/src/adapters/inbox.ts:打开页面期间每 20 秒(标签页隐藏时停读,回到页面或网络恢复时立即读一次;失败安静退避至最长 2 分钟)读取比已持有行更新的记录,长时间离开会有界回翻;只接收她的主动行(proactive、companion、sent),你自己的轮次及她的回复按 turn_id 已在页面上,绝不重复插入;重复读取、重连、epoch 变化返回同一 id 不再插入。未读规则:进入交谈且页面可见时,已进入页面记录的主动留言都算已读(单句在舞台呈现;多句从此刻留言卡或「回她」进入时打开完整记录并定位第一句;直接点导航「交谈」时舞台只呈现最新一句,较早一句在记录里);交谈中排队、尚未呈现的不算已读;标记为本浏览器 localStorage cg.real.seen(最新已读 id),不写服务器;本浏览器首次加载时既有历史一律视为已读,不把旧历史标成刚来的新消息。3) web/src/app/RealApp.tsx:此刻页沿用已认可的留言卡(letter)——有未读时显示「她留给你的 · 还没看」与她的原话(多句时显示「N 句还没看」),无未读时「最近说过的话」用既有 .letter.read 状态;导航「交谈」沿用既有未读微光点(.nav-item.unread .badge);日子/设置页沿用演示已认可的主动留言卡(.reach),换成真实原话、去掉「模拟」,只有「回她 / 稍后再看」,绝不自行打开交流;一句未读回她时在舞台呈现,多句时打开完整记录并定位第一句未读。4) web/src/chat/ChatPanel.tsx:交谈中到达的新留言先排队,在她正在回答、你暂停或回看、完整记录/全文打开、鼠标停在台词上、当前最后一句尚未停留满时都不插入;条件满足后作为新一轮按既有新轮呈现动效进入;被你的阅读卡住时状态行只用既有文字样式说明「她刚又说了一句 · 继续后呈现 / 读完这段呈现」;草稿不受影响;离开交谈时排队留言并入记录并保持未读。5) 测试与证据:web/tests/inbox.test.ts(隔离 HTTP 合同 fixture,7 项);web/tests/hel875_mock.mjs + hel875_shots.mjs(隔离持久消息 mock + 无头 Edge,25 项浏览器检查、两主题电脑/手机截图与三段帧序列)。普通入口与 ?demo=1 完全分开:演示的「模拟主动消息」未改、未搬入真实入口,演示入口不请求 /api/messages。未新增 CSS、颜色、动效或组件(构建 CSS 产物哈希与改动前一致 index-DdDYzR5S.css)。", + "acceptance": [ + "页面打开时收到服务器新主动留言:此刻页留言卡换成原话并显示未读,导航「交谈」亮未读点;日子/设置页出现主动留言卡;从不自行打开交流(浏览器检查 2–3、9)", + "关闭重开:完整记录、行 id 唯一不重复;未读跨重开保留;本浏览器首次加载既有历史不算新消息(浏览器检查 1、7;inbox.test unread 3 项)", + "重复读取、epoch 递增、503 失败后重连:只保留一份、画面无报错提示(浏览器检查 2;inbox.test 第 1 项)", + "不抢断:她回答中到达的留言等回答结束且最后一句停留满后才作为新一轮进入;暂停时停在你的那一句并以文字说明,继续后进入;草稿保持不变(浏览器检查 4–5,帧序列 m2-admit)", + "读过按明确规则清未读:进入交谈且页面可见时清除(排队未呈现的除外);稍后再看只收起卡片、未读点保留;多句未读时打开记录并定位第一句(浏览器检查 3、8、9)", + "只做网页内文字:未申请麦克风、摄像头或通知权限(浏览器检查计数为 0),无外部推送", + "工程:web tsc 0 错;eslint src 0 错(1 条既有警告在 ChatPanel 全文按钮焦点归还处,非本卡引入);vitest 6 文件 49 项全过(原 42 + 新 7);vite build 通过" + ], + "deferred": [ + "真机手机与 Safari 未测;手机为 390×844 等效视口,不冒充真机(UX-001)", + "未读标记只存本浏览器;同一账号换浏览器或清除站点数据后,既有留言按首次加载规则视为已读;多标签页之间不实时同步已读(下一次读取时各自按自己的标记)。若需要跨设备已读,须后端增加已读字段,此为最小后端需求,交总管决定,本卡未改后端", + "轮询为 20 秒;若要即时到达须后端推送通道(SSE/WebSocket),属后端合同变更,本卡未做", + "她在你等待回复期间生成的主动留言,服务器行 id 可能落在你那一轮的用户行与回复行之间;本页面先呈现回复再呈现留言,重开后按服务器 id 顺序显示,记录内容一致、顺序可能与本次所见不同", + "日子页电脑 1440×900 下,既有 .reach 卡与既有「她此刻」人物窗说明相距很近(未重叠);位置沿用既有样式,如需调整属新视觉,先出样图", + "R5 语音连续对话、语音开启时主动开口;R6 天气/素材包/备份;R7 有条件视觉观察——均未接,界面诚实文案不变", + "用户整体沉浸体验判断与验收由白栖知决定;本卡为功能接线,工程通过不代表项目完成" + ], + "requirement_changes": [], + "verification": [ + "实际读取提交 31970c4dd3851aae67a922b03264743d504b8e97(gitea 归档下载,origin/hel-870-assembly 分支头 API 核对一致);已读 AGENTS.md、docs/requirements/README.md、current.md v1.11、runbook-hel870.md §5/§7、handoff-HEL-870.json 及后端 /api/messages 与 reach_out 实现", + "web:tsc --noEmit 0;eslint src 0 错;vitest run 49/49;vite build 成功(依赖按 package-lock 由本机既有 npm 缓存离线铺设,176 个包,与此前 npm ci 一致,无新增依赖、无下载)", + "浏览器:node tests/hel875_mock.mjs 8875 + node tests/hel875_shots.mjs 8875,25/25 通过;截图覆盖月光/雾光 × 电脑 1440×900 / 手机 390×844", + "需求结构检查:本运行环境无 Python 与 git,check_requirements.py 由逐行移植的 Node 版本执行,比较目标为 gitea 只读取得的 origin/main 与 origin/agent/agent/de8874a3803b,各含/不含本交接;结果见交付评论。Python 原脚本须由总管/总工在有 Python 的环境复跑" + ] +} diff --git a/web/src/adapters/inbox.ts b/web/src/adapters/inbox.ts new file mode 100644 index 0000000..98e9638 --- /dev/null +++ b/web/src/adapters/inbox.ts @@ -0,0 +1,60 @@ +import type { Message } from '../domain/model'; +import { api, toMessage, within, type MessagePage } from './real'; +// ── Her own messages arriving while the page is open (R4 reach-out, CG-011). ── +// The server keeps every reach-out as a chat_message row with proactive=1 and serves it from GET /api/messages with +// ascending ids; there is no push channel, so an open page reads the newest rows now and then. Only text inside the +// page: no microphone, notification or push permission is ever asked for. + +// Server ids are positive integers; local, not yet confirmed messages carry random ids and never count. +export const serverId = (m: Message) => /^[1-9]\d{0,18}$/u.test(m.id) ? Number(m.id) : 0; +export const newestId = (messages: Message[]) => messages.reduce((top, m) => Math.max(top, serverId(m)), 0); +const newestProactive = (messages: Message[]) => messages.reduce((top, m) => m.proactive ? Math.max(top, serverId(m)) : top, 0); + +// Rows newer than `after`, oldest first. Pages back only as far as needed (a long absence on a sleeping tab), bounded. +export async function fetchAfter(after: number, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; newest: number }> { + const rows: Message[] = []; + let before: number | null = null, newest = after; + for (let i = 0; i < pages; i++) { + const page: MessagePage = await api(`messages?limit=50${before ? `&before=${before}` : ''}`, { signal: within(signal, 10000) }); + const list = Array.isArray(page.messages) ? page.messages.filter(m => Number.isSafeInteger(m?.id) && m.id > 0 && typeof m.text === 'string') : []; + for (const m of list) newest = Math.max(newest, m.id); + const fresh = list.filter(m => m.id > after); + rows.unshift(...fresh.map(toMessage)); + // Reached what the page already holds (or the start of the record): stop. + if (fresh.length < list.length || !page.next_before || !after) break; + before = page.next_before; + } + return { rows, newest }; +} + +// What may be added from a poll: her own reach-out rows the page does not hold yet. Your own turns (and her replies to +// them) are already on the page under their turn ids and come in through the chat stream, so they are never added +// twice; a repeated read, a reconnect or a restarted server epoch returns the same ids and adds nothing. +export function arrivals(known: Message[], rows: Message[]): Message[] { + const ids = new Set(known.map(m => m.id)), turns = new Set(known.flatMap(m => m.turnId ? [`${m.role}:${m.turnId}`] : [])); + const out: Message[] = []; + for (const m of [...rows].sort((a, b) => serverId(a) - serverId(b))) { + if (!m.proactive || m.role !== 'companion' || m.status !== 'sent' || !m.text.trim() || ids.has(m.id) || (m.turnId && turns.has(`companion:${m.turnId}`))) continue; + ids.add(m.id); out.push(m); + } + return out; +} + +// ── Unread. One rule: a reach-out that is in the page's record is read once you are in the conversation with the +// page visible (one plays on the stage; several open the record at the first). One still queued is not. The mark is +// the newest seen id, kept in this browser only; it never changes the server. ── +const SEEN = 'cg.real.seen'; +export function storedSeen(): number | null { + try { const raw = localStorage.getItem(SEEN); if (raw === null || !/^\d{1,19}$/u.test(raw)) return null; const v = Number(raw); return Number.isSafeInteger(v) ? v : null; } catch { return null; } +} +export function storeSeen(id: number) { try { localStorage.setItem(SEEN, String(id)); } catch { /* The mark stays in memory for this visit. */ } } +// First visit from this browser: everything already in the record is history, not "just arrived". +export function initialSeen(messages: Message[]): number { + const stored = storedSeen(); + if (stored !== null) return stored; + const seen = newestProactive(messages); + storeSeen(seen); + return seen; +} +export const unreadOf = (messages: Message[], seen: number) => messages.filter(m => m.proactive && serverId(m) > seen); +export const seenThrough = newestProactive; diff --git a/web/src/adapters/real.ts b/web/src/adapters/real.ts index 166b09d..10720f5 100644 --- a/web/src/adapters/real.ts +++ b/web/src/adapters/real.ts @@ -121,7 +121,7 @@ export interface PersonaView { export const loadPersona = (signal: AbortSignal) => api('persona', { signal: within(signal, 10000) }); // Creation-time generation is bounded server-side (one model call, 30 s cap); give the explicit retry room for it. export const retryPersona = (signal: AbortSignal) => api('persona/retry', { method: 'POST', signal: within(signal, 90000) }); -function within(signal: AbortSignal, ms: number) { +export function within(signal: AbortSignal, ms: number) { const c = new AbortController(), stop = () => c.abort(), timer = setTimeout(stop, ms); signal.addEventListener('abort', () => { clearTimeout(timer); stop(); }, { once: true }); return c.signal; @@ -144,15 +144,18 @@ export async function loadReal(): Promise { try { settings.reduced = JSON.parse(localStorage.getItem('cg.real.display') || '{}').reduced === true; } catch { /* Invalid local preference does not affect server data. */ } return { version: 1, identity, settings, events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() }; } +export interface ServerMessage { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status']; lifeIds?: number[]; proactive?: boolean } +export interface MessagePage { messages: ServerMessage[]; next_before: number | null; epoch?: number } +// lifeIds arrive already trimmed by the server to records she has told; older servers omit them. +// proactive arrives only on her own persisted reach-out rows (R4); older servers omit it. +export const toMessage = (m: ServerMessage): Message => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: ints(m.lifeIds) ? m.lifeIds.map(String) : [], turnId: m.turn_id, status: m.status, ...(m.proactive === true ? { proactive: true } : {}) }); export async function loadMessages(): Promise { // Cursor pages are bounded by the server. All pages restore in deterministic id order. const messages: Message[] = []; let before: number | null = null; do { - const page: { messages: { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status']; lifeIds?: number[]; proactive?: boolean }[]; next_before: number | null } = await api(`messages?limit=200${before ? `&before=${before}` : ''}`); - // lifeIds arrive already trimmed by the server to records she has told; older servers omit them. - // proactive arrives only on her own persisted reach-out rows (R4); older servers omit it. - messages.unshift(...page.messages.map(m => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: ints(m.lifeIds) ? m.lifeIds.map(String) : [], turnId: m.turn_id, status: m.status, ...(m.proactive === true ? { proactive: true } : {}) }))); + const page: MessagePage = await api(`messages?limit=200${before ? `&before=${before}` : ''}`); + messages.unshift(...page.messages.map(toMessage)); before = page.next_before; } while (before); return messages; diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx index dccb1a9..544af64 100644 --- a/web/src/app/RealApp.tsx +++ b/web/src/app/RealApp.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, lifeStatus, loadReal, memoryNote, type Health } from '../adapters/real'; +import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, unreadOf } from '../adapters/inbox'; import type { DemoState, Message, Theme } from '../domain/model'; import { Presenter } from '../stage/Presenter'; import { CreationWizard } from '../manage/CreationWizard'; @@ -16,9 +17,14 @@ export function RealApp() { const [theme, setTheme] = useState(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } }); const [herFocus, setHerFocus] = useState(0); const latest = useRef(state); latest.current = state; + // ── Her reach-outs while the page is open (inbox.ts). `cursor` is the newest server row this page has read; + // `incoming` holds what arrived during a conversation until the stage lets it in; `seen` is the unread mark. ── + const cursor = useRef(0), [incoming, setIncoming] = useState([]), held = useRef(incoming); held.current = incoming; + const [seen, setSeen] = useState(0), [fresh, setFresh] = useState(null), [visible, setVisible] = useState(!document.hidden); + const areaNow = useRef(area); areaNow.current = area; const initialize = useCallback(async () => { setError(''); setReady(false); - try { setHealth(await api('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); } + try { setHealth(await api('health')); const next = await loadReal(); cursor.current = next ? newestId(next.messages) : 0; setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); } catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); } }, []); useEffect(() => { void initialize(); }, [initialize]); @@ -28,6 +34,50 @@ export function RealApp() { useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]); useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]); const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []); + // Read the newest rows every 20 s while the page is shown, at once on return or reconnect. A failed read is quiet + // (the next one simply tries again, slower); nothing on screen changes until a new row actually exists. + const live = !!state && !creating && ready; + useEffect(() => { + if (!live) return; + let timer: ReturnType | undefined, running = false, failures = 0, stopped = false, controller = new AbortController(); + const poll = async () => { + if (running || stopped || document.hidden) return; + running = true; clearTimeout(timer); controller = new AbortController(); + try { + const { rows, newest } = await fetchAfter(cursor.current, controller.signal); + if (stopped) return; + cursor.current = Math.max(cursor.current, newest); failures = 0; + const add = latest.current ? arrivals([...latest.current.messages, ...held.current], rows) : []; + if (add.length && latest.current) { + if (areaNow.current === 'talk') setIncoming(list => [...list, ...add]); + else { changeMessages([...latest.current.messages, ...add]); setFresh(add[add.length - 1]); } + } + } catch { failures++; } + finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : 20000); } + }; + const wake = () => { setVisible(!document.hidden); if (!document.hidden) void poll(); }; + document.addEventListener('visibilitychange', wake); addEventListener('online', wake); + timer = setTimeout(() => void poll(), 20000); + return () => { stopped = true; controller.abort(); clearTimeout(timer); document.removeEventListener('visibilitychange', wake); removeEventListener('online', wake); }; + }, [live, changeMessages]); + // Leaving the conversation before the stage let them in: they join the record and stay unread. + useEffect(() => { if (area !== 'talk' && incoming.length && latest.current) { const ids = new Set(latest.current.messages.map(m => m.id)); changeMessages([...latest.current.messages, ...incoming.filter(m => !ids.has(m.id))]); setIncoming([]); } }, [area, incoming, changeMessages]); + const admitted = useCallback((ids: string[]) => setIncoming(list => list.filter(m => !ids.includes(m.id))), []); + // The one unread rule: on the conversation with the page visible, what the page's record holds has been seen + // (a message still queued in `incoming` is not in it yet). + useEffect(() => { + if (area !== 'talk' || !visible || !state) return; + const top = seenThrough(state.messages); + if (top > seen) { setSeen(top); storeSeen(top); } + setFresh(null); + }, [area, visible, state, seen]); + const unread = useMemo(() => state ? unreadOf(state.messages, seen) : [], [state, seen]); + // On 此刻 the letter already carries her words; the card speaks up only over 日子 and 设置. It keeps its words while + // it fades out. + const card = !!fresh && (area === 'days' || area === 'settings'), shownFresh = useRef(null); if (fresh) shownFresh.current = fresh; + useEffect(() => { if (fresh && !unread.some(m => m.id === fresh.id)) setFresh(null); }, [fresh, unread]); + // Reply to her: one new message plays on the stage; several open the record at the first one you have not seen. + const openUnread = () => { setFresh(null); const first = unread[0]; leaveSettingsThen(() => { setArea('talk'); setFocusLife(''); setLog(unread.length > 1); setFocusIds(unread.length > 1 && first ? [first.id] : []); }); }; const ignoreVoice = useCallback(() => {}, []); // Status words follow the server, re-read when you open a page that shows them; a failed read keeps the last one. const life = lifeStatus(health); @@ -53,10 +103,12 @@ export function RealApp() { {!ready ?

一扇亮着的窗。

{error && <>

请检查真实服务是否可达。资料仍在服务器保存。

}
: <> {creating && setCreating(false)}/>} {!creating && state && <> - {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉
{lastReply && }

{memoryNote(health, life)}

{health && !health.llm.ok && }
} - {area === 'talk' && } + {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉
{unread.length ? : lastReply && }

{memoryNote(health, life)}

{health && !health.llm.ok && }
} + {area === 'talk' && } navigate('now')} onChat={openTold} onSaid={openSaid} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/> navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/> - } + {/* Same card as the approved demo reach-out, now with her real words; it never opens the conversation by itself. */} + + } }; } diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index eb8d0e2..3cbc6f5 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -15,11 +15,14 @@ const START: Anchor = { mi: 0, offset: 0 }; const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; }; const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]')); -const NO_IDS: string[] = []; -export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt }: { +const NO_IDS: string[] = [], NO_MESSAGES: Message[] = []; +export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt, incoming = NO_MESSAGES, onAdmit }: { real?: boolean; // Real only: the server accepted a complete-receipt confirmation (the life it may unlock is decided later). onReceipt?: () => void; + // Real only: her own messages that reached the open page while you are here, not yet on the stage. + incoming?: Message[]; + onAdmit?: (ids: string[]) => void; state: DemoState; onChange: (messages: Message[]) => void; onPresence: (p: string) => void; @@ -72,6 +75,11 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, if (cursor > headIndex) head.current = anchor; const held = useRef(new Set()), clock = useRef(new ReadingClock()); const reading = useRef({ pages, cursor, flow }); reading.current = { pages, cursor, flow }; + // Her own message that arrives while you are here waits its turn: never while she is answering you, a sheet is + // open, you paused or went back, or the line on screen has not had its time. Then it comes in as a new turn. + const waiting = useRef({ count: 0, busy: false, sheet: false }), admit = useRef(() => {}); + waiting.current = { count: incoming.length, busy, sheet: log || full }; + admit.current = () => { const add = incoming.filter(m => !live.current.some(k => k.id === m.id)); onAdmit?.(incoming.map(m => m.id)); if (add.length) change([...live.current, ...add]); }; function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); } function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); } @@ -119,8 +127,10 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, let raf = 0; const c = clock.current, away = () => c.pause(); const tick = (now: number) => { - const { pages: list, cursor: at, flow: on } = reading.current; - if (c.tick(now, list[at], on && !held.current.size && !document.hidden) && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); } + const { pages: list, cursor: at, flow: on } = reading.current, w = waiting.current; + const running = on && !held.current.size && !document.hidden, done = c.tick(now, list[at], running); + if (done && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); } + else if (w.count && !w.busy && !w.sheet && running && at >= list.length - 1 && (done || !list.length)) { w.count = 0; admit.current(); } if (meter.current) meter.current.style.transform = `scaleX(${c.progress})`; raf = requestAnimationFrame(tick); }; @@ -214,7 +224,9 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, } const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); }; const lastUser = [...view.messages].reverse().find(m => m.role === 'user'); - const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : !pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型'); + // Only says why her new words wait when it is your own reading that holds them; otherwise they simply come in. + const queued = incoming.length && !busy ? (!flow ? `${name}刚又说了一句 · 继续后呈现` : !atEnd ? `${name}刚又说了一句 · 读完这段呈现` : '') : ''; + const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : queued || (!pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型')); const unread = pages.length - 1 - cursor, single = pages.length <= 1 && !busy, fullMessage = page?.message; const textarea = useRef(null); useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]); diff --git a/web/tests/hel875_mock.mjs b/web/tests/hel875_mock.mjs new file mode 100644 index 0000000..3b062c5 --- /dev/null +++ b/web/tests/hel875_mock.mjs @@ -0,0 +1,63 @@ +// HEL-875 isolated contract mock for her reach-outs on an open page. Serves web/dist and the persisted-message +// contract of the HEL-870 backend: GET /api/messages pages newest-first by row id (limit+1 probe, rows oldest-first, +// next_before) and marks only her reach-out rows proactive:true (api/app.py, life/public.py). Synthetic data only: +// no model, no embeddings, no network. +// Usage: node tests/hel875_mock.mjs . Control: POST-free GETs /__reach?text=, /__epoch, /__down/, /__log. +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dist = fileURLToPath(new URL('../dist/', import.meta.url)); +const port = Number(process.argv[2] || 8875); +const T0 = Date.UTC(2026, 9, 25, 12, 0); +const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } }; +let epoch = 3, down = 0; +const log = [], rows = []; +const add = row => { const id = rows.length + 1; rows.push({ id, ts: T0 + id * 60e3, status: 'sent', lifeIds: [], ...row }); return id; }; +// History: two turns and one reach-out she left long before this browser first opened the page. +add({ role: 'user', text: '下雨天的时候,你会想听什么?', turn_id: '00000000-0000-4000-8000-000000000001' }); +add({ role: 'companion', text: '会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累?', turn_id: '00000000-0000-4000-8000-000000000001' }); +add({ role: 'companion', text: '昨晚的月亮很亮,想起你说过喜欢夜里散步。', turn_id: 'reach-old', proactive: true }); +add({ role: 'user', text: '今天加班到九点,好累。', turn_id: '00000000-0000-4000-8000-000000000002' }); +add({ role: 'companion', text: '那先别急着做别的,喝口热水,慢慢跟我说。', turn_id: '00000000-0000-4000-8000-000000000002' }); +const dto = r => ({ id: r.id, ts: r.ts, role: r.role, text: r.text, turn_id: r.turn_id, status: r.status, lifeIds: r.lifeIds, ...(r.proactive ? { proactive: true } : {}) }); +const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); }; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'), p = url.pathname; + if (!p.startsWith('/__')) log.push(`${req.method} ${p}${url.search}`); + if (p === '/__log') return send(res, 200, log.splice(0)); + if (p === '/__rows') return send(res, 200, rows.map(dto)); + if (p === '/__reach') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '窗外的雨停了,想跟你说一声。', turn_id: `reach-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); } + if (p === '/__epoch') { epoch++; return send(res, 200, { epoch }); } + if (p.startsWith('/__down/')) { down = Number(p.slice(8)); return send(res, 200, { down }); } + if (p === '/api/health') return send(res, 200, { ok: true, version: 'R4-assembly', epoch, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true, mood: true, reach_out: true, semantic: false, semantic_backend: null }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, life: false, voice: false, vision: false, weather: false, materials: false, backup: false, memory: true, semantic_memory: null } }); + if (p === '/api/companion') return send(res, 200, identity); + if (p === '/api/messages') { + if (down > 0) { down--; return send(res, 503, { detail: 'Service Unavailable' }); } + const limit = Math.min(200, Number(url.searchParams.get('limit') || 200)), before = Number(url.searchParams.get('before') || Number.MAX_SAFE_INTEGER); + const desc = rows.filter(r => r.id < before).sort((a, b) => b.id - a.id).slice(0, limit + 1), more = desc.length > limit, page = desc.slice(0, limit); + return send(res, 200, { messages: [...page].reverse().map(dto), next_before: more ? page[page.length - 1].id : null, epoch }); + } + if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] }); + if (p === '/api/life') return send(res, 200, { events: [], next_before: null, epoch }); + if (p === '/api/memories') return send(res, 200, { memories: [], next_before: null, epoch }); + if (p === '/api/chat' && req.method === 'POST') { + let body = ''; for await (const chunk of req) body += chunk; + const { text: said, turn_id } = JSON.parse(body), reply = '我在。今天的雨停了没有?要是还在下,就慢点回家。'; + add({ role: 'user', text: said, turn_id, status: 'pending' }); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + // A slow stream (about 4 s), so a reach-out can land in the middle of her answer. + for (const part of reply.match(/.{1,4}/gu)) { res.write(`data: ${JSON.stringify({ t: 'chat.delta', epoch, turn_id, delta: part })}\n\n`); await sleep(330); } + add({ role: 'companion', text: reply, turn_id }); rows.forEach(r => { if (r.turn_id === turn_id) r.status = 'sent'; }); + res.end(`data: ${JSON.stringify({ t: 'chat.final', epoch, turn_id, utterance: { text: reply, speech_tags: [] }, receipt: null })}\n\n`); + return; + } + if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' }); + const file = normalize(join(dist, p === '/' ? 'index.html' : p)); + if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain'); + try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); } + catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); } +}).listen(port, '127.0.0.1', () => console.log(`hel875 mock on ${port}`)); diff --git a/web/tests/hel875_shots.mjs b/web/tests/hel875_shots.mjs new file mode 100644 index 0000000..90deb8c --- /dev/null +++ b/web/tests/hel875_shots.mjs @@ -0,0 +1,153 @@ +// HEL-875 browser check: drives headless Edge over CDP against tests/hel875_mock.mjs (reach-outs on an open page). +// Usage: node tests/hel875_shots.mjs [port]. Prints one PASS/FAIL line per check, exits 1 on any FAIL. +// A read is triggered the way a returning tab triggers it (visibilitychange), instead of waiting the 20 s interval. +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const [edge, out, portArg] = process.argv.slice(2); +const port = Number(portArg || 8875), base = `http://127.0.0.1:${port}`, cdpPort = 9475; +mkdirSync(out, { recursive: true }); +const profile = join(tmpdir(), `hel875-edge-${Date.now()}`); +const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', 'about:blank'], { stdio: 'ignore' }); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let targets; +for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } } +const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl); +await new Promise(r => ws.addEventListener('open', r, { once: true })); +let seq = 0; const waiting = new Map(); +ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } }); +const cdp = (method, params = {}) => new Promise((ok, ko) => { const id = ++seq; waiting.set(id, { ok, ko }); ws.send(JSON.stringify({ id, method, params })); }); +const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; }; +const results = []; +const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); }; +const ctl = async path => (await fetch(`${base}${path}`)).json(); +const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); }; +const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.click(); return true; })()`); +const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].map(e => e.innerText).join('\\n')`); +const exists = sel => js(`!!document.querySelector(${JSON.stringify(sel)})`); +const said = () => js(`document.querySelector('.conversation .sr')?.textContent || ''`); +const status = () => js(`document.querySelector('.talk-status')?.innerText || ''`); +const read = async () => { await js(`document.dispatchEvent(new Event('visibilitychange')), true`); await sleep(700); }; +const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } }; +async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); } +async function boot(theme, { fresh = false } = {}) { + await cdp('Page.navigate', { url: `${base}/` }); await sleep(500); + await js(`${fresh ? 'localStorage.clear();' : ''} localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`); + await cdp('Page.navigate', { url: `${base}/` }); await sleep(1800); +} +// Frames for the motion evidence: every ~80 ms for `ms`. +async function frames(name, ms) { const list = []; const end = Date.now() + ms; while (Date.now() < end) { const { data } = await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 }); list.push(data); } list.forEach((d, i) => writeFileSync(join(out, `${name}-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64'))); return list.length; } +await cdp('Page.enable'); await cdp('Runtime.enable'); +// Count any request for microphone, camera or notifications. +await cdp('Page.addScriptToEvaluateOnNewDocument', { source: `window.__asked = 0; try { const md = navigator.mediaDevices; if (md) md.getUserMedia = () => { window.__asked++; return Promise.reject(new Error('blocked')); }; } catch {} try { if (window.Notification) Notification.requestPermission = () => { window.__asked++; return Promise.resolve('denied'); }; } catch {}` }); + +// ── 1. First visit: the old reach-out in the record is history, not new. ── +await view('desktop'); await boot('moon', { fresh: true }); await ctl('/__log'); +check('first visit marks existing history as seen', await js(`localStorage.getItem('cg.real.seen')`) === '3' && !(await exists('.nav-item.unread')) && (await text('.home .letter')).includes('最近说过的话'), await js(`localStorage.getItem('cg.real.seen')`)); +await shot('01-moon-desktop-home-history'); + +// ── 2. A reach-out lands while the page is open on the home view. ── +await ctl('/__reach?text=' + encodeURIComponent('窗外的雨停了,想跟你说一声。')); +await js(`document.dispatchEvent(new Event('visibilitychange')), true`); +await frames('m1-reach-in', 1100); +check('on 此刻 the letter takes her real words with the nav mark; no second card', (await text('.home .letter')).includes('窗外的雨停了') && (await text('.home .letter')).includes('还没看') && await exists('.nav-item.unread') && !(await exists('.reach.show'))); +check('it does not open the conversation by itself', await js(`!document.body.classList.contains('talk')`)); +await shot('02-moon-desktop-home-letter'); +// Repeated reads, a restarted epoch and a failed read add nothing and show nothing. +await read(); await read(); await ctl('/__epoch'); await read(); await ctl('/__down/1'); await read(); await read(); +check('repeat/epoch/reconnect reads keep one copy and no error on screen', !(await exists('.toast.show')) && (await text('.home')).split('窗外的雨停了').length === 2); + +// ── 3. Over 日子 the card speaks up; reply to her: the stage presents it; then it counts as seen. ── +await click('.nav-item', '日子'); await sleep(1300); +check('over 日子 the reach-out card shows her real words', (await text('.reach.show')).includes('窗外的雨停了')); +await shot('02b-moon-desktop-days-reach'); +await click('.reach .btn-primary', '回她'); await sleep(1500); +check('回她 opens the conversation at her message', (await said()).includes('窗外的雨停了'), await said()); +check('seen once shown on the stage: mark cleared, stored id advanced', !(await exists('.nav-item.unread')) && await js(`localStorage.getItem('cg.real.seen')`) === '6'); +await shot('03-moon-desktop-talk-reach'); +await click('.log-btn'); await sleep(900); +check('record holds it once, tagged as hers', (await text('.log-panel .scroll')).split('窗外的雨停了').length === 2 && (await text('.log-panel')).includes('她主动说的')); +await click('.log-panel .btn-round'); await sleep(600); + +// ── 4. A reach-out during her answer waits; your draft stays. ── +await js(`(() => { const t = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, '我刚到家'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); +await js(`document.querySelector('.send').click(), true`); await sleep(1200); +await js(`(() => { const t = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, '还没写完的草稿'); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); +await ctl('/__reach?text=' + encodeURIComponent('刚才楼下有人在弹吉他,弹得很慢。')); +await read(); +const during = await said(); +check('while she is answering, the new reach-out does not cut in', !during.includes('弹吉他'),`${during} | ${await status()}`); +await shot('04-moon-desktop-during-reply'); +// Wait for the stream to end and her last line to have its time; then it comes in on its own. +// Frames are kept around the moment it enters: the last ones before and about 1.2 s after. +const film = []; let arrived = false; +for (let i = 0; i < 160 && !arrived; i++) { film.push((await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 })).data); arrived = (await said()).includes('弹吉他'); } +for (const end = Date.now() + 1200; Date.now() < end;) film.push((await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 })).data); +film.slice(-16).forEach((d, i) => writeFileSync(join(out, `m2-admit-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64'))); +check('after her answer has had its time, the reach-out comes in as a new turn', arrived); +check('draft untouched', await js(`document.querySelector('textarea').value`) === '还没写完的草稿'); +await shot('05-moon-desktop-admitted'); + +// ── 5. Paused reading holds it, with a quiet reason; resuming lets it in. ── +await click('.reader-bar .flow'); await sleep(400); +await ctl('/__reach?text=' + encodeURIComponent('今天的晚霞是粉色的。')); await read(); await sleep(1500); +check('paused: stays on your line and says why', !(await said()).includes('晚霞') && (await status()).includes('继续后呈现'), await status()); +await shot('06-moon-desktop-paused-held'); +await click('.reader-bar .flow'); arrived = false; for (let i = 0; i < 30 && !arrived; i++) { await sleep(500); arrived = (await said()).includes('晚霞'); } +check('resume lets it in', arrived); + +// ── 6. Leaving the conversation with one held: it joins the record as unread. ── +await click('.reader-bar .flow'); await sleep(300); +await ctl('/__reach?text=' + encodeURIComponent('我把书签夹在第三十页了。')); await read(); await sleep(600); +await click('.nav-item', '此刻'); await sleep(1200); +check('held one becomes unread on leaving', await exists('.nav-item.unread') && (await text('.home .letter')).includes('书签')); +await shot('07-moon-desktop-home-unread'); + +// ── 7. Reopen: same record, no duplicates, unread still unread. ── +const before = await ctl('/__rows'); +await cdp('Page.reload'); await sleep(2000); +const ids = await js(`(async () => (await (await fetch('/api/messages?limit=200')).json()).messages.map(m => m.id))()`); +check('reopen: full record, unique ids', ids.length === before.length && new Set(ids).size === ids.length, `${ids.length} rows`); +check('reopen: unread survives a reload', await exists('.nav-item.unread') && (await text('.home .letter')).includes('书签')); + +// ── 8. Two unread: 回她/letter opens the record at the first one. ── +await ctl('/__reach?text=' + encodeURIComponent('还有,明天可能降温。')); await read(); +check('two unread counted on the letter', (await text('.home .letter')).includes('2 句还没看')); +await click('.home .letter'); await sleep(1300); +check('several unread open the record at the first', await js(`!!document.querySelector('.log-panel:not([hidden]) .message.hl')`) && (await text('.log-panel .message.hl')).includes('书签')); +await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900); +check('all seen after opening the record', !(await exists('.nav-item.unread'))); +check('no microphone, camera or notification request', await js('window.__asked') === 0); + +// ── 9. Evidence: both themes, desktop and phone, with a fresh reach-out on home and in the days pane. ── +for (const theme of ['moon', 'fog']) for (const v of ['desktop', 'phone']) { + await view(v); await boot(theme); + await click('.nav-item', '日子'); await sleep(1300); + await ctl('/__reach?text=' + encodeURIComponent('雨停了,窗台上还有一小洼水。')); await read(); await sleep(500); + await shot(`10-${theme}-${v}-days-reach`); + await click('.reach button', '稍后再看'); await sleep(700); + check(`${theme}/${v}: 稍后再看 hides the card, the mark stays`, !(await exists('.reach.show')) && await exists('.nav-item.unread')); + await click('.nav-item', '此刻'); await sleep(1200); + await shot(`11-${theme}-${v}-home-unread`); + await click('.nav-item', '交谈'); await sleep(1600); + await shot(`12-${theme}-${v}-talk`); + await click('.nav-item', '此刻'); await sleep(800); +} +await view('phone'); await boot('fog'); +await click('.nav-item', '日子'); await sleep(1200); +await ctl('/__reach?text=' + encodeURIComponent('我在窗边等你。')); await read(); await sleep(400); +await frames('m3-reach-paned-phone', 900); +await shot('13-fog-phone-days-reach'); + +// ── 10. The demo entry stays separate: no real message reads. ── +await ctl('/__log'); +await cdp('Page.navigate', { url: `${base}/?demo=1` }); await sleep(2500); +check('demo entry makes no real message requests', !(await ctl('/__log')).some(l => l.includes('/api/messages'))); + +ws.close(); browser.kill(); +try { rmSync(profile, { recursive: true, force: true }); } catch { /* Edge may still hold the profile. */ } +const failed = results.filter(r => !r).length; +console.log(`${results.length - failed}/${results.length} passed`); +process.exit(failed ? 1 : 0); diff --git a/web/tests/inbox.test.ts b/web/tests/inbox.test.ts new file mode 100644 index 0000000..568d306 --- /dev/null +++ b/web/tests/inbox.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, storedSeen, unreadOf } from '../src/adapters/inbox'; +import { loadMessages, type ServerMessage } from '../src/adapters/real'; +import type { Message } from '../src/domain/model'; + +// An isolated copy of the persisted-message contract (api/app.py GET /api/messages + life/public.py message()): +// newest first by id, limit+1 probe, rows returned oldest-first, next_before = oldest id on the page when more exist. +// No model, no embeddings, no network. +class Archive { + rows: ServerMessage[] = []; epoch = 1; calls: string[] = []; down = 0; + add(row: Partial & { role: ServerMessage['role'] }) { const id = this.rows.length + 1; this.rows.push({ id, text: `第${id}条`, turn_id: `t${id}`, status: 'sent', lifeIds: [], ...row } as ServerMessage); return id; } + reach(text = '窗外的雨停了,想跟你说一声。') { return this.add({ role: 'companion', text, proactive: true, turn_id: `reach-${this.rows.length + 1}` }); } + fetch = vi.fn(async (url: string) => { + this.calls.push(url); + if (this.down) { this.down--; throw new TypeError('Failed to fetch'); } + const u = new URL(url, 'http://x'), limit = Number(u.searchParams.get('limit') || 200), before = Number(u.searchParams.get('before') || Number.MAX_SAFE_INTEGER); + const desc = this.rows.filter(r => r.id < before).sort((a, b) => b.id - a.id).slice(0, limit + 1), more = desc.length > limit, page = desc.slice(0, limit); + return new Response(JSON.stringify({ messages: [...page].reverse(), next_before: more ? page[page.length - 1].id : null, epoch: this.epoch })); + }); +} +function storage() { const data = new Map(); vi.stubGlobal('localStorage', { getItem: (k: string) => data.has(k) ? data.get(k)! : null, setItem: (k: string, v: string) => void data.set(k, v) }); return data; } +const signal = () => new AbortController().signal; +// One poll the way RealApp runs it: read after the cursor, keep only what the page does not hold. +async function poll(page: { messages: Message[]; cursor: number }) { + const { rows, newest } = await fetchAfter(page.cursor, signal()); + page.cursor = Math.max(page.cursor, newest); + const add = arrivals(page.messages, rows); page.messages = [...page.messages, ...add]; + return add; +} +afterEach(() => vi.unstubAllGlobals()); + +describe('reach-outs reaching an open page', () => { + it('adds a new reach-out once; repeated reads, a restarted epoch and a reconnect add nothing more', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user', turn_id: 'a' }); server.add({ role: 'companion', turn_id: 'a' }); + const page = { messages: await loadMessages(), cursor: 0 }; page.cursor = newestId(page.messages); + expect(await poll(page)).toEqual([]); + const id = server.reach(); + const first = await poll(page); + expect(first.map(m => [m.id, m.proactive, m.role])).toEqual([[String(id), true, 'companion']]); + expect(await poll(page)).toEqual([]); + server.epoch = 2; expect(await poll(page)).toEqual([]); + server.down = 1; await expect(poll(page)).rejects.toThrow(); expect(await poll(page)).toEqual([]); + expect(page.messages.filter(m => m.id === String(id))).toHaveLength(1); + // Reopening reads the same record in the same order: nothing lost, nothing doubled. + expect((await loadMessages()).map(m => m.id)).toEqual(page.messages.map(m => m.id)); + }); + it('never re-adds your own turn or her reply to it, and ignores rows still being written', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + // The page holds your message and her streamed reply under local ids; the server has them under row ids. + const page: { messages: Message[]; cursor: number } = { messages: [{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }], cursor: 0 }; + server.add({ role: 'user', turn_id: 'turn-x', text: '在吗' }); server.add({ role: 'companion', turn_id: 'turn-x', text: '在' }); + server.add({ role: 'user', turn_id: 'turn-y', status: 'pending' }); + server.add({ role: 'companion', proactive: true, turn_id: 'turn-x' }); + expect(await poll(page)).toEqual([]); + expect(page.cursor).toBe(4); + }); + it('pages back after a long absence and keeps the order of what arrived', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = { messages: await loadMessages(), cursor: 1 }; + const ids = [server.reach('一')]; for (let i = 0; i < 70; i++) server.add({ role: 'companion' }); ids.push(server.reach('二')); + const added = await poll(page); + expect(added.map(m => m.text)).toEqual(['一', '二']); + expect(added.map(m => m.id)).toEqual(ids.map(String)); + expect(server.calls.filter(c => c.includes('limit=50'))).toHaveLength(2); + }); + it('reads from an empty record without paging the whole history', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + const page = { messages: [] as Message[], cursor: 0 }; + server.reach(); + expect(await poll(page)).toHaveLength(1); + expect(server.calls).toHaveLength(1); + }); +}); + +describe('unread', () => { + it('first visit from this browser counts the old record as seen; later arrivals stay unread until seen', () => { + const data = storage(); + const history: Message[] = [{ id: '3', role: 'companion', text: '旧的', lifeIds: [], proactive: true }, { id: '4', role: 'user', text: '嗯', lifeIds: [] }]; + const seen = initialSeen(history); + expect(seen).toBe(3); expect(unreadOf(history, seen)).toEqual([]); expect(data.get('cg.real.seen')).toBe('3'); + const later = [...history, { id: '9', role: 'companion' as const, text: '新的', lifeIds: [], proactive: true }]; + expect(unreadOf(later, initialSeen(later)).map(m => m.id)).toEqual(['9']); + storeSeen(seenThrough(later)); expect(unreadOf(later, storedSeen()!)).toEqual([]); + }); + it('an empty record on first visit leaves a zero mark, so her first reach-out is unread', () => { + storage(); + expect(initialSeen([])).toBe(0); + expect(unreadOf([{ id: '1', role: 'companion', text: '你好', lifeIds: [], proactive: true }], initialSeen([]))).toHaveLength(1); + }); + it('a damaged or unavailable mark falls back to treating the record as history', () => { + vi.stubGlobal('localStorage', { getItem: () => 'abc', setItem: () => {} }); + expect(initialSeen([{ id: '5', role: 'companion', text: 'x', lifeIds: [], proactive: true }])).toBe(5); + vi.stubGlobal('localStorage', { getItem: () => { throw new Error('denied'); }, setItem: () => { throw new Error('denied'); } }); + expect(initialSeen([{ id: '6', role: 'companion', text: 'x', lifeIds: [], proactive: true }])).toBe(6); + }); +}); From bf234570c0b6595a72f8dbc79e42c63685e08e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E6=A0=96=E7=9F=A5?= <2298888111@qq.com> Date: Mon, 26 Oct 2026 22:28:54 +0800 Subject: [PATCH 2/2] HEL-880: preserve inbox pagination gaps until reach-outs are delivered Co-authored-by: multica-agent --- docs/requirements/handoff-HEL-880.json | 62 ++++++++++++++ web/src/adapters/inbox.ts | 35 +++++--- web/src/app/RealApp.tsx | 17 ++-- web/tests/hel875_mock.mjs | 4 +- web/tests/hel875_shots.mjs | 13 +++ web/tests/inbox.test.ts | 109 ++++++++++++++++++++++--- 6 files changed, 210 insertions(+), 30 deletions(-) create mode 100644 docs/requirements/handoff-HEL-880.json diff --git a/docs/requirements/handoff-HEL-880.json b/docs/requirements/handoff-HEL-880.json new file mode 100644 index 0000000..1fe85e3 --- /dev/null +++ b/docs/requirements/handoff-HEL-880.json @@ -0,0 +1,62 @@ +{ + "baseline_version": "1.11", + "read_commit": "1ae243f286b14789289e0bdca77eec818074c879", + "task": "HEL-880 返工(父卡 HEL-875):补齐长时间离开页面后的主动留言续读——有界分页读取未覆盖旧游标时不再把游标推进到最新行,保留缺口并在后续读取中续读,所有主动留言按 id 顺序且仅一次进入;只改前端逻辑与测试/交接,不改界面、CSS、后端、已读政策,不增加推送", + "requirement_ids": [ + "CG-008", + "CG-011", + "CG-018", + "CG-031", + "VIS-001", + "VIS-002", + "VIS-003", + "VIS-004", + "VIS-005", + "VIS-006", + "VIS-007", + "VIS-008", + "VIS-009", + "VIS-010", + "VIS-011", + "VIS-012", + "VIS-013", + "UX-001", + "UX-002", + "UX-005", + "UX-006", + "AM-001", + "GOV-001", + "GOV-002", + "GOV-003", + "GOV-004", + "GOV-005", + "GOV-006", + "DEL-001" + ], + "deliverable": "自候选 hel-875-proactive-wiring @ 1ae243f 起的前端补丁(仅 web/ 与本交接):1) 缺陷:web/src/adapters/inbox.ts 的 fetchAfter 每次最多读 5 页×50 条,未读到旧游标就把本次见到的最新 id 作为 newest 返回,RealApp 随即推进游标,缺口内较早的主动留言永远不再读取(总管 fixture:页面持有 id=1,id=2 为主动留言,普通记录至 id=301,arrivals 为空)。另:after=0 时只读首页即停,超过 50 条同样漏读。2) 修复:游标改为 InboxCursor { through, gap }——through 以下全部已读并已交出;有界读取停在中途时 gap 记住下一次从哪一行(before)往下续读、已读到哪一行(top),以及途中读到的她的主动留言(held,只留主动行,内存有界)。下一次调用先沿 gap 往下续读,碰到 through 或记录开头即一次性按 id 顺序交出 held,through 推进到 top;若这个缺口是之前留下的,同一次调用再读一次最新页取新到的记录。读取失败或中止时整体抛出,不改调用方游标,下次从同一位置重来;不以扩大上限掩盖。较新的留言只等待尚欠的读取(每次 250 条,缺口未补齐时 RealApp 1 秒后续读,而非 20 秒),不会因最旧记录永久卡住。3) RealApp.tsx:游标存 InboxCursor,成功才提交;缺口存在时 1 秒后续读,失败退避与隐藏时停读不变。去重、排序(arrivals 不变,交出顺序随 through 单调递增)、交谈中排队不抢断、草稿、未读规则 cg.real.seen、演示入口隔离均未改。ChatPanel、CSS、后端、服务、档案均未改;构建 CSS 产物仍为 index-DdDYzR5S.css。4) 测试:web/tests/inbox.test.ts 改用新游标并新增 6 项(总管原 fixture 精确复现、1200 条散布 13 句主动留言、after=0 超过 50 条、分页第 3 页失败后恢复、追读期间又有新消息、重复读取/epoch 变化/页面已持有不重复);web/tests/hel875_mock.mjs 增 /__bulk/,hel875_shots.mjs 增 8b 长时间离开步骤(2 项)。", + "acceptance": [ + "总管 fixture(持有 id=1;id=2 主动留言;普通记录至 301):首次读取不交出、游标保留缺口 before=52/top=301;第二次读取交出 id=2,游标推进至 301(inbox.test「HEL-875 review fixture」)", + "超过 250 条:1200 条中散布的 13 句主动留言全部按 id 顺序各进入一次,追平后再读不新增(inbox.test)", + "after=0 且超过 50 条:读过首页继续续读,两句主动留言全部进入(inbox.test)", + "分页中途失败:调用抛出、页面游标与失败前完全一致;恢复后补齐全部留言(inbox.test)", + "分页途中又有新消息:先交出缺口内较早的,再交出新到的,各一次(inbox.test)", + "重复读取同一游标结果一致、epoch 变化不影响、页面已持有的不再加入(inbox.test)", + "无头 Edge:长时间离开(中间 320 条普通记录)回到页面后此刻留言卡显示「2 句还没看」,记录里各一句、较早者在前并定位到较早一句(浏览器检查 8b);原 25 项检查全部仍通过", + "工程:tsc 0 错;eslint src 0 错(1 条既有警告,非本卡);vitest 6 文件 55/55(原 49 + 新 6);vite build 通过" + ], + "deferred": [ + "原版 Python 需求检查未在本运行时执行(无 Python、无 git);须总管/总工在有 Python 的环境对 origin/hel-870-assembly、origin/main 与原产品目标 agent/agent/de8874a3803b 含/不含本交接复跑", + "真机手机与 Safari 未测;手机为 390×844 等效视口(UX-001)", + "缺口追读期间较新的主动留言要等缺口补齐才出现(每秒续读 250 条;例如离开期间 1 万条约需 40 秒);若需即时到达须后端推送或按 id 正向分页(after 参数),属后端合同变更,本卡未做", + "页面若在缺口补齐前关闭或刷新,重开时按完整记录加载,不丢失;但 held 不跨页面保存", + "HEL-875 既有承接项不变:未读标记只存本浏览器、20 秒轮询、等待回复期间的留言顺序说明、日子页 .reach 位置等,见 handoff-HEL-875.json", + "R5 语音、R6 天气/素材/备份、R7 有条件视觉观察仍由主卡 HEL-797 承接;用户整体沉浸体验验收由白栖知决定" + ], + "requirement_changes": [], + "verification": [ + "实际读取提交 1ae243f286b14789289e0bdca77eec818074c879(gitea 归档下载 zip SHA256 E55C5E95B7DF575D56E305961E29A85B46CCBE963818271754208641C1FCE87C);已读 AGENTS.md、docs/requirements/README.md、current.md v1.11、coverage.md、changes.md、sources/integration-plan-v1.md、handoff-HEL-875.json 及后端 GET /api/messages(api/app.py:id < before 倒序、limit+1 探测、next_before 为页内最旧 id)", + "web:tsc --noEmit 0;eslint src 0 错;vitest run 55/55;vite build 成功(node_modules 自 HEL-875 同一 package-lock 树离线复制,无新增依赖、无下载)", + "浏览器:node tests/hel875_mock.mjs 8880 + node tests/hel875_shots.mjs 8880,27/27 通过(含新增 8b 两项)", + "需求结构检查:仅用 HEL-875 留下的 Node 移植版作辅助参考(基准文件经 gitea 只读取得)——origin/main、agent/agent/de8874a3803b、hel-870-assembly@31970c4(本候选实际基点)各含/不含本交接均通过;对 hel-870-assembly 现头 e978e878 失败,原因是该分支在候选之后合入 HEL-874、追加了来源 comment-evidence-full-experience-20261026.json,候选尚未包含,与本补丁无关,合并前需先同步该分支。不代表原 Python 脚本通过" + ] +} diff --git a/web/src/adapters/inbox.ts b/web/src/adapters/inbox.ts index 98e9638..627f3c7 100644 --- a/web/src/adapters/inbox.ts +++ b/web/src/adapters/inbox.ts @@ -10,21 +10,34 @@ export const serverId = (m: Message) => /^[1-9]\d{0,18}$/u.test(m.id) ? Number(m export const newestId = (messages: Message[]) => messages.reduce((top, m) => Math.max(top, serverId(m)), 0); const newestProactive = (messages: Message[]) => messages.reduce((top, m) => m.proactive ? Math.max(top, serverId(m)) : top, 0); -// Rows newer than `after`, oldest first. Pages back only as far as needed (a long absence on a sleeping tab), bounded. -export async function fetchAfter(after: number, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; newest: number }> { +// Where reading stands. Every row up to `through` has been read and handed over. The server only pages backwards from +// the newest row, so after a long absence one bounded read may stop short: then `gap` remembers it. Rows from `before` +// up to `top` are read, and her reach-outs among them are `held`; rows between `through` and `before` are not read yet. +export interface InboxCursor { through: number; gap: { before: number; top: number; held: Message[] } | null } +export const startAt = (through: number): InboxCursor => ({ through, gap: null }); + +// Her reach-outs newer than the cursor, oldest first, and where the next read starts. At most `pages` pages per call. +// A read that stops short hands over nothing yet: the next call goes on down from where it stopped (before looking for +// anything newer), and when it meets `through` everything held is handed over at once, in id order, exactly once. So an +// older reach-out is never skipped, and newer ones wait only for the reads still owed, never for good. A failed or +// aborted read throws and leaves the cursor it was given untouched; the same call simply runs again. +export async function fetchAfter(cursor: InboxCursor, signal: AbortSignal, pages = 5): Promise<{ rows: Message[]; cursor: InboxCursor }> { const rows: Message[] = []; - let before: number | null = null, newest = after; + let through = cursor.through, gap = cursor.gap && { ...cursor.gap, held: [...cursor.gap.held] }, carried = !!gap; for (let i = 0; i < pages; i++) { - const page: MessagePage = await api(`messages?limit=50${before ? `&before=${before}` : ''}`, { signal: within(signal, 10000) }); + const page: MessagePage = await api(`messages?limit=50${gap ? `&before=${gap.before}` : ''}`, { signal: within(signal, 10000) }); const list = Array.isArray(page.messages) ? page.messages.filter(m => Number.isSafeInteger(m?.id) && m.id > 0 && typeof m.text === 'string') : []; - for (const m of list) newest = Math.max(newest, m.id); - const fresh = list.filter(m => m.id > after); - rows.unshift(...fresh.map(toMessage)); - // Reached what the page already holds (or the start of the record): stop. - if (fresh.length < list.length || !page.next_before || !after) break; - before = page.next_before; + const fresh = list.filter(m => m.id > through), opened = !gap; + gap ??= { before: 0, top: list.reduce((top, m) => Math.max(top, m.id), through), held: [] }; + gap.held.unshift(...fresh.filter(m => m.proactive === true).map(toMessage)); + // Not yet down to what was read before, and more of the record below: keep the gap for the next page or call. + if (fresh.length === list.length && page.next_before && Number.isSafeInteger(page.next_before)) { gap.before = page.next_before; continue; } + rows.push(...gap.held); through = gap.top; gap = null; + // A gap left by an earlier call is closed; look once for anything newer. Otherwise the newest rows were read. + if (opened || !carried) break; + carried = false; } - return { rows, newest }; + return { rows, cursor: { through, gap } }; } // What may be added from a poll: her own reach-out rows the page does not hold yet. Your own turns (and her replies to diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx index 544af64..25758f0 100644 --- a/web/src/app/RealApp.tsx +++ b/web/src/app/RealApp.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, lifeStatus, loadReal, memoryNote, type Health } from '../adapters/real'; -import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, unreadOf } from '../adapters/inbox'; +import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, unreadOf } from '../adapters/inbox'; import type { DemoState, Message, Theme } from '../domain/model'; import { Presenter } from '../stage/Presenter'; import { CreationWizard } from '../manage/CreationWizard'; @@ -17,14 +17,14 @@ export function RealApp() { const [theme, setTheme] = useState(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } }); const [herFocus, setHerFocus] = useState(0); const latest = useRef(state); latest.current = state; - // ── Her reach-outs while the page is open (inbox.ts). `cursor` is the newest server row this page has read; + // ── Her reach-outs while the page is open (inbox.ts). `cursor` is how far this page has read the server record; // `incoming` holds what arrived during a conversation until the stage lets it in; `seen` is the unread mark. ── - const cursor = useRef(0), [incoming, setIncoming] = useState([]), held = useRef(incoming); held.current = incoming; + const cursor = useRef(startAt(0)), [incoming, setIncoming] = useState([]), held = useRef(incoming); held.current = incoming; const [seen, setSeen] = useState(0), [fresh, setFresh] = useState(null), [visible, setVisible] = useState(!document.hidden); const areaNow = useRef(area); areaNow.current = area; const initialize = useCallback(async () => { setError(''); setReady(false); - try { setHealth(await api('health')); const next = await loadReal(); cursor.current = next ? newestId(next.messages) : 0; setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); } + try { setHealth(await api('health')); const next = await loadReal(); cursor.current = startAt(next ? newestId(next.messages) : 0); setSeen(next ? initialSeen(next.messages) : 0); setState(next); setCreating(!next); setReady(true); } catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); } }, []); useEffect(() => { void initialize(); }, [initialize]); @@ -35,7 +35,8 @@ export function RealApp() { useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]); const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []); // Read the newest rows every 20 s while the page is shown, at once on return or reconnect. A failed read is quiet - // (the next one simply tries again, slower); nothing on screen changes until a new row actually exists. + // (the next one simply tries again, slower); nothing on screen changes until a new row actually exists. A read that + // stopped short of what was read before goes on after a second, until it has caught up. const live = !!state && !creating && ready; useEffect(() => { if (!live) return; @@ -44,16 +45,16 @@ export function RealApp() { if (running || stopped || document.hidden) return; running = true; clearTimeout(timer); controller = new AbortController(); try { - const { rows, newest } = await fetchAfter(cursor.current, controller.signal); + const { rows, cursor: next } = await fetchAfter(cursor.current, controller.signal); if (stopped) return; - cursor.current = Math.max(cursor.current, newest); failures = 0; + cursor.current = next; failures = 0; const add = latest.current ? arrivals([...latest.current.messages, ...held.current], rows) : []; if (add.length && latest.current) { if (areaNow.current === 'talk') setIncoming(list => [...list, ...add]); else { changeMessages([...latest.current.messages, ...add]); setFresh(add[add.length - 1]); } } } catch { failures++; } - finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : 20000); } + finally { running = false; if (!stopped) timer = setTimeout(() => void poll(), failures ? Math.min(120000, 20000 * 2 ** failures) : cursor.current.gap ? 1000 : 20000); } }; const wake = () => { setVisible(!document.hidden); if (!document.hidden) void poll(); }; document.addEventListener('visibilitychange', wake); addEventListener('online', wake); diff --git a/web/tests/hel875_mock.mjs b/web/tests/hel875_mock.mjs index 3b062c5..e812bb4 100644 --- a/web/tests/hel875_mock.mjs +++ b/web/tests/hel875_mock.mjs @@ -2,7 +2,7 @@ // contract of the HEL-870 backend: GET /api/messages pages newest-first by row id (limit+1 probe, rows oldest-first, // next_before) and marks only her reach-out rows proactive:true (api/app.py, life/public.py). Synthetic data only: // no model, no embeddings, no network. -// Usage: node tests/hel875_mock.mjs . Control: POST-free GETs /__reach?text=, /__epoch, /__down/, /__log. +// Usage: node tests/hel875_mock.mjs . Control: POST-free GETs /__reach?text=, /__epoch, /__down/, /__bulk/, /__log. import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; @@ -31,6 +31,8 @@ createServer(async (req, res) => { if (p === '/__log') return send(res, 200, log.splice(0)); if (p === '/__rows') return send(res, 200, rows.map(dto)); if (p === '/__reach') { const id = add({ role: 'companion', text: url.searchParams.get('text') || '窗外的雨停了,想跟你说一声。', turn_id: `reach-${rows.length + 1}`, proactive: true }); return send(res, 200, { id }); } + // HEL-880: a long absence elsewhere — n plain turns (no reach-outs) land at once. + if (p.startsWith('/__bulk/')) { const n = Number(p.slice(8)); for (let i = 0; i < n; i++) add({ role: i % 2 ? 'companion' : 'user', text: `别处的第${i + 1}句`, turn_id: `bulk-${rows.length + 1}` }); return send(res, 200, { top: rows.length }); } if (p === '/__epoch') { epoch++; return send(res, 200, { epoch }); } if (p.startsWith('/__down/')) { down = Number(p.slice(8)); return send(res, 200, { down }); } if (p === '/api/health') return send(res, 200, { ok: true, version: 'R4-assembly', epoch, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true, mood: true, reach_out: true, semantic: false, semantic_backend: null }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, life: false, voice: false, vision: false, weather: false, materials: false, backup: false, memory: true, semantic_memory: null } }); diff --git a/web/tests/hel875_shots.mjs b/web/tests/hel875_shots.mjs index 90deb8c..6c2c2a0 100644 --- a/web/tests/hel875_shots.mjs +++ b/web/tests/hel875_shots.mjs @@ -119,6 +119,19 @@ await click('.home .letter'); await sleep(1300); check('several unread open the record at the first', await js(`!!document.querySelector('.log-panel:not([hidden]) .message.hl')`) && (await text('.log-panel .message.hl')).includes('书签')); await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900); check('all seen after opening the record', !(await exists('.nav-item.unread'))); + +// ── 8b. HEL-880: back after a long absence — more rows than one bounded read (5 × 50). The older reach-out is not +// skipped; both arrive once, in order, after the follow-up reads. ── +await ctl('/__reach?text=' + encodeURIComponent('你走之后我把灯调暗了一点。')); await ctl('/__bulk/320'); +await ctl('/__reach?text=' + encodeURIComponent('回来了就好。')); await ctl('/__log'); +await read(); await sleep(2500); +const caught = await text('.home .letter'), reads = (await ctl('/__log')).filter(l => l.includes('/api/messages')).length; +check('long absence: the older reach-out is not skipped; both counted once', caught.includes('2 句还没看') && caught.includes('回来了就好'), `${caught.replace(/\n/g, ' ')} | ${reads} reads`); +await click('.home .letter'); await sleep(1300); +const absent = await text('.log-panel .scroll'); +check('long absence: record holds each once, older one first and opened at', absent.split('灯调暗').length === 2 && absent.split('回来了就好').length === 2 && absent.indexOf('灯调暗') < absent.indexOf('回来了就好') && (await text('.log-panel .message.hl')).includes('灯调暗')); +await shot('08b-moon-desktop-long-absence'); +await click('.log-panel .btn-round'); await sleep(500); await click('.nav-item', '此刻'); await sleep(900); check('no microphone, camera or notification request', await js('window.__asked') === 0); // ── 9. Evidence: both themes, desktop and phone, with a fresh reach-out on home and in the days pane. ── diff --git a/web/tests/inbox.test.ts b/web/tests/inbox.test.ts index 568d306..27091d8 100644 --- a/web/tests/inbox.test.ts +++ b/web/tests/inbox.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, storeSeen, storedSeen, unreadOf } from '../src/adapters/inbox'; +import { arrivals, fetchAfter, initialSeen, newestId, seenThrough, startAt, storeSeen, storedSeen, unreadOf, type InboxCursor } from '../src/adapters/inbox'; import { loadMessages, type ServerMessage } from '../src/adapters/real'; import type { Message } from '../src/domain/model'; @@ -20,20 +20,29 @@ class Archive { } function storage() { const data = new Map(); vi.stubGlobal('localStorage', { getItem: (k: string) => data.has(k) ? data.get(k)! : null, setItem: (k: string, v: string) => void data.set(k, v) }); return data; } const signal = () => new AbortController().signal; -// One poll the way RealApp runs it: read after the cursor, keep only what the page does not hold. -async function poll(page: { messages: Message[]; cursor: number }) { - const { rows, newest } = await fetchAfter(page.cursor, signal()); - page.cursor = Math.max(page.cursor, newest); +// One poll the way RealApp runs it: read after the cursor, commit it only when the read succeeded, keep only what the +// page does not hold. +type Page = { messages: Message[]; cursor: InboxCursor }; +async function poll(page: Page) { + const { rows, cursor } = await fetchAfter(page.cursor, signal()); + page.cursor = cursor; const add = arrivals(page.messages, rows); page.messages = [...page.messages, ...add]; return add; } +// Polls again while a gap is open, as RealApp does a second later; returns everything that arrived, in order. +async function catchUp(page: Page, most = 50) { + const all: Message[] = []; + for (let i = 0; i < most; i++) { all.push(...await poll(page)); if (!page.cursor.gap) return all; } + throw new Error('never caught up'); +} +const opened = (messages: Message[]): Page => ({ messages, cursor: startAt(newestId(messages)) }); afterEach(() => vi.unstubAllGlobals()); describe('reach-outs reaching an open page', () => { it('adds a new reach-out once; repeated reads, a restarted epoch and a reconnect add nothing more', async () => { const server = new Archive(); vi.stubGlobal('fetch', server.fetch); server.add({ role: 'user', turn_id: 'a' }); server.add({ role: 'companion', turn_id: 'a' }); - const page = { messages: await loadMessages(), cursor: 0 }; page.cursor = newestId(page.messages); + const page = opened(await loadMessages()); expect(await poll(page)).toEqual([]); const id = server.reach(); const first = await poll(page); @@ -48,17 +57,17 @@ describe('reach-outs reaching an open page', () => { it('never re-adds your own turn or her reply to it, and ignores rows still being written', async () => { const server = new Archive(); vi.stubGlobal('fetch', server.fetch); // The page holds your message and her streamed reply under local ids; the server has them under row ids. - const page: { messages: Message[]; cursor: number } = { messages: [{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }], cursor: 0 }; + const page = opened([{ id: 'local-1', role: 'user', text: '在吗', lifeIds: [], turnId: 'turn-x', status: 'sent' }, { id: 'local-2', role: 'companion', text: '在', lifeIds: [], turnId: 'turn-x' }]); server.add({ role: 'user', turn_id: 'turn-x', text: '在吗' }); server.add({ role: 'companion', turn_id: 'turn-x', text: '在' }); server.add({ role: 'user', turn_id: 'turn-y', status: 'pending' }); server.add({ role: 'companion', proactive: true, turn_id: 'turn-x' }); expect(await poll(page)).toEqual([]); - expect(page.cursor).toBe(4); + expect(page.cursor).toEqual(startAt(4)); }); it('pages back after a long absence and keeps the order of what arrived', async () => { const server = new Archive(); vi.stubGlobal('fetch', server.fetch); server.add({ role: 'user' }); - const page = { messages: await loadMessages(), cursor: 1 }; + const page = opened(await loadMessages()); const ids = [server.reach('一')]; for (let i = 0; i < 70; i++) server.add({ role: 'companion' }); ids.push(server.reach('二')); const added = await poll(page); expect(added.map(m => m.text)).toEqual(['一', '二']); @@ -67,13 +76,93 @@ describe('reach-outs reaching an open page', () => { }); it('reads from an empty record without paging the whole history', async () => { const server = new Archive(); vi.stubGlobal('fetch', server.fetch); - const page = { messages: [] as Message[], cursor: 0 }; + const page = opened([]); server.reach(); expect(await poll(page)).toHaveLength(1); expect(server.calls).toHaveLength(1); }); }); +// HEL-880: a sleeping tab can come back to more rows than one bounded read covers (5 pages × 50). +describe('catching up after a long absence', () => { + it('keeps the oldest reach-out when more than five pages arrived (HEL-875 review fixture)', async () => { + // Exactly the reviewer's case: the page holds id 1; id 2 is her reach-out; plain rows follow up to id 301. + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = opened(await loadMessages()); + server.reach('最早的那句'); while (server.rows.length < 301) server.add({ role: 'companion' }); + const first = await fetchAfter(page.cursor, signal()); + expect(first.rows).toEqual([]); + expect(first.cursor).toMatchObject({ through: 1, gap: { before: 52, top: 301 } }); + const second = await fetchAfter(first.cursor, signal()); + expect(arrivals(page.messages, [...first.rows, ...second.rows]).map(m => m.id)).toEqual(['2']); + expect(second.cursor).toEqual(startAt(301)); + }); + it('more than 250 rows with reach-outs spread through them: each arrives once, in order, nothing skipped', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = opened(await loadMessages()), ids: number[] = []; + for (let i = 0; i < 1200; i++) { if (i % 97 === 3) ids.push(server.reach(`第${i}句`)); else server.add({ role: i % 2 ? 'companion' : 'user' }); } + const added = await catchUp(page); + expect(added.map(m => m.id)).toEqual(ids.map(String)); + expect(page.cursor).toEqual(startAt(server.rows.length)); + expect(await poll(page)).toEqual([]); + expect(page.messages.filter(m => m.proactive)).toHaveLength(ids.length); + }); + it('an empty page (cursor 0) facing more than 50 rows reads past the first page', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + const page = opened([]); + const ids = [server.reach('一')]; for (let i = 0; i < 120; i++) server.add({ role: 'user' }); ids.push(server.reach('二')); + expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String)); + expect(server.calls).toHaveLength(3); + }); + it('a read that fails mid-way commits nothing; the next one resumes from the same place', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = opened(await loadMessages()); + const ids = [server.reach('一')]; for (let i = 0; i < 400; i++) server.add({ role: 'user' }); ids.push(server.reach('二')); + expect(await poll(page)).toEqual([]); + const kept = structuredClone(page.cursor); + // Two pages succeed, the third fails: the call throws and the page keeps the cursor it had. + let left = 2; const ok = server.fetch.getMockImplementation()!; + server.fetch.mockImplementation(async (url: string) => { if (left-- <= 0) throw new TypeError('Failed to fetch'); return ok(url); }); + await expect(poll(page)).rejects.toThrow(); + expect(page.cursor).toEqual(kept); + server.fetch.mockImplementation(ok); + expect((await catchUp(page)).map(m => m.id)).toEqual(ids.map(String)); + }); + it('rows that arrive while it is catching up are read too, after the older ones, once', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = opened(await loadMessages()); + const ids = [server.reach('久别前')]; for (let i = 0; i < 600; i++) server.add({ role: 'companion' }); + expect(await poll(page)).toEqual([]); + ids.push(server.reach('追读时新来的')); for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach('最新的')); + const added = await catchUp(page); + expect(added.map(m => m.id)).toEqual(ids.map(String)); + expect(page.cursor).toEqual(startAt(server.rows.length)); + }); + it('repeated reads and a restarted epoch during catch-up add nothing twice', async () => { + const server = new Archive(); vi.stubGlobal('fetch', server.fetch); + server.add({ role: 'user' }); + const page = opened(await loadMessages()); + const ids = [server.reach()]; for (let i = 0; i < 300; i++) server.add({ role: 'user' }); ids.push(server.reach()); + const first = await fetchAfter(page.cursor, signal()); + // The same cursor read twice (a retried poll) gives the same answer; the epoch changing does not matter. + server.epoch = 2; + const again = await fetchAfter(page.cursor, signal()); + expect(again.cursor).toEqual(first.cursor); + page.cursor = first.cursor; + const added = await catchUp(page); + expect(arrivals(added, added)).toEqual([]); + expect(added.map(m => m.id)).toEqual(ids.map(String)); + expect(await poll(page)).toEqual([]); + // A reach-out the page already holds (it came in through a reload) is not added again. + const record = await loadMessages(), page2: Page = { messages: [record[0], record.find(m => m.id === String(ids[1]))!], cursor: startAt(1) }; + expect((await catchUp(page2)).map(m => m.id)).toEqual([String(ids[0])]); + }); +}); + describe('unread', () => { it('first visit from this browser counts the old record as seen; later arrivals stay unread until seen', () => { const data = storage();