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:
311
web/src/components/KeyPanel.tsx
Normal file
311
web/src/components/KeyPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user