hmc-rag / frontend /src /lib /storage.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
5.54 kB
/**
* Typed localStorage read/write helpers for the health-marketing-compliance-rag frontend.
*
* Keys:
* hmc-conversations — array of Conversation objects (max 20, sorted by last_active_at desc)
* hmc-saved-responses — array of SavedResponse objects (max 50, sorted by saved_at desc)
*/
import type { Conversation, Message, SavedResponse } from "../types";
const CONVERSATIONS_KEY = "hmc-conversations";
const SAVED_RESPONSES_KEY = "hmc-saved-responses";
const CONVERSATIONS_CAP = 20;
const SAVED_RESPONSES_CAP = 50;
const TITLE_MAX_LENGTH = 80;
// ---------------------------------------------------------------------------
// Conversations
// ---------------------------------------------------------------------------
/**
* Reads the stored conversations array from localStorage.
* Returns an empty array on missing key or JSON parse error.
*/
export function readConversations(): Conversation[] {
try {
const raw = localStorage.getItem(CONVERSATIONS_KEY);
if (raw === null) return [];
return JSON.parse(raw) as Conversation[];
} catch {
return [];
}
}
/**
* Writes conversations to localStorage, enforcing a cap of 20 entries.
* Entries are sorted by `last_active_at` descending before the cap is applied,
* so the oldest entry is always the one evicted.
*
* On `QuotaExceededError`: evicts the oldest conversation and retries once.
* If the retry also fails, emits a console warning and swallows the error.
*/
export function writeConversations(conversations: Conversation[]): void {
// Sort descending by last_active_at, then cap at 20.
const sorted = [...conversations].sort(
(a, b) =>
new Date(b.last_active_at).getTime() -
new Date(a.last_active_at).getTime()
);
const capped = sorted.slice(0, CONVERSATIONS_CAP);
try {
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(capped));
} catch (err) {
if (isQuotaError(err)) {
// Evict the oldest entry (last in the sorted array) and retry once.
const evicted = capped.slice(0, capped.length - 1);
try {
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(evicted));
} catch (retryErr) {
console.warn(
"[storage] Failed to write conversations after quota eviction:",
retryErr
);
}
}
}
}
// ---------------------------------------------------------------------------
// Saved responses
// ---------------------------------------------------------------------------
/**
* Reads the stored saved-responses array from localStorage.
* Returns an empty array on missing key or JSON parse error.
*/
export function readSavedResponses(): SavedResponse[] {
try {
const raw = localStorage.getItem(SAVED_RESPONSES_KEY);
if (raw === null) return [];
return JSON.parse(raw) as SavedResponse[];
} catch {
return [];
}
}
/**
* Writes saved responses to localStorage, enforcing a cap of 50 entries.
* Entries are sorted by `saved_at` descending before the cap is applied,
* so the oldest entry is always the one evicted.
*
* On `QuotaExceededError`: evicts the oldest response and retries once.
* If the retry also fails, emits a console warning and swallows the error.
*/
export function writeSavedResponses(responses: SavedResponse[]): void {
// Sort descending by saved_at, then cap at 50.
const sorted = [...responses].sort(
(a, b) =>
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime()
);
const capped = sorted.slice(0, SAVED_RESPONSES_CAP);
try {
localStorage.setItem(SAVED_RESPONSES_KEY, JSON.stringify(capped));
} catch (err) {
if (isQuotaError(err)) {
// Evict the oldest entry (last in the sorted array) and retry once.
const evicted = capped.slice(0, capped.length - 1);
try {
localStorage.setItem(SAVED_RESPONSES_KEY, JSON.stringify(evicted));
} catch (retryErr) {
console.warn(
"[storage] Failed to write saved responses after quota eviction:",
retryErr
);
}
}
}
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
/**
* Derives a conversation title from a message array.
* Finds the first message with role "user" and truncates its content to 80
* characters, appending the Unicode ellipsis character (U+2026) if truncated.
* Returns "New conversation" when no user message is present.
*/
export function titleFromMessages(messages: Message[]): string {
const userMessage = messages.find((m) => m.role === "user");
if (!userMessage) return "New conversation";
const { content } = userMessage;
if (content.length > TITLE_MAX_LENGTH) {
return content.slice(0, TITLE_MAX_LENGTH) + "\u2026";
}
return content;
}
/**
* Returns the stable ID for a saved response: `"{conversationId}:{msgIndex}"`.
*/
export function savedResponseId(
conversationId: string,
msgIndex: number
): string {
return `${conversationId}:${msgIndex}`;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function isQuotaError(err: unknown): boolean {
if (err instanceof DOMException) {
// Standard name across modern browsers.
return (
err.name === "QuotaExceededError" ||
// Legacy Firefox name.
err.name === "NS_ERROR_DOM_QUOTA_REACHED"
);
}
return false;
}