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 | 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 | 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(path: string, payload: unknown): Promise { 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 { 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 { const response = await postJson("/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 { const response = await postJson("/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 { 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 { 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, }; }