274 lines
8.4 KiB
JavaScript
274 lines
8.4 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_models: per-conversation provider and model
|
|
* selections, keyed by conversation ID.
|
|
* - 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 CONVERSATION_MODELS_KEY = 'odidere_conversation_models';
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* _loadConversationModels reads the per-conversation model map.
|
|
* @returns {Object<string, {provider: string, model: string}>}
|
|
* @private
|
|
*/
|
|
_loadConversationModels() {
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(CONVERSATION_MODELS_KEY));
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? parsed
|
|
: {};
|
|
} catch (error) {
|
|
console.error('failed to load conversation models:', error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getConversationModel returns the provider and model stored for
|
|
* one conversation, or null fields when none is stored.
|
|
* @param {string} id - conversation ID.
|
|
* @returns {{provider: string | null, model: string | null}}
|
|
*/
|
|
getConversationModel(id) {
|
|
const entry = this._loadConversationModels()[id];
|
|
return {
|
|
provider: entry?.provider || null,
|
|
model: entry?.model || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* setConversationModel stores the provider and model for one
|
|
* conversation.
|
|
* @param {string} id - conversation ID.
|
|
* @param {{provider: string, model: string}} selection
|
|
*/
|
|
setConversationModel(id, { provider, model }) {
|
|
if (!id || !provider || !model) return;
|
|
try {
|
|
const map = this._loadConversationModels();
|
|
map[id] = { provider, model };
|
|
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
|
} catch (error) {
|
|
console.error('failed to save conversation model:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* deleteConversationModel removes the stored model entry for one
|
|
* conversation.
|
|
* @param {string} id - conversation ID.
|
|
*/
|
|
deleteConversationModel(id) {
|
|
if (!id) return;
|
|
try {
|
|
const map = this._loadConversationModels();
|
|
if (!(id in map)) return;
|
|
delete map[id];
|
|
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
|
} catch (error) {
|
|
console.error('failed to delete conversation model:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pruneConversationModels removes stored model entries 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.
|
|
*/
|
|
pruneConversationModels(validIds) {
|
|
try {
|
|
const keep = new Set(validIds);
|
|
const map = this._loadConversationModels();
|
|
for (const id of Object.keys(map)) {
|
|
if (!keep.has(id)) delete map[id];
|
|
}
|
|
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
|
|
} catch (error) {
|
|
console.error('failed to prune conversation models:', 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.
|
|
* @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));
|
|
}
|
|
}
|