feat: AgentMail —— 以邮件为统一范式的多智能体协作平台

Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

View File

@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as api from '../api/client';
import { useMailStore } from '../stores/mailStore';
import type { ThreadNode } from '../types';
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
/**
* 对话树视图(按方向分块加载)。
*
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
*
* 加载策略:首屏取锚点附近一块,往上滑补祖先、往下滑补子孙,直到两端都取完。
* 一条线索可以有几百封,一次全取要把几 MB 预览塞给前端。
*
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
*/
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
const [nodes, setNodes] = useState<ThreadNode[]>([]);
const [hidden, setHidden] = useState(0);
const [moreUp, setMoreUp] = useState(false);
const [moreDown, setMoreDown] = useState(false);
const [nextUp, setNextUp] = useState(0);
const [nextDown, setNextDown] = useState(0);
const [err, setErr] = useState('');
const [initial, setInitial] = useState(true);
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinel = useRef<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
const gen = useRef(0);
// loading 的同步副本。setState 是异步的,两个 sentinel 同时进入视口时
// 读 state 会双双看到 false 而并发发两个请求。
const busy = useRef(false);
const merge = useCallback((incoming: ThreadNode[]) => {
setNodes(prev => {
const seen = new Set(prev.map(n => n.mail_id));
const added = incoming.filter(n => !seen.has(n.mail_id));
// 按相对深度排;同深度保持服务端给的时间序
return [...prev, ...added].sort((a, b) => a.depth - b.depth);
});
}, []);
// 首屏
useEffect(() => {
const myGen = ++gen.current;
setNodes([]);
setHidden(0);
setErr('');
setInitial(true);
busy.current = true;
api
.getMailThread(mailID, { dir: 'around', limit: 40 })
.then(p => {
if (gen.current !== myGen) return;
setNodes(p.nodes.slice().sort((a, b) => a.depth - b.depth));
setHidden(p.hidden);
setMoreUp(p.has_more_up);
setMoreDown(p.has_more_down);
setNextUp(p.next_up);
setNextDown(p.next_down);
})
.catch(e => {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (gen.current === myGen) {
setInitial(false);
busy.current = false;
}
});
}, [mailID]);
const loadMore = useCallback(
async (dir: 'up' | 'down') => {
if (busy.current) return;
if (dir === 'up' && !moreUp) return;
if (dir === 'down' && !moreDown) return;
const myGen = gen.current;
busy.current = true;
setLoading(true);
// 往上加载会在列表顶部插入内容,浏览器保持 scrollTop 不变 → 视觉上内容整体跳走。
// 记住加载前的「滚动高度」,加载后按增量补偿,让用户视线停在原处。
const el = scrollRef.current;
const beforeHeight = el?.scrollHeight ?? 0;
const beforeTop = el?.scrollTop ?? 0;
try {
const p = await api.getMailThread(mailID, {
dir,
offset: dir === 'up' ? nextUp : nextDown,
limit: 40
});
if (gen.current !== myGen) return;
merge(p.nodes);
setHidden(h => h + p.hidden);
if (dir === 'up') {
setMoreUp(p.has_more_up);
setNextUp(p.next_up);
} else {
setMoreDown(p.has_more_down);
setNextDown(p.next_down);
}
if (dir === 'up' && el) {
// 等这批节点真正渲染出来再补偿,否则读到的还是旧高度
requestAnimationFrame(() => {
el.scrollTop = beforeTop + (el.scrollHeight - beforeHeight);
});
}
} catch (e) {
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
} finally {
if (gen.current === myGen) setLoading(false);
busy.current = false;
}
},
[mailID, merge, moreUp, moreDown, nextUp, nextDown]
);
// 两端各一个哨兵,进入视口就加载对应方向。
// rootMargin 提前 200px 触发,让加载在用户滑到边界前完成。
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting) continue;
if (e.target === topSentinel.current) loadMore('up');
if (e.target === bottomSentinel.current) loadMore('down');
}
},
{ root, rootMargin: '200px' }
);
if (topSentinel.current) obs.observe(topSentinel.current);
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
return () => obs.disconnect();
}, [loadMore]);
// 相对深度可能是负数(祖先);缩进按「最浅的那个」归零,
// 否则祖先未加载时首屏内容会整体缩进一大截。
const baseDepth = nodes.length > 0 ? Math.min(...nodes.map(n => n.depth)) : 0;
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">对话树</span>
<span className="text-xs text-gray-400">
已加载 {nodes.length} 封
{(moreUp || moreDown) && ',滑动加载更多'}
{hidden > 0 && `,${hidden} 封无权查看`}
</span>
<div className="flex-1" />
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
关闭
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
加载中
</div>
)}
{err && <p className="text-xs text-red-600">{err}</p>}
{!initial && (
<>
<div ref={topSentinel} className="h-px" />
{moreUp && (
<button
onClick={() => loadMore('up')}
className="w-full mb-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
加载更早的往来
</button>
)}
<div className="space-y-1.5">
{nodes.map(n => (
<Node key={n.mail_id} node={n} baseDepth={baseDepth} anchorID={mailID} />
))}
</div>
{moreDown && (
<button
onClick={() => loadMore('down')}
className="w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
加载后续分支
</button>
)}
<div ref={bottomSentinel} className="h-px" />
</>
)}
</div>
</div>
);
}
function Node({
node,
baseDepth,
anchorID
}: {
node: ThreadNode;
baseDepth: number;
anchorID: string;
}) {
const openMailByID = useMailStore(s => s.openMailByID);
const isPermission = node.mail_type === 'permission_request';
const isAnchor = node.mail_id === anchorID;
// 缩进上限 8 级,再深就不缩了 —— 否则长链条会把卡片挤成竖条
const indent = Math.min(Math.max(node.depth - baseDepth, 0), 8) * 20;
const time = new Date(node.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return (
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
<button
onClick={() => openMailByID(node.mail_id)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center gap-1.5">
{node.from_workspace ? (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
<span className="text-xs font-mono text-gray-700 truncate">
{node.from_name}
{node.from_workspace && `@${node.from_workspace}`}
</span>
<span className="text-[10px] text-gray-400">→</span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
title="上一封不在你的可见范围内"
>
上游不可见
</span>
)}
{isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
<ShieldIcon className="w-2.5 h-2.5" />
权限
</span>
)}
{node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
<PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count}
</span>
)}
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{node.body_preview && (
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}
{node.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
)}
</button>
</div>
);
}