Files
odidere/internal/service/static/js/storage.js
T

277 lines
8.8 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: default selections.
* - odidere_conversation_settings: per-conversation settings, keyed
* by conversation ID. Each entry is complete: it holds the
* provider, model, max iterations, and reasoning budget together.
* - odidere_max_turns, odidere_reasoning_budget: default compose
* settings, used when a conversation has no stored entry.
* - odidere_mute: whether speech playback is muted.
*/
const CONVERSATION_KEY = 'odidere_conversation';
const CONVERSATION_SETTINGS_KEY = 'odidere_conversation_settings';
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 default provider and model.
* @param {{provider: string, model: string}} selection
*/
setModelSelection({ provider, model }) {
localStorage.setItem(PROVIDER_KEY, provider);
localStorage.setItem(MODEL_KEY, model);
}
/**
* _loadConversationSettings reads the per-conversation settings map.
* @returns {Object<string, {provider: string, model: string, maxIterations: number, reasoningBudget: number | null}>}
* @private
*/
_loadConversationSettings() {
try {
const parsed = JSON.parse(
localStorage.getItem(CONVERSATION_SETTINGS_KEY),
);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed
: {};
} catch (error) {
console.error('failed to load conversation settings:', error);
return {};
}
}
/**
* getConversationSettings returns the settings stored for one
* conversation, or null when none are stored.
* @param {string} id - conversation ID.
* @returns {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null} | null}
*/
getConversationSettings(id) {
const entry = this._loadConversationSettings()[id];
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return null;
}
return entry;
}
/**
* setConversationSettings stores the settings for one
* conversation. Store the complete settings: a stored entry is
* used as stored, and no lookup completes it from the defaults.
* @param {string} id - conversation ID.
* @param {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null}} settings
*/
setConversationSettings(id, settings) {
if (!id) return;
try {
const map = this._loadConversationSettings();
map[id] = settings;
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
} catch (error) {
console.error('failed to save conversation settings:', error);
}
}
/**
* deleteConversationSettings removes the stored settings for one
* conversation.
* @param {string} id - conversation ID.
*/
deleteConversationSettings(id) {
if (!id) return;
try {
const map = this._loadConversationSettings();
if (!(id in map)) return;
delete map[id];
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
} catch (error) {
console.error('failed to delete conversation settings:', error);
}
}
/**
* pruneConversationSettings removes stored settings for
* conversations that no longer exist on the server (for example,
* conversations deleted from another device or browser tab), so
* the localStorage map does not grow without bound.
* @param {Iterable<string>} validIds - conversation IDs currently
* on the server.
*/
pruneConversationSettings(validIds) {
try {
const keep = new Set(validIds);
const map = this._loadConversationSettings();
for (const id of Object.keys(map)) {
if (!keep.has(id)) delete map[id];
}
localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
} catch (error) {
console.error('failed to prune conversation settings:', error);
}
}
/** @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.
* The value is returned as stored and is not checked:
* resolveConversationSettings corrects invalid values before use.
* @returns {number}
*/
getMaxIterations() {
return Number(localStorage.getItem(MAX_TURNS_KEY));
}
/**
* 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. The
* value is returned as stored and is not checked:
* resolveConversationSettings corrects invalid values before use.
* @returns {number | null}
*/
getReasoningBudget() {
const stored = localStorage.getItem(REASONING_BUDGET_KEY);
return stored === null ? null : Number(stored);
}
/**
* 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));
}
}