From a4cc2d31ba6728397f7c10224d650da79b1bbecd Mon Sep 17 00:00:00 2001 From: dwrz Date: Sun, 1 Nov 2026 14:50:54 +0000 Subject: [PATCH] Add conversation and message model selection --- internal/service/static/css/main.css | 19 +++ internal/service/static/js/main.js | 51 ++++++- internal/service/static/js/storage.js | 93 +++++++++++- internal/service/static/js/ui.js | 136 +++++++++++++++--- .../templates/static/footer/compose.gohtml | 1 + .../static/modal/compose-settings.gohtml | 11 ++ .../templates/static/modal/settings.gohtml | 6 +- 7 files changed, 289 insertions(+), 28 deletions(-) diff --git a/internal/service/static/css/main.css b/internal/service/static/css/main.css index 46a0e65..748dba5 100644 --- a/internal/service/static/css/main.css +++ b/internal/service/static/css/main.css @@ -141,6 +141,7 @@ body { } .compose__action-btn { + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -241,14 +242,32 @@ body { display: flex; align-items: center; gap: 0.125rem; + min-width: 0; + flex: 1; } .compose__actions-right { + flex-shrink: 0; display: flex; align-items: center; gap: 0.125rem; } +.compose__model { + padding: 0 var(--s-1); + font-size: 0.7rem; + font-family: var(--font-mono); + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1; +} + +.compose__model:empty { + display: none; +} + .compose__attachment { display: flex; align-items: center; diff --git a/internal/service/static/js/main.js b/internal/service/static/js/main.js index 4194b53..75a090d 100644 --- a/internal/service/static/js/main.js +++ b/internal/service/static/js/main.js @@ -55,8 +55,15 @@ class Odidere { ); }, onComposeSubmit: () => this.submit(), - onModelChange: ({ provider, model }) => { - this.storage.setModelSelection({ provider, model }); + onDefaultModelChange: ({ provider, model }) => + this.storage.setModelSelection({ provider, model }), + onComposeModelChange: ({ provider, model }) => { + if (this.conversationId) { + this.storage.setConversationModel(this.conversationId, { + provider, + model, + }); + } this.ui.updateContext( this.totalTokens, this.ui.getSelectedModel().contextSize, @@ -583,7 +590,15 @@ 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. const { provider, model } = this.ui.getSelectedModel(); + if (provider && model) { + this.storage.setConversationModel(this.conversationId, { + provider, + model, + }); + } if (provider) body.provider = provider; if (model) body.model = model; const maxIterations = this.storage.getMaxIterations(); @@ -707,6 +722,7 @@ class Odidere { if (id) { try { await this.api.deleteConversation(id); + this.storage.deleteConversationModel(id); } catch (error) { // The conversation survives on the server, but the local // selection is still cleared. @@ -732,6 +748,7 @@ class Odidere { this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); + this.ui.selectComposeModel(this.resolveConversationModel(null)); this.ui.updateContext(0, null); this.updateSendEnabled(); } @@ -778,6 +795,9 @@ class Odidere { this.ui.closeConversations(); return; } + // Remove model entries for conversations deleted in other tabs + // or on other devices. + this.storage.pruneConversationModels(this.conversations.map((c) => c.id)); this.ui.renderConversationList(this.conversations, { currentId: this.conversationId, }); @@ -850,6 +870,7 @@ class Odidere { this.ui.setPendingApprovals([]); this.ui.updateContext(0, null); this.conversationId = id; + this.ui.selectComposeModel(this.resolveConversationModel(id)); await this.resync(); this.updateSendEnabled(); } @@ -890,6 +911,7 @@ class Odidere { this.ui.renderError(`Failed to delete conversation: ${error.message}`); return; } + this.storage.deleteConversationModel(id); this.conversations = this.conversations.filter((c) => c.id !== id); if (id === this.conversationId) { // The server-side delete cancels an ongoing inference. @@ -1009,6 +1031,9 @@ class Odidere { * request. Used when the server reports the conversation gone. */ async clearSelection() { + if (this.conversationId) { + this.storage.deleteConversationModel(this.conversationId); + } this.messages.clear(); this.drafts.clear(); this.storage.clearConversation(); @@ -1018,6 +1043,7 @@ class Odidere { this.ui.clearChat(); this.ui.setPendingApprovals([]); this.ui.renderSystemDefault(this.storage.getSystemMessage()); + this.ui.selectComposeModel(this.resolveConversationModel(null)); this.ui.updateContext(0, null); this.updateSendEnabled(); } @@ -1567,10 +1593,29 @@ class Odidere { // MODELS AND VOICES // ==================== + /** + * resolveConversationModel returns the provider and model stored + * for the given conversation ID, falling back to the default + * model selection when none is stored. + * @param {string | null} [id] + * @returns {{provider: string | null, model: string | null}} + */ + resolveConversationModel(id = this.conversationId) { + if (id) { + const stored = this.storage.getConversationModel(id); + if (stored.provider && stored.model) return stored; + } + return this.storage.getModelSelection(); + } + async loadModels() { try { const data = await this.api.getModels(); - this.ui.populateModels(data, this.storage.getModelSelection()); + this.ui.populateModels( + data, + this.storage.getModelSelection(), + this.resolveConversationModel(), + ); this.ui.updateContext( this.totalTokens, this.ui.getSelectedModel().contextSize, diff --git a/internal/service/static/js/storage.js b/internal/service/static/js/storage.js index 3ac9a9b..327213d 100644 --- a/internal/service/static/js/storage.js +++ b/internal/service/static/js/storage.js @@ -11,7 +11,9 @@ * - 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_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. * @@ -20,6 +22,7 @@ */ 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'; @@ -100,7 +103,7 @@ export class Storage { } /** - * setModelSelection stores the selected provider and model. + * setModelSelection stores the default provider and model. * @param {{provider: string, model: string}} selection */ setModelSelection({ provider, model }) { @@ -108,6 +111,92 @@ export class Storage { localStorage.setItem(MODEL_KEY, model); } + /** + * _loadConversationModels reads the per-conversation model map. + * @returns {Object} + * @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} 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) || ''; diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js index c69b219..f3e4228 100644 --- a/internal/service/static/js/ui.js +++ b/internal/service/static/js/ui.js @@ -132,6 +132,8 @@ export class UI { this.$composeSettingsClose = document.getElementById( 'compose-settings-close', ); + this.$composeModel = document.getElementById('compose-model'); + this.$composeModelLabel = document.getElementById('compose-model-label'); this.$maxTurnsInput = document.getElementById('max-turns-input'); this.$reasoningBudgetInput = document.getElementById( 'reasoning-budget-input', @@ -703,14 +705,19 @@ export class UI { // ==================== /** - * populateModels fills the model selection. stored selects the - * model chosen earlier; without it the server default is used, - * then the first available model. + * populateModels fills both the default model selection in + * Settings and the per-conversation model selection in Message + * Options. defaultStored selects the default model chosen earlier; + * composeStored selects the active conversation's model. Without a + * stored match, the server default is used, then the first + * available model. * @param {Object} data - /models response. - * @param {{provider: string | null, model: string | null}} stored + * @param {{provider: string | null, model: string | null}} defaultStored + * @param {{provider: string | null, model: string | null}} composeStored */ - populateModels(data, stored) { + populateModels(data, defaultStored, composeStored = defaultStored) { this.$model.replaceChildren(); + this.$composeModel.replaceChildren(); this.modelContextSizes.clear(); for (const provider of Object.keys(data.providers).sort()) { @@ -719,12 +726,13 @@ export class UI { for (const model of [...data.providers[provider]].sort((a, b) => a.model.localeCompare(b.model), )) { + const key = `${provider}/${model.model}`; const $option = this.document.createElement('option'); - $option.value = model.model; + $option.value = key; $option.dataset.provider = provider; $option.dataset.model = model.model; if (model.context_size) { - this.modelContextSizes.set(model.model, model.context_size); + this.modelContextSizes.set(key, model.context_size); } switch (model.status) { case 'available': @@ -743,45 +751,119 @@ export class UI { $group.appendChild($option); } this.$model.appendChild($group); + this.$composeModel.appendChild($group.cloneNode(true)); } + this._applyModelSelect(this.$model, data, defaultStored); + this._applyModelSelect(this.$composeModel, data, composeStored); + this._updateComposeModelLabel(); + } + + /** + * _applyModelSelect selects the matching option in $select, falling + * back to the server default, then the first enabled option. When + * no option can be selected, it replaces the select's children + * with a disabled failure placeholder. + * @param {HTMLSelectElement} $select + * @param {Object} data + * @param {{provider?: string | null, model?: string | null}} stored + * @private + */ + _applyModelSelect($select, data, stored) { const find = (provider, model) => - this.$model.querySelector( + $select.querySelector( `option[data-provider="${CSS.escape(provider ?? '')}"]` + `[data-model="${CSS.escape(model ?? '')}"]`, ); let $option = - (stored.provider && - stored.model && + (stored?.provider && + stored?.model && find(stored.provider, stored.model)) || find(data.default_provider, data.default_model); if (!$option) { - $option = [...this.$model.options].find((option) => !option.disabled); + $option = [...$select.options].find((option) => !option.disabled); } if ($option) { - this.$model.value = $option.value; + $select.value = $option.value; } else { // No model answered: name the failure in the selection. const $none = this.document.createElement('option'); $none.value = ''; $none.disabled = true; $none.textContent = 'Failed to load'; - this.$model.replaceChildren($none); + $select.replaceChildren($none); } } /** - * getSelectedModel reports the selected provider and model, with - * the model's context size when the provider reported one. + * selectComposeModel selects the given provider and model in the + * Message Options dropdown, falling back to the current default + * model selection when no matching option exists, and updates the + * compose model label. + * @param {{provider: string | null, model: string | null}} selection + */ + selectComposeModel({ provider, model }) { + const $option = + provider && + model && + this.$composeModel.querySelector( + `option[data-provider="${CSS.escape(provider)}"]` + + `[data-model="${CSS.escape(model)}"]`, + ); + if ($option) { + this.$composeModel.value = $option.value; + } else if (this.$model.value) { + this.$composeModel.value = this.$model.value; + } + this._updateComposeModelLabel(); + } + + /** + * _updateComposeModelLabel sets the text and hover title of the + * compose bar's model indicator to the active provider/model. + * @private + */ + _updateComposeModelLabel() { + const { provider, model } = this.getSelectedModel(); + const label = provider && model ? `${provider}/${model}` : ''; + this.$composeModelLabel.textContent = label; + this.$composeModelLabel.title = label; + } + + /** + * getSelectedModel reports the conversation's selected provider + * and model from Message Options, with the model's context size + * when the provider reported one. * @returns {{provider: string | null, model: string | null, contextSize: number | null}} */ getSelectedModel() { - const $option = this.$model.options[this.$model.selectedIndex]; + return this._readModelSelect(this.$composeModel); + } + + /** + * getDefaultModel reports the default provider and model selected + * in Settings. + * @returns {{provider: string | null, model: string | null, contextSize: number | null}} + */ + getDefaultModel() { + return this._readModelSelect(this.$model); + } + + /** + * _readModelSelect extracts the selected provider, model, and + * context size from a model
diff --git a/internal/service/templates/static/modal/compose-settings.gohtml b/internal/service/templates/static/modal/compose-settings.gohtml index e2b2389..efc5ac8 100644 --- a/internal/service/templates/static/modal/compose-settings.gohtml +++ b/internal/service/templates/static/modal/compose-settings.gohtml @@ -14,6 +14,17 @@
+
+ + +
+