import axios, { AxiosError } from "axios"; import { BACKEND_URL } from "./client"; const SESSION_KEY = "b2v_support_session"; const CONVERSATION_KEY = "b2v_support_conversation_id"; export type GuidanceStep = { selector: string; tooltip: string; placement?: string; }; export type UIGuidance = { action_id: string; steps: GuidanceStep[]; }; export type NavigationHint = { target_route: string; requires_project_id: boolean; description: string; }; export type ChatResponse = { conversation_id: number; answer: string; citations: string[]; ui_guidance: UIGuidance[]; navigation: NavigationHint | null; }; export type SupportMessage = { id: number; role: "user" | "assistant"; content: string; page_path: string | null; cited_docs: string[] | null; ui_guidance: UIGuidance[] | null; created_at: string; }; export type ConversationDetail = { id: number; summary: string; session_state: Record; messages: SupportMessage[]; }; export class HttpError extends Error { status: number; constructor(status: number, message?: string) { super(message ?? `HTTP ${status}`); this.status = status; } } function getOrCreateSessionId(): string { let sid = localStorage.getItem(SESSION_KEY); if (!sid) { sid = crypto.randomUUID(); localStorage.setItem(SESSION_KEY, sid); } return sid; } export function getStoredConversationId(): number | null { const raw = localStorage.getItem(CONVERSATION_KEY); if (!raw) return null; const n = Number(raw); return Number.isFinite(n) && n > 0 ? n : null; } export function setStoredConversationId(id: number | null): void { if (id == null) { localStorage.removeItem(CONVERSATION_KEY); } else { localStorage.setItem(CONVERSATION_KEY, String(id)); } } const supportApi = axios.create({ baseURL: `${BACKEND_URL}/api/support`, headers: { "Content-Type": "application/json" }, }); supportApi.interceptors.request.use((config) => { const token = localStorage.getItem("b2v_token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } config.headers["X-Support-Session"] = getOrCreateSessionId(); return config; }); export async function sendChat( message: string, pagePath: string, conversationId: number | null, ): Promise { const { data } = await supportApi.post("/chat", { message, page_path: pagePath, conversation_id: conversationId, }); setStoredConversationId(data.conversation_id); return data; } export type StreamDonePayload = Omit; export type StreamCallbacks = { onToken: (token: string) => void; /** Fired as soon as the visible answer finishes streaming — before citations/ui_guidance * are extracted — so the UI can stop the cursor and re-enable input immediately. */ onAnswerDone?: (data: { conversation_id: number }) => void; onDone: (data: StreamDonePayload) => void; onError: (err: Error) => void; }; export async function sendChatStream( message: string, pagePath: string, conversationId: number | null, callbacks: StreamCallbacks, ): Promise { const authToken = localStorage.getItem("b2v_token"); const sessionId = getOrCreateSessionId(); let res: Response; try { res = await fetch(`${BACKEND_URL}/api/support/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), "X-Support-Session": sessionId, }, body: JSON.stringify({ message, page_path: pagePath, conversation_id: conversationId }), }); } catch (err) { callbacks.onError(err instanceof Error ? err : new Error(String(err))); return; } if (!res.ok || !res.body) { callbacks.onError(new HttpError(res.status)); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let eventType = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (line.startsWith("event:")) { eventType = line.slice(6).trim(); } else if (line.startsWith("data:")) { // Strip only the single leading space SSE mandates; do NOT trim() — tokens may start/end with spaces. const raw = line.slice(5); const data = raw.startsWith(" ") ? raw.slice(1) : raw; if (!data && eventType !== "token") continue; if (eventType === "token") { // Empty data line represents a newline character (SSE can't embed raw newlines) callbacks.onToken(data === "" ? "\n" : data); } else if (eventType === "answer_done") { const parsed: { conversation_id: number } = JSON.parse(data); setStoredConversationId(parsed.conversation_id); callbacks.onAnswerDone?.(parsed); } else if (eventType === "done") { const parsed: StreamDonePayload = JSON.parse(data); setStoredConversationId(parsed.conversation_id); callbacks.onDone(parsed); } else if (eventType === "error") { callbacks.onError(new Error(data)); } eventType = ""; } } } } catch (err) { callbacks.onError(err instanceof Error ? err : new Error(String(err))); } } export async function getConversation( conversationId: number, ): Promise { try { const { data } = await supportApi.get( `/conversations/${conversationId}`, ); return data; } catch (err) { if ((err as AxiosError).response?.status === 404 || (err as AxiosError).response?.status === 403) { return null; } throw err; } } export async function startNewConversation(): Promise { setStoredConversationId(null); try { await supportApi.post("/conversations/new", {}); } catch { /* no-op endpoint, ignore */ } } export async function claimConversation(conversationId: number): Promise { await supportApi.post("/conversations/claim", { conversation_id: conversationId, }); }