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