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:
212
web/src/types/index.ts
Normal file
212
web/src/types/index.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user