/** * 收件箱渲染与已读策略 —— 所有平台插件共用。 * * 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与 * 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool, * 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。 */ import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js'; /** 人类可读的字节数,用于附件清单展示。 */ export function formatSize(n) { if (typeof n !== 'number' || !Number.isFinite(n)) return '?'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } /** * 把一封邮件渲染成模型可读的文本块。 * * @param {any} m `/mail/inbox` 返回的一封邮件 * @param {number} bodyLimit 正文截断长度 * @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」 * 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。 * @returns {string} */ export function renderMail(m, bodyLimit = 200, selfName = '') { const alias = m?.session_alias || ''; const lines = [ `[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`, `邮件 ID: ${m?.mail_id ?? 'unknown'}`, `会话: #${alias || '未命名'}`, ]; // 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁, // 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」, // 抄送方却看不到收件人叫什么。 if (m?.to_name) { let toLine = `收件人: ${m.to_name}`; if (m?.to_workspace) toLine += `@${m.to_workspace}`; lines.push(toLine); } // 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。 // 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。 if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) { lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、')); } // 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会 // 以为自己是负责人,或者都以为自己只是旁观者。 if (selfName) { const role = roleOf(m, selfName); if (role === 'to') lines.push('你的身份: 收件人(主办)'); else if (role === 'cc') lines.push('你的身份: 抄送方(配合)'); } // **必须给出 attachment_id**:只说「有附件」模型就无从下载。 if (Array.isArray(m?.attachments) && m.attachments.length > 0) { lines.push( '附件: ' + m.attachments .map(a => `${a?.filename ?? '?'}(${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'})`) .join('、') ); lines.push('下载附件请用 download_attachment 工具。'); } // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 // bodyLimit <= 0 表示**不截断**(read_mail 用 0 要全文,HTTP 侧也是这个语义)。 // // 这一条曾经是线上故障:zcode 桥的 read_mail 传 0,而这里 `slice(0, 0)` 把正文 // 渲染成**空字符串** —— 模型拿到的是"内容: ",于是它永远读不到全文,只能看 // 收件箱里那段被截断的预览。实测:Agent 明确回信说「read_mail 返回的正文是空的, // 收件箱预览在「点击计数…」处被截断」,并按残缺的要求做了活(漏掉第 3 条)。 // // 不截断时优先取 `body`:单封接口可能同时带两个字段,而 body_preview 是短的那个。 const full = !(bodyLimit > 0); const body = full ? m?.body || m?.body_preview || '' : m?.body_preview || m?.body || ''; const bodyText = String(body); lines.push(`内容: ${full ? bodyText : bodyText.slice(0, bodyLimit)}`); // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 // // 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个 // `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条 // 平行会话。这里给的地址全部已经把 session 位换成真实别名。 if (selfName && alias) { const parts = participantsOfMail(m, selfName, alias); const others = parts.filter(p => !p.is_self && p.address); if (others.length > 0) { lines.push( '可投递地址: ' + others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、') ); lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`); } } return lines.join('\n'); } /** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */ function roleLabel(role) { switch (role) { case 'from': return '发件人'; case 'to': return '收件人'; case 'cc': return '抄送方'; default: return role; } } /** * 渲染整个收件箱。 * @param {any[]} mails * @param {number} bodyLimit * @param {string} [selfName] 自己的 Agent 名,透传给 renderMail * @returns {string} */ export function renderInbox(mails, bodyLimit = 200, selfName = '') { const list = Array.isArray(mails) ? mails : []; if (list.length === 0) return '收件箱为空。'; return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n'); } /** * 判断本次读取该标记哪些邮件为已读。 * * 两条规则: * * 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过, * 一并标掉等于让它们凭空消失。 * 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读 * 会让下一轮真正的新邮件混在里面认不出来。 * * 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起, * 模型分不清哪封该回。 * * @param {string|undefined} status 本次查询用的过滤条件 * @param {any[]} mails 本次返回的邮件 * @returns {string[]} 待标记的 mail_id,空数组表示不需要标记 */ export function idsToMarkRead(status, mails) { if (status === 'all') return []; const list = Array.isArray(mails) ? mails : []; return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id); } /** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */ export const DEFAULT_INBOX_STATUS = 'unread'; /** 收件箱默认返回条数。 */ export const DEFAULT_INBOX_LIMIT = 5;