Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

363 lines
12 KiB

import axios from "axios";
import type { MetaResponse, SearchRequest, SearchResponse, ChatRequest, ChatResponse, ChatMessage, ChatReply, PersonDetail, ChatSessionData, SessionUpsertData, SnapshotData, SnapshotTurn, ResearchBoardSummary, ResearchBoardDetail, ResearchEventsResponse, ResearchSpawnExtras, ResearchThread, ResearchLead, AuthResponse, PreloginResponse, MyChatRow } from "./types";
const client = axios.create({
baseURL: "/api",
});
let activeSessionId: string | null = null;
let authToken: string | null = null;
export const AUTH_TOKEN_STORAGE_KEY = "riksdagen-auth-token";
/**
* Persist the caller’s session id so every request can be associated with the same browser.
*/
export function setSessionId(sessionId: string): void {
activeSessionId = sessionId;
client.defaults.headers.common["X-Session-Id"] = sessionId;
}
/**
* Attach (or drop) the account bearer token on every subsequent request.
* The token is opaque — it never contains or reveals any encryption key.
*/
export function setAuthToken(token: string | null): void {
authToken = token;
if (token) client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
else delete client.defaults.headers.common["Authorization"];
}
// Eagerly restore the session id + auth token from localStorage at module
// load, so requests fired before App's mount effect runs (e.g. the /research
// list query) already carry X-Session-Id/Authorization.
try {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("riksdagen-session-id");
if (stored) setSessionId(stored);
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (token) setAuthToken(token);
}
} catch {
/* localStorage unavailable — App's effect will set it */
}
/**
* Provide fetch-compatible headers carrying the current session id + auth token.
*/
export function getSessionHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (activeSessionId) headers["X-Session-Id"] = activeSessionId;
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
return headers;
}
/* ── Account auth (zero-knowledge: only derived keys ever sent) ── */
export async function preLogin(username: string): Promise<PreloginResponse> {
const { data } = await client.get<PreloginResponse>("/auth/prelogin", { params: { username } });
return data;
}
export async function signupAccount(payload: {
username: string;
auth_key: string;
kdf_salt: string;
kdf_iterations: number;
wrapped_dek: string;
}): Promise<AuthResponse> {
const { data } = await client.post<AuthResponse>("/auth/signup", payload);
return data;
}
export async function loginAccount(payload: { username: string; auth_key: string }): Promise<AuthResponse> {
const { data } = await client.post<AuthResponse>("/auth/login", payload);
return data;
}
export async function logoutAccount(): Promise<void> {
await client.post("/auth/logout").catch(() => {});
}
export async function fetchMyChats(): Promise<MyChatRow[]> {
const { data } = await client.get<MyChatRow[]>("/me/chats");
return data;
}
export async function deleteMyChat(id: string): Promise<void> {
await client.delete(`/me/chats/${encodeURIComponent(id)}`);
}
export async function fetchMeta(): Promise<MetaResponse> {
const { data } = await client.get<MetaResponse>("/meta");
return data;
}
export async function fetchGuide(): Promise<string> {
const response = await fetch("/api/guide", { headers: getSessionHeaders() });
if (!response.ok) throw new Error(`${response.status}`);
return response.text();
}
export async function searchTalks(payload: SearchRequest): Promise<SearchResponse> {
const { data } = await client.post<SearchResponse>("/search", payload);
return data;
}
export async function sendFeedback(message: string): Promise<void> {
await client.post("/feedback", { message });
}
export async function chatWithRiksdagen(payload: ChatRequest): Promise<ChatResponse> {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...getSessionHeaders(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Chat request failed");
}
return response.json();
}
export async function sendChat(
messages: ChatMessage[],
focusIds: string[],
): Promise<ChatReply> {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...getSessionHeaders(),
},
body: JSON.stringify({
messages,
focus_ids: focusIds,
session_id: activeSessionId,
}),
});
if (!response.ok) {
throw new Error("Chat request failed");
}
const data = await response.json();
return {
answer: data.answer,
sources: data.sources ?? [],
tables: data.tables ?? [],
focus_ids: data.focus_ids ?? [],
};
}
/**
* Fetches a single talk by its ID, including person information.
*
* @param id - The talk ID (e.g., "H40911")
* @returns Promise containing the talk data with person info
*/
export async function fetchTalk(id: string): Promise<any> {
const response = await fetch(`/api/talk/${encodeURIComponent(id)}`, {
headers: getSessionHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to fetch talk: ${response.status}`);
}
return response.json();
}
/**
* Fetches a single motion by its doc_id, including authors and yrkanden.
*
* @param id - The motion doc_id (e.g., "HD02846"), with or without "documents/" prefix
*/
export async function fetchMotion(id: string): Promise<any> {
const bare = id.startsWith("documents/") ? id.slice("documents/".length) : id;
const response = await fetch(`/api/motion/${encodeURIComponent(bare)}`, {
headers: getSessionHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to fetch motion: ${response.statusText}`);
}
return response.json();
}
/**
* Load a chat session by UUID. Returns null if not found or expired.
*/
export async function getSession(uuid: string): Promise<ChatSessionData | null> {
const response = await fetch(`/api/sessions/${encodeURIComponent(uuid)}`, {
headers: getSessionHeaders(),
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to load session: ${response.statusText}`);
return response.json();
}
/**
* Create or update a chat session. Fire-and-forget safe (errors are logged only).
* Logged-in callers pass enc_payload/enc_title (client-encrypted) instead of
* the plaintext fields.
*/
export async function upsertSession(uuid: string, data: SessionUpsertData): Promise<void> {
await fetch(`/api/sessions/${encodeURIComponent(uuid)}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...getSessionHeaders() },
body: JSON.stringify(data),
});
}
/**
* Create a frozen shareable snapshot of a chat. Returns the new snapshot UUID.
*/
export async function createSnapshot(data: {
session_type: "general" | "mp";
person_id?: string | null;
initial_speech_id?: string | null;
llm_messages?: unknown[];
turns: SnapshotTurn[];
focus_ids?: string[];
}): Promise<string> {
const response = await fetch("/api/snapshots", {
method: "POST",
headers: { "Content-Type": "application/json", ...getSessionHeaders() },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to create snapshot: ${response.statusText}`);
const json = await response.json();
return json.id as string;
}
/**
* Fork a snapshot into a new editable session. Returns the new session UUID.
*/
export async function forkSnapshot(snapshotId: string): Promise<string> {
const response = await fetch(`/api/snapshots/${encodeURIComponent(snapshotId)}/fork`, {
method: "POST",
headers: getSessionHeaders(),
});
if (!response.ok) throw new Error(`Failed to fork snapshot: ${response.statusText}`);
const json = await response.json();
return json.session_id as string;
}
/**
* Load a snapshot by UUID. Returns null if not found.
*/
export async function getSnapshot(uuid: string): Promise<SnapshotData | null> {
const response = await fetch(`/api/snapshots/${encodeURIComponent(uuid)}`, {
headers: getSessionHeaders(),
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to load snapshot: ${response.statusText}`);
return response.json();
}
/**
* Fetches basic info for a Riksdag member by person_id.
*/
export async function fetchPerson(person_id: string): Promise<PersonDetail> {
const { data } = await client.get<PersonDetail>(`/person/${encodeURIComponent(person_id)}`);
return data;
}
/* ── Account settings (client-encrypted AI config) ──────────────── */
export async function getMySettings(): Promise<{ enc_settings: string | null; updated_at: string | null }> {
const { data } = await client.get("/me/settings");
return data;
}
export async function putMySettings(encSettings: string): Promise<void> {
await client.put("/me/settings", { enc_settings: encSettings });
}
export async function deleteMySettings(): Promise<void> {
await client.delete("/me/settings");
}
/* ── Deep research ─────────────────────────────────────────────── */
export async function createResearch(
payload: { topic: string; title?: string; wrapped_board_key?: string } & ResearchSpawnExtras,
): Promise<{ board_id: string; job_id: string }> {
const { data } = await client.post("/research", payload);
return data;
}
export async function listResearch(): Promise<ResearchBoardSummary[]> {
const { data } = await client.get<ResearchBoardSummary[]>("/research");
return data;
}
export async function getResearchBoard(boardId: string): Promise<ResearchBoardDetail> {
const { data } = await client.get<ResearchBoardDetail>(`/research/${encodeURIComponent(boardId)}`);
return data;
}
export async function getResearchEvents(
boardId: string,
jobId: string,
offset: number,
): Promise<ResearchEventsResponse> {
const { data } = await client.get<ResearchEventsResponse>(
`/research/${encodeURIComponent(boardId)}/events`,
{ params: { job_id: jobId, offset } },
);
return data;
}
export async function seedResearchThread(
boardId: string,
payload: { text: string } & ResearchSpawnExtras,
): Promise<{ thread: ResearchThread; job_id: string | null }> {
const { data } = await client.post(`/research/${encodeURIComponent(boardId)}/threads`, payload);
return data;
}
export async function deepenResearch(
boardId: string,
payload: { thread_id?: string; lead?: ResearchLead; sweep?: boolean } & ResearchSpawnExtras,
): Promise<{ job_id: string }> {
const { data } = await client.post(`/research/${encodeURIComponent(boardId)}/deepen`, payload);
return data;
}
export async function activateResearchThreads(
boardId: string,
payload: { selections: { thread_id: string; guidance?: string }[]; dig?: boolean } & ResearchSpawnExtras,
): Promise<{ activated: string[]; job_id: string | null }> {
const { data } = await client.post(
`/research/${encodeURIComponent(boardId)}/threads/activate`,
{ dig: true, ...payload },
);
return data;
}
export async function archiveResearchThread(
boardId: string,
threadId: string,
): Promise<{ archived: boolean }> {
const { data } = await client.post(
`/research/${encodeURIComponent(boardId)}/threads/${encodeURIComponent(threadId)}/archive`,
);
return data;
}
export async function generateResearchReport(
boardId: string,
payload: ResearchSpawnExtras = {},
): Promise<{ job_id: string }> {
const { data } = await client.post(`/research/${encodeURIComponent(boardId)}/report`, payload);
return data;
}
export async function cancelResearch(boardId: string): Promise<{ cancelled: boolean }> {
const { data } = await client.post(`/research/${encodeURIComponent(boardId)}/cancel`);
return data;
}
export async function deleteResearch(boardId: string): Promise<void> {
await client.delete(`/research/${encodeURIComponent(boardId)}`);
}