Add conversation and message model selection
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) || '';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user