## 事故现场 用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是 自己选的那个。授权页只显示 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 断言) - 前端构建通过
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
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 而不是 NaN:NaN 参与比较恒为 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);
|
||
});
|
||
}
|