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:
489
web/src/api/client.ts
Normal file
489
web/src/api/client.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget } from '../types';
|
||||
import { API_BASE, authHeaders, withToken } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
|
||||
|
||||
const BASE = API_BASE;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
retryAfter?: number;
|
||||
constructor(status: number, message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
// Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
export async function setupStatus() {
|
||||
return request<{ needs_setup: boolean }>('GET', '/setup/status');
|
||||
}
|
||||
|
||||
export async function setupAdmin(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/setup/admin', payload);
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return request<{ user: User }>('POST', '/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return request<{ status: string }>('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
return request<{ user: User }>('GET', '/auth/me');
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', '/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
export async function adminListUsers() {
|
||||
return request<{ users: User[] }>('GET', '/admin/users');
|
||||
}
|
||||
|
||||
export async function adminCreateUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/admin/users', payload);
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(
|
||||
id: string,
|
||||
payload: {
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}
|
||||
) {
|
||||
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function adminDisableUser(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function adminResetPassword(id: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminListScopes() {
|
||||
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- 密钥 ----------
|
||||
|
||||
export type KeyType = 'permanent' | 'one_time' | 'timed';
|
||||
|
||||
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
|
||||
export interface AgentKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
agent_name: string | null;
|
||||
key_type: KeyType;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
label: string;
|
||||
key_type: KeyType;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateKeyPayload {
|
||||
key_type: KeyType;
|
||||
label?: string;
|
||||
/** 仅 timed 需要 */
|
||||
expires_hours?: number;
|
||||
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
|
||||
agent_name?: string;
|
||||
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
|
||||
key_token?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentKeys(agentName?: string) {
|
||||
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
|
||||
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
|
||||
}
|
||||
|
||||
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
|
||||
}
|
||||
|
||||
export async function adminDeleteAgentKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function adminBindAgentKey(id: string, agentName: string) {
|
||||
return request<{ status: string; agent_name: string }>(
|
||||
'POST',
|
||||
`/admin/agent-keys/${id}/bind`,
|
||||
{ agent_name: agentName }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyKeys() {
|
||||
return request<{ keys: UserKey[] }>('GET', '/me/keys');
|
||||
}
|
||||
|
||||
export async function createMyKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: UserKey }>('POST', '/me/keys', payload);
|
||||
}
|
||||
|
||||
export async function deleteMyKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
|
||||
}
|
||||
|
||||
// ---------- Agents ----------
|
||||
|
||||
export async function listAgents(status?: string) {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
|
||||
}
|
||||
|
||||
// ---------- 自己的邮箱 ----------
|
||||
|
||||
export interface SendMailOpts {
|
||||
cc?: string;
|
||||
reply_to?: string;
|
||||
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
|
||||
session_alias?: string;
|
||||
/** 先用 uploadAttachment 上传取得的 id 列表 */
|
||||
attachment_ids?: string[];
|
||||
/**
|
||||
* 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效;
|
||||
* 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
opts: SendMailOpts = {}
|
||||
) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
budget_max?: number;
|
||||
budget_used?: number;
|
||||
budget_remaining?: number;
|
||||
}>('POST', '/me/mail/send', {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc: opts.cc ?? '',
|
||||
reply_to: opts.reply_to ?? '',
|
||||
session_alias: opts.session_alias ?? '',
|
||||
attachment_ids: opts.attachment_ids ?? [],
|
||||
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
||||
max_rounds: opts.max_rounds ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInbox(status = 'all', limit = 50) {
|
||||
return request<{ mails: Mail[]; total: number }>(
|
||||
'GET',
|
||||
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSent() {
|
||||
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
|
||||
}
|
||||
|
||||
export async function getMail(id: string) {
|
||||
return request<Mail>('GET', `/mail/${id}`);
|
||||
}
|
||||
|
||||
export async function markMailRead(id: string) {
|
||||
return request<{ status: string }>('POST', `/mail/${id}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取线索的一块。
|
||||
*
|
||||
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。
|
||||
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
|
||||
*/
|
||||
export async function getMailThread(
|
||||
id: string,
|
||||
opts: { dir?: 'around' | 'up' | 'down'; offset?: number; limit?: number } = {}
|
||||
) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.dir) q.set('dir', opts.dir);
|
||||
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
|
||||
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
|
||||
const qs = q.toString();
|
||||
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
/**
|
||||
* 上传附件,返回 attachment_id。
|
||||
*
|
||||
* 不能走 request():那里固定 Content-Type: application/json,
|
||||
* 而 multipart 必须让浏览器自己带 boundary。
|
||||
*/
|
||||
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
|
||||
if (onProgress) {
|
||||
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/me/attachments`);
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
let payload: { attachment?: Attachment; error?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* 非 JSON 响应按状态码处理 */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
|
||||
resolve({ attachment: payload.attachment });
|
||||
} else {
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/me/attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
|
||||
headers: authHeaders(),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401) onUnauthorized?.();
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ attachment: Attachment }>;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
|
||||
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
|
||||
*/
|
||||
export function attachmentURL(id: string) {
|
||||
return withToken(`${BASE}/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
export function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface ForwardPayload {
|
||||
/** 新收件人的三维地址 */
|
||||
to: string;
|
||||
/** 转发说明,置于引用原文之前 */
|
||||
comment?: string;
|
||||
cc?: string;
|
||||
/** 留空则自动加 Fwd: 前缀 */
|
||||
subject?: string;
|
||||
/** 仅当 to 以 .new 结尾时生效 */
|
||||
session_alias?: string;
|
||||
}
|
||||
|
||||
export async function forwardMail(id: string, payload: ForwardPayload) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
forwarded_from: string;
|
||||
}>('POST', `/me/mail/${id}/forward`, {
|
||||
to: payload.to,
|
||||
comment: payload.comment ?? '',
|
||||
cc: payload.cc ?? '',
|
||||
subject: payload.subject ?? '',
|
||||
session_alias: payload.session_alias ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 配额 ----------
|
||||
|
||||
export interface Quota {
|
||||
agent_name: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限额时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export async function adminListQuotas() {
|
||||
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
|
||||
}
|
||||
|
||||
/** 设上限(0 = 不限)或把已用次数归零 */
|
||||
export async function adminSetQuota(
|
||||
agentName: string,
|
||||
payload: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
|
||||
export async function getHumanSessions() {
|
||||
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
|
||||
}
|
||||
|
||||
export async function getSessionDetail(id: string) {
|
||||
return request<SessionDetail>('GET', `/sessions/${id}`);
|
||||
}
|
||||
|
||||
export async function updateSessionAlias(id: string, alias: string) {
|
||||
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。
|
||||
* 已接受(提议就是当前别名)或已驳回的不再返回。
|
||||
*/
|
||||
export async function getRenameProposal(id: string) {
|
||||
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
|
||||
}
|
||||
|
||||
/** 本会话(= 本任务)的往返预算。 */
|
||||
export async function getSessionBudget(id: string) {
|
||||
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改本会话的往返预算。
|
||||
*
|
||||
* max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给
|
||||
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
|
||||
*/
|
||||
export async function updateSessionBudget(
|
||||
id: string,
|
||||
patch: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
|
||||
}
|
||||
|
||||
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
|
||||
export async function dismissRenameProposal(id: string) {
|
||||
return request<{ status: string; dismissed?: string }>(
|
||||
'POST',
|
||||
`/sessions/${id}/rename-proposal/dismiss`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Contacts ----------
|
||||
|
||||
export async function listContacts(archived = false) {
|
||||
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
|
||||
}
|
||||
|
||||
export async function suggestAddress(name?: string, path?: string) {
|
||||
const p = new URLSearchParams();
|
||||
if (name) p.set('name', name);
|
||||
if (path) p.set('path', path);
|
||||
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
|
||||
}
|
||||
|
||||
export async function archiveContact(payload: { address?: string; session_id?: string }) {
|
||||
return request<{ status: string; session_id: string; session_alias: string }>(
|
||||
'POST',
|
||||
'/contacts/archive',
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
export async function decidePermission(mailId: string, decision: string, note?: string) {
|
||||
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
|
||||
mail_id: mailId,
|
||||
decision,
|
||||
note: note ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPendingPermissions() {
|
||||
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
|
||||
}
|
||||
68
web/src/api/config.ts
Normal file
68
web/src/api/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* API 接入配置。
|
||||
*
|
||||
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
|
||||
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1;独立部署的客户端需要指向具体主机
|
||||
* 2. 凭证:浏览器用登录 Cookie;第三方客户端用用户密钥(Authorization: Bearer)
|
||||
*
|
||||
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
|
||||
* 不需要改任何调用点。
|
||||
*/
|
||||
|
||||
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
|
||||
declare global {
|
||||
interface Window {
|
||||
__AGENTMAIL_API_BASE__?: string;
|
||||
__AGENTMAIL_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
|
||||
*
|
||||
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
|
||||
*/
|
||||
function resolveBase(): string {
|
||||
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
|
||||
const build = import.meta.env?.VITE_API_BASE as string | undefined;
|
||||
const base = (runtime || build || '/api/v1').trim();
|
||||
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE = resolveBase();
|
||||
|
||||
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
|
||||
let bearerToken: string | null =
|
||||
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
|
||||
|
||||
/**
|
||||
* 设置用户密钥。第三方客户端在启动时调用一次即可,
|
||||
* 之后所有请求(含 SSE 与附件下载)自动带上。
|
||||
*/
|
||||
export function setToken(token: string | null) {
|
||||
bearerToken = token && token.trim() !== '' ? token.trim() : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
/** 认证请求头。用 Cookie 时返回空对象。 */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
* - <a download> / <img src> 由浏览器直接发起
|
||||
*
|
||||
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=,
|
||||
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
|
||||
*/
|
||||
export function withToken(url: string): string {
|
||||
if (!bearerToken) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
|
||||
}
|
||||
76
web/src/api/sse.ts
Normal file
76
web/src/api/sse.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
|
||||
const EVENTS = [
|
||||
'new_mail',
|
||||
'permission_decision',
|
||||
'session_update',
|
||||
'session_archived',
|
||||
'agent_online'
|
||||
] as const;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let handlers: SSEEventHandler[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoff = 1000;
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
if (!es) open();
|
||||
|
||||
return () => {
|
||||
handlers = handlers.filter(h => h !== onEvent);
|
||||
if (handlers.length === 0) close();
|
||||
};
|
||||
}
|
||||
|
||||
function open() {
|
||||
close(false);
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
backoff = 1000;
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
es.addEventListener(name, (e: MessageEvent) => {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* 忽略非 JSON 负载 */
|
||||
}
|
||||
handlers.forEach(h => h(name, data));
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
open();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
function close(clearHandlers = true) {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
if (clearHandlers) handlers = [];
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
close();
|
||||
}
|
||||
Reference in New Issue
Block a user