feat: save account model and thinking settings for Hermes
Rebuild & Deploy / rebuild (push) Successful in 1m9s

This commit is contained in:
snowspeeder committed 2026-10-01 16:38:34 -04:00
1 parent a45569f8c1
commit 6cb4427210
13 files changed
+798 -16

No files matched your search

+171 -3
View File
@@ -586,6 +586,163 @@ function handleDataPacket(payload, participant, topic) {
}
}
// ── Account model settings (server saved; never browser persisted) ──────────
const modelSettingsForm = document.getElementById("modelSettingsForm");
const modelSelect = document.getElementById("modelSelect");
const thinkingLevelSelect = document.getElementById("thinkingLevelSelect");
const saveModelSettingsBtn = document.getElementById("saveModelSettings");
const retryModelSettingsBtn = document.getElementById("retryModelSettings");
const modelSettingsStatus = document.getElementById("modelSettingsStatus");
const thinkingLevelNote = document.getElementById("thinkingLevelNote");
const THINKING_LEVELS = [
["none", "Off"], ["minimal", "Minimal"], ["low", "Low"],
["medium", "Medium"], ["high", "High"], ["xhigh", "Extra high"],
];
const THINKING_NOTE = "Thinking requests may be limited or ignored by the selected model.";
let settingsAccount = null;
let settingsGeneration = 0;
let settingsAbort = null;
let settingsBusy = false;
let modelCatalog = [];
let catalogAvailable = false;
let savedModelSettings = null;
function accountIdentity() {
return HopeAuth.user ? String(HopeAuth.user.id) : null;
}
function settingsResponseIsCurrent(account, generation) {
return account !== null && account === accountIdentity() && account === settingsAccount && generation === settingsGeneration;
}
function settingsOption(value, label) {
const option = document.createElement("option");
option.value = value; option.textContent = label;
return option;
}
function setSettingsStatus(message, state = "") {
modelSettingsStatus.textContent = message;
modelSettingsStatus.dataset.state = state;
}
function updateSettingsControls() {
const available = !!HopeAuth.user && catalogAvailable && !settingsBusy;
modelSelect.disabled = !available;
thinkingLevelSelect.disabled = !available;
const model = modelCatalog.find(item => item.id === modelSelect.value);
const valid = model && model.thinking_levels.includes(thinkingLevelSelect.value);
const changed = savedModelSettings && (modelSelect.value !== savedModelSettings.model || thinkingLevelSelect.value !== savedModelSettings.thinking_level);
saveModelSettingsBtn.disabled = !available || !valid || !changed;
retryModelSettingsBtn.disabled = settingsBusy;
saveModelSettingsBtn.textContent = settingsBusy && modelSettingsForm.dataset.operation === "save" ? "Saving…" : "Save";
modelSettingsForm.setAttribute("aria-busy", String(settingsBusy));
}
function renderThinkingLevels(preferred) {
const model = modelCatalog.find(item => item.id === modelSelect.value);
const levels = model?.thinking_levels || [];
thinkingLevelSelect.replaceChildren();
for (const [value, label] of THINKING_LEVELS) {
if (levels.includes(value) || value === preferred) thinkingLevelSelect.append(settingsOption(value, label));
}
if (!thinkingLevelSelect.options.length) thinkingLevelSelect.append(settingsOption("", "No thinking levels available"));
if ([...thinkingLevelSelect.options].some(option => option.value === preferred)) thinkingLevelSelect.value = preferred;
updateSettingsControls();
}
function renderSavedModelSettings() {
modelSelect.replaceChildren();
for (const model of modelCatalog) modelSelect.append(settingsOption(model.id, model.name || model.id));
if (savedModelSettings && !modelCatalog.some(model => model.id === savedModelSettings.model)) {
modelSelect.prepend(settingsOption(savedModelSettings.model, `${savedModelSettings.model} (saved; unavailable)`));
}
if (!modelSelect.options.length) modelSelect.append(settingsOption("", "Model catalog unavailable"));
if (savedModelSettings) modelSelect.value = savedModelSettings.model;
renderThinkingLevels(savedModelSettings?.thinking_level);
}
function resetModelSettings(user) {
settingsGeneration++;
settingsAbort?.abort(); settingsAbort = null;
settingsAccount = user ? String(user.id) : null;
settingsBusy = false; catalogAvailable = false; modelCatalog = []; savedModelSettings = null;
modelSettingsForm.dataset.operation = "";
modelSelect.replaceChildren(settingsOption("", "Open settings to load models"));
thinkingLevelSelect.replaceChildren(settingsOption("", "Open settings to load thinking levels"));
thinkingLevelNote.textContent = THINKING_NOTE;
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Open settings to load your saved preferences.");
updateSettingsControls();
}
function readSavedSettings(data) {
if (!data?.settings || typeof data.settings.model !== "string" || !THINKING_LEVELS.some(([level]) => level === data.settings.thinking_level)) {
throw new Error("The server did not return valid saved settings.");
}
return { model: data.settings.model, thinking_level: data.settings.thinking_level };
}
async function loadModelSettings() {
const account = accountIdentity();
if (!account || settingsBusy) return;
if (settingsAccount !== account) resetModelSettings(HopeAuth.user);
const generation = ++settingsGeneration;
settingsAbort?.abort(); settingsAbort = new AbortController();
settingsBusy = true; modelSettingsForm.dataset.operation = "load";
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Loading your saved model settings…"); updateSettingsControls();
try {
const data = await HopeAuth.request("/api/settings/model", { signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data);
modelCatalog = Array.isArray(data.models) ? data.models.filter(model => typeof model?.id === "string" && Array.isArray(model.thinking_levels)).map(model => ({
id: model.id, name: typeof model.name === "string" ? model.name : model.id,
thinking_levels: model.thinking_levels.filter(level => THINKING_LEVELS.some(([value]) => value === level)),
})) : [];
catalogAvailable = data.catalog_available === true && modelCatalog.length > 0;
thinkingLevelNote.textContent = typeof data.thinking_note === "string" && data.thinking_note ? data.thinking_note : THINKING_NOTE;
renderSavedModelSettings();
retryModelSettingsBtn.hidden = catalogAvailable;
setSettingsStatus(catalogAvailable ? (data.message || "Your saved settings are loaded. Choose preferences, then Save.") : `${data.message || "Model catalog unavailable."} Your saved preferences are shown; retry to make changes.`, catalogAvailable ? "" : "error");
} catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return;
catalogAvailable = false;
retryModelSettingsBtn.hidden = false;
// Keep this account's last saved display when a refresh fails.
renderSavedModelSettings();
setSettingsStatus(`Could not load model settings: ${error.message} Retry to load your saved preferences.`, "error");
} finally {
if (settingsResponseIsCurrent(account, generation)) {
settingsBusy = false; settingsAbort = null; updateSettingsControls();
}
}
}
modelSelect.addEventListener("change", () => {
const previous = thinkingLevelSelect.value;
const model = modelCatalog.find(item => item.id === modelSelect.value);
renderThinkingLevels(model?.thinking_levels.includes(previous) ? previous : model?.thinking_levels[0]);
setSettingsStatus("Unsaved changes. Save to apply them to your account.");
});
thinkingLevelSelect.addEventListener("change", () => {
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
});
retryModelSettingsBtn.addEventListener("click", loadModelSettings);
modelSettingsForm.addEventListener("submit", async event => {
event.preventDefault(); updateSettingsControls();
if (saveModelSettingsBtn.disabled) return;
const account = accountIdentity(), generation = ++settingsGeneration;
const settings = { model: modelSelect.value, thinking_level: thinkingLevelSelect.value };
settingsAbort = new AbortController(); settingsBusy = true; modelSettingsForm.dataset.operation = "save";
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Saving your model settings…"); updateSettingsControls();
try {
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
} catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return;
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`Could not save settings: ${error.message} Your changes are not confirmed saved. Try Save again, or Retry to reload saved preferences.`, "error");
} finally {
if (settingsResponseIsCurrent(account, generation)) {
settingsBusy = false; settingsAbort = null; updateSettingsControls();
}
}
});
// ── Voice settings sheet ────────────────────────────────────────────────────
function currentVoiceId() {
const saved = localStorage.getItem("preferred_voice");
@@ -631,13 +788,16 @@ function selectVoice(voiceId) {
}
function openSheet() {
if (!HopeAuth.user) return;
renderVoiceList();
settingsSheet.hidden = false; settingsSheet.inert = false;
document.getElementById("authenticatedApp").inert = true;
sheetBackdrop.hidden = false;
settingsSheet.classList.add("open");
settingsBtn.setAttribute("aria-expanded", "true");
settingsSheet.querySelector(".voice-card")?.focus();
loadModelSettings();
// The catalog is loading, so the first stable enabled control is Close.
sheetFocusableControls()[0]?.focus();
}
function closeSheet() {
@@ -649,15 +809,22 @@ function closeSheet() {
settingsBtn.focus();
}
function sheetFocusableControls() {
return [...settingsSheet.querySelectorAll('button, select, input, textarea, a[href], [tabindex]')].filter(el =>
!el.disabled && el.tabIndex >= 0 && !el.closest('[hidden], [inert]') &&
el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"
);
}
settingsBtn.setAttribute("aria-expanded", "false");
settingsBtn.addEventListener("click", openSheet);
closeSheetBtn.addEventListener("click", closeSheet);
sheetBackdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && settingsSheet.classList.contains("open")) {
const focusable = [...settingsSheet.querySelectorAll('button:not(:disabled), [tabindex="0"]')];
const focusable = sheetFocusableControls();
const first = focusable[0], last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
if (!focusable.includes(document.activeElement)) { event.preventDefault(); first?.focus(); }
else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
if (event.key !== "Escape") return;
@@ -961,6 +1128,7 @@ function sendModeMessage(mode, targetRoom) {
HopeAuth.onChange((user, changed) => {
if (changed || !user || user.status !== "ready") stopConversation();
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
if (changed) {
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;