Files
MailUI4Agents/web/src/lib/mailGroups.ts
JianFeeeee e8583ecd41 fix: 事故全链路修复 — 调度器合流 + 接管保护 + 补全去重 + 权限显示 + homeagent SSE 振荡
## 事故现场

用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是
自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。

## 四处因果链

**① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler)
与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了
handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 →
命名同步冲掉接管会话的别名。

修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。
handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。

**② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug,
任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。

**③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话
出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。
改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。

**④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 →
FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。

## 权限页

**CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 →
前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录
哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。

**SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人
不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出
`pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。

**mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是
Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址
`agent@path.alias`。

## NarrowStack z-index

窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层
auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。

修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文;
覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的
z-index 又会复现。

## homeagent SSE 自激振荡

根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。

1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时)
2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)`
3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID`
4. 无邮件级去重 → 补 `deliveredMails map[string]bool`
5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap
   → Read 零长切片 → 满速空转。改 `bufio.Reader`。

## 清库

保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/
calendar_events/attachments/agent_platform_sessions/relayed_mails/
permission_requests/rate_limits。测试数据已全部清零。

## 测试

- gateway 7 包全过;repo + 7 例(adopt_alias_test.go)
- web 182 例(mailGroups 新增 session_workspace 断言)
- 前端构建通过
2026-09-04 15:35:48 +08:00

179 lines
6.7 KiB
TypeScript
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.

import type { Mail } from '../types';
/**
* 邮件列表的两种分组:收件箱按会话折叠,授权请求单独成项。
*
* 为什么需要它:`/me/mail/inbox` 返回的是平铺的邮件流(`ORDER BY created_at DESC`
* 而一次 Agent 任务会在同一会话里产生几十封邮件 —— 权限询问尤其密集,
* 每个被拦下的 bash/write 都是一封。实测生产库里一个会话独占 17 封权限邮件,
* 把整个收件箱挤满,另外两个会话的信被压到看不见的地方。
*
* 缺了分组的后果不是「不好看」,而是收件箱失去了它唯一的作用:
* 让人知道「有哪几件事在等我」。17 行同一件事和 3 件不同的事,占的视觉权重一样。
*/
/** 一个会话在列表里折叠成的一组。 */
export interface MailGroup {
sessionId: string;
/** 会话别名,空串表示尚未命名 */
alias: string;
/** 组标题:取最新一封的主题(会话主题会随任务推进被改写,最新的那个最贴切) */
subject: string;
/** 最新一封,组头的摘要与时间都取自它 */
latest: Mail;
/** 组内全部邮件,时间倒序 */
mails: Mail[];
unreadCount: number;
}
function timeOf(m: Mail): number {
const t = new Date(m.created_at).getTime();
// created_at 解析失败时给 0 而不是 NaNNaN 参与比较恒为 false
// 会让排序结果依赖于原数组顺序,表现为「刷新一次顺序就变了」
return Number.isNaN(t) ? 0 : t;
}
/** 时间倒序;同一时刻用 mail_id 兜底,与后端 `ORDER BY created_at DESC, mail_id DESC` 一致。 */
function byNewest(a: Mail, b: Mail): number {
const d = timeOf(b) - timeOf(a);
return d !== 0 ? d : b.mail_id.localeCompare(a.mail_id);
}
/**
* 按 session_id 把邮件桶化。
*
* session_id 缺失的邮件(理论上不该有,但前端不该因为一条脏数据整栏空白)
* 各自成组:用 mail_id 兜底键,保证它至少能被看见。
*/
function bucketBySession(mails: Mail[], keep: (m: Mail) => boolean): Map<string, Mail[]> {
const bySession = new Map<string, Mail[]>();
for (const m of mails) {
if (!keep(m)) continue;
const key = m.session_id || `mail:${m.mail_id}`;
const bucket = bySession.get(key);
if (bucket) bucket.push(m);
else bySession.set(key, [m]);
}
return bySession;
}
/**
* 把平铺的邮件流按 session_id 折叠成组,组之间按最新邮件时间倒序。
*
* 不修改入参;对同一输入永远给出同一输出(组内、组间都有确定的排序),
* 因此可以直接在 render 里调用。
*/
export function groupMailsBySession(mails: Mail[]): MailGroup[] {
const groups: MailGroup[] = [];
for (const [sessionId, bucket] of bucketBySession(mails, () => true)) {
const sorted = [...bucket].sort(byNewest);
const latest = sorted[0];
groups.push({
sessionId,
alias: latest.session_alias || '',
subject: latest.subject,
latest,
mails: sorted,
unreadCount: sorted.filter(m => m.status === 'unread').length
});
}
return groups.sort((a, b) => byNewest(a.latest, b.latest));
}
/**
* 单封邮件的组不算「组」,平铺显示即可。
*
* 给一封孤立的邮件套上可折叠的组头会多一次点击才能读到内容,
* 而收件箱里大多数人类来信就是孤立的一封。
*/
export function isFlatGroup(g: MailGroup): boolean {
return g.mails.length === 1;
}
/** 权限邮件且尚无决策结果。空串与 null 都算未决策(后端用 COALESCE 归一成空串)。 */
export function isPendingPermission(m: Mail): boolean {
return m.mail_type === 'permission_request' && !m.permission_result;
}
/**
* 把权限请求从普通邮件里分出来。
*
* 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」,
* 而普通邮件是要读的内容。混在一个收件箱里两者互相伤害 ——
* 一次 Agent 任务能产生十几个权限请求,把真正需要阅读的来信压到看不见的地方;
* 反过来,人要找「有什么在等我批」也得在几十封信里翻。
*
* 所以它们各归各的导航项:收件箱只放要读的,授权项只放要批的。
*/
export function splitByPermission(mails: Mail[]): { normal: Mail[]; permissions: Mail[] } {
const normal: Mail[] = [];
const permissions: Mail[] = [];
for (const m of mails) {
if (m.mail_type === 'permission_request') permissions.push(m);
else normal.push(m);
}
return { normal, permissions };
}
/** 待决策的权限请求数 —— 授权项的徽标数字,也是「要人动手」的唯一信号。 */
export function countPendingPermissions(mails: Mail[]): number {
let n = 0;
for (const m of mails) if (isPendingPermission(m)) n += 1;
return n;
}
/** 授权项里的一个会话分组:一级是会话,二级是该会话的授权请求。 */
export interface PermissionGroup {
sessionId: string;
alias: string;
/** 发起请求的 Agent权限请求一定由 Agent 发出) */
agentName: string;
/** Agent 的工作目录,同名 Agent 在不同目录是不同的活 */
path: string;
/** 还等着人点头的,时间倒序 */
pending: Mail[];
/** 已决策的历史记录,时间倒序 */
settled: Mail[];
/** 组内最新一封,组头时间取自它 */
latest: Mail;
}
/**
* 把权限请求按会话折成组,**有待决策的会话永远排在前面**。
*
* 排序判据不是时间而是「要不要我动手」:一个三天前发起、至今还卡着的授权请求
* 比十分钟前刚批完的那条重要得多。纯按时间排会把它压到列表底部,
* 而 Agent 那条会话正在那儿等着 —— 这正是权限死锁在 UI 上的样子。
*/
export function groupPermissions(mails: Mail[]): PermissionGroup[] {
const groups: PermissionGroup[] = [];
for (const [sessionId, bucket] of bucketBySession(
mails,
m => m.mail_type === 'permission_request'
)) {
const sorted = [...bucket].sort(byNewest);
const latest = sorted[0];
groups.push({
sessionId,
alias: latest.session_alias || '',
agentName: latest.from_name,
path: latest.session_workspace || '',
pending: sorted.filter(isPendingPermission),
settled: sorted.filter(m => !isPendingPermission(m)),
latest
});
}
return groups.sort((a, b) => {
// 有待决策的先来;组内待决策数多的更靠前(那条会话卡得更久)
if ((a.pending.length > 0) !== (b.pending.length > 0)) {
return a.pending.length > 0 ? -1 : 1;
}
if (a.pending.length !== b.pending.length) return b.pending.length - a.pending.length;
return byNewest(a.latest, b.latest);
});
}