chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
685
client/electron/src/api/client.ts
Normal file
685
client/electron/src/api/client.ts
Normal 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 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
||||
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-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 ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 配额 ----------
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
}
|
||||
68
client/electron/src/api/config.ts
Normal file
68
client/electron/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)}`;
|
||||
}
|
||||
109
client/electron/src/api/sse.ts
Normal file
109
client/electron/src/api/sse.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user