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

489
web/src/api/client.ts Normal file
View File

@ -0,0 +1,489 @@
import type { User } from '../types';
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
import { API_BASE, authHeaders, withToken } from './config';
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
const BASE = API_BASE;
export class ApiError extends Error {
status: number;
retryAfter?: number;
constructor(status: number, message: string, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const init: RequestInit = {
method,
// Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...authHeaders() }
};
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, init);
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
onUnauthorized?.();
}
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
}
return res.json();
}
// ---------- 首次初始化 ----------
export async function setupStatus() {
return request<{ needs_setup: boolean }>('GET', '/setup/status');
}
export async function setupAdmin(payload: {
username: string;
password: string;
display_name?: string;
}) {
return request<{ user: User }>('POST', '/setup/admin', payload);
}
// ---------- 认证 ----------
export async function login(username: string, password: string) {
return request<{ user: User }>('POST', '/auth/login', { username, password });
}
export async function logout() {
return request<{ status: string }>('POST', '/auth/logout');
}
export async function me() {
return request<{ user: User }>('GET', '/auth/me');
}
export async function changePassword(oldPassword: string, newPassword: string) {
return request<{ status: string }>('POST', '/auth/password', {
old_password: oldPassword,
new_password: newPassword
});
}
// ---------- 管理员:用户管理 ----------
export async function adminListUsers() {
return request<{ users: User[] }>('GET', '/admin/users');
}
export async function adminCreateUser(payload: {
username: string;
password: string;
display_name?: string;
role?: string;
allowed_agents?: string[];
allowed_paths?: string[];
}) {
return request<{ user: User }>('POST', '/admin/users', payload);
}
export async function adminUpdateUser(
id: string,
payload: {
display_name?: string;
role?: string;
status?: string;
allowed_agents?: string[];
allowed_paths?: string[];
}
) {
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
}
export async function adminDisableUser(id: string) {
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
}
export async function adminResetPassword(id: string, newPassword: string) {
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
new_password: newPassword
});
}
export async function adminListScopes() {
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
return r;
}
// ---------- 密钥 ----------
export type KeyType = 'permanent' | 'one_time' | 'timed';
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
export interface AgentKey {
key_id: string;
key_token?: string;
token_hint: string;
agent_name: string | null;
key_type: KeyType;
label: string;
expires_at: string | null;
used_at: string | null;
created_at: string;
}
export interface UserKey {
key_id: string;
key_token?: string;
token_hint: string;
label: string;
key_type: KeyType;
expires_at: string | null;
used_at: string | null;
created_at: string;
}
export interface CreateKeyPayload {
key_type: KeyType;
label?: string;
/** 仅 timed 需要 */
expires_hours?: number;
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
agent_name?: string;
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
key_token?: string;
}
export async function adminListAgentKeys(agentName?: string) {
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
}
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
}
export async function adminDeleteAgentKey(id: string) {
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
}
export async function adminBindAgentKey(id: string, agentName: string) {
return request<{ status: string; agent_name: string }>(
'POST',
`/admin/agent-keys/${id}/bind`,
{ agent_name: agentName }
);
}
export async function listMyKeys() {
return request<{ keys: UserKey[] }>('GET', '/me/keys');
}
export async function createMyKey(payload: CreateKeyPayload) {
return request<{ key: UserKey }>('POST', '/me/keys', payload);
}
export async function deleteMyKey(id: string) {
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
}
// ---------- Agents ----------
export async function listAgents(status?: string) {
const q = status ? `?status=${encodeURIComponent(status)}` : '';
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
}
// ---------- 自己的邮箱 ----------
export interface SendMailOpts {
cc?: string;
reply_to?: string;
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
session_alias?: string;
/** 先用 uploadAttachment 上传取得的 id 列表 */
attachment_ids?: string[];
/**
* 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效;
* 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。
*/
max_rounds?: number;
}
export async function sendMail(
to: string,
subject: string,
body: string,
opts: SendMailOpts = {}
) {
return request<{
mail_id: string;
session_id: string;
session_alias: string;
budget_max?: number;
budget_used?: number;
budget_remaining?: number;
}>('POST', '/me/mail/send', {
to,
subject,
body,
cc: opts.cc ?? '',
reply_to: opts.reply_to ?? '',
session_alias: opts.session_alias ?? '',
attachment_ids: opts.attachment_ids ?? [],
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
max_rounds: opts.max_rounds ?? null
});
}
export async function getInbox(status = 'all', limit = 50) {
return request<{ mails: Mail[]; total: number }>(
'GET',
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
);
}
export async function getSent() {
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
}
export async function getMail(id: string) {
return request<Mail>('GET', `/mail/${id}`);
}
export async function markMailRead(id: string) {
return request<{ status: string }>('POST', `/mail/${id}/read`);
}
/**
* 取线索的一块。
*
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
*/
export async function getMailThread(
id: string,
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
) {
const q = new URLSearchParams();
if (opts.dir) q.set('dir', opts.dir);
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
const qs = q.toString();
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
}
// ---------- 附件 ----------
/**
* 上传附件,返回 attachment_id。
*
* 不能走 request():那里固定 Content-Type: application/json,
* 而 multipart 必须让浏览器自己带 boundary。
*/
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
const form = new FormData();
form.append('file', file);
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
if (onProgress) {
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${BASE}/me/attachments`);
xhr.withCredentials = true;
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
let payload: { attachment?: Attachment; error?: string } = {};
try {
payload = JSON.parse(xhr.responseText);
} catch {
/* 非 JSON 响应按状态码处理 */
}
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
resolve({ attachment: payload.attachment });
} else {
if (xhr.status === 401) onUnauthorized?.();
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
xhr.send(form);
});
}
const res = await fetch(`${BASE}/me/attachments`, {
method: 'POST',
credentials: 'include',
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
headers: authHeaders(),
body: form
});
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
if (res.status === 401) onUnauthorized?.();
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
}
return res.json() as Promise<{ attachment: Attachment }>;
}
export async function deleteAttachment(id: string) {
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
}
/**
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
*/
export function attachmentURL(id: string) {
return withToken(`${BASE}/me/attachments/${id}`);
}
/** 人类可读的字节数 */
export function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export interface ForwardPayload {
/** 新收件人的三维地址 */
to: string;
/** 转发说明,置于引用原文之前 */
comment?: string;
cc?: string;
/** 留空则自动加 Fwd: 前缀 */
subject?: string;
/** 仅当 to 以 .new 结尾时生效 */
session_alias?: string;
}
export async function forwardMail(id: string, payload: ForwardPayload) {
return request<{
mail_id: string;
session_id: string;
session_alias: string;
forwarded_from: string;
}>('POST', `/me/mail/${id}/forward`, {
to: payload.to,
comment: payload.comment ?? '',
cc: payload.cc ?? '',
subject: payload.subject ?? '',
session_alias: payload.session_alias ?? ''
});
}
// ---------- 配额 ----------
export interface Quota {
agent_name: string;
max_rounds: number;
used_rounds: number;
/** 不限额时为 -1 */
remaining: number;
unlimited: boolean;
}
export async function adminListQuotas() {
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
}
/** 设上限(0 = 不限)或把已用次数归零 */
export async function adminSetQuota(
agentName: string,
payload: { max_rounds?: number; reset?: boolean }
) {
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
}
// ---------- Sessions ----------
export async function getHumanSessions() {
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
}
export async function getSessionDetail(id: string) {
return request<SessionDetail>('GET', `/sessions/${id}`);
}
export async function updateSessionAlias(id: string, alias: string) {
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
}
/**
* 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。
* 已接受(提议就是当前别名)或已驳回的不再返回。
*/
export async function getRenameProposal(id: string) {
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
}
/** 本会话(= 本任务)的往返预算。 */
export async function getSessionBudget(id: string) {
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
}
/**
* 改本会话的往返预算。
*
* max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
*/
export async function updateSessionBudget(
id: string,
patch: { max_rounds?: number; reset?: boolean }
) {
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
}
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
export async function dismissRenameProposal(id: string) {
return request<{ status: string; dismissed?: string }>(
'POST',
`/sessions/${id}/rename-proposal/dismiss`
);
}
// ---------- Contacts ----------
export async function listContacts(archived = false) {
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
}
export async function suggestAddress(name?: string, path?: string) {
const p = new URLSearchParams();
if (name) p.set('name', name);
if (path) p.set('path', path);
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
}
export async function archiveContact(payload: { address?: string; session_id?: string }) {
return request<{ status: string; session_id: string; session_alias: string }>(
'POST',
'/contacts/archive',
payload
);
}
// ---------- Permission ----------
export async function decidePermission(mailId: string, decision: string, note?: string) {
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
mail_id: mailId,
decision,
note: note ?? ''
});
}
export async function listPendingPermissions() {
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
}

68
web/src/api/config.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* API 接入配置。
*
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1;独立部署的客户端需要指向具体主机
* 2. 凭证:浏览器用登录 Cookie;第三方客户端用用户密钥(Authorization: Bearer)
*
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
* 不需要改任何调用点。
*/
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
declare global {
interface Window {
__AGENTMAIL_API_BASE__?: string;
__AGENTMAIL_TOKEN__?: string;
}
}
/**
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
*
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
*/
function resolveBase(): string {
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
const build = import.meta.env?.VITE_API_BASE as string | undefined;
const base = (runtime || build || '/api/v1').trim();
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
return base.replace(/\/+$/, '');
}
export const API_BASE = resolveBase();
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
let bearerToken: string | null =
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
/**
* 设置用户密钥。第三方客户端在启动时调用一次即可,
* 之后所有请求(含 SSE 与附件下载)自动带上。
*/
export function setToken(token: string | null) {
bearerToken = token && token.trim() !== '' ? token.trim() : null;
}
export function getToken(): string | null {
return bearerToken;
}
/** 认证请求头。用 Cookie 时返回空对象。 */
export function authHeaders(): Record<string, string> {
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
}
/**
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
* - EventSource(SSE)不支持自定义头
* - <a download> / <img src> 由浏览器直接发起
*
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=,
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
*/
export function withToken(url: string): string {
if (!bearerToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
}

76
web/src/api/sse.ts Normal file
View File

@ -0,0 +1,76 @@
import { API_BASE, withToken } from './config';
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
const EVENTS = [
'new_mail',
'permission_decision',
'session_update',
'session_archived',
'agent_online'
] as const;
let es: EventSource | null = null;
let handlers: SSEEventHandler[] = [];
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let backoff = 1000;
export function connectSSE(onEvent: SSEEventHandler): () => void {
handlers.push(onEvent);
if (!es) open();
return () => {
handlers = handlers.filter(h => h !== onEvent);
if (handlers.length === 0) close();
};
}
function open() {
close(false);
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
es.addEventListener('connected', () => {
backoff = 1000;
});
for (const name of EVENTS) {
es.addEventListener(name, (e: MessageEvent) => {
let data: Record<string, unknown> = {};
try {
data = JSON.parse(e.data);
} catch {
/* 忽略非 JSON 负载 */
}
handlers.forEach(h => h(name, data));
});
}
es.onerror = () => {
close(false);
if (handlers.length === 0) return;
if (retryTimer) return;
retryTimer = setTimeout(() => {
retryTimer = null;
backoff = Math.min(backoff * 2, 15000);
open();
}, backoff);
};
}
function close(clearHandlers = true) {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
if (es) {
es.close();
es = null;
}
if (clearHandlers) handlers = [];
}
export function disconnectSSE() {
close();
}