/** * Storage reads and writes the browser's local cache and the saved * client settings. It is the only module that touches localStorage. * It does no rendering and sends no requests: main.js decides what * to cache and when, and Storage only stores and returns it. * * Keys and their contents: * - odidere_conversation: the selected conversation ID and its saved * message rows, for offline viewing and a fast start. The server * owns the history; this entry is a copy, replaced by every sync. * - odidere_system_message: the system message used when the next * conversation is created. It does not change a conversation that * already exists. * - odidere_provider, odidere_model, odidere_voice: selections. * - odidere_max_turns, odidere_reasoning_budget: compose settings. * - odidere_mute: whether speech playback is muted. * * The browser-only history that older versions kept under * odidere_history is not read and is left untouched. */ const CONVERSATION_KEY = 'odidere_conversation'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; const PROVIDER_KEY = 'odidere_provider'; const MODEL_KEY = 'odidere_model'; const VOICE_KEY = 'odidere_voice'; const MAX_TURNS_KEY = 'odidere_max_turns'; const REASONING_BUDGET_KEY = 'odidere_reasoning_budget'; const MUTE_KEY = 'odidere_mute'; export class Storage { /** * loadConversation returns the cached selection: the conversation * ID and its message rows in saved order. Rows are stored under * their message IDs; the array order is the saved order. * @returns {{id: string, rows: Array} | null} */ loadConversation() { try { const cached = JSON.parse(localStorage.getItem(CONVERSATION_KEY)); if (!cached?.id || !Array.isArray(cached.rows)) return null; return { id: cached.id, rows: cached.rows }; } catch (error) { console.error('failed to load conversation cache:', error); return null; } } /** * saveConversation caches the selected conversation and its rows. * The server owns the history; this is a local copy for offline * viewing. Streaming drafts are never passed to this method. * @param {string} id * @param {Array} rows */ saveConversation(id, rows) { try { localStorage.setItem(CONVERSATION_KEY, JSON.stringify({ id, rows })); } catch (error) { console.error('failed to cache conversation:', error); } } /** * clearConversation drops the cached selection. It does not * change or delete the conversation on the server. */ clearConversation() { try { localStorage.removeItem(CONVERSATION_KEY); } catch (error) { console.error('failed to clear conversation cache:', error); } } // --- System message --- /** @returns {string} the saved default system message, or '' */ getSystemMessage() { return localStorage.getItem(SYSTEM_MESSAGE_KEY) || ''; } /** * setSystemMessage stores the default system message for the next * conversation. It does not change any existing conversation. * @param {string} value */ setSystemMessage(value) { localStorage.setItem(SYSTEM_MESSAGE_KEY, value); } // --- Model and voice --- /** @returns {{provider: string | null, model: string | null}} */ getModelSelection() { return { provider: localStorage.getItem(PROVIDER_KEY), model: localStorage.getItem(MODEL_KEY), }; } /** * setModelSelection stores the selected provider and model. * @param {{provider: string, model: string}} selection */ setModelSelection({ provider, model }) { localStorage.setItem(PROVIDER_KEY, provider); localStorage.setItem(MODEL_KEY, model); } /** @returns {string} the selected voice, or '' for automatic */ getVoice() { return localStorage.getItem(VOICE_KEY) || ''; } /** * setVoice stores the selected voice. '' selects the default. * @param {string} voice */ setVoice(voice) { localStorage.setItem(VOICE_KEY, voice); } // --- Compose settings --- /** * getMaxIterations returns the agent loop limit. Zero means no * limit: the model replies without tool calls or the run fails. * @returns {number} */ getMaxIterations() { const stored = Number(localStorage.getItem(MAX_TURNS_KEY)); return Number.isFinite(stored) && stored > 0 ? stored : 0; } /** * setMaxIterations stores the agent loop limit. Zero means no * limit. * @param {number} value */ setMaxIterations(value) { localStorage.setItem(MAX_TURNS_KEY, String(value)); } /** * getReasoningBudget returns the llama.cpp reasoning token budget, * or null when unset. -1 means unlimited and 0 means off. * @returns {number | null} */ getReasoningBudget() { const stored = localStorage.getItem(REASONING_BUDGET_KEY); if (stored === null) return null; const value = Number(stored); return Number.isFinite(value) ? value : null; } /** * setReasoningBudget stores the reasoning token budget. null * removes the setting and leaves the choice to the server. * @param {number | null} value */ setReasoningBudget(value) { if (value === null) { localStorage.removeItem(REASONING_BUDGET_KEY); } else { localStorage.setItem(REASONING_BUDGET_KEY, String(value)); } } // --- Mute --- /** @returns {boolean} whether speech playback is muted */ getMuted() { return localStorage.getItem(MUTE_KEY) === 'true'; } /** * setMuted stores the speech mute state. * @param {boolean} muted */ setMuted(muted) { localStorage.setItem(MUTE_KEY, String(muted)); } }