Add conversation and message model selection

This commit is contained in:
dwrz
2026-11-01 14:53:11 +00:00
parent 019ff64621
commit a4cc2d31ba
7 changed files with 289 additions and 28 deletions
+19
View File
@@ -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;
+48 -3
View File
@@ -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,
+91 -2
View File
@@ -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<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) || '';
+116 -20
View File
@@ -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 <select>.
* @param {HTMLSelectElement} $select
* @private
*/
_readModelSelect($select) {
const $option = $select.options[$select.selectedIndex];
const provider = $option?.dataset.provider ?? null;
const model = $option?.dataset.model ?? null;
const key = provider && model ? `${provider}/${model}` : null;
return {
provider: $option?.dataset.provider ?? null,
provider,
model,
contextSize: model ? (this.modelContextSizes.get(model) ?? null) : null,
contextSize: key ? (this.modelContextSizes.get(key) ?? null) : null,
};
}
@@ -1218,8 +1300,8 @@ export class UI {
});
this.$saveSystemMessage.onclick = () => this._saveSystemMessage();
this.$model.addEventListener('change', () => {
const { provider, model } = this.getSelectedModel();
this.handlers.onModelChange({ provider, model });
const { provider, model } = this.getDefaultModel();
this.handlers.onDefaultModelChange({ provider, model });
});
this.$voice.addEventListener('change', () =>
this.handlers.onVoiceChange(this.$voice.value),
@@ -1254,6 +1336,20 @@ export class UI {
this._closeComposeSettings();
}
});
this.document.addEventListener('keydown', (event) => {
if (
event.key === 'Escape' &&
this.$composeSettingsOverlay.classList.contains('open')
) {
event.preventDefault();
this._closeComposeSettings();
}
});
this.$composeModel.addEventListener('change', () => {
this._updateComposeModelLabel();
const { provider, model } = this.getSelectedModel();
this.handlers.onComposeModelChange({ provider, model });
});
}
/**