chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,685 @@
import type { User } from '../types';
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } 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;
session_alias?: string;
attachment_ids?: string[];
max_rounds?: number;
/** 权限档位仅新建会话时生效plan / workspace / full */
permission_mode?: string;
}
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 而非 00 是「不限」的合法取值,省略才表示「不设置」
max_rounds: opts.max_rounds ?? null,
permission_mode: opts.permission_mode ?? '',
});
}
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: { offset?: number; limit?: number } = {}
) {
const q = new URLSearchParams();
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-Typemultipart 的 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 ?? ''
});
}
// ---------- 配额 ----------
/**
* Agent 的新任务默认预算与累计统计。
*
* 没有「剩余额度」字段 —— 额度属于具体任务(会话),见 SessionBudget。
* 这里只有「派给它的新任务默认几个来回」与「一共发了多少信」。
*/
export interface AgentStats {
agent_name: string;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds: number;
/** 累计发信数,纯统计,不拦请求 */
sent_total: number;
/** 参与的未归档会话数,配合默认值判断设多少合适 */
active_sessions: number;
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
status?: string;
}
export async function adminListAgentStats() {
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
}
/** 停用true或恢复false一个 Agent。返回撤销的密钥数等信息。 */
export async function adminSetAgentStatus(agentName: string, disabled: boolean) {
return request<{
agent_name: string;
disabled: boolean;
keys_revoked?: number;
/** 恢复路径为 true停用时撤销的密钥不会自动回来必须重新签发 */
needs_new_key?: boolean;
detail: string;
}>(
'PUT',
`/admin/agents/${encodeURIComponent(agentName)}/status`,
{ disabled }
);
}
/**
* 彻底删除一个 Agent。保留邮件历史与会话清除密钥、镜像、日历。
* 名字今后不可再注册(防止同名新注册冒用历史署名)。
*/
export async function adminDeleteAgent(agentName: string) {
return request<{
agent_name: string;
keys_revoked: number;
detail: string;
}>(
'DELETE',
`/admin/agents/${encodeURIComponent(agentName)}`
);
}
/** 改该 Agent 的新任务默认预算0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>(
'PUT',
`/admin/quotas/${encodeURIComponent(agentName)}`,
{ default_rounds: defaultRounds }
);
}
// ---------- 邮件场景下可用的模型范围 ----------
/** 平台上报的一个模型,带「是否已被选入」标记。 */
export interface CatalogModel {
provider: string;
model: string;
display_name?: string;
allowed: boolean;
/** 仅 allowed 为真时有意义,越小越先试 */
rank?: number;
}
export interface ModelRoute {
provider: string;
model: string;
}
/**
* 读某 Agent 的模型目录。
*
* `stale` 是「已选但平台当前目录里没有」的那些 —— 平台可能临时下线了某个模型,
* 而管理员的选择是持久的。界面上不显示会让人以为自己没选过它。
*/
export async function adminGetAgentModels(agentName: string) {
return request<{ agent_name: string; catalog: CatalogModel[]; stale: ModelRoute[] }>(
'GET',
`/admin/agents/${encodeURIComponent(agentName)}/models`
);
}
/** 保存选择。数组顺序即优先级(插件按这个顺序降级尝试)。 */
export async function adminSetAgentModels(agentName: string, models: ModelRoute[]) {
return request<{ status: string; models: ModelRoute[] }>(
'PUT',
`/admin/agents/${encodeURIComponent(agentName)}/models`,
{ models }
);
}
// ---------- 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 updateSessionPermission(
id: string,
permission_mode: string
) {
return request<{ permission_mode: string; permission_enforcement: string }>(
'PUT', `/sessions/${id}/permission`, { permission_mode }
);
}
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
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');
}
// ---------- Calendar ----------
/**
* 列事件。
*
* from/to 是 ISO 8601 区间;省略时后端给「前一个月到后一个月」。
* 月视图要显示跨月的首尾几天,所以查询区间必须按**格子**算而不是按月首月末算。
*/
export async function listCalendarEvents(opts: {
from?: string;
to?: string;
status?: string;
} = {}) {
const p = new URLSearchParams();
if (opts.from) p.set('from', opts.from);
if (opts.to) p.set('to', opts.to);
if (opts.status) p.set('status', opts.status);
const qs = p.toString();
return request<{ events: CalendarEvent[] }>('GET', `/calendar/events${qs ? '?' + qs : ''}`);
}
export async function getCalendarEvent(id: string) {
return request<CalendarEvent>('GET', `/calendar/events/${id}`);
}
export async function createCalendarEvent(input: CalendarEventInput) {
return request<CalendarEvent>('POST', '/calendar/events', input);
}
export async function updateCalendarEvent(id: string, input: Partial<CalendarEventInput>) {
return request<CalendarEvent>('PUT', `/calendar/events/${id}`, input);
}
export async function deleteCalendarEvent(id: string) {
return request<{ status: string }>('DELETE', `/calendar/events/${id}`);
}
export async function listCalendarAttachments(id: string) {
return request<{ attachments: CalendarAttachment[] }>(
'GET',
`/calendar/events/${id}/attachments`
);
}
/**
* 导出 .ics。
*
* 不走 request():那个函数假定响应是 JSON而这里是 text/calendar。
* 返回文本由调用方自己触发下载 —— 用 Blob 而不是让浏览器直接导航,
* 因为导航会丢掉 cookie 之外的认证头(附件下载同理,见 withToken
*/
export async function exportCalendarICS(from?: string, to?: string): Promise<string> {
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
const qs = p.toString();
const res = await fetch(`${API_BASE}/calendar/export.ics${qs ? '?' + qs : ''}`, {
headers: authHeaders(),
credentials: 'include'
});
if (!res.ok) {
throw new ApiError(res.status, `导出失败HTTP ${res.status}`);
}
return res.text();
}
/** 导入 .ics。整份文本原样 POST后端解析 VEVENT。 */
export async function importCalendarICS(ics: string) {
const res = await fetch(`${API_BASE}/calendar/import.ics`, {
method: 'POST',
headers: { 'Content-Type': 'text/calendar', ...authHeaders() },
credentials: 'include',
body: ics
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, payload.error || `导入失败HTTP ${res.status}`);
}
return payload as { imported: number; skipped: number };
}
/**
* 日历事件附件:上传。
*
* 与邮件附件走不同的存储关联calendar_attachments 表),因此不能复用
* uploadAttachment —— 那个挂到 mail_id 上,日历要挂到 event_id 上。
* 事件必须先存在,所以新建流程是「先创建事件拿到 event_id再传附件」。
*/
export async function uploadCalendarAttachment(eventId: string, file: File) {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${API_BASE}/calendar/events/${eventId}/attachments`, {
method: 'POST',
headers: authHeaders(),
credentials: 'include',
body: form
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, payload.error || `上传失败HTTP ${res.status}`);
}
return payload as CalendarAttachment;
}
export async function deleteCalendarAttachment(attachmentId: string) {
return request<{ status: string; attachment_id: string }>(
'DELETE',
`/calendar/attachments/${attachmentId}`
);
}

View 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 附加认证信息,供无法设置请求头的场景使用:
* - EventSourceSSE不支持自定义头
* - <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)}`;
}

View File

@ -0,0 +1,109 @@
import { API_BASE, withToken } from './config';
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
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;
let _status: SSEStatus = 'disconnected';
let statusHandlers: Array<(s: SSEStatus) => void> = [];
/** 监听 SSE 连接状态变化 */
export function onSSEStatus(handler: (s: SSEStatus) => void): () => void {
statusHandlers.push(handler);
return () => {
statusHandlers = statusHandlers.filter(h => h !== handler);
};
}
/** 当前 SSE 连接状态 */
export function getSSEStatus(): SSEStatus {
return _status;
}
function setStatus(s: SSEStatus) {
if (_status === s) return;
_status = s;
statusHandlers.forEach(h => h(s));
}
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);
setStatus('connecting');
// EventSource 无法设置请求头Cookie 模式靠同源 Cookie
// 密钥模式只能把令牌放进 query服务端仅此端点与附件下载接受 ?access_token=)。
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
// EventSource 会自动重连,但它的 readyState 在网络断开时
// 不一定及时反映状态。用 onopen 判断实际连上了。
es.onopen = () => {
backoff = 1000;
setStatus('connected');
};
es.addEventListener('connected', () => {
backoff = 1000;
setStatus('connected');
});
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;
setStatus('reconnecting');
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 = [];
setStatus('disconnected');
}
export function disconnectSSE() {
close();
}