| 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, | |
| }; | |
| } | |