Add stored conversation settings

This commit is contained in:
dwrz
2026-11-04 16:17:02 +00:00
parent caaacf1c98
commit 866c6585c7
2 changed files with 141 additions and 89 deletions
+82 -33
View File
@@ -43,25 +43,38 @@ class Odidere {
document, document,
handlers: { handlers: {
onComposeInput: () => this.updateSendEnabled(), onComposeInput: () => this.updateSendEnabled(),
onComposeSettings: () => onComposeSettings: () => {
const settings = this.resolveConversationSettings();
this.ui.openComposeSettings({ this.ui.openComposeSettings({
maxIterations: this.storage.getMaxIterations(), maxIterations: settings.maxIterations,
reasoningBudget: this.storage.getReasoningBudget(), reasoningBudget: settings.reasoningBudget,
}), });
},
onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => { onComposeSettingsChange: ({ maxIterations, reasoningBudget }) => {
this.storage.setMaxIterations(maxIterations); if (!this.conversationId) {
this.storage.setReasoningBudget( this.storage.setMaxIterations(maxIterations);
Number.isFinite(reasoningBudget) ? reasoningBudget : null, this.storage.setReasoningBudget(reasoningBudget);
); return;
}
const { provider, model } = this.ui.getSelectedModel();
this.storage.setConversationSettings(this.conversationId, {
provider,
model,
maxIterations,
reasoningBudget,
});
}, },
onComposeSubmit: () => this.submit(), onComposeSubmit: () => this.submit(),
onDefaultModelChange: ({ provider, model }) => onDefaultModelChange: ({ provider, model }) =>
this.storage.setModelSelection({ provider, model }), this.storage.setModelSelection({ provider, model }),
onComposeModelChange: ({ provider, model }) => { onComposeModelChange: ({ provider, model }) => {
if (this.conversationId) { if (this.conversationId) {
this.storage.setConversationModel(this.conversationId, { const settings = this.resolveConversationSettings();
this.storage.setConversationSettings(this.conversationId, {
provider, provider,
model, model,
maxIterations: settings.maxIterations,
reasoningBudget: settings.reasoningBudget,
}); });
} }
this.ui.updateContext( this.ui.updateContext(
@@ -597,21 +610,28 @@ class Odidere {
try { try {
const body = { stream: true }; const body = { stream: true };
if (message) body.message = message; if (message) body.message = message;
// Read the selected model once. Save it for this conversation, // Read the conversation's settings once: the stored entry, or
// which always has an ID here, and send it with the request. // 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(); const { provider, model } = this.ui.getSelectedModel();
if (provider && model) { if (provider && model) {
this.storage.setConversationModel(this.conversationId, { this.storage.setConversationSettings(this.conversationId, {
provider, provider,
model, model,
maxIterations: settings.maxIterations,
reasoningBudget: settings.reasoningBudget,
}); });
} }
if (provider) body.provider = provider; if (provider) body.provider = provider;
if (model) body.model = model; if (model) body.model = model;
const maxIterations = this.storage.getMaxIterations(); if (settings.maxIterations > 0) {
if (maxIterations > 0) body.max_iterations = maxIterations; body.max_iterations = settings.maxIterations;
const budget = this.storage.getReasoningBudget(); }
if (budget !== null) body.thinking_budget_tokens = budget; if (settings.reasoningBudget !== null) {
body.thinking_budget_tokens = settings.reasoningBudget;
}
for await (const { type, data } of this.api.inference( for await (const { type, data } of this.api.inference(
this.conversationId, this.conversationId,
@@ -727,7 +747,7 @@ class Odidere {
if (id) { if (id) {
try { try {
await this.api.deleteConversation(id); await this.api.deleteConversation(id);
this.storage.deleteConversationModel(id); this.storage.deleteConversationSettings(id);
} catch (error) { } catch (error) {
// The conversation survives on the server, but the local // The conversation survives on the server, but the local
// selection is still cleared. // selection is still cleared.
@@ -753,7 +773,7 @@ class Odidere {
this.ui.clearChat(); this.ui.clearChat();
this.ui.setPendingApprovals([]); this.ui.setPendingApprovals([]);
this.ui.renderSystemDefault(this.storage.getSystemMessage()); this.ui.renderSystemDefault(this.storage.getSystemMessage());
this.ui.selectComposeModel(this.resolveConversationModel(null)); this.ui.selectComposeModel(this.resolveConversationSettings(null));
this.ui.updateContext(0, null); this.ui.updateContext(0, null);
this.updateSendEnabled(); this.updateSendEnabled();
} }
@@ -801,9 +821,9 @@ class Odidere {
this.ui.closeConversations(); this.ui.closeConversations();
return; return;
} }
// Remove model entries for conversations deleted in other tabs // Remove settings entries for conversations deleted in other tabs
// or on other devices. // 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, { this.ui.renderConversationList(this.conversations, {
currentId: this.conversationId, currentId: this.conversationId,
}); });
@@ -877,7 +897,7 @@ class Odidere {
this.ui.setPendingApprovals([]); this.ui.setPendingApprovals([]);
this.ui.updateContext(0, null); this.ui.updateContext(0, null);
this.conversationId = id; this.conversationId = id;
this.ui.selectComposeModel(this.resolveConversationModel(id)); this.ui.selectComposeModel(this.resolveConversationSettings(id));
await this.sync(); await this.sync();
this.updateSendEnabled(); this.updateSendEnabled();
} }
@@ -918,7 +938,7 @@ class Odidere {
this.ui.renderError(`Failed to delete conversation: ${error.message}`); this.ui.renderError(`Failed to delete conversation: ${error.message}`);
return; return;
} }
this.storage.deleteConversationModel(id); this.storage.deleteConversationSettings(id);
this.conversations = this.conversations.filter((c) => c.id !== id); this.conversations = this.conversations.filter((c) => c.id !== id);
if (id === this.conversationId) { if (id === this.conversationId) {
// The server-side delete cancels an ongoing inference. // The server-side delete cancels an ongoing inference.
@@ -1009,7 +1029,7 @@ class Odidere {
*/ */
async clearSelection() { async clearSelection() {
if (this.conversationId) { if (this.conversationId) {
this.storage.deleteConversationModel(this.conversationId); this.storage.deleteConversationSettings(this.conversationId);
} }
this.messages.clear(); this.messages.clear();
this.drafts.clear(); this.drafts.clear();
@@ -1020,7 +1040,7 @@ class Odidere {
this.ui.clearChat(); this.ui.clearChat();
this.ui.setPendingApprovals([]); this.ui.setPendingApprovals([]);
this.ui.renderSystemDefault(this.storage.getSystemMessage()); this.ui.renderSystemDefault(this.storage.getSystemMessage());
this.ui.selectComposeModel(this.resolveConversationModel(null)); this.ui.selectComposeModel(this.resolveConversationSettings(null));
this.ui.updateContext(0, null); this.ui.updateContext(0, null);
this.updateSendEnabled(); this.updateSendEnabled();
} }
@@ -1570,18 +1590,47 @@ class Odidere {
// ==================== // ====================
/** /**
* resolveConversationModel returns the provider and model stored * resolveConversationSettings returns the settings stored for the
* for the given conversation ID, falling back to the default * given conversation ID, falling back to the default selections
* model selection when none is stored. * 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] * @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) { if (id) {
const stored = this.storage.getConversationModel(id); const stored = this.storage.getConversationSettings(id);
if (stored.provider && stored.model) return stored; 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() { async loadModels() {
@@ -1590,7 +1639,7 @@ class Odidere {
this.ui.populateModels( this.ui.populateModels(
data, data,
this.storage.getModelSelection(), this.storage.getModelSelection(),
this.resolveConversationModel(), this.resolveConversationSettings(),
); );
this.ui.updateContext( this.ui.updateContext(
this.totalTokens, this.totalTokens,
+59 -56
View File
@@ -12,17 +12,16 @@
* conversation is created. It does not change a conversation that * conversation is created. It does not change a conversation that
* already exists. * already exists.
* - odidere_provider, odidere_model, odidere_voice: default selections. * - odidere_provider, odidere_model, odidere_voice: default selections.
* - odidere_conversation_models: per-conversation provider and model * - odidere_conversation_settings: per-conversation settings, keyed
* selections, keyed by conversation ID. * by conversation ID. Each entry is complete: it holds the
* - odidere_max_turns, odidere_reasoning_budget: compose settings. * 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. * - 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_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 SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const PROVIDER_KEY = 'odidere_provider'; const PROVIDER_KEY = 'odidere_provider';
const MODEL_KEY = 'odidere_model'; const MODEL_KEY = 'odidere_model';
@@ -112,88 +111,91 @@ export class Storage {
} }
/** /**
* _loadConversationModels reads the per-conversation model map. * _loadConversationSettings reads the per-conversation settings map.
* @returns {Object<string, {provider: string, model: string}>} * @returns {Object<string, {provider: string, model: string, maxIterations: number, reasoningBudget: number | null}>}
* @private * @private
*/ */
_loadConversationModels() { _loadConversationSettings() {
try { 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) return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed ? parsed
: {}; : {};
} catch (error) { } catch (error) {
console.error('failed to load conversation models:', error); console.error('failed to load conversation settings:', error);
return {}; return {};
} }
} }
/** /**
* getConversationModel returns the provider and model stored for * getConversationSettings returns the settings stored for one
* one conversation, or null fields when none is stored. * conversation, or null when none are stored.
* @param {string} id - conversation ID. * @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) { getConversationSettings(id) {
const entry = this._loadConversationModels()[id]; const entry = this._loadConversationSettings()[id];
return { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
provider: entry?.provider || null, return 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);
} }
return entry;
} }
/** /**
* deleteConversationModel removes the stored model entry for one * setConversationSettings stores the settings for one
* conversation. * 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 {string} id - conversation ID.
* @param {{provider: string, model: string, maxIterations: number, reasoningBudget: number | null}} settings
*/ */
deleteConversationModel(id) { setConversationSettings(id, settings) {
if (!id) return; if (!id) return;
try { try {
const map = this._loadConversationModels(); const map = this._loadConversationSettings();
if (!(id in map)) return; map[id] = settings;
delete map[id]; localStorage.setItem(CONVERSATION_SETTINGS_KEY, JSON.stringify(map));
localStorage.setItem(CONVERSATION_MODELS_KEY, JSON.stringify(map));
} catch (error) { } 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 that no longer exist on the server (for example,
* conversations deleted from another device or browser tab), so * conversations deleted from another device or browser tab), so
* the localStorage map does not grow without bound. * the localStorage map does not grow without bound.
* @param {Iterable<string>} validIds - conversation IDs currently * @param {Iterable<string>} validIds - conversation IDs currently
* on the server. * on the server.
*/ */
pruneConversationModels(validIds) { pruneConversationSettings(validIds) {
try { try {
const keep = new Set(validIds); const keep = new Set(validIds);
const map = this._loadConversationModels(); const map = this._loadConversationSettings();
for (const id of Object.keys(map)) { for (const id of Object.keys(map)) {
if (!keep.has(id)) delete map[id]; 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) { } 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 * getMaxIterations returns the agent loop limit. Zero means no
* limit: the model replies without tool calls or the run fails. * 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} * @returns {number}
*/ */
getMaxIterations() { getMaxIterations() {
const stored = Number(localStorage.getItem(MAX_TURNS_KEY)); return Number(localStorage.getItem(MAX_TURNS_KEY));
return Number.isFinite(stored) && stored > 0 ? stored : 0;
} }
/** /**
@@ -233,14 +236,14 @@ export class Storage {
/** /**
* getReasoningBudget returns the llama.cpp reasoning token budget, * 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} * @returns {number | null}
*/ */
getReasoningBudget() { getReasoningBudget() {
const stored = localStorage.getItem(REASONING_BUDGET_KEY); const stored = localStorage.getItem(REASONING_BUDGET_KEY);
if (stored === null) return null; return stored === null ? null : Number(stored);
const value = Number(stored);
return Number.isFinite(value) ? value : null;
} }
/** /**