import { apiFetch, apiUrl } from "@/lib/api"; import { invalidateClientCache, withClientCache } from "@/lib/client-cache"; import type { LLMSelection, StreamEvent } from "@/lib/unified-ws"; export interface SessionMessage { id: number; session_id: string; role: "user" | "assistant" | "system"; content: string; capability?: string; events: StreamEvent[]; attachments: Array<{ type: string; filename?: string; base64?: string; url?: string; mime_type?: string; id?: string; extracted_text?: string; generated?: boolean; size_bytes?: number; }>; metadata?: Record; created_at: number; /** Edit-branching: id of the message this row continues. `null` for the * first message in a session. Siblings share the same parent. */ parent_message_id?: number | null; } export interface SessionSummary { id: string; session_id: string; title: string; created_at: number; updated_at: number; message_count: number; last_message: string; status?: | "idle" | "running" | "completed" | "failed" | "cancelled" | "rejected"; active_turn_id?: string; preferences?: { capability?: string; tools?: string[]; knowledge_bases?: string[]; language?: string; llm_selection?: LLMSelection | null; /** Session-level persona preference; "" / absent = Default (no persona). */ persona?: string; /** Edit-branching: maps a parent_message_id → the child id currently * shown at that branch point. Missing keys default to the latest * sibling (most recently created child). */ selected_branches?: Record; }; } export interface ActiveTurnSummary { id: string; turn_id: string; session_id: string; capability: string; status: "running" | "completed" | "failed" | "cancelled" | "rejected"; error: string; created_at: number; updated_at: number; finished_at?: number | null; last_seq: number; } export interface SessionDetail { id: string; session_id: string; title: string; created_at: number; updated_at: number; status?: | "idle" | "running" | "completed" | "failed" | "cancelled" | "rejected"; active_turn_id?: string; compressed_summary?: string; summary_up_to_msg_id?: number; preferences?: { capability?: string; tools?: string[]; knowledge_bases?: string[]; language?: string; llm_selection?: LLMSelection | null; /** Session-level persona preference; "" / absent = Default (no persona). */ persona?: string; /** Edit-branching: maps a parent_message_id → the child id currently * shown at that branch point. Missing keys default to the latest * sibling (most recently created child). */ selected_branches?: Record; }; messages: SessionMessage[]; active_turns?: ActiveTurnSummary[]; } export interface QuizResultItem { question_id?: string; question: string; question_type?: string; options?: Record; user_answer: string; correct_answer: string; explanation?: string; difficulty?: string; is_correct: boolean; } async function expectJson(response: Response): Promise { if (response.status === 401 && typeof window !== "undefined") { const next = encodeURIComponent(window.location.pathname); window.location.href = `/login?next=${next}`; return new Promise(() => {}); } if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } return response.json() as Promise; } export async function listSessions( limit = 50, offset = 0, options?: { force?: boolean }, ): Promise { const qs = new URLSearchParams({ limit: String(limit), offset: String(offset), }); return withClientCache( `sessions:${limit}:${offset}`, async () => { const response = await apiFetch( apiUrl(`/api/v1/sessions?${qs.toString()}`), { cache: "no-store", }, ); const data = await expectJson<{ sessions: SessionSummary[] }>(response); return data.sessions ?? []; }, { force: options?.force, ttlMs: 15_000, }, ); } export async function getSession( sessionId: string, signal?: AbortSignal, ): Promise { const response = await apiFetch(apiUrl(`/api/v1/sessions/${sessionId}`), { cache: "no-store", signal, }); return expectJson(response); } export async function updateSessionTitle( sessionId: string, title: string, ): Promise { const response = await apiFetch(apiUrl(`/api/v1/sessions/${sessionId}`), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); const data = await expectJson<{ session: SessionDetail }>(response); invalidateClientCache("sessions:"); return data.session; } export async function deleteSession(sessionId: string): Promise { const response = await apiFetch(apiUrl(`/api/v1/sessions/${sessionId}`), { method: "DELETE", }); await expectJson<{ deleted: boolean }>(response); invalidateClientCache("sessions:"); } export async function recordQuizResults( sessionId: string, answers: QuizResultItem[], turnId?: string | null, ): Promise { const response = await apiFetch( apiUrl(`/api/v1/sessions/${sessionId}/quiz-results`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ answers, turn_id: turnId || "" }), }, ); await expectJson<{ recorded: boolean }>(response); } export async function deleteMessage( sessionId: string, messageId: number, ): Promise { const response = await apiFetch( apiUrl(`/api/v1/sessions/${sessionId}/messages/${messageId}`), { method: "DELETE" }, ); await expectJson<{ deleted: boolean }>(response); } export async function updateBranchSelection( sessionId: string, selectedBranches: Record, ): Promise { const response = await apiFetch( apiUrl(`/api/v1/sessions/${sessionId}/branch-selection`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected_branches: selectedBranches }), }, ); await expectJson<{ selected_branches: Record }>(response); }