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(method: string, path: string, body?: unknown): Promise { 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('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('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}`); } /** * 附件下载链接。由浏览器直接发起(),因此无法带 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; } export async function adminListAgentStats() { return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas'); } /** 改该 Agent 的新任务默认预算(0 = 不限)。 */ export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) { return request<{ quota: AgentStats }>( 'PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, { default_rounds: defaultRounds } ); } // ---------- Sessions ---------- export async function getHumanSessions() { return request<{ sessions: HumanSession[] }>('GET', '/me/sessions'); } export async function getSessionDetail(id: string) { return request('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('GET', `/sessions/${id}/budget`); } /** * 改本会话的往返预算。 * * max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给 * (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。 */ export async function updateSessionBudget( id: string, patch: { max_rounds?: number; reset?: boolean } ) { return request('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('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'); }