feat: switch LLM to OpenWebUI with live model selection
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:
snowspeeder committed 2026-08-29 12:09:03 -04:00
1 parent 024c42ffb1
commit 360e3276b0
14 files changed
+334 -76

No files matched your search

+86 -2
View File
@@ -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,
});
}