import Dexie, { type Table } from "dexie"; import { addLogEntry } from "./logEntries"; let currentSearchRunId: string | null = null; function generateSearchRunId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } /** * Gets the current search run ID, generating one if it doesn't exist * @returns The current search run ID */ export function getCurrentSearchRunId(): string { if (!currentSearchRunId) { currentSearchRunId = generateSearchRunId(); } return currentSearchRunId; } /** * Sets the current search run ID * @param id - The search run ID to set */ export function setCurrentSearchRunId(id: string): void { currentSearchRunId = id; } /** * Resets the current search run ID to null */ export function resetSearchRunId(): void { currentSearchRunId = null; } function measurePerformance( operation: string, fn: () => Promise, ): Promise { const startTime = performance.now(); return fn().then( (result) => { const endTime = performance.now(); const duration = endTime - startTime; if (duration > 100) { addLogEntry(`${operation} completed in ${duration.toFixed(2)}ms`); } return result; }, (error) => { const endTime = performance.now(); const duration = endTime - startTime; addLogEntry( `${operation} failed after ${duration.toFixed(2)}ms: ${error}`, ); throw error; }, ); } /** * Text search results structure */ export interface TextResults { type: "text"; items: Array<{ title: string; url: string; snippet: string; }>; } /** * Image search results structure */ export interface ImageResults { type: "image"; items: Array<{ title: string; url: string; thumbnail: string; sourceUrl?: string; }>; } /** * Search history entry structure */ export interface SearchEntry { id?: number; searchRunId?: string; query: string; timestamp: number; results?: TextResults | ImageResults; // Legacy field for backward compatibility textResults?: TextResults; imageResults?: ImageResults; llmResponse?: string; chatMessages?: Array<{ role: string; content: string; }>; pinned?: boolean; source?: string; } interface LLMResponse { id?: number; searchRunId?: string; prompt: string; response: string; model: string; timestamp: number; searchId?: number; quality?: number; } interface ChatHistoryMessage { id?: number; role: "user" | "assistant"; content: string; timestamp: number; conversationId: string; metadata?: Record; } class HistoryDatabase extends Dexie { searches!: Table; llmResponses!: Table; chatHistory!: Table; constructor() { super("History"); this.version(1).stores({ searches: "++id, searchRunId, query, timestamp, source, pinned, results", llmResponses: "++id, searchId, searchRunId, timestamp, model", chatHistory: "++id, conversationId, timestamp, [conversationId+timestamp]", }); this.searches.hook("creating", () => { this.performCleanup().catch((error) => { addLogEntry(`Cleanup hook error: ${error}`); }); }); } async performCleanup(): Promise { try { const { getSettings } = await import("./pubSub"); const globalSettings = getSettings(); const settings = { autoCleanup: globalSettings.historyAutoCleanup, retentionDays: globalSettings.historyRetentionDays, maxEntries: globalSettings.historyMaxEntries, }; if (!settings.autoCleanup) return; if (settings.retentionDays > 0) { const cutoffTime = Date.now() - settings.retentionDays * 24 * 60 * 60 * 1000; const oldSearches = await this.searches .where("timestamp") .below(cutoffTime) .and((search) => !search.pinned) .limit(100) .toArray(); if (oldSearches.length > 0) { await this.searches.bulkDelete( oldSearches .map((s) => s.id) .filter((id): id is number => id !== undefined), ); addLogEntry(`Cleaned up ${oldSearches.length} old search entries`); } } const totalCount = await this.searches.count(); if (totalCount > settings.maxEntries) { const excess = await this.searches .orderBy("timestamp") .reverse() .offset(settings.maxEntries) .filter((search) => !search.pinned) .toArray(); if (excess.length > 0) { await this.searches.bulkDelete( excess .map((s) => s.id) .filter((id): id is number => id !== undefined), ); addLogEntry(`Removed ${excess.length} excess search entries`); } } } catch (error) { addLogEntry(`Cleanup error: ${error}`); } } } /** * History database instance for search history management */ export const historyDatabase = new HistoryDatabase(); /** * Helper function to get results from a search entry with backward compatibility * @param entry - The search entry * @returns The appropriate results object (text or image) or null */ export function getResultsFromEntry( entry: SearchEntry, ): TextResults | ImageResults | null { // First try new structure if (entry.textResults) return entry.textResults; if (entry.imageResults) return entry.imageResults; // Fallback to legacy structure return entry.results || null; } /** * Helper function to check if an entry has text results * @param entry - The search entry * @returns True if the entry has text results */ export function hasTextResults(entry: SearchEntry): boolean { return !!( entry.textResults || (entry.results && entry.results.type === "text") ); } /** * Helper function to check if an entry has image results * @param entry - The search entry * @returns True if the entry has image results */ export function hasImageResults(entry: SearchEntry): boolean { return !!( entry.imageResults || (entry.results && entry.results.type === "image") ); } /** * Updates search results for a given search run ID * @param searchRunId - The search run ID to update * @param results - The search results to update (text or image) */ export async function updateSearchResults( searchRunId: string, results: TextResults | ImageResults, ): Promise { try { await measurePerformance("Update search results", async () => { const latest = await historyDatabase.searches .where("searchRunId") .equals(searchRunId) .first(); if (latest?.id !== undefined) { const updatedEntry = { ...latest }; if (results.type === "text") { updatedEntry.textResults = results; } else { updatedEntry.imageResults = results; } await historyDatabase.searches.update(latest.id, updatedEntry); } }); } catch (error) { addLogEntry(`Error updating search results: ${error}`); } } /** * Adds a search entry to the history * @param query - The search query * @param results - The search results (text or image) * @param source - The source of the search (user, followup, or suggestion) * @returns Promise resolving to the ID of the added entry */ export async function addSearchToHistory( query: string, results: TextResults | ImageResults, source: "user" | "followup" | "suggestion" = "user", ): Promise { return measurePerformance("Add search to history", async () => { return await historyDatabase.searches.add({ searchRunId: getCurrentSearchRunId(), query, results, // Store in legacy field for backward compatibility ...(results.type === "text" ? { textResults: results } : { imageResults: results }), timestamp: Date.now(), source, pinned: false, }); }).catch((error) => { addLogEntry(`Error adding search to history: ${error}`); return undefined; }); } /** * Gets recent searches from history * @param limit - Maximum number of searches to retrieve * @returns Promise resolving to array of recent search entries */ export async function getRecentSearches( limit: number = 10, ): Promise { return measurePerformance("Get recent searches", async () => { return await historyDatabase.searches .orderBy("timestamp") .reverse() .limit(limit) .toArray(); }).catch((error) => { addLogEntry(`Error fetching recent searches: ${error}`); return []; }); } export async function saveLlmResponseForQuery( query: string, response: string, model: string = "", ): Promise { try { const searchRunId = getCurrentSearchRunId(); const latest = await historyDatabase.searches .where("searchRunId") .equals(searchRunId) .first(); await historyDatabase.llmResponses.add({ searchRunId, prompt: query, response, model, timestamp: Date.now(), searchId: latest?.id, }); } catch (error) { addLogEntry(`Error saving LLM response: ${error}`); } } export async function getLatestLlmResponseForEntry( entry: SearchEntry, ): Promise { try { const searchRunId = entry.searchRunId || entry.query; const records = await historyDatabase.llmResponses .where("searchRunId") .equals(searchRunId) .toArray(); if (records.length > 0) { const latest = records.reduce((a, b) => a.timestamp > b.timestamp ? a : b, ); return latest.response; } return null; } catch (error) { addLogEntry(`Error getting latest LLM response: ${error}`); return null; } } export async function saveChatMessageForQuery( _query: string, role: "user" | "assistant", content: string, ): Promise { try { const searchRunId = getCurrentSearchRunId(); await historyDatabase.chatHistory.add({ role, content, timestamp: Date.now(), conversationId: searchRunId, }); } catch (error) { addLogEntry(`Error saving chat message: ${error}`); } } export async function getChatMessagesForQuery( searchRunId: string, ): Promise> { try { const messages = await historyDatabase.chatHistory .where("conversationId") .equals(searchRunId) .sortBy("timestamp"); return messages.map((msg) => ({ role: msg.role, content: msg.content })); } catch (error) { addLogEntry(`Error getting chat messages: ${error}`); return []; } }