Kanna-v4 / src /lib /api.ts
SAINTHALF's picture
Deploy v4: Obfuscated Config
0a56405 verified
export type SearchHit = {
doc_id: string;
page_num: number;
score: number;
image_key: string;
image_url?: string | null;
width: number;
height: number;
};
export type SearchResponse = {
query: string;
results: SearchHit[];
};
export type ChatResponse = {
answer: string;
model: string;
citations: SearchHit[];
usage?: Record<string, unknown> | null;
};
export type ExportRequest = {
query: string;
top_k: number;
include_images: boolean;
hybrid: boolean;
format: "json" | "markdown";
include_snippets?: boolean;
max_snippet_chars?: number;
answer?: string | null;
};
export type ChatStreamEvent =
| { event: "citations"; data: { citations: SearchHit[] } }
| { event: "delta"; data: { text: string } }
| { event: "done"; data: { model?: string; usage?: Record<string, unknown> | null } }
| { event: "error"; data: { message: string } }
| { event: "message"; data: unknown };
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") ||
"/api";
const DEFAULT_TIMEOUT_MS = 30000;
const MAX_ERROR_CHARS = 600;
function resolvePath(path: string) {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (!path.startsWith("/")) {
return `${API_BASE}/${path}`;
}
return `${API_BASE}${path}`;
}
export function resolveImageUrl(imageUrl?: string | null): string | null {
if (!imageUrl) return null;
if (imageUrl.startsWith("/files/")) return imageUrl;
return resolvePath(imageUrl);
}
async function postJson<T>(path: string, payload: unknown): Promise<T> {
const res = await fetchWithTimeout(resolvePath(path), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
if (!res.ok) {
const text = truncateError(await res.text());
throw new Error(`Request failed: ${res.status} ${text}`);
}
return (await res.json()) as T;
}
export async function exportEvidence(params: ExportRequest): Promise<Blob> {
const res = await fetchWithTimeout(resolvePath("/export"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
cache: "no-store",
}, DEFAULT_TIMEOUT_MS);
if (!res.ok) {
const text = truncateError(await res.text());
throw new Error(`Export failed: ${res.status} ${text}`);
}
return await res.blob();
}
export async function runSearch(params: {
query: string;
top_k: number;
include_images: boolean;
hybrid: boolean;
}): Promise<SearchResponse> {
const response = await postJson<SearchResponse>("/search", params);
const results = Array.isArray(response.results)
? response.results
.map(normalizeHit)
.filter((item): item is SearchHit => item !== null)
: [];
return {
query: typeof response.query === "string" ? response.query : params.query,
results,
};
}
export async function runChat(params: {
query: string;
top_k: number;
include_images: boolean;
hybrid: boolean;
}): Promise<ChatResponse> {
const response = await postJson<ChatResponse>("/chat", params);
const citations = Array.isArray(response.citations)
? response.citations
.map(normalizeHit)
.filter((item): item is SearchHit => item !== null)
: [];
return {
answer: typeof response.answer === "string" ? response.answer : "",
model: typeof response.model === "string" ? response.model : "",
citations,
usage: typeof response.usage === "object" ? response.usage : null,
};
}
function parseEventBlock(block: string): { event: string; data: string } | null {
const lines = block.split(/\r?\n/);
let event = "message";
let data = "";
for (const line of lines) {
if (!line) continue;
if (line.startsWith(":") || line.startsWith("retry:") || line.startsWith("id:")) {
continue;
}
if (line.startsWith("event:")) {
event = line.replace("event:", "").trim();
continue;
}
if (line.startsWith("data:")) {
let chunk = line.slice(5);
if (chunk.startsWith(" ")) {
chunk = chunk.slice(1);
}
data += chunk;
}
}
if (!data) return null;
return { event, data };
}
export async function streamChat(
params: {
query: string;
top_k: number;
include_images: boolean;
hybrid: boolean;
},
onEvent: (event: ChatStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(resolvePath("/chat/stream"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(params),
cache: "no-store",
signal,
});
if (!res.ok) {
const text = truncateError(await res.text());
throw new Error(`Stream failed: ${res.status} ${text}`);
}
if (!res.body) {
throw new Error("Stream failed: response body is empty.");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const parsed = parseEventBlock(block);
if (!parsed) continue;
try {
const payload = JSON.parse(parsed.data);
onEvent({ event: parsed.event as ChatStreamEvent["event"], data: payload } as ChatStreamEvent);
} catch {
onEvent({ event: parsed.event as ChatStreamEvent["event"], data: parsed.data } as ChatStreamEvent);
}
}
}
if (buffer.trim()) {
const parsed = parseEventBlock(buffer);
if (parsed) {
try {
const payload = JSON.parse(parsed.data);
onEvent({ event: parsed.event as ChatStreamEvent["event"], data: payload } as ChatStreamEvent);
} catch {
onEvent({ event: parsed.event as ChatStreamEvent["event"], data: parsed.data } as ChatStreamEvent);
}
}
}
}
function truncateError(text: string): string {
if (!text) return "";
if (text.length <= MAX_ERROR_CHARS) return text;
return `${text.slice(0, MAX_ERROR_CHARS)}…`;
}
async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit,
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController();
if (init.signal) {
if (init.signal.aborted) {
controller.abort();
} else {
init.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
}
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function normalizeHit(raw: SearchHit | null | undefined): SearchHit | null {
if (!raw || typeof raw !== "object") return null;
const docId = typeof raw.doc_id === "string" ? raw.doc_id : "";
const imageKey = typeof raw.image_key === "string" ? raw.image_key : "";
const pageNum = Number(raw.page_num);
const score = Number(raw.score);
const width = Number(raw.width);
const height = Number(raw.height);
if (!docId || !imageKey || !Number.isFinite(pageNum)) return null;
return {
doc_id: docId,
page_num: Number.isFinite(pageNum) ? pageNum : 0,
score: Number.isFinite(score) ? score : 0,
image_key: imageKey,
image_url: typeof raw.image_url === "string" ? raw.image_url : (imageKey ? `/files/${imageKey}` : null),
width: Number.isFinite(width) ? width : 0,
height: Number.isFinite(height) ? height : 0,
};
}