Fix admin authorization and harden chat requests
This commit is contained in:
+149
-75
@@ -1,94 +1,168 @@
|
||||
/**
|
||||
* @file Bündelt JSON-Anfragen und verarbeitet Ereignisse gestreamter Chat-Antworten im Browser.
|
||||
*/
|
||||
export async function request(url, data, method = "POST") {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {error: "Network error - please try again."};
|
||||
/** 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};
|
||||
}
|
||||
}
|
||||
|
||||
export const get = (url) => request(url, null, "GET");
|
||||
export const post = (url, data) => request(url, data, "POST");
|
||||
export const put = (url, data) => request(url, data, "PUT");
|
||||
export const del = (url) => request(url, null, "DELETE");
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
export async function request(url, data, method = "POST", options = {}) {
|
||||
const scope = requestScope(options);
|
||||
let response;
|
||||
try {
|
||||
response = await abortable(() => fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {"Accept": "application/json", "Content-Type": "application/json"},
|
||||
body: data == null ? undefined : JSON.stringify(data),
|
||||
signal: scope.signal
|
||||
}), scope.signal);
|
||||
return await readJson(response, scope.signal);
|
||||
} catch (error) {
|
||||
return normalizeError(error, scope, response).toJSON();
|
||||
} finally {
|
||||
scope.close();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState
|
||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}}
|
||||
* | {event: "chunk", data: {chunk: string, fullReply: string}}
|
||||
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
|
||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}}
|
||||
* | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent
|
||||
* 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 = {}, signal = undefined) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data || {}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
let error = "Streaming request failed";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
error = payload.error || error;
|
||||
} catch {
|
||||
// Keep the generic error if the server did not send JSON.
|
||||
}
|
||||
if (handlers.onError) {
|
||||
handlers.onError({error});
|
||||
return;
|
||||
}
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let terminal = false;
|
||||
|
||||
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 {
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
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/);
|
||||
// Das letzte SSE-Ereignis kann unvollständig sein und wird mit dem nächsten Netzwerkblock ergänzt.
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
const eventLine = lines.find((line) => line.startsWith("event:"));
|
||||
const dataLine = lines.find((line) => line.startsWith("data:"));
|
||||
if (!eventLine || !dataLine) continue;
|
||||
const event = eventLine.slice(6).trim();
|
||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
||||
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 = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
|
||||
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 (terminal) continue;
|
||||
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
|
||||
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
|
||||
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
|
||||
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
||||
if (event === "done" || event === "error") terminal = true;
|
||||
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) break;
|
||||
}
|
||||
// Ohne explizites Abschluss- oder Fehlerereignis gilt die Verbindung als vorzeitig abgebrochen.
|
||||
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
||||
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 {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
scope.close();
|
||||
if (reader) {
|
||||
// Cancelling a custom or stalled source must not prevent the request from settling.
|
||||
void reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user