Files
KrisHD_AI/public/js/api.js
T
2026-10-06 00:32:18 +02:00

181 lines
8.7 KiB
JavaScript

/** JSON requests return error records; streaming requests reject unless onError handles them. */
export class ApiError extends Error {
constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.details = details;
}
toJSON() {
return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId};
}
}
function requestScope({signal, timeoutMs = 30000} = {}) {
const controller = new AbortController();
const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"}));
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, {once: true});
const timer = timeoutMs > 0 ? setTimeout(() => {
controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"}));
}, timeoutMs) : null;
return {
signal: controller.signal,
close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
};
}
// Also bound body reads, even after fetch has resolved its response headers.
function abortable(operation, signal) {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
signal.addEventListener("abort", abort, {once: true});
try {
Promise.resolve(operation()).then(resolve, reject)
.finally(() => signal.removeEventListener("abort", abort));
} catch (error) {
signal.removeEventListener("abort", abort);
reject(error);
}
});
}
function responseError(response, payload = {}, fallback = "", code = "") {
const details = payload && typeof payload === "object" ? payload : {};
const error = details.error;
return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {
...details,
status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status,
code: details.code || error?.code || code || `HTTP_${response.status}`,
requestId: details.requestId || response.headers.get("X-Request-ID") || null
});
}
async function readJson(response, signal) {
const text = await abortable(() => response.text(), signal);
let payload;
if (text.trim()) {
try { payload = JSON.parse(text); }
catch {
if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE");
}
}
if (!response.ok) throw responseError(response, payload);
return payload ?? {};
}
function normalizeError(error, scope, response) {
const failure = scope.signal.aborted ? scope.signal.reason : error;
if (failure instanceof ApiError) {
if (!failure.status && response) failure.status = response.status;
failure.requestId ||= response?.headers.get("X-Request-ID") || null;
return failure;
}
return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {
status: response?.status || 0,
code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR",
requestId: response?.headers.get("X-Request-ID") || null
});
}
async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) {
const scope = requestScope(options);
let response;
try {
response = await abortable(() => fetch(url, {
method,
credentials: "same-origin",
headers,
body,
signal: scope.signal
}), scope.signal);
if (!asBlob || !response.ok) return await readJson(response, scope.signal);
return {blob: await abortable(() => response.blob(), scope.signal)};
} catch (error) {
return normalizeError(error, scope, response).toJSON();
} finally {
scope.close();
}
}
export function request(url, data, method = "POST", options = {}) {
return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: data == null ? undefined : JSON.stringify(data)}, options);
}
// Let fetch set the multipart boundary; share deadlines, cancellation and errors.
export const multipart = (url, form, options = {}) => bufferedRequest(url,
{method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
export const download = (url, options = {}) => bufferedRequest(url,
{method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
export const get = (url, options) => request(url, null, "GET", options);
export const post = (url, data, options) => request(url, data, "POST", options);
export const put = (url, data, options) => request(url, data, "PUT", options);
export const del = (url, options) => request(url, null, "DELETE", options);
/**
* SSE text events carry {chunk}; corrections carry {target: "reply"|"thinking", text}.
* A legacy AbortSignal is also accepted as the fourth argument. Set timeoutMs: 0 to disable the deadline.
*/
export async function stream(url, data, handlers = {}, options = {}) {
if (options && typeof options.addEventListener === "function") options = {signal: options};
const scope = requestScope({timeoutMs: 660000, ...options});
let response, reader;
try {
response = await abortable(() => fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"},
body: JSON.stringify(data || {}),
signal: scope.signal
}), scope.signal);
if (!response.ok) await readJson(response, scope.signal);
if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE");
reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
while (!terminal) {
const {value, done} = await abortable(() => reader.read(), scope.signal);
if (done) break;
buffer += decoder.decode(value, {stream: true});
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop() || "";
for (const part of parts) {
const lines = part.split(/\r?\n/);
const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim();
const dataLines = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart());
if (!event || !dataLines.length) continue;
const payload = JSON.parse(dataLines.join("\n"));
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
const textFields = {status: "status", done: "reply", error: "error", replace: "text"};
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
if (event === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new Error("Invalid response stream.");
if (event === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new Error("Invalid response stream.");
if (event === "replace" && !["reply", "thinking"].includes(payload.target)) throw new Error("Invalid replacement stream.");
if (event === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR");
const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[event];
if (handler) handlers[handler]?.(payload);
if (event === "done") { terminal = true; break; }
}
}
if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED");
} catch (error) {
const failure = normalizeError(error, scope, response);
if (handlers.onError) handlers.onError(failure.toJSON());
else throw failure;
} finally {
scope.close();
if (reader) {
// Cancelling a custom or stalled source must not prevent the request from settling.
void reader.cancel().catch(() => {});
reader.releaseLock();
}
}
}