fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来
- 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知)

后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库)
  载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」
前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级)
  + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表)
  + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result)
  + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑)
双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次
契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
This commit is contained in:
AskaEth committed 2026-10-05 16:02:36 +08:00
1 parent 1bf85a955b
commit 84c124f5a9
18 files changed
+1545 -46

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
/**
* 主动消息的**浏览器通知**判定(切片 P0「她主动找你」)。
*
* 本模块是**纯逻辑**:不碰 DOM、不碰 store、不读全局变量,因此可以在没有 jsdom 的
* 纯 node 下逐条钉住主人裁定的口径 ——
*
* 1. **气泡恒落,通知可省**:气泡(消息 store)与前后台无关,永远落;浏览器通知
* 只是「人不在看的时候补一发」,是提醒、不是记录。
* 2. **只在不在前台时补一发**:判定 = Page Visibility **且** 焦点。只判可见性会漏掉
* 「窗口被别的窗口盖住、visibilityState 仍是 visible」这一半 —— 那种时候主人
* 根本看不见,正该提醒。
* 3. **同一条只打扰一次**:去重表按消息 id 记(含「同一条既走实时帧、又被 IM 镜像
* 回主会话」这种同时到达的情形)。气泡**不参与**提醒去重 —— 它是记录,去重由
* 消息 store 按 id 负责。
* 4. **拒绝授权 ⇒ 如实降级**:没授权就只留气泡,并让调用方留一句可见说明;
* **绝不静默丢弃**,也绝不当成故障崩掉。
*/
/** 浏览器通知权限(`Notification.permission` 的三态;运行时不支持该 API 时由调用方
* 归到 `"denied"` —— 拿不到通知就是没授权,绝不假装已授权)。 */
export type NotifyPermission = "granted" | "denied" | "default";
/** 一次提醒的判定结果。 */
export type NoticeDecision =
/** 不在前台且已授权 ⇒ 补一发系统通知。 */
| "show"
/** 同一条已经提醒过(或已在前台露过面)⇒ 不打扰第二次。 */
| "duplicate"
/** 页面可见且聚焦 ⇒ 按裁定不弹(气泡已经落了)。 */
| "foreground"
/** 通知权限未授权/被拒 ⇒ 如实降级为只留气泡。 */
| "not-granted";
/** 「不在前台」的判定:**可见且聚焦**才算前台。`visibility` 用
* `document.visibilityState` 的原值("visible" | "hidden" | …),未知取值一律按
* 「不在前台」处理 —— 提醒多发一次不可怕,人错过了才可怕。 */
export function isBackground(visibility: string, focused: boolean): boolean {
return !(visibility === "visible" && focused);
}
/** 决定这一刻要不要为这条主动消息补一发通知。纯函数:同样的输入永远同样的结论。 */
export function decideProactiveNotice(input: {
/** 同一条消息此前是否已经提醒过(调用方查去重表)。 */
seen: boolean;
/** `document.visibilityState`。 */
visibility: string;
/** `document.hasFocus()`。 */
focused: boolean;
/** 当前通知权限。 */
permission: NotifyPermission;
}): NoticeDecision {
if (input.seen) return "duplicate";
if (!isBackground(input.visibility, input.focused)) return "foreground";
if (input.permission !== "granted") return "not-granted";
return "show";
}
/** 有界的「已提醒」表。它是**本页存活期间的打扰记录**,不是消息台账:超过上限就
* 淘汰最旧的一条(一条很老的重复消息至多多提醒一次,绝不会让表无限长)。 */
export function createSeenNotices(limit: number): {
has(id: string): boolean;
add(id: string): void;
size(): number;
} {
const order: string[] = [];
const seen = new Set<string>();
return {
has(id) {
return seen.has(id);
},
add(id) {
if (!id || seen.has(id)) return;
seen.add(id);
order.push(id);
while (order.length > limit) {
const oldest = order.shift();
if (oldest !== undefined) seen.delete(oldest);
}
},
size() {
return seen.size;
},
};
}