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,109 @@
import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
InboxIcon,
SentIcon,
ContactsIcon,
ComposeIcon,
UsersIcon,
LogoutIcon
} from './icons';
const navItems: {
short: string;
title: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function Sidebar() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
const active = viewMode === mode && !composing;
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
title={title}
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
active
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
}`}
>
<Icon />
<span className="text-[9px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => startCompose()}
title="新建邮件"
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
}`}
>
<ComposeIcon />
<span className="text-[9px] leading-none">新建</span>
</button>
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
<button
onClick={() => setViewMode('account')}
title={`${user?.display_name || user?.username}(点击管理账号)`}
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing
? 'bg-blue-500 text-white'
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
}`}
>
{(user?.display_name || user?.username || '?').slice(0, 2)}
</button>
<button
onClick={logout}
title="退出登录"
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
>
<LogoutIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}