用户报告:"我点到授权界面,才更新显示授权请求"。
先排除了推送本身:实测徽标是**实时**更新的(gui-lab 授权 7→8、jianf 1→2,
都没导航)。问题在内容:`PermissionList` 的展开状态
`const openSet = expanded ?? new Set(autoOpen)` —— 一旦手动点过一次,
`expanded` 就冻结成"点的那一刻"的快照,此后新到的待决请求落在一个折叠的分组里:
徽标数字变了,正文却看不见,直到离开再回到授权页(组件重挂载、`expanded`
回到 null、默认展开重算)才出现。
这违反代码自己的设计意图(注释写着「有待决策请求的会话默认展开:那些是在等人
动手的,藏起来等于没解决问题」)。
修法:加一条**状态迁移**判据 —— 新出现的待决邮件(`sessionId:mailId`)让它所在
的会话自动展开。用 mail_id 而不是"会话有没有待决"作判据,是因为实测撞到的正是
"会话早就有待决、用户把它折叠了,之后又来了一条";而用户在那之后再手动折叠同一
条不会被弹开(没有新 mail_id)。
验证:
· 真浏览器复现:授权 2 → 3 而新请求正文不可见,重进页面才可见(复现成功)
· 新增 `test/components/PermissionList-autopen.test.tsx`(3 条,含反向对照)
· 扰动验证:撤掉修复 → 2 条目标判据红、对照判据仍绿;恢复 → 3/3
· 部署后同一探针复验:折叠状态下新请求**立刻可见**,不再需要重进页面
· 前端 239 测试全绿;桌面重打包与 WebUI 同源(index-jaRgHqX2.js)
顺带修掉一个**更严重的缺陷**(在做「用 zcode 写个网页」时被 agent 自己报出来的):
fix(plugins): zcode 的 read_mail 永远返回空正文
agent 回信原话:「read_mail 返回的正文是空的,收件箱预览在「点击计数…」处被截断」
—— 它因此只看到前两条要求,写出来的页面漏了第 3 条(生成时间)。
根因在 `lib/inbox-format.js` 的渲染端:
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
zcode 的 read_mail 用 `bodyLimit = 0` 表示"要全文"(HTTP 侧 `?body_limit=0`
也确实是这个语义,服务端返回了完整正文),但这里 `slice(0, 0)` 把正文渲染成
**空字符串** ⇒ 模型永远读不到全文,只能看收件箱里那段预览。
修法:`bodyLimit <= 0` 视为不截断;不截断时优先取 `body`(单封接口可能同时带
`body_preview`,那是短的那个)。四份副本逐字节同源(`check-shared-libs.sh`
通过),每个桥各加 2 条判据:0 = 不截断、不截断时优先全文。
扰动验证:退回旧写法 → 2 条红。
端到端验证:让 zcode 读全文并原样回报最后一行(一个随机标记)。
修复后它精确回出 `最后一行标记:ZTOKEN-2c7561fd` ✓ —— 修复前这不可能。
四家桥都已重新部署到新快照(pi/opencode/dsh/zcode),部署漂移检查:
「四个宿主都在跑当前代码」。测试基线:pi 417 / opencode 323 / dsh 372 / zcode 382。
155 lines
6.6 KiB
JavaScript
155 lines
6.6 KiB
JavaScript
/**
|
||
* 收件箱渲染与已读策略 —— 所有平台插件共用。
|
||
*
|
||
* 提到 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;
|