export type ChatSummary = { id: string; title: string | null; createdAt: string; updatedAt: string; initiatedProvider: Provider | null; initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; }; export type SearchSummary = { id: string; title: string | null; query: string | null; createdAt: string; updatedAt: string; }; export type Message = { id: string; createdAt: string; role: "system" | "user" | "assistant" | "tool"; content: string; name: string | null; metadata: unknown | null; }; export type ToolCallEvent = { toolCallId: string; name: string; status: "completed" | "failed"; summary: string; args: Record; startedAt: string; completedAt: string; durationMs: number; error?: string; resultPreview?: string; }; export type ChatDetail = { id: string; title: string | null; createdAt: string; updatedAt: string; initiatedProvider: Provider | null; initiatedModel: string | null; lastUsedProvider: Provider | null; lastUsedModel: string | null; messages: Message[]; }; export type SearchResultItem = { id: string; createdAt: string; rank: number; title: string | null; url: string; publishedDate: string | null; author: string | null; text: string | null; highlights: string[] | null; highlightScores: number[] | null; score: number | null; favicon: string | null; image: string | null; }; export type SearchDetail = { id: string; title: string | null; query: string | null; createdAt: string; updatedAt: string; requestId: string | null; latencyMs: number | null; error: string | null; answerText: string | null; answerRequestId: string | null; answerCitations: Array<{ id?: string; url?: string; title?: string | null; publishedDate?: string | null; author?: string | null; text?: string | null; }> | null; answerError: string | null; results: SearchResultItem[]; }; export type ChatImageAttachment = { kind: "image"; id: string; filename: string; mimeType: "image/png" | "image/jpeg"; sizeBytes: number; dataUrl: string; }; export type ChatTextAttachment = { kind: "text"; id: string; filename: string; mimeType: string; sizeBytes: number; text: string; truncated?: boolean; }; export type ChatAttachment = ChatImageAttachment | ChatTextAttachment; export type SearchRunRequest = { query?: string; title?: string; type?: "auto" | "fast" | "deep" | "instant"; numResults?: number; includeDomains?: string[]; excludeDomains?: string[]; }; export type CompletionRequestMessage = { role: "system" | "user" | "assistant" | "tool"; content: string; name?: string; attachments?: ChatAttachment[]; }; export type Provider = "openai" | "anthropic" | "xai"; export type ProviderModelInfo = { models: string[]; loadedAt: string | null; error: string | null; }; export type ModelCatalogResponse = { providers: Record; }; type CompletionResponse = { chatId: string | null; message: { role: "assistant"; content: string; }; }; type CompletionStreamHandlers = { onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void; onToolCall?: (payload: ToolCallEvent) => void; onDelta?: (payload: { text: string }) => void; onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void; onError?: (payload: { message: string }) => void; }; type CreateChatRequest = { title?: string; provider?: Provider; model?: string; messages?: CompletionRequestMessage[]; }; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null; let authToken: string | null = ENV_ADMIN_TOKEN; export function getConfiguredToken() { return ENV_ADMIN_TOKEN; } export function setAuthToken(token: string | null) { authToken = token?.trim() || null; } async function api(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers ?? {}); const hasBody = init?.body !== undefined && init.body !== null; if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (authToken) { headers.set("Authorization", `Bearer ${authToken}`); } const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers, }); if (!response.ok) { const fallback = `${response.status} ${response.statusText}`; let message = fallback; try { const body = (await response.json()) as { message?: string }; if (body.message) message = body.message; } catch { // keep fallback message } throw new Error(message); } return (await response.json()) as T; } export async function listChats() { const data = await api<{ chats: ChatSummary[] }>("/v1/chats"); return data.chats; } export async function verifySession() { return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session"); } export async function listModels() { return api("/v1/models"); } export async function createChat(input?: string | CreateChatRequest) { const body = typeof input === "string" ? { title: input } : input ?? {}; const data = await api<{ chat: ChatSummary }>("/v1/chats", { method: "POST", body: JSON.stringify(body), }); return data.chat; } export async function getChat(chatId: string) { const data = await api<{ chat: ChatDetail }>(`/v1/chats/${chatId}`); return data.chat; } export async function updateChatTitle(chatId: string, title: string) { const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, { method: "PATCH", body: JSON.stringify({ title }), }); return data.chat; } export async function suggestChatTitle(body: { chatId: string; content: string }) { const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", { method: "POST", body: JSON.stringify(body), }); return data.chat; } export async function deleteChat(chatId: string) { await api<{ deleted: true }>(`/v1/chats/${chatId}`, { method: "DELETE" }); } export async function listSearches() { const data = await api<{ searches: SearchSummary[] }>("/v1/searches"); return data.searches; } export async function createSearch(body?: { title?: string; query?: string }) { const data = await api<{ search: SearchSummary }>("/v1/searches", { method: "POST", body: JSON.stringify(body ?? {}), }); return data.search; } export async function getSearch(searchId: string) { const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`); return data.search; } export async function createChatFromSearch(searchId: string, body?: { title?: string }) { const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, { method: "POST", body: JSON.stringify(body ?? {}), }); return data.chat; } export async function deleteSearch(searchId: string) { await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" }); } export function getMessageAttachments(metadata: unknown): ChatAttachment[] { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return []; const attachments = (metadata as Record).attachments; if (!Array.isArray(attachments)) return []; const parsed: ChatAttachment[] = []; for (const entry of attachments) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; const record = entry as Record; const kind = record.kind; const id = typeof record.id === "string" ? record.id : ""; const filename = typeof record.filename === "string" ? record.filename : ""; const mimeType = typeof record.mimeType === "string" ? record.mimeType : ""; const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0; if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) { parsed.push({ kind, id, filename, mimeType, sizeBytes, dataUrl: record.dataUrl, } satisfies ChatImageAttachment); continue; } if (kind === "text" && typeof record.text === "string") { parsed.push({ kind, id, filename, mimeType, sizeBytes, text: record.text, truncated: record.truncated === true, } satisfies ChatTextAttachment); } } return parsed; } type RunSearchStreamHandlers = { onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void; onSearchError?: (payload: { error: string }) => void; onAnswer?: (payload: { answerText: string | null; answerRequestId: string | null; answerCitations: SearchDetail["answerCitations"] }) => void; onAnswerError?: (payload: { error: string }) => void; onDone?: (payload: { search: SearchDetail }) => void; onError?: (payload: { message: string }) => void; }; export async function runSearchStream( searchId: string, body: SearchRunRequest, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal } ) { const headers = new Headers({ Accept: "text/event-stream", "Content-Type": "application/json", }); if (authToken) { headers.set("Authorization", `Bearer ${authToken}`); } const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream`, { method: "POST", headers, body: JSON.stringify(body), signal: options?.signal, }); if (!response.ok) { const fallback = `${response.status} ${response.statusText}`; let message = fallback; try { const body = (await response.json()) as { message?: string }; if (body.message) message = body.message; } catch { // keep fallback message } throw new Error(message); } if (!response.body) { throw new Error("No response stream"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let eventName = "message"; let dataLines: string[] = []; const flushEvent = () => { if (!dataLines.length) { eventName = "message"; return; } const dataText = dataLines.join("\n"); let payload: any = null; try { payload = JSON.parse(dataText); } catch { payload = { message: dataText }; } if (eventName === "search_results") handlers.onSearchResults?.(payload); else if (eventName === "search_error") handlers.onSearchError?.(payload); else if (eventName === "answer") handlers.onAnswer?.(payload); else if (eventName === "answer_error") handlers.onAnswerError?.(payload); else if (eventName === "done") handlers.onDone?.(payload); else if (eventName === "error") handlers.onError?.(payload); dataLines = []; eventName = "message"; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex = buffer.indexOf("\n"); while (newlineIndex >= 0) { const rawLine = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (!line) { flushEvent(); } else if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } newlineIndex = buffer.indexOf("\n"); } } buffer += decoder.decode(); if (buffer.length) { const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } } flushEvent(); } export async function runCompletion(body: { chatId: string; provider: Provider; model: string; messages: CompletionRequestMessage[]; }) { return api("/v1/chat-completions", { method: "POST", body: JSON.stringify(body), }); } export async function runCompletionStream( body: { chatId?: string | null; persist?: boolean; provider: Provider; model: string; messages: CompletionRequestMessage[]; }, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal } ) { const headers = new Headers({ Accept: "text/event-stream", "Content-Type": "application/json", }); if (authToken) { headers.set("Authorization", `Bearer ${authToken}`); } const response = await fetch(`${API_BASE_URL}/v1/chat-completions/stream`, { method: "POST", headers, body: JSON.stringify(body), signal: options?.signal, }); if (!response.ok) { const fallback = `${response.status} ${response.statusText}`; let message = fallback; try { const body = (await response.json()) as { message?: string }; if (body.message) message = body.message; } catch { // keep fallback message } throw new Error(message); } if (!response.body) { throw new Error("No response stream"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let eventName = "message"; let dataLines: string[] = []; const flushEvent = () => { if (!dataLines.length) { eventName = "message"; return; } const dataText = dataLines.join("\n"); let payload: any = null; try { payload = JSON.parse(dataText); } catch { payload = { message: dataText }; } if (eventName === "meta") handlers.onMeta?.(payload); else if (eventName === "tool_call") handlers.onToolCall?.(payload); else if (eventName === "delta") handlers.onDelta?.(payload); else if (eventName === "done") handlers.onDone?.(payload); else if (eventName === "error") handlers.onError?.(payload); dataLines = []; eventName = "message"; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex = buffer.indexOf("\n"); while (newlineIndex >= 0) { const rawLine = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (!line) { flushEvent(); } else if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } newlineIndex = buffer.indexOf("\n"); } } buffer += decoder.decode(); if (buffer.length) { const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; if (line.startsWith("event:")) { eventName = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } } flushEvent(); }