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:
109
web/src/components/Sidebar.tsx
Normal file
109
web/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
LogoutIcon
|
||||
} from './icons';
|
||||
|
||||
const navItems: {
|
||||
short: string;
|
||||
title: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
|
||||
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
|
||||
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const unread = inbox.filter(m => m.status === 'unread').length;
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
|
||||
{navItems
|
||||
.filter(n => !n.adminOnly || isAdmin)
|
||||
.map(({ short, title, mode, Icon }) => {
|
||||
const active = viewMode === mode && !composing;
|
||||
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
title={title}
|
||||
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[9px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[9px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-slate-700">
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className="w-9 h-7 rounded flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-800"
|
||||
>
|
||||
<LogoutIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user