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,130 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
/**
* Agent 发信配额面板(管理员)。
*
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
*/
export default function QuotaPanel() {
const [quotas, setQuotas] = useState<api.Quota[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
const r = await api.adminListQuotas();
setQuotas(r.quotas);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, []);
useEffect(() => {
load();
}, [load]);
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
setBusy(name);
setError(null);
try {
await api.adminSetQuota(name, payload);
await load();
setDrafts(d => {
const next = { ...d };
delete next[name];
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent 发信配额</h3>
<span className="text-xs text-gray-400">{quotas.length}</span>
</div>
<p className="text-[11px] text-gray-500">
限制 Agent 主动发信的次数(不限制收信)。上限填 0 表示不限。
剩余次数会随心跳与发信响应回传给 Agent,好让它在额度用尽前主动发最终总结。
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{quotas.length === 0 ? (
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{quotas.map(q => {
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
const dirty = draft !== String(q.max_rounds);
const exhausted = !q.unlimited && q.remaining === 0;
return (
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{q.agent_name}
</span>
<div className="min-w-0 flex-1">
{q.unlimited ? (
<span className="text-xs text-gray-500">不限额(已用 {q.used_rounds})</span>
) : (
<div className="flex items-center gap-2">
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
style={{
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
}}
/>
</div>
<span
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
>
{q.used_rounds}/{q.max_rounds}
{exhausted && ' · 已用尽'}
</span>
</div>
)}
</div>
<input
type="number"
min={0}
value={draft}
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<button
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
disabled={!dirty || busy === q.agent_name}
title="保存上限"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>
<button
onClick={() => apply(q.agent_name, { reset: true })}
disabled={busy === q.agent_name || q.used_rounds === 0}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
重置
</button>
</div>
);
})}
</div>
)}
</div>
);
}