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;
+24 -4
View File
@@ -103,7 +103,7 @@
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
</button>
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
<button id="settingsBtn" class="btn btn-icon" aria-label="Hope settings" title="Hope settings">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
</button>
</div>
@@ -146,14 +146,34 @@
<div class="sheet-header">
<div>
<h2>Hope’s settings</h2>
<p>Your voice preference applies to this conversation. Reasoning is managed by your private assistant.</p>
<p>Your voice preference applies to this conversation.</p>
</div>
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList"></div>
<div class="sheet-content">
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
<h3 id="modelSettingsHeading">Model and thinking</h3>
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
<div>
<label for="modelSelect">Model</label>
<select id="modelSelect" name="model" required disabled aria-describedby="modelSettingsStatus"><option value="">Open settings to load models</option></select>
</div>
<div>
<label for="thinkingLevelSelect">Thinking level</label>
<select id="thinkingLevelSelect" name="thinking_level" required disabled aria-describedby="thinkingLevelNote"><option value="">Open settings to load thinking levels</option></select>
<p id="thinkingLevelNote" class="model-settings-note">Thinking requests may be limited or ignored by the selected model.</p>
</div>
<p id="modelSettingsStatus" class="model-settings-status" role="status" aria-live="polite" aria-atomic="true">Open settings to load your saved preferences.</p>
<div class="model-settings-actions">
<button id="saveModelSettings" type="submit" class="btn btn-primary" disabled>Save</button>
<button id="retryModelSettings" type="button" class="btn btn-secondary" hidden>Retry</button>
</div>
</form>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="Voice"></div>
</div>
</div>
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
+35 -3
View File
@@ -995,10 +995,42 @@ body[data-mode="text"] .mic-meter {
padding: 0.15rem 0.2rem 0 0;
}
/* Two pickers (voice + brain) share the settings sheet height. */
.settings-sheet .voice-list {
flex: 1 1 0;
/* One scroll area keeps both model controls and all voices reachable. */
.sheet-content {
min-height: 0;
overflow-y: auto;
padding: 0.15rem 0.2rem 0.25rem;
}
.settings-sheet .sheet-header { flex-shrink: 0; }
.settings-sheet .voice-list { overflow-y: visible; }
.model-settings {
min-width: 0;
padding-bottom: 1.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.model-settings h3 { font-size: 0.9rem; font-weight: 650; }
.model-settings select {
width: 100%;
max-width: 100%;
min-width: 0;
text-overflow: ellipsis;
font-size: 0.85rem;
}
.model-settings select:disabled { color: var(--ink-soft); cursor: not-allowed; }
.model-settings-note,
.model-settings-status {
color: var(--ink-soft);
font-size: 0.78rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
#thinkingLevelNote { margin-top: 0.5rem; }
.model-settings-status[data-state="error"] { color: var(--danger); }
.model-settings-status[data-state="success"] { color: var(--mint); }
.model-settings-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; }
.model-settings-actions .btn { min-width: 90px; }
.model-settings-actions .btn:disabled { display: inline-block; }
.voice-card {
min-height: 54px;