// 真实数据层:直连本地 maestro daemon 的 REST + WS(按 ⑧ 前端 API 契约)。 // 路径用同源相对地址,dev 下经 vite.config.js 的 proxy 反代到 :4517。 async function http(method, path, body) { const opt = { method, headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } const res = await fetch(path, opt); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).error || msg; } catch { /* 非 JSON 错误体 */ } throw new Error(`${method} ${path} → ${res.status} ${msg}`); } if (res.status === 204) return null; const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : res.text(); } export const api = { listProjects: () => http('GET', '/api/projects'), projectTasks: (pid) => http('GET', `/api/projects/${pid}/tasks`), projectEvents: (pid) => http('GET', `/api/projects/${pid}/events`), agents: () => http('GET', '/api/agents'), approvals: () => http('GET', '/api/approvals'), usage: () => http('GET', '/api/usage'), syncProject: (pid) => http('POST', `/api/projects/${pid}/sync`), decide: (taskId, action, reason, merge) => http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }), createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body), patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body), reorderProjects: (order) => http('POST', '/api/projects/reorder', { order }), createProject: (body) => http('POST', '/api/projects', body), getSettings: () => http('GET', '/api/settings'), putSettings: (data) => http('PUT', '/api/settings', data), takeover: (taskId) => http('POST', `/api/tasks/${taskId}/takeover`), patchTask: (taskId, body) => http('PATCH', `/api/tasks/${taskId}`, body), cancelTask: (taskId) => http('POST', `/api/tasks/${taskId}/cancel`), requeueTask: (taskId) => http('POST', `/api/tasks/${taskId}/requeue`), deleteTask: (taskId) => http('DELETE', `/api/tasks/${taskId}`), // 附件上传走 multipart(不能用 JSON helper,content-type 由浏览器带 boundary) uploadAttachments: async (taskId, files) => { const fd = new FormData(); for (const f of files) fd.append('files', f, f.name); const res = await fetch(`/api/tasks/${taskId}/attachments`, { method: 'POST', body: fd }); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).error || msg; } catch { /* 非 JSON */ } throw new Error(`上传附件失败 → ${res.status} ${msg}`); } return res.json(); }, }; // WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。 export function subscribeEvents(onEvent, onStatus) { let ws = null; let backoff = 500; let closedByUser = false; function connect() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { backoff = 500; onStatus?.('open'); }; ws.onmessage = (msg) => { try { onEvent(JSON.parse(msg.data)); } catch { /* 忽略非 JSON 帧 */ } }; ws.onclose = () => { onStatus?.('closed'); if (closedByUser) return; setTimeout(connect, backoff); backoff = Math.min(backoff * 2, 10000); }; ws.onerror = () => ws.close(); } connect(); return () => { closedByUser = true; ws?.close(); }; }