import { API_BASE, getToken, withToken } from './config'; export type SSEEventHandler = (eventType: string, data: Record) => void; export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'; const EVENTS = [ 'new_mail', 'permission_decision', 'session_update', 'session_archived', 'agent_online' ] as const; let es: EventSource | null = null; let handlers: SSEEventHandler[] = []; let retryTimer: ReturnType | null = null; let backoff = 1000; let _status: SSEStatus = 'disconnected'; let statusHandlers: Array<(s: SSEStatus) => void> = []; // 当前连接用的是哪套凭证。断线重连时若凭证已变,就**换凭证**而不是续用旧的 // —— 这是「切号后新邮件收不到」那条缺陷的落点。 let connectedWith = ''; // 最近一次收到的事件 id(来自 SSE 帧的 id: 行)。断线重连时交给服务端回放。 let lastEventId = ''; /* ★ 2026-10-02:SSE 连接必须**跟着账号凭证**走。 # 缺陷(用户报:页面停留不动,新邮件不自动同步) `App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE, 而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase** ⇒ 连接仍是旧账号的:旧账号的新邮件照收,新账号的一封都不推; 而 `fetchInbox` 走的是新凭证 ⇒ 数据是新的。 ⇒ 表现正是「不自动同步,但手动刷新能看到」。 # 为什么记凭证身份而不是让调用方传 凭证变化有两条路径(切号、令牌刷新),都在 api/config 里; 在那一层发信号 = 只有一处知道「凭证换了」,不依赖调用方记得调。 # 身份用什么 `base + token` 的组合。**不含 user** —— 同一账号重新登录 token 变了, 但那个账号的邮件仍然该收(服务端按 user 或 agent 绑通道,见 sse.bufferKey); 按 base+token 判会让「同一账号换令牌」误触发重连(无害,只是多一次连接)。 */ /** 当前凭证身份(base + token)。切号或令牌刷新后变化。 */ export function credentialSignature(): string { return `${API_BASE}|${getToken() ?? ''}`; } /** 监听 SSE 连接状态变化 */ export function onSSEStatus(handler: (s: SSEStatus) => void): () => void { statusHandlers.push(handler); return () => { statusHandlers = statusHandlers.filter(h => h !== handler); }; } /** 当前 SSE 连接状态 */ export function getSSEStatus(): SSEStatus { return _status; } function setStatus(s: SSEStatus) { if (_status === s) return; _status = s; statusHandlers.forEach(h => h(s)); } /** * 凭证变了就重连(SSE 订阅必须跟着账号走)。 * * ★ 2026-10-02:这是用户报的「页面停留不动、新邮件不自动同步」的修复点。 * `App.tsx` 的 effect 依赖是 `[phase]`,而切号不改 phase ⇒ 连接绑在旧账号上。 * 与其让调用方记得在切号后重建订阅(多处调用点、漏一处就不刷新), * 不如在这一层自己发现凭证变了 —— **凭证变化的唯一发生地就是 api/config**。 * * 返回值给调用方放进 useEffect 依赖:变化时 React 会重跑 effect → 触发重连。 * 不直接在这里重连,是因为 effect 重跑时会 close+open 一次,语义更清楚。 */ export function useCredentialChange(): string { return credentialSignature(); } export function connectSSE(onEvent: SSEEventHandler): () => void { handlers.push(onEvent); if (!es) open(); return () => { handlers = handlers.filter(h => h !== onEvent); if (handlers.length === 0) close(); }; } function open(resumeFrom = '') { close(false); setStatus('connecting'); connectedWith = credentialSignature(); lastEventId = resumeFrom; // EventSource 无法设置请求头:Cookie 模式靠同源 Cookie, // 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。 let url = withToken(`${API_BASE}/events/stream`); if (lastEventId) { // ★ EventSource 不能设请求头 ⇒ lastEventId 只能进 query。 // 服务端要相应支持读它(见 sse/manager.go 的 lastID 取值)。 const sep = url.includes('?') ? '&' : '?'; url += `${sep}lastEventId=${encodeURIComponent(lastEventId)}`; } es = new EventSource(url, { withCredentials: true }); // EventSource 会自动重连,但它的 readyState 在网络断开时 // 不一定及时反映状态。用 onopen 判断实际连上了。 es.onopen = () => { backoff = 1000; setStatus('connected'); }; es.addEventListener('connected', (e: MessageEvent) => { backoff = 1000; setStatus('connected'); // 服务端把当前游标放在 connected 帧的 data.id 里 try { const d = JSON.parse(e.data) as { id?: string }; if (typeof d?.id === 'string' && d.id) lastEventId = d.id; } catch { /* 非 JSON 负载:保持原值 */ } }); for (const name of EVENTS) { es.addEventListener(name, (e: MessageEvent) => { let data: Record = {}; try { data = JSON.parse(e.data); } catch { /* 忽略非 JSON 负载 */ } // 记下游标:断线后从这儿续,服务端按 ring 里的 id 回放其后的��件 if (typeof (e as MessageEvent).lastEventId === 'string' && e.lastEventId) { lastEventId = e.lastEventId; } handlers.forEach(h => h(name, data)); }); } /* * ★ 2026-10-02 断线重连带 Last-Event-ID(修复「断线期间的新邮件永远看不到」)。 # 为什么不能靠浏览器自动重连 `EventSource` 断线后**本来会自己重连并带上 Last-Event-ID**, 服务端也据此回放(`sse/manager.go` 的 ring.replay,500 条缓冲)。 但我们原来的 onerror 主动 `close(false)` 再 `open()` —— **换了一个 EventSource 对象**,而 Last-Event-ID 是浏览器为**那个对象**记的, 新对象什么都不知道 ⇒ 服务端拿不到 lastID ⇒ 不回放。 # 为什么仍要自己重连(而不是交给浏览器) 状态可见性:`readyState` 在网络断开时不一定及时反映, 而 UI 要显示「重连中」。这是原注释里已有的理由,保留。 # 怎么做才能既带 id 又换凭证 EventSource 不能设请求头 ⇒ lastEventId 只能进 **query** (服务端目前只读 header,所以这条路要求服务端配合)。 两种重连要分开: · **断线**(凭证没变)⇒ 带上次的 id,服务端回放断线期间的事件 · **切号**(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号, 会把别的事件流搅乱(id 是全局自增,但回放按该账号的 ring 匹配) */ es.onerror = () => { const credsChanged = connectedWith !== '' && connectedWith !== credentialSignature(); const resumeFrom = credsChanged ? '' : lastEventId; close(false); if (handlers.length === 0) return; if (retryTimer) return; setStatus('reconnecting'); retryTimer = setTimeout(() => { retryTimer = null; backoff = Math.min(backoff * 2, 15000); open(resumeFrom); }, backoff); }; } function close(clearHandlers = true) { if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } if (es) { es.close(); es = null; } if (clearHandlers) handlers = []; setStatus('disconnected'); } export function disconnectSSE() { close(); }