宿主机 data 挂载会遮盖镜像内 heaven_knowledge.json;增加不受挂载影响的 seed, 并将缺文件/坏 JSON 转为结构化中文错误,前端展示可读提示而非 Failed to fetch。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
120 lines
3.8 KiB
JavaScript
120 lines
3.8 KiB
JavaScript
(function exposeApiClient(global) {
|
|
"use strict";
|
|
|
|
let csrfTokenSupplier = () => "";
|
|
let unauthorizedHandler = () => {};
|
|
|
|
class ApiError extends Error {
|
|
constructor(message, status = 0, payload = null) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.payload = payload;
|
|
}
|
|
}
|
|
|
|
function configure(options = {}) {
|
|
if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken;
|
|
if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized;
|
|
}
|
|
|
|
function requestOptions(method, body, signal) {
|
|
const normalizedMethod = String(method || "GET").toUpperCase();
|
|
const options = { method: normalizedMethod, headers: {}, signal };
|
|
const csrfToken = csrfTokenSupplier();
|
|
if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) {
|
|
options.headers["X-CSRF-Token"] = csrfToken;
|
|
}
|
|
if (body !== null && body !== undefined) {
|
|
options.headers["Content-Type"] = "application/json";
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
async function parseJson(response) {
|
|
try {
|
|
return await response.json();
|
|
} catch (_error) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function handleUnauthorized(response, url) {
|
|
if (response.status === 401 && !String(url).startsWith("/api/auth/")) {
|
|
unauthorizedHandler({ response, url });
|
|
}
|
|
}
|
|
|
|
function readableRequestError(error) {
|
|
const message = String(error?.message || "");
|
|
if (
|
|
error instanceof TypeError
|
|
|| /failed to fetch|networkerror|load failed|network request failed/i.test(message)
|
|
) {
|
|
return "网络请求失败,服务暂时不可用,请稍后重试。";
|
|
}
|
|
return message || "请求失败";
|
|
}
|
|
|
|
async function request(url, method = "GET", body = null, options = {}) {
|
|
let response;
|
|
try {
|
|
response = await fetch(url, requestOptions(method, body, options.signal));
|
|
} catch (error) {
|
|
throw new ApiError(readableRequestError(error), 0, null);
|
|
}
|
|
const payload = await parseJson(response);
|
|
handleUnauthorized(response, url);
|
|
if (!response.ok || payload.error) {
|
|
const message = payload.message || payload.error || "请求失败";
|
|
throw new ApiError(message, response.status, payload);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function streamNdjson(url, options = {}) {
|
|
const response = await fetch(
|
|
url,
|
|
requestOptions(options.method || "POST", options.body, options.signal),
|
|
);
|
|
if (!response.ok) {
|
|
const payload = await parseJson(response);
|
|
handleUnauthorized(response, url);
|
|
throw new ApiError(
|
|
payload.error || options.errorMessage || "流式请求暂不可用",
|
|
response.status,
|
|
payload,
|
|
);
|
|
}
|
|
if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status);
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
const consume = (line) => {
|
|
if (!line.trim()) return;
|
|
let event;
|
|
try {
|
|
event = JSON.parse(line);
|
|
} catch (_error) {
|
|
throw new ApiError("流式响应格式错误", response.status);
|
|
}
|
|
if (event.type === "error") {
|
|
throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event);
|
|
}
|
|
options.onEvent?.(event);
|
|
};
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
lines.forEach(consume);
|
|
if (done) break;
|
|
}
|
|
if (buffer.trim()) consume(buffer);
|
|
}
|
|
|
|
global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson });
|
|
})(window);
|