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,311 @@
import { useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
permanent: '长期',
one_time: '一次性',
timed: '限时'
};
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
permanent: '永不过期,可重复使用',
one_time: '首次使用后立即失效',
timed: '指定小时数后过期'
};
/** 一条密钥在列表里的状态:过期/已用完/可用 */
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
return { text: '已过期', cls: 'text-red-500' };
return { text: '可用', cls: 'text-green-600' };
}
/**
* 新签发密钥的一次性展示条。
*
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}
};
return (
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-amber-900">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
{token}
</code>
<button
onClick={copy}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
<button onClick={onDismiss} className="shrink-0 text-xs text-amber-800 hover:underline">
</button>
</div>
</div>
);
}
interface CreateFormProps {
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
variant: 'agent' | 'user';
busy: boolean;
onSubmit: (payload: api.CreateKeyPayload) => void;
}
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
const [open, setOpen] = useState(false);
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
const [label, setLabel] = useState('');
const [hours, setHours] = useState(24);
const [agentName, setAgentName] = useState('');
const [keyToken, setKeyToken] = useState('');
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
);
}
const submit = () => {
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
if (keyType === 'timed') payload.expires_hours = hours;
if (variant === 'agent') {
if (agentName.trim()) payload.agent_name = agentName.trim();
if (keyToken.trim()) payload.key_token = keyToken.trim();
}
onSubmit(payload);
setOpen(false);
setLabel('');
setAgentName('');
setKeyToken('');
};
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
onClick={() => setKeyType(t)}
className={`text-left px-2.5 py-2 rounded border text-xs ${
keyType === t
? 'border-blue-400 bg-white ring-2 ring-blue-100'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
</button>
))}
</div>
<div className="flex gap-2">
<input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="备注(如 我的笔记本 / CI 机器)"
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyType === 'timed' && (
<div className="flex items-center gap-1">
<input
type="number"
min={1}
value={hours}
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<span className="text-[11px] text-gray-500"></span>
</div>
)}
</div>
{variant === 'agent' && (
<div className="space-y-2">
<input
value={agentName}
onChange={e => setAgentName(e.target.value)}
placeholder="绑定到 Agent留空 = 首次注册时自动落定)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<input
value={keyToken}
onChange={e => setKeyToken(e.target.value)}
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex-1" />
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
</button>
<button
onClick={submit}
disabled={busy}
className="text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
</div>
</div>
);
}
/**
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
*/
export default function KeyPanel({
variant,
keys,
loading,
error,
newToken,
onCreate,
onDelete,
onBind,
onDismissToken
}: {
variant: 'agent' | 'user';
keys: (api.AgentKey | api.UserKey)[];
loading: boolean;
error: string | null;
newToken: string | null;
onCreate: (payload: api.CreateKeyPayload) => void;
onDelete: (id: string) => void;
onBind?: (id: string, agentName: string) => void;
onDismissToken: () => void;
}) {
const [bindingID, setBindingID] = useState<string | null>(null);
const [bindName, setBindName] = useState('');
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<KeyIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
</h3>
<div className="flex-1" />
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
</div>
<p className="text-[11px] text-gray-500">
{variant === 'agent'
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
: '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'}
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
{keys.length === 0 ? (
<div className="text-xs text-gray-400 py-3"></div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{keys.map(k => {
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-900 truncate">
{k.label || <span className="text-gray-400"></span>}
</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{KEY_TYPE_LABEL[k.key_type]}
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
{agentKey &&
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
</div>
</div>
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
{agentKey && onBind && bindingID === k.key_id ? (
<div className="flex items-center gap-1 shrink-0">
<input
value={bindName}
onChange={e => setBindName(e.target.value)}
placeholder="Agent 名"
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
/>
<button
onClick={() => {
if (bindName.trim()) onBind(k.key_id, bindName.trim());
setBindingID(null);
setBindName('');
}}
className="text-[11px] text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setBindingID(null)}
className="text-[11px] text-gray-500 hover:underline"
>
</button>
</div>
) : (
agentKey &&
onBind && (
<button
onClick={() => {
setBindingID(k.key_id);
setBindName(agentKey.agent_name ?? '');
}}
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
>
</button>
)
)}
<button
onClick={() => onDelete(k.key_id)}
title="吊销"
className="shrink-0 text-gray-400 hover:text-red-600"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
);
}