Files
MailUI4Agents/plugins/opencode-mail-bridge/lib/sse-client.js
JianFeeeee 19a3161ee4 feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
2026-09-11 11:32:47 +08:00

193 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 共用 SSE 客户端:跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。
*
* 三平台桥原本各自手写 SSE 解析,且都有同一个 bug
* - `evt` / `data` 是每次 `read()` 的局部变量TCP 把一帧
* `event: xxx\ndata: {...}\n\n` 切在换行处时,第一段只剩
* `event:` 而第二段只有 `data:` —— 整帧被静默丢弃。
* - 重连不带 `Last-Event-ID`,断线期间的事件只在服务端环形
* 缓冲里等着永远回放不出来Gateway 有 per-agent ring buffer
* pi 与 homeagent 已正确利用DSH/opencode 没有)。
*
* 这个模块把 pi 桥 `src/gateway.mjs` 里那份验证过的实现抽成共用件,
* 三桥逐字节同源deploy/check-shared-libs.sh 校验)。
*/
/**
* 增量 SSE 帧解析器。
*
* `push(chunk)` 可以喂任意切分的文本片段,返回本次完整解析出的事件数组。
* 所有跨帧状态(缓冲、当前 event/data/id都保存在闭包里**不随 chunk 重置** ——
* 这正是原实现丢帧的根因。
*
* 协议细节:
* - `:` 开头 = 注释/心跳,忽略
* - `id:` / `event:` / `data:` 各取字段;`data:` 后的单个空格是分隔符
* - 多行 data 用 `\n` 拼接
* - 空行 = 帧结束;只有 event 与 data 都非空才派发(与旧行为一致)
* - 兼容 CRLF
* - `lastEventId` 在**派发之前**记下:回调抛异常也不该让断点回退。
*
* @returns {{push: (chunk: string) => Array<{event: string, data: string, id: string}>,
* reset: () => void,
* lastEventId: () => string,
* setLastEventId: (id: string) => void}}
*/
export function createFrameParser() {
let buffer = "";
let lastEventId = "";
let curEvent = "";
let curData = "";
let curId = "";
function push(chunk) {
buffer += chunk;
const events = [];
const lines = buffer.split("\n");
// 最后一段可能是被切断的半行,留到下一个 chunk
buffer = lines.pop() ?? "";
for (let line of lines) {
if (line.length > 0 && line.charAt(line.length - 1) === "\r") {
line = line.slice(0, -1);
}
if (line.startsWith(":")) continue;
if (line.startsWith("id:")) {
curId = line.slice(3).trim();
} else if (line.startsWith("event:")) {
curEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
let value = line.slice(5);
if (value.startsWith(" ")) value = value.slice(1);
curData = curData.length > 0 ? `${curData}\n${value}` : value;
} else if (line === "") {
if (curEvent.length > 0 && curData.length > 0) {
if (curId.length > 0) lastEventId = curId;
events.push({ event: curEvent, data: curData, id: curId });
}
curEvent = "";
curData = "";
curId = "";
}
}
return events;
}
function reset() {
buffer = "";
curEvent = "";
curData = "";
curId = "";
}
return {
push,
reset,
lastEventId: () => lastEventId,
setLastEventId: (id) => {
lastEventId = id || "";
},
};
}
/**
* @param {object} deps
* @param {() => Record<string,string>} deps.authHeaders 认证头(每次重连重取,密钥可能已换)
* @param {string} deps.baseURL Gateway 基地址(不带末尾 /
* @param {string} deps.path SSE 路径(如 /api/v1/events/stream
* @param {(evt: string, data: any) => void} deps.onEvent 事件分发回调
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error
* @returns {{stop: () => void}} stop() 终止重连与在途请求
*/
export function createSSEClient({
authHeaders,
baseURL,
path,
onEvent,
log = console.error,
}) {
const controller = new AbortController();
const parser = createFrameParser();
function stop() {
controller.abort();
}
function reconnect(delay) {
if (controller.signal.aborted) return;
setTimeout(() => connect(), delay);
}
function connect() {
if (controller.signal.aborted) return;
const headers = { ...authHeaders(), Accept: "text/event-stream" };
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
const lastEventID = parser.lastEventId();
if (lastEventID) {
headers["Last-Event-ID"] = lastEventID;
log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
}
fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
.then((res) => {
if (!res.ok || !res.body) {
log(`SSE 建连失败: HTTP ${res.status}`);
return reconnect(5000);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
parser.reset();
return reconnect(3000);
}
for (const ev of parser.push(
decoder.decode(value, { stream: true }),
)) {
try {
onEvent(ev.event, JSON.parse(ev.data));
} catch (e) {
log(`SSE 事件处理失败: ${e?.message || e}`);
}
}
read();
})
.catch((e) => {
if (controller.signal.aborted) return;
log(`SSE 读取中断: ${e?.message || e}`);
parser.reset();
reconnect(5000);
});
}
read();
})
.catch((e) => {
if (controller.signal.aborted) return;
log(`SSE 连接错误: ${e?.message || e}`);
parser.reset();
reconnect(5000);
});
}
connect();
return {
stop,
/**
* 清掉断点(不终止连接)。
*
* 换 Gateway 地址时必须调lastEventID 是**旧** Gateway 环形缓冲里的序号,
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
* 得到的事件属于别人的会话。
*/
reset: () => parser.setLastEventId(""),
};
}