diff --git a/internal/service/static/js/main.js b/internal/service/static/js/main.js index 51e897b..77d3624 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -43,25 +43,38 @@ class Odidere { document, handlers: { onComposeInput: () => this.updateSendEnabled(), - onComposeSettings: () => + onComposeSettings: () => { + const settings = this.resolveConversationSettings(); this.ui.openComposeSettings({ - maxIterations: this.storage.getMaxIterations(), - reasoningBudget: this.storage.getReasoningBudget(), - }), + maxIterations: settings.maxIterations, + reasoningBudget: settings.reasoningBudget, + }); + }, onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => { - this.storage.setMaxIterations(maxIterations); - this.storage.setReasoningBudget( - Number.isFinite(reasoningBudget) ? reasoningBudget : null, - ); + if (!this.conversationId) { + this.storage.setMaxIterations(maxIterations); + this.storage.setReasoningBudget(reasoningBudget); + return; + } + const { provider, model } = this.ui.getSelectedModel(); + this.storage.setConversationSettings(this.conversationId, { + provider, + model, + maxIterations, + reasoningBudget, + }); }, onComposeSubmit: () => this.submit(), onDefaultModelChange: ({ provider, model }) => this.storage.setModelSelection({ provider, model }), onComposeModelChange: ({ provider, model }) => { if (this.conversationId) { - this.storage.setConversationModel(this.conversationId, { + const settings = this.resolveConversationSettings(); + this.storage.setConversationSettings(this.conversationId, { provider, model, + maxIterations: settings.maxIterations, + reasoningBudget: settings.reasoningBudget, }); } this.ui.updateContext( @@ -597,21 +610,28 @@ class Odidere { try { const body = { stream: true }; if (message) body.message = message; - // Read the selected model once. Save it for this conversation, - // which always has an ID here, and send it with the request. + // Read the conversation's settings once: the stored entry, or + // the defaults when the conversation has none. Save them for + // this conversation, which always has an ID here, and send them + // with the request. + const settings = this.resolveConversationSettings(); const { provider, model } = this.ui.getSelectedModel(); if (provider && model) { - this.storage.setConversationModel(this.conversationId, { + this.storage.setConversationSettings(this.conversationId, { provider, model, + maxIterations: settings.maxIterations, + reasoningBudget: settings.reasoningBudget, }); } if (provider) body.provider = provider; if (model) body.model = model; - const maxIterations = this.storage.getMaxIterations(); - if (maxIterations > 0) body.max_iterations = maxIterations; - const budget = this.storage.getReasoningBudget(); - if (budget !== null) body.thinking_budget_tokens = budget; + if (settings.maxIterations > 0) { + body.max_iterations = settings.maxIterations; + } + if (settings.reasoningBudget !== null) { + body.thinking_budget_tokens = settings.reasoningBudget; + } for await (const { type, data } of this.api.inference( this.conversationId, @@ -727,7 +747,7 @@ class Odidere { if (id) { try { await this.api.deleteConversation(id); - this.storage.deleteConversationModel(id); + this.storage.deleteConversationSettings(id); } catch (error) { // The conversation survives on the server, but the local // selection is still cleared. @@ -753,7 +773,7 @@ class Odidere { this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); - this.ui.selectComposeModel(this.resolveConversationModel(null)); + this.ui.selectComposeModel(this.resolveConversationSettings(null)); this.ui.updateContext(0, null); this.updateSendEnabled(); } @@ -801,9 +821,9 @@ class Odidere { this.ui.closeConversations(); return; } - // Remove model entries for conversations deleted in other tabs + // Remove settings entries for conversations deleted in other tabs // or on other devices. - this.storage.pruneConversationModels(this.conversations.map((c) => c.id)); + this.storage.pruneConversationSettings(this.conversations.map((c) => c.id)); this.ui.renderConversationList(this.conversations, { currentId: this.conversationId, }); @@ -877,7 +897,7 @@ class Odidere { this.ui.setPendingApprovals([]); this.ui.updateContext(0, null); this.conversationId = id; - this.ui.selectComposeModel(this.resolveConversationModel(id)); + this.ui.selectComposeModel(this.resolveConversationSettings(id)); await this.sync(); this.updateSendEnabled(); } @@ -918,7 +938,7 @@ class Odidere { this.ui.renderError(`Failed to delete conversation: ${error.message}`); return; } - this.storage.deleteConversationModel(id); + this.storage.deleteConversationSettings(id); this.conversations = this.conversations.filter((c) => c.id !== id); if (id === this.conversationId) { // The server-side delete cancels an ongoing inference. @@ -1009,7 +1029,7 @@ class Odidere { */ async clearSelection() { if (this.conversationId) { - this.storage.deleteConversationModel(this.conversationId); + this.storage.deleteConversationSettings(this.conversationId); } this.messages.clear(); this.drafts.clear(); @@ -1020,7 +1040,7 @@ class Odidere { this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); - this.ui.selectComposeModel(this.resolveConversationModel(null)); + this.ui.selectComposeModel(this.resolveConversationSettings(null)); this.ui.updateContext(0, null); this.updateSendEnabled(); } @@ -1570,18 +1590,47 @@ class Odidere { // ==================== /** - * resolveConversationModel returns the provider and model stored - * for the given conversation ID, falling back to the default - * model selection when none is stored. + * resolveConversationSettings returns the settings stored for the + * given conversation ID, falling back to the default selections + * when none are stored. Invalid numbers never reach a request: + * iterations that are not a positive safe integer read as 0, and + * a budget that is not a safe integer reads as null. A budget + * below -1 reads as -1. * @param {string | null} [id] - * @returns {{provider: string | null, model: string | null}} + * @returns {{provider: string | null, model: string | null, maxIterations: number, reasoningBudget: number | null}} */ - resolveConversationModel(id = this.conversationId) { + resolveConversationSettings(id = this.conversationId) { if (id) { - const stored = this.storage.getConversationModel(id); - if (stored.provider && stored.model) return stored; + const stored = this.storage.getConversationSettings(id); + if (stored) { + return { + provider: stored.provider, + model: stored.model, + maxIterations: + Number.isSafeInteger(stored.maxIterations) && + stored.maxIterations > 0 + ? stored.maxIterations + : 0, + reasoningBudget: Number.isSafeInteger(stored.reasoningBudget) + ? Math.max(stored.reasoningBudget, -1) + : null, + }; + } } - return this.storage.getModelSelection(); + const { provider, model } = this.storage.getModelSelection(); + const maxIterations = this.storage.getMaxIterations(); + const budget = this.storage.getReasoningBudget(); + return { + provider, + model, + maxIterations: + Number.isSafeInteger(maxIterations) && maxIterations > 0 + ? maxIterations + : 0, + reasoningBudget: Number.isSafeInteger(budget) + ? Math.max(budget, -1) + : null, + }; } async loadModels() { @@ -1590,7 +1639,7 @@ class Odidere { this.ui.populateModels( data, this.storage.getModelSelection(), - this.resolveConversationModel(), + this.resolveConversationSettings(), ); this.ui.updateContext( this.totalTokens, diff --git a/internal/service/static/js/storage.js b/internal/service/static/js/storage.js index 327213d..97be8f1 100644 --- a/internal/service/static/js/storage.js +++ b/internal/service/static/js/storage.js @@ -12,17 +12,16 @@ * 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_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. - * - * 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 CONVERSATION_SETTINGS_KEY = 'odidere_conversation_settings'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; const PROVIDER_KEY = 'odidere_provider'; const MODEL_KEY = 'odidere_model'; @@ -112,88 +111,91 @@ export class Storage { } /** - * _loadConversationModels reads the per-conversation model map. - * @returns {Object} + * _loadConversationSettings reads the per-conversation settings map. + * @returns {Object} * @private */ - _loadConversationModels() { + _loadConversationSettings() { try { - const parsed = JSON.parse(localStorage.getItem(CONVERSATION_MODELS_KEY)); + 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 models:', error); + console.error('failed to load conversation settings:', error); return {}; } } /** - * getConversationModel returns the provider and model stored for - * one conversation, or null fields when none is stored. + * getConversationSettings returns the settings stored for one + * conversation, or null when none are stored. * @param {string} id - conversation ID. - * @returns {{provider: string | null, model: string | null}} + * @returns {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null} | 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); + getConversationSettings(id) { + const entry = this._loadConversationSettings()[id]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return null; } + return entry; } /** - * deleteConversationModel removes the stored model entry for one - * conversation. + * 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 */ - deleteConversationModel(id) { + setConversationSettings(id, settings) { if (!id) return; try { - const map = this._loadConversationModels(); - if (!(id in map)) return; - delete map[id]; - localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map)); + const map = this._loadConversationSettings(); + map[id] = settings; + localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map)); } catch (error) { - console.error('failed to delete conversation model:', error); + console.error('failed to save conversation settings:', error); } } /** - * pruneConversationModels removes stored model entries for + * 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} validIds - conversation IDs currently * on the server. */ - pruneConversationModels(validIds) { + pruneConversationSettings(validIds) { try { const keep = new Set(validIds); - const map = this._loadConversationModels(); + const map = this._loadConversationSettings(); for (const id of Object.keys(map)) { if (!keep.has(id)) delete map[id]; } - localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map)); + localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map)); } catch (error) { - console.error('failed to prune conversation models:', error); + console.error('failed to prune conversation settings:', error); } } @@ -215,11 +217,12 @@ export class Storage { /** * 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() { - const stored = Number(localStorage.getItem(MAX_TURNS_KEY)); - return Number.isFinite(stored) && stored > 0 ? stored : 0; + return Number(localStorage.getItem(MAX_TURNS_KEY)); } /** @@ -233,14 +236,14 @@ export class Storage { /** * getReasoningBudget returns the llama.cpp reasoning token budget, - * or null when unset. -1 means unlimited and 0 means off. + * 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); - if (stored === null) return null; - const value = Number(stored); - return Number.isFinite(value) ? value : null; + return stored === null ? null : Number(stored); } /**