File size: 5,728 Bytes
6db5454 df790cc 6db5454 df790cc 6db5454 df790cc 6db5454 df790cc 6db5454 d784651 6db5454 d784651 6db5454 df790cc a686c43 df790cc a686c43 df790cc a686c43 df790cc a686c43 df790cc 6db5454 df790cc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | /**
* Browser-side API client.
* All calls go through /api/proxy/* β token added server-side.
*/
import type {
AskRequest,
AskResponse,
AskSmartRequest,
AskSmartResponse,
AskSmartStreamEvent,
CreateDocumentRequest,
DocumentContentResponse,
DocumentMeta,
DocumentsListResponse,
HealthResponse,
PingResponse,
ReindexResponse,
} from "./types";
const PROXY = "/api/proxy";
class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, message: string, body?: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = path ? `${PROXY}/${path}` : PROXY;
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
const text = await res.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!res.ok) {
const msg =
typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail)
: `HTTP ${res.status}`;
throw new ApiError(res.status, msg, data);
}
return data as T;
}
export { ApiError };
// βββ Health & Ops βββββββββββββββββββββββββββββββββββββββββββββββββ
export const api = {
health: () => request<HealthResponse>(""),
ping: () => request<PingResponse>("ping"),
reindex: (force_full = false, rebuild_anchors = false) =>
request<ReindexResponse>("reindex", {
method: "POST",
body: JSON.stringify({ force_full, rebuild_anchors }),
}),
// βββ Documents βββββββββββββββββββββββββββββββββββββββββββββββββββ
listDocuments: () => request<DocumentsListResponse>("documents"),
getDocument: (doc_id: string) => request<DocumentMeta>(`documents/${doc_id}`),
getDocumentContent: (doc_id: string) =>
request<DocumentContentResponse>(`documents/${doc_id}/content`),
createDocument: (req: CreateDocumentRequest) =>
request<DocumentMeta>("documents", {
method: "POST",
body: JSON.stringify(req),
}),
deleteDocument: (doc_id: string) =>
request<{ status: string; doc_id: string }>(`documents/${doc_id}`, {
method: "DELETE",
}),
// βββ Inference βββββββββββββββββββββββββββββββββββββββββββββββββββ
askSmart: (req: AskSmartRequest, signal?: AbortSignal) =>
request<AskSmartResponse>("ask_smart", {
method: "POST",
body: JSON.stringify(req),
signal,
}),
/**
* Doc-scoped inference (retrieval bypass) β used when the user pins a
* conversation to a single doc.
*/
ask: (req: AskRequest, signal?: AbortSignal) =>
request<AskResponse>("ask", {
method: "POST",
body: JSON.stringify(req),
signal,
}),
/**
* Streaming variant of /ask_smart. Emits SSE events; caller receives a
* sequence of typed events via the `onEvent` callback. Resolves when the
* stream closes (after `done`/`rejected`/`error`).
*/
askSmartStream: async (
req: AskSmartRequest,
onEvent: (e: AskSmartStreamEvent) => void,
signal?: AbortSignal
): Promise<void> => {
const res = await fetch(`${PROXY}/ask_smart`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ ...req, stream: true }),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new ApiError(res.status, `stream HTTP ${res.status}`, text);
}
// Fallback: backend didn't honour stream=true (old deploy / proxy buffering).
// Parse the body as JSON and emit a single equivalent event.
const ct = res.headers.get("content-type") || "";
if (!ct.includes("text/event-stream")) {
const data = await res.json().catch(() => null);
if (!data) {
throw new ApiError(res.status, "non-SSE response could not be parsed");
}
if (data._grounding_status === "rejected_low_similarity") {
onEvent({ event: "rejected", data });
} else {
onEvent({ event: "done", data });
}
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let sawAny = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const parsed = parseSSE(frame);
if (parsed) {
sawAny = true;
onEvent(parsed);
}
}
}
if (!sawAny) {
throw new ApiError(0, "stream closed with no events received");
}
},
};
function parseSSE(frame: string): AskSmartStreamEvent | null {
let event = "message";
let dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return null;
try {
const data = JSON.parse(dataLines.join("\n"));
return { event, data } as AskSmartStreamEvent;
} catch {
return null;
}
}
|