feat: save account model and thinking settings for Hermes
Rebuild & Deploy / rebuild (push) Successful in 1m9s
Rebuild & Deploy / rebuild (push) Successful in 1m9s
This commit is contained in:
1 parent
a45569f8c1
commit
6cb4427210
13 files changed
+798
-16
No files matched your search
+171
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user