feat: switch LLM to OpenWebUI with live model selection
Rebuild & Deploy / rebuild (push) Successful in 3m18s
Rebuild & Deploy / rebuild (push) Successful in 3m18s
- Point the agent at OpenWebUI's OpenAI-compatible API (LLM_BASE_URL,
LLM_API_KEY, LLM_MODEL) with GEMMA_* env fallbacks
- Add list_available_models() fetching {base_url}/models, filtering
non-chat ids (embedding/reranker)
- Rename GemmaLLM -> HopeLLM with update_options(model=...) for runtime
model switching (mutates _opts.model) and instance-based compaction
- Data channel: agent publishes {type: models} after connect and on
get_models; set_model switches live and acks model_changed
- Web UI: Brain section in the settings sheet with a model picker that
persists the choice in localStorage
- Update tests, CI workflow env, and docs for the new endpoint
This commit is contained in:
1 parent
024c42ffb1
commit
360e3276b0
14 files changed
+334
-76
No files matched your search
+86
-2
@@ -27,6 +27,7 @@ const VOICES = [
|
||||
const HISTORY_KEY = "voice_history";
|
||||
const HISTORY_MAX = 50;
|
||||
const MODE_KEY = "hope_conversation_mode";
|
||||
const MODEL_KEY = "preferred_model";
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
let room = null;
|
||||
@@ -40,6 +41,10 @@ let wakeLock = null;
|
||||
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
|
||||
let connectionPromise = null;
|
||||
|
||||
// ── Model (brain) state — populated from the agent's data channel ──────────
|
||||
let availableModels = []; // ids published by the agent
|
||||
let selectedModel = localStorage.getItem(MODEL_KEY) || null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
@@ -63,6 +68,8 @@ const settingsSheet = document.getElementById("settingsSheet");
|
||||
const sheetBackdrop = document.getElementById("sheetBackdrop");
|
||||
const closeSheetBtn = document.getElementById("closeSheetBtn");
|
||||
const voiceListEl = document.getElementById("voiceList");
|
||||
const modelListEl = document.getElementById("modelList");
|
||||
const modelNoteEl = document.getElementById("modelNote");
|
||||
const clearBtn = document.getElementById("clearBtn");
|
||||
const tasksBtn = document.getElementById("tasksBtn");
|
||||
const tasksPanel = document.getElementById("tasksPanel");
|
||||
@@ -547,8 +554,20 @@ function handleDataPacket(payload, participant, topic) {
|
||||
}
|
||||
} else if (msg.type === "task_event") {
|
||||
handleTaskEvent(msg);
|
||||
} else if (msg.type === "models") {
|
||||
availableModels = Array.isArray(msg.models) ? msg.models : [];
|
||||
// Don't clobber a saved preference — the agent acks the applied
|
||||
// model separately via model_changed.
|
||||
if (!selectedModel && msg.selected) selectedModel = msg.selected;
|
||||
renderModelList();
|
||||
} else if (msg.type === "model_changed") {
|
||||
if (msg.model) {
|
||||
selectedModel = msg.model;
|
||||
localStorage.setItem(MODEL_KEY, selectedModel);
|
||||
renderModelList();
|
||||
}
|
||||
}
|
||||
// set_voice messages flow the other way; nothing to do client-side.
|
||||
// set_voice/set_model messages flow the other way; nothing else to do client-side.
|
||||
} catch (e) {
|
||||
console.warn("bad data packet", e);
|
||||
}
|
||||
@@ -598,8 +617,61 @@ function selectVoice(voiceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model (brain) settings ──────────────────────────────────────────────────
|
||||
function currentModelId() {
|
||||
if (availableModels.length === 0) return selectedModel;
|
||||
if (selectedModel && availableModels.includes(selectedModel)) return selectedModel;
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
function renderModelList() {
|
||||
modelListEl.innerHTML = "";
|
||||
if (!availableModels.length) {
|
||||
modelNoteEl.hidden = false;
|
||||
modelNoteEl.textContent = room
|
||||
? "Loading available models…"
|
||||
: "Start a conversation to load the model list.";
|
||||
return;
|
||||
}
|
||||
modelNoteEl.hidden = true;
|
||||
const active = currentModelId();
|
||||
for (const id of availableModels) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "voice-card" + (id === active ? " active" : "");
|
||||
card.dataset.model = id;
|
||||
card.setAttribute("role", "radio");
|
||||
card.setAttribute("aria-checked", String(id === active));
|
||||
card.tabIndex = 0;
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "voice-name";
|
||||
name.textContent = id;
|
||||
|
||||
card.appendChild(name);
|
||||
card.addEventListener("click", () => selectModel(id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectModel(id);
|
||||
}
|
||||
});
|
||||
modelListEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function selectModel(modelId) {
|
||||
selectedModel = modelId;
|
||||
localStorage.setItem(MODEL_KEY, modelId);
|
||||
renderModelList();
|
||||
if (room && agentParticipant) {
|
||||
sendModelMessage(modelId);
|
||||
addMessage("system", `Brain changed to ${modelId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
renderVoiceList();
|
||||
renderModelList();
|
||||
sheetBackdrop.hidden = false;
|
||||
settingsSheet.classList.add("open");
|
||||
settingsBtn.setAttribute("aria-expanded", "true");
|
||||
@@ -665,8 +737,9 @@ function markAgentFound(rm, participant) {
|
||||
statusEl.textContent = conversationMode === "text"
|
||||
? "Hope joined · Text chat ready"
|
||||
: "Hope joined · Preparing microphone…";
|
||||
// Synchronize voice and interaction mode when the agent joins.
|
||||
// Synchronize voice, brain and interaction mode when the agent joins.
|
||||
sendVoiceMessage(currentVoiceId(), rm);
|
||||
if (selectedModel) sendModelMessage(selectedModel, rm);
|
||||
sendModeMessage(conversationMode, rm);
|
||||
}
|
||||
|
||||
@@ -980,3 +1053,14 @@ function sendModeMessage(mode, targetRoom) {
|
||||
reliable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helper: send model selection to the agent via data channel ─────────────
|
||||
function sendModelMessage(model, targetRoom) {
|
||||
const r = targetRoom || room;
|
||||
if (!r || !r.localParticipant || !model) return;
|
||||
const payload = new TextEncoder().encode(JSON.stringify({ type: "set_model", model }));
|
||||
r.localParticipant.publishData(payload, {
|
||||
topic: "voice-control",
|
||||
reliable: true,
|
||||
});
|
||||
}
|
||||
+9
-5
@@ -75,7 +75,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="Voice and brain settings" title="Voice and brain 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>
|
||||
@@ -113,18 +113,22 @@
|
||||
</main>
|
||||
|
||||
<div class="sheet-backdrop" id="sheetBackdrop" hidden></div>
|
||||
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Voice settings">
|
||||
<div class="settings-sheet" id="settingsSheet" role="dialog" aria-modal="true" aria-label="Hope settings">
|
||||
<div class="sheet-handle" aria-hidden="true"></div>
|
||||
<div class="sheet-header">
|
||||
<div>
|
||||
<h2>Choose Hope’s voice</h2>
|
||||
<p>Changes apply to this conversation.</p>
|
||||
<h2>Hope’s settings</h2>
|
||||
<p>Voice and brain — changes apply to this conversation.</p>
|
||||
</div>
|
||||
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close voice settings">
|
||||
<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>
|
||||
<h3 class="sheet-section-title">Brain</h3>
|
||||
<div class="voice-list" id="modelList"></div>
|
||||
<p class="sheet-note" id="modelNote" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="tasks-panel" id="tasksPanel" aria-label="Background tasks">
|
||||
|
||||
@@ -1043,6 +1043,22 @@ body[data-mode="text"] .mic-meter {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.sheet-section-title {
|
||||
margin: 0.4rem 0 0.55rem;
|
||||
padding: 0 0.2rem;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sheet-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.voice-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1052,6 +1068,11 @@ 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;
|
||||
}
|
||||
|
||||
.voice-card {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user