Add stored conversation settings
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user