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

212
web/src/types/index.ts Normal file
View File

@ -0,0 +1,212 @@
export interface User {
user_id: string;
username: string;
display_name: string;
role: 'admin' | 'user';
status: 'active' | 'disabled';
allowed_agents: string[];
allowed_paths: string[];
last_login?: string;
created_at?: string;
}
export interface Workspace {
name: string;
path: string;
}
export interface Agent {
agent_id?: string;
agent_name: string;
workspaces: Workspace[];
platform: string;
status: string;
}
export interface Address {
name: string;
path: string;
session: string;
raw: string;
}
export interface Session {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count?: number;
/**
* 别名是谁定的:
* platform = Agent 平台自动同步来的,后续同步可以覆盖
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
*/
alias_source?: 'platform' | 'manual';
/** 用户驳回过的改名提议 */
rename_dismissed?: string;
/**
* 本任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
*
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
*/
max_rounds?: number;
used_rounds?: number;
}
/** 会话往返预算快照 */
export interface SessionBudget {
session_id: string;
max_rounds: number;
used_rounds: number;
/** 不限时为 -1 */
remaining: number;
unlimited: boolean;
}
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
export interface Attachment {
attachment_id: string;
/** 为 null 表示已上传但尚未随邮件发出 */
mail_id: string | null;
uploader: string;
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
created_at: string;
}
export interface Mail {
mail_id: string;
session_id: string;
parent_mail_id: string | null;
from_name: string;
from_workspace: string;
to_name: string;
to_workspace: string;
cc_list: Address[];
subject: string;
body: string;
mail_type: 'normal' | 'permission_request';
permission_options: string[] | null;
permission_result: string | null;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;
session_alias?: string;
body_preview?: string;
attachments?: Attachment[];
}
/**
* 对话树节点。
*
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
*
* depth 是**相对锚点**的层级0 = 锚点,负数 = 祖先,正数 = 子孙。
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」,后者会随上滑补齐) */
parent_hidden?: boolean;
body?: string;
}
export interface ThreadPage {
anchor_mail_id: string;
dir: 'around' | 'up' | 'down';
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more_up: boolean;
has_more_down: boolean;
/** 下一页 offset原样回传即可 */
next_up: number;
next_down: number;
}
/**
* Agent 在邮件正文里提议的新会话别名。
*
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
* name@path.别名。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
export interface RenameProposal {
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
alias: string;
reason?: string;
}
export interface Contact {
session_id: string;
agent_name: string;
path: string;
session_alias: string;
address: string;
status: string;
mail_count: number;
unread_count: number;
last_activity: string;
}
export interface PermissionRequest {
request_id: string;
mail_id: string;
session_id: string;
agent_name: string;
question: string;
options: string[];
context: string;
result: string | null;
decided_at: string | null;
created_at: string;
}
export interface SessionDetail {
session: Session;
mails: Mail[];
}
export interface HumanSession {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count: number;
unread_count: number;
/** 本任务的往返预算0 = 不限) */
max_rounds?: number;
used_rounds?: number;
}
export type SuggestKind = 'name' | 'path' | 'session';
export interface SuggestResult {
kind: SuggestKind;
suggestions: string[];
}
/** 系统初始化状态 */
export interface SetupStatus {
needs_setup: boolean;
}
/** 管理员可授权范围候选 */
export interface AdminScopes {
agents: string[];
paths: string[];
}