import { apiPost, getApiBaseUrl } from "@/lib/api/client"; import { getAuthToken } from "@/lib/auth/auth-client"; export const CHAT_ORIGIN_PAGES = [ "home", "tuition", "learn-anything", "study-chat", "classes", "notes", "quizzes", "pyq-analyzer", "progress", "bookmarks", "notifications", "settings", "workspace", ] as const; export type ChatOriginPage = (typeof CHAT_ORIGIN_PAGES)[number]; export type StudyChatAcademicContext = { origin?: string | null; subject?: string | null; chapter?: string | null; topic?: string | null; objective?: string | null; current_step?: string | null; relevant_context?: string | null; source_id?: string | null; source_ref?: string | null; source_label?: string | null; activity_type?: string | null; question_id?: string | null; question_label?: string | null; return_href?: string | null; captured_at?: string | null; }; export function isChatOriginPage(value: string | null): value is ChatOriginPage { return CHAT_ORIGIN_PAGES.some((page) => page === value); } export type ChatRequest = { message: string; intent?: string; source_ids?: string[]; subject?: string; language?: string; origin_page?: ChatOriginPage; academic_context?: StudyChatAcademicContext; mode?: StudyChatMode; correction_question?: string; student_answer?: string; marks?: number; session_id?: string; idempotency_key?: string; }; export type StudyChatMode = | "general_explanation" | "selected_sources" | "pyq_verified" | "pyq_style" | "answer_correction"; export type SourceCitation = { source_id: string; source_title: string; source_version: string; chunk_id: string; chunk_index: number; page_number?: number | null; heading?: string | null; excerpt: string; confidence: number; availability?: "live" | "snapshot"; }; export type VerifiedPyqAnalysis = { available: boolean; status: "available" | "unavailable" | "awaiting_verification"; message: string; disclaimer: string; repeated_topics: Array<{ topic: string; chapter: string; count: number; years: number[] }>; marks_distribution: Array<{ marks: number; count: number; percentage: number }>; data_coverage: { papers: number; questions: number; years: number[] }; source_count: number; }; export type StudyChatEvidence = { mode: StudyChatMode; trust_state: | "general_knowledge" | "source_grounded" | "verified_pyq" | "pyq_style_only" | "deterministic_correction" | "web_grounded" | "web_unavailable"; evidence_scope: string; source_ids: string[]; citations: SourceCitation[]; pyq_classification: "verified_pyq" | "pyq_style" | "not_pyq"; pyq_analysis?: VerifiedPyqAnalysis | null; }; export type WebSource = { title: string; url: string; publisher: string; published_date?: string | null; author?: string | null; snippet?: string | null; is_official: boolean; }; export type ChatApiResponse = { answer: string; intent_detected: string; model_used: string; evidence_label?: string | null; web_sources: WebSource[]; evidence: StudyChatEvidence; correction?: Record | null; }; export async function sendChatMessage( payload: ChatRequest, options?: { signal?: AbortSignal; timeoutMs?: number }, ): Promise { return apiPost("/chat", payload, { signal: options?.signal, timeoutMs: options?.timeoutMs ?? 25000, }); } /** * Stream chat tokens from DocDoe via SSE. * Calls onDelta for each token, onDone when stream ends, onError on failure. */ export async function streamChatMessage( payload: ChatRequest, callbacks: { onDelta: (token: string) => void; onDone: (meta: { intent: string; model: string; evidenceLabel: string | null; webSources: WebSource[]; evidence: StudyChatEvidence; correction: Record | null; }) => void; onError: (message: string) => void; }, signal?: AbortSignal, ): Promise { const authToken = getAuthToken(); const streamSignal = AbortSignal.any([ AbortSignal.timeout(120_000), ...(signal ? [signal] : []), ]); let response: Response; try { response = await fetch(`${getApiBaseUrl()}/chat/stream`, { method: "POST", signal: streamSignal, headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), }, body: JSON.stringify(payload), }); } catch { if (streamSignal.aborted) { callbacks.onError(signal?.aborted ? "Response stopped." : "The response timed out. Please retry."); return; } callbacks.onError("DocDoe backend is offline or unreachable."); return; } if (!response.ok) { // Forward the real backend reason so quota/limit/auth errors are classified // correctly downstream instead of all looking like "AI service offline". let detail = ""; try { const body = (await response.clone().json()) as { error?: { message?: string }; detail?: unknown; message?: string }; detail = body?.error?.message || (typeof body?.detail === "string" ? body.detail : "") || (body?.detail && typeof body.detail === "object" && "message" in body.detail ? String((body.detail as { message?: unknown }).message ?? "") : "") || body?.message || ""; } catch { detail = ""; } if (response.status === 402) { callbacks.onError(detail || "You've reached your plan limit. Try again later or upgrade."); } else if (response.status === 429) { callbacks.onError("Too many requests. Wait a few seconds and try again."); } else if (response.status === 401 || response.status === 403) { callbacks.onError("Your session expired. Please sign in again to continue."); } else { callbacks.onError(detail || "DocDoe could not reach the AI service. Please try again."); } return; } const reader = response.body?.getReader(); if (!reader) { callbacks.onError("Streaming not supported. Please try again."); return; } const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) { callbacks.onError("The response ended before it was complete. Please retry."); return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const raw = line.slice(6).trim(); if (!raw) continue; try { const event = JSON.parse(raw) as Record; if (event.error) { callbacks.onError(String(event.error)); return; } if (event.delta) { callbacks.onDelta(String(event.delta)); } if (event.done) { callbacks.onDone({ intent: String(event.intent ?? ""), model: String(event.model ?? ""), evidenceLabel: typeof event.evidence_label === "string" ? event.evidence_label : null, webSources: Array.isArray(event.web_sources) ? (event.web_sources as WebSource[]) : [], evidence: event.evidence as StudyChatEvidence, correction: event.correction && typeof event.correction === "object" ? (event.correction as Record) : null, }); return; } } catch { // malformed SSE line — skip } } } } catch { if (streamSignal.aborted) { callbacks.onError(signal?.aborted ? "Response stopped." : "The response timed out. Please retry."); return; } callbacks.onError("Connection lost. Please try again."); } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } }