Files
plantai-desktop/ui/src/api/http.js
T

262 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// api/http.js —— B1 归属,B2/B3 复用。fetch 封装 + 统一错误信封解析。
// 契约(API.md §1):非 2xx 与降级响应返回 {"ok":false,"error":{"code","message","degraded"}}。
// 网络错误与信封错误一律归一为结构化 ApiError。
//
// 超时策略(BUG-R1-07 修复,API.md 全表读超时:qa chat 300s / qa search 180s /
// extraction upload 600s / kg overview 60s / 邻域 30s):
// - 默认读超时 600s = API.md 全表最大值。前端兜底永不先于 BFF 超时自断——
// 真实超时时由 BFF 返回 TIMEOUT 信封(504),前端展示契约文案「检索超时」,
// 而不是自己 fabricate 一个 NETWORK 错误。
// - 调用方可传 opts.timeout(毫秒)做更紧的超时(如已知快路径);
// timeout: 0 表示不设超时。
// - SSE 流式(raw:true)不走读超时——上游最长 240s,由调用方 AbortSignal 控制生命周期。
//
// 查询参数(BUG-R1-01 修复):get(path, params, opts) 的 params 经 URLSearchParams
// 序列化拼到 query string(undefined/null 跳过),匹配 B3 kg.js / extraction.js 的调用形态:
// get('/api/kg/entities', { q, limit }) → /api/kg/entities?q=…&limit=20
// get('/api/kg/entities/{id}/neighborhood', { depth, limit }) → …/neighborhood?depth=1&limit=50
export class ApiError extends Error {
constructor(message, { code = 'NETWORK', status = 0, degraded = false, cause } = {}) {
super(message);
this.name = 'ApiError';
this.code = code; // 信封 error.code,网络错误为 'NETWORK'
this.status = status; // HTTP 状态码,网络错误为 0
this.degraded = degraded; // true = 上游不可用(UI 走降级提示而非错误弹窗)
if (cause) this.cause = cause;
}
}
/** 默认读超时:API.md 全表最大值(extraction upload 600s)——前端兜底不得先于 BFF 超时 */
const DEFAULT_TIMEOUT_MS = 600000;
/** 鉴权 token + 401 处理(惰性读 localStorage,避免与 auth store 循环引用) */
const AUTH_TOKEN_KEY = 'pa-auth-token';
let _onUnauthorized = null;
export function setUnauthorizedHandler(fn) { _onUnauthorized = fn; }
function authToken() {
try { return localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch { return ''; }
}
function notifyUnauthorized() { if (_onUnauthorized) _onUnauthorized(); }
function buildQuery(params) {
if (!params) return '';
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
sp.set(k, String(v));
}
const qs = sp.toString();
return qs ? `?${qs}` : '';
}
function _parseXhrPayload(xhr) {
const ct = xhr.getResponseHeader('content-type') || '';
if (!ct.includes('application/json')) return null;
try {
return JSON.parse(xhr.responseText);
} catch {
return null;
}
}
/** FormData + onUploadProgress → XHR(fetch 无上传进度事件)。
* 契约与 fetch 分支完全一致:2xx 返回 JSON payload;非 2xx 走统一错误信封抛 ApiError。 */
function xhrUpload(url, method, body, { timeout, signal, onUploadProgress }) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
const _token = authToken();
if (_token) xhr.setRequestHeader('Authorization', 'Bearer ' + _token);
// 不设 Content-Type:浏览器自动带 multipart boundary(与 fetch 分支一致)
if (body instanceof FormData) {
if (body.has('code_zip')) {
const f = body.get('code_zip');
if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size;
} else if (body.has('code_files')) {
// 目录形态:全部文件大小求和(多值字段)
let sum = 0;
for (const f of body.getAll('code_files')) {
if (f instanceof Blob) sum += f.size || 0;
}
if (sum > 0) xhr._paSizeHint = sum;
} else if (body.has('file')) {
const f = body.get('file');
if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size;
}
}
xhr.responseType = 'text';
let timedOut = false;
let abortedBySignal = false;
const t = timeout === 0 ? null : setTimeout(() => {
timedOut = true;
xhr.abort();
}, timeout);
const onAbort = () => {
if (signal && signal.aborted) {
abortedBySignal = true;
xhr.abort();
}
};
if (signal) {
if (signal.aborted) abortedBySignal = true;
else signal.addEventListener('abort', onAbort);
}
if (onUploadProgress && xhr.upload) {
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const total = e.total || xhr._paSizeHint || 0;
onUploadProgress({ loaded: e.loaded, total, pct: total ? Math.min(1, e.loaded / total) : 0 });
};
}
xhr.onload = () => {
if (t) clearTimeout(t);
if (signal) signal.removeEventListener('abort', onAbort);
const payload = _parseXhrPayload(xhr);
if (xhr.status === 401) notifyUnauthorized();
if (xhr.status >= 200 && xhr.status < 300) {
resolve(payload);
return;
}
if (payload && payload.ok === false && payload.error) {
reject(new ApiError(payload.error.message || '服务错误', {
code: payload.error.code || 'INTERNAL',
status: xhr.status,
degraded: !!payload.error.degraded,
}));
return;
}
reject(new ApiError(`请求失败(HTTP ${xhr.status})`, { code: 'INTERNAL', status: xhr.status }));
};
xhr.onerror = () => {
if (t) clearTimeout(t);
if (signal) signal.removeEventListener('abort', onAbort);
if (abortedBySignal) {
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
return;
}
reject(new ApiError('网络异常,请检查连接', {
code: 'NETWORK',
degraded: false,
cause: timedOut ? new Error('timeout') : undefined,
}));
};
xhr.ontimeout = () => xhr.abort();
xhr.send(body);
});
}
async function request(path, { method = 'GET', body, headers = {}, timeout = DEFAULT_TIMEOUT_MS, signal, raw = false, params, onUploadProgress } = {}) {
// GET 的 params 序列化为 query string(BUG-R1-01)
let url = path;
if (method === 'GET' && params) url += buildQuery(params);
// 上传进度(2026-09-04):FormData + onUploadProgress → XHR 分支(fetch 无 upload 事件)
if (onUploadProgress && typeof FormData !== 'undefined' && body instanceof FormData) {
return xhrUpload(url, method, body, { timeout, signal, onUploadProgress });
}
const opts = { method, headers: { ...headers }, signal };
const token = authToken();
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body !== undefined && body !== null) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
// multipart(FR-EXTRACT-004 上传):不设 Content-Type(浏览器自动带 boundary)
opts.body = body;
} else {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
}
let res;
try {
const ctrl = new AbortController();
// raw(SSE 流式)不设读超时;timeout:0 显式关闭
const t = raw || timeout === 0 ? null : setTimeout(() => ctrl.abort(), timeout);
res = await fetch(url, {
...opts,
signal: signal || ctrl.signal,
});
if (t) clearTimeout(t);
if (res.status === 401) notifyUnauthorized();
} catch (e) {
// AbortError 也可能是调用方主动中断流式请求——继续抛原始错误,由调用方处理
if (e.name === 'AbortError' && signal && signal.aborted) throw e;
throw new ApiError('网络异常,请检查连接', {
code: 'NETWORK',
degraded: false,
cause: e,
});
}
// raw:true —— 返回原始 Response 供 SSE 流式消费(B2 consumeSseStream);
// 非 2xx 仍按统一错误信封抛 ApiError(HTTP 错误在流开始前就被拒绝)
if (raw) {
if (!res.ok) {
let payload = null;
if ((res.headers.get('content-type') || '').includes('application/json')) {
try {
payload = await res.json();
} catch {
payload = null;
}
}
if (payload && payload.ok === false && payload.error) {
throw new ApiError(payload.error.message || '服务错误', {
code: payload.error.code || 'INTERNAL',
status: res.status,
degraded: !!payload.error.degraded,
});
}
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
}
return res;
}
let payload = null;
const isJson = (res.headers.get('content-type') || '').includes('application/json');
if (isJson) {
try {
payload = await res.json();
} catch {
payload = null;
}
}
if (!res.ok) {
// 统一错误信封(API.md §1)
if (payload && payload.ok === false && payload.error) {
throw new ApiError(payload.error.message || '服务错误', {
code: payload.error.code || 'INTERNAL',
status: res.status,
degraded: !!payload.error.degraded,
});
}
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
}
return payload;
}
export const api = {
/** params 序列化为 query string(BUG-R1-01);opts 可传 { timeout, signal, raw } */
get: (path, params = {}, opts = {}) => request(path, { ...opts, method: 'GET', params }),
/** body 为普通对象 → JSON;FormData → multipart。
* opts 可传 { timeout, signal, raw, onUploadProgress }(onUploadProgress 仅 FormData 生效) */
post: (path, body, opts = {}) => request(path, { ...opts, method: 'POST', body }),
patch: (path, body, opts = {}) => request(path, { ...opts, method: 'PATCH', body }),
del: (path, opts = {}) => request(path, { ...opts, method: 'DELETE' }),
/** 原始 fetch(无封装),供 SSE 流式调用(B2 用 ReadableStream 消费 POST /api/qa/chat/stream) */
rawFetch: fetch,
};
// 命名导出(B2/B3 api/* 层按此 import;与 api 对象同函数,两种取法等价)
export const get = api.get;
export const post = api.post;
export const patch = api.patch;
export const del = api.del;
// 别名:B2 代码注释中的 HttpError 即 ApiError(统一错误信封,契约 API.md §1)
export const HttpError = ApiError;