185 lines
5.5 KiB
JavaScript
185 lines
5.5 KiB
JavaScript
/**
|
|||
|
|
* 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<Object>} | 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<Object>} 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));
|
||
|
|
}
|
||
|
|
}
|