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;
|
||||
|
||||
+24
-4
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user