Skip to content

Instantly share code, notes, and snippets.

@pfn
Created April 1, 2026 04:57
Show Gist options
  • Select an option

  • Save pfn/34a9c9cf7b2f9861c3e0afdac80a2814 to your computer and use it in GitHub Desktop.

Select an option

Save pfn/34a9c9cf7b2f9861c3e0afdac80a2814 to your computer and use it in GitHub Desktop.
OpenWebUI loaded models extension for VLLM & llama.cpp server
/**
* Loaded Models Extension for Open WebUI
*
* This extension adds a "Loaded Models" section to the sidebar that displays
* OpenAI-type models with status.value === 'loaded'. Users can click on these
* models to immediately start a new chat with that model selected.
*
* Features:
* - Fetches models from /api/models endpoint
* - Filters for owned_by === 'openai' AND status?.value === 'loaded'
* - Injects section after Workspace button (or fallback positions)
* - Continuous polling every 30s to keep model data fresh
* - Caches models in memory for instant sidebar population
* - Watches for sidebar DOM element creation for immediate detection
*
* Dependencies: None (vanilla JavaScript)
* Required Access: localStorage.token for API authentication
*/
// === CONFIG ===
const CONFIG = {
POLLING_INTERVAL: 30000, // 30 seconds
SECTION_ID: 'sidebar-loaded-models',
LOADING_ID: 'loaded-models-loading'
};
// === CACHE ===
// Hybrid cache: raw data array + pre-rendered DOM elements
let loadedModelsCache = [];
let domCache = new Map();
// === STATE ===
let state = {
sidebarObserver: null, // Watches for sidebar DOM changes
stateObserver: null, // Watches for data-state changes
pollTimeoutId: null,
isPolling: false
};
// === DATA LAYER ===
/**
* Fetch loaded models from the Open WebUI API
* @returns {Promise<Array>} Array of loaded OpenAI models
*/
async function fetchLoadedModels() {
try {
const token = localStorage.token;
if (!token) {
console.warn('[Loaded Models Extension] No authentication token found');
return [];
}
const response = await fetch(`${window.WEBUI_BASE_URL || ''}/api/models`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...(token && { 'authorization': `Bearer ${token}` })
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('[Loaded Models Extension] Failed to fetch models:', error);
return [];
}
const data = await response.json();
const allModels = data.data || [];
// Filter for OpenAI models with status.value === 'loaded'
const loadedModels = allModels.filter(model => {
// model missing status implies it is loaded, e.g. in vllm
// models that are presets will automatically appear in models tab, those are
// aliases around a base model, we can't tell if they're loaded or not
// well, we could tell, but that would involve diving down the rabbit hole of base models
return model.owned_by === 'openai' && !model.preset && ((
model.status &&
model.status.value === 'loaded') ||
(!model.status && (!model.info || !model.info.meta || !model.info.meta.hidden)));
});
console.log(`[Loaded Models Extension] Found ${loadedModels.length} loaded models`, loadedModels);
return loadedModels;
} catch (error) {
console.error('[Loaded Models Extension] Error fetching models:', error);
return [];
}
}
/**
* Check if the model ID set has changed
* @param {Set} newIds - New set of model IDs
* @param {Set} oldIds - Old set of model IDs
* @returns {boolean} True if IDs differ
*/
function hasCacheChanged(newIds, oldIds) {
if (newIds.size !== oldIds.size) return true;
for (const id of newIds) {
if (!oldIds.has(id)) return true;
}
return false;
}
/**
* Update the model cache with new data
* Rebuilds DOM cache only when model IDs change
* @param {Array} newModels - New array of model objects
* @returns {boolean} True if cache was updated
*/
function updateCache(newModels) {
const newIds = new Set(newModels.map(m => m.id));
const oldIds = new Set(loadedModelsCache.map(m => m.id));
if (!hasCacheChanged(newIds, oldIds)) {
return false;
}
// Only remove old DOM nodes if sidebar is NOT open
// If sidebar is open, keep the nodes in place to avoid visual flicker
const sidebarIsOpen = isSidebarOpen();
if (!sidebarIsOpen) {
// Safely remove old DOM nodes before clearing cache
domCache.forEach(el => {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
});
}
domCache.clear();
// Build new DOM cache
newModels.forEach(model => {
domCache.set(model.id, createModelItem(model));
});
loadedModelsCache = newModels;
return true;
}
// === DOM LAYER ===
/**
* Create a model item element matching PinnedModelItem.svelte styling
* Uses Tailwind classes from the original Open WebUI component
*
* @param {Object} model - The model object to render
* @returns {HTMLAnchorElement} Clickable model item element
*/
function createModelItem(model) {
const WEBUI_BASE_URL = window.WEBUI_BASE_URL || '';
// Create the anchor element with exact Tailwind classes from PinnedModelItem.svelte
const link = document.createElement('a');
link.className = 'grow flex items-center space-x-2.5 rounded-xl px-2.5 py-[7px] group-hover:bg-gray-100 dark:group-hover:bg-gray-900 transition';
link.href = `/?model=${encodeURIComponent(model.id)}`;
link.draggable = 'false';
// Create container for the model icon
const iconContainer = document.createElement('div');
iconContainer.className = 'self-center shrink-0';
// Create the model image element
const img = document.createElement('img');
img.src = `${WEBUI_BASE_URL}/api/v1/models/model/profile/image?id=${encodeURIComponent(model.id)}&lang=en-US`;
img.className = 'size-5 rounded-full -translate-x-[0.5px]';
img.alt = 'model icon';
img.draggable = 'false';
// Create container for the model name
const nameContainer = document.createElement('div');
nameContainer.className = 'flex self-center translate-y-[0.5px]';
// Create the model name text element
const nameText = document.createElement('div');
nameText.className = 'self-center text-sm font-primary line-clamp-1';
nameText.textContent = model.name || model.id;
// Assemble the elements
nameContainer.appendChild(nameText);
iconContainer.appendChild(img);
link.appendChild(iconContainer);
link.appendChild(nameContainer);
return link;
}
/**
* Create the "Loaded Models" section container element
* Matches Folder component styling from Sidebar.svelte
*
* @returns {Object} Object with section element and container div
*/
function createLoadedModelsSection() {
// Container div (matches Folder's inner structure)
const container = document.createElement('div');
container.className = 'px-2 py-2 mt-0.5 pb-1.5';
container.id = `${CONFIG.SECTION_ID}-container`;
// Loading indicator (matches PinnedModelList loading style)
const loadingEl = document.createElement('div');
loadingEl.className = 'px-2 w-full flex py-2 text-xs items-center gap-2';
loadingEl.id = CONFIG.LOADING_ID;
loadingEl.textContent = "Loaded Models";
container.appendChild(loadingEl);
// Section wrapper (matches Folder component structure)
const section = document.createElement('div');
section.className = 'flex flex-col space-y-1 rounded-xl';
section.id = CONFIG.SECTION_ID;
return { section, container };
}
/**
* Render the loaded models from cache into the sidebar
* Only called when sidebar is confirmed open and cache has data
*/
function renderFromCache() {
if (!isSidebarOpen()) {
return;
}
// Don't render if no data
if (loadedModelsCache.length === 0) {
return;
}
// Remove existing section
const existingSection = document.getElementById(CONFIG.SECTION_ID);
if (existingSection) {
existingSection.remove();
}
const { section, container } = createLoadedModelsSection();
// Append cached DOM elements
domCache.forEach(el => container.appendChild(el));
// Add section header
const headerDiv = document.createElement('div');
headerDiv.className = 'mb-1';
headerDiv.appendChild(container);
section.appendChild(headerDiv);
// Find injection point using fallback strategy
const targetElement = findInjectionPoint();
if (targetElement) {
// Insert after the target element - find its parent wrapper
let parentWrapper = targetElement.parentNode;
// Try to find a reasonable parent container for injection
if (parentWrapper && parentWrapper.tagName !== 'NAV') {
parentWrapper.parentNode.insertBefore(section, parentWrapper.nextSibling);
} else {
// Fallback: append directly to sidebar content
const navContent = document.querySelector('nav > div > div.overflow-y-auto');
if (navContent) {
navContent.appendChild(section);
}
}
console.log('[Loaded Models Extension] Section injected successfully');
} else {
// Try to find the main sidebar content container and append there
const navContent = document.querySelector('nav > div > div.overflow-y-auto');
if (navContent) {
navContent.appendChild(section);
console.log('[Loaded Models Extension] Section appended to sidebar as fallback');
} else {
console.warn('[Loaded Models Extension] Could not find injection point for Loaded Models section');
}
}
}
/**
* Find the optimal injection point in the sidebar using fallback strategy
* Priority order: Workspace → Models folder → Search button
*
* @returns {HTMLElement|null} Target element to inject after, or null
*/
function findInjectionPoint() {
// Priority 1: After Workspace button (preferred)
const workspaceButton = document.getElementById('sidebar-workspace-button');
if (workspaceButton && document.contains(workspaceButton)) {
console.log('[Loaded Models Extension] Found Workspace button as injection point');
return workspaceButton;
}
// Priority 2: Before Models folder (if Workspace doesn't exist)
const modelsFolder = document.getElementById('sidebar-models');
if (modelsFolder && document.contains(modelsFolder)) {
console.log('[Loaded Models Extension] Found Models folder as injection point');
return modelsFolder;
}
// Priority 3: After Search button (guaranteed to exist)
const searchButton = document.getElementById('sidebar-search-button');
if (searchButton && document.contains(searchButton)) {
console.log('[Loaded Models Extension] Found Search button as fallback injection point');
return searchButton;
}
// Priority 4: After New Chat button (last resort)
const newChatButton = document.getElementById('sidebar-new-chat-button');
if (newChatButton && document.contains(newChatButton)) {
console.log('[Loaded Models Extension] Found New Chat button as last resort injection point');
return newChatButton;
}
console.warn('[Loaded Models Extension] No suitable injection point found');
return null;
}
/**
* Check if the sidebar is currently open/visible by checking Svelte's data-state attribute
* @returns {boolean} True if sidebar is visible
*/
function isSidebarOpen() {
// Primary check: Look for the nav element with data-state="true"
const navElement = document.querySelector('nav[data-state="true"]');
if (navElement) {
return true;
}
// Secondary check: Look for sidebar div that is visible in DOM
const sidebarElement = document.getElementById('sidebar');
if (sidebarElement && document.contains(sidebarElement)) {
// Check data-state attribute first
const dataState = sidebarElement.getAttribute('data-state');
if (dataState === 'true') {
return true;
}
// Check if it has visibility classes that indicate it's shown
const classList = sidebarElement.className || '';
if (classList.includes('visible') && !classList.includes('hidden')) {
return true;
}
// Fallback: Check computed styles and actual rendering
const style = window.getComputedStyle(sidebarElement);
if (style.display !== 'none' && style.visibility !== 'hidden') {
const rect = sidebarElement.getBoundingClientRect();
if (rect.width > 100 && rect.height > 100) { // Minimum reasonable size
return true;
}
}
}
return false;
}
// === POLLING LAYER ===
/**
* Run a single poll cycle
* Fetches models and updates cache
*/
async function runPoll() {
if (state.isPolling) {
console.log('[Loaded Models Extension] Already polling, skipping');
return;
}
state.isPolling = true;
try {
const models = await fetchLoadedModels();
const cacheChanged = updateCache(models);
// If sidebar is open and cache changed, re-render to update the items
if (cacheChanged && isSidebarOpen()) {
console.log('[Loaded Models Extension] Cache changed, sidebar open, re-rendering');
renderFromCache();
} else if (isSidebarOpen() && loadedModelsCache.length > 0) {
// Fallback: ensure section exists if sidebar is open
const existingSection = document.getElementById(CONFIG.SECTION_ID);
if (!existingSection) {
console.log('[Loaded Models Extension] Sidebar open but section missing, rendering');
renderFromCache();
}
}
} catch (error) {
console.error('[Loaded Models Extension] Poll error:', error);
// Keep existing data intact on error
} finally {
state.isPolling = false;
}
// Schedule next poll 30s after this one completes (sequential polling)
scheduleNextPoll(CONFIG.POLLING_INTERVAL);
}
/**
* Schedule the next poll cycle
* @param {number} delayMs - Delay in milliseconds
*/
function scheduleNextPoll(delayMs) {
if (state.pollTimeoutId) {
clearTimeout(state.pollTimeoutId);
}
state.pollTimeoutId = setTimeout(() => {
runPoll();
}, delayMs);
}
/**
* Start the polling cycle
* Begins with immediate fetch, then schedules subsequent polls
*/
function startPolling() {
// Clear any existing timeout
if (state.pollTimeoutId) {
clearTimeout(state.pollTimeoutId);
}
// Immediate first poll, then schedule subsequent ones
runPoll();
}
// === SIDEBAR OBSERVER LAYER ===
/**
* Setup observer to watch for sidebar element being added/removed from DOM
* Attached to div.app for immediate detection of sidebar creation
*/
function setupSidebarElementObserver() {
// Clean up existing observer
if (state.sidebarObserver) {
state.sidebarObserver.disconnect();
state.sidebarObserver = null;
}
// Find the app container
const appContainer = document.querySelector('div.app') || document.body;
console.log('[Loaded Models Extension] Setting up sidebar element observer on', appContainer.tagName);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if sidebar was added
if (mutation.addedNodes.length > 0) {
for (let node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this is the sidebar or contains it
const sidebar = node.id === 'sidebar' ? node : node.querySelector('#sidebar');
if (sidebar) {
console.log('[Loaded Models Extension] Sidebar element detected in DOM');
// Check if it's already open
if (isSidebarOpen()) {
console.log('[Loaded Models Extension] Sidebar is open, rendering from cache');
renderFromCache();
}
// Set up state observer on the nav/sidebar
setupSidebarStateObserver();
return;
}
}
}
}
// Check if sidebar was removed
if (mutation.removedNodes.length > 0) {
for (let node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const sidebar = node.id === 'sidebar' ? node : node.querySelector('#sidebar');
if (sidebar) {
console.log('[Loaded Models Extension] Sidebar element removed from DOM');
// Clean up state observer
if (state.stateObserver) {
state.stateObserver.disconnect();
state.stateObserver = null;
}
return;
}
}
}
}
}
});
});
// Observe for child elements being added/removed
observer.observe(appContainer, {
childList: true,
subtree: true
});
state.sidebarObserver = observer;
console.log('[Loaded Models Extension] Sidebar element observer attached');
}
/**
* Setup observer to watch for sidebar open/close state changes
* Attached to nav and #sidebar elements
*/
function setupSidebarStateObserver() {
// Clean up existing observer
if (state.stateObserver) {
state.stateObserver.disconnect();
state.stateObserver = null;
}
const navElement = document.querySelector('nav');
const sidebarElement = document.getElementById('sidebar');
if (!navElement && !sidebarElement) {
console.log('[Loaded Models Extension] No nav/sidebar found for state observer');
return;
}
console.log('[Loaded Models Extension] Setting up sidebar state observer');
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
mutation.attributeName === 'data-state') {
const nowOpen = isSidebarOpen();
if (nowOpen) {
console.log('[Loaded Models Extension] Sidebar state changed to OPEN, rendering from cache');
renderFromCache();
} else {
console.log('[Loaded Models Extension] Sidebar state changed to CLOSED');
}
}
});
});
// Observe nav element for state changes
if (navElement) {
observer.observe(navElement, {
attributes: true,
attributeFilter: ['data-state']
});
console.log('[Loaded Models Extension] State observer attached to nav');
}
// Observe sidebar element for state changes
if (sidebarElement && sidebarElement !== navElement) {
observer.observe(sidebarElement, {
attributes: true,
attributeFilter: ['data-state']
});
console.log('[Loaded Models Extension] State observer attached to #sidebar');
}
state.stateObserver = observer;
}
/**
* Initialize sidebar observers - both element detection and state changes
*/
function initializeSidebarObservers() {
// First, check if sidebar already exists
const existingSidebar = document.getElementById('sidebar');
if (existingSidebar) {
console.log('[Loaded Models Extension] Sidebar already exists on init');
if (isSidebarOpen()) {
console.log('[Loaded Models Extension] Sidebar is open, rendering from cache');
renderFromCache();
}
setupSidebarStateObserver();
}
// Set up observer for when sidebar is added
setupSidebarElementObserver();
}
// === LIFECYCLE ===
/**
* Initialize the Loaded Models Extension
* Main entry point called by loader.js
*/
function initLoadedModelsExtension() {
console.log('[Loaded Models Extension] Initializing...');
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDomReady);
} else {
onDomReady();
}
// Setup cleanup on page unload
window.addEventListener('beforeunload', cleanup);
console.log('[Loaded Models Extension] Initialization complete');
}
/**
* Handle DOM ready event - main initialization logic
*/
function onDomReady() {
console.log('[Loaded Models Extension] DOM is ready, starting extension');
// Initialize sidebar observers (watches for sidebar creation and state changes)
initializeSidebarObservers();
// Start polling for model updates
startPolling();
}
/**
* Clean up extension resources (call on page unload or reinitialization)
*/
function cleanup() {
if (state.pollTimeoutId) {
clearTimeout(state.pollTimeoutId);
}
if (state.sidebarObserver) {
state.sidebarObserver.disconnect();
state.sidebarObserver = null;
}
if (state.stateObserver) {
state.stateObserver.disconnect();
state.stateObserver = null;
}
// Clear caches
loadedModelsCache = [];
domCache.clear();
// Remove the section from DOM
const existingSection = document.getElementById(CONFIG.SECTION_ID);
if (existingSection) {
existingSection.remove();
}
console.log('[Loaded Models Extension] Cleanup completed');
}
// Export initialization function for loader.js
export { initLoadedModelsExtension };
export default initLoadedModelsExtension;
/**
* Loaded Models Extension Loader for Open WebUI
*
* This loader injects the "Loaded Models" sidebar extension into Open WebUI.
* It supports ES6 modules and provides fallback paths for different deployment scenarios.
*/
// Check if browser supports ES6 modules
const supportsModules = 'noModule' in HTMLScriptElement.prototype;
if (supportsModules) {
console.log('[Loaded Models Extension] Loading extension with ES6 modules...');
const loadApp = () => {
// Version for cache busting
const VERSION = '1.0.0';
const v = `?v=${VERSION}`;
// Determine base URL from the current script's location
let baseUrl = '';
try {
if (document.currentScript) {
baseUrl = document.currentScript.src.substring(0, document.currentScript.src.lastIndexOf('/') + 1);
} else {
// Fallback: search for loader.js in script tags
const scripts = document.querySelectorAll('script');
for (let script of scripts) {
if (script.src && script.src.includes('loader.js')) {
baseUrl = script.src.substring(0, script.src.lastIndexOf('/') + 1);
break;
}
}
}
} catch (e) {
console.warn('[Loaded Models Extension] Could not determine base URL:', e);
}
// Helper to load a module with error handling
const tryLoad = (path, label) => {
let fullPath = path;
if (baseUrl && path.startsWith('./')) {
fullPath = new URL(path.substring(2), baseUrl).href;
}
console.log(`[Loaded Models Extension] Trying to load from ${label}: ${fullPath}`);
return import(fullPath + v).then(module => {
console.log(`[Loaded Models Extension] ✅ Successfully loaded from ${label}`);
return module;
});
};
// Standard Path: js/loaded-models-extension/app.js (relative to loader.js)
return tryLoad('./loaded-models-extension-app.js', 'Standard Path')
.catch(() => {
console.warn('[Loaded Models Extension] Standard path failed, trying fallback...');
// Fallback: app.js in same directory as loader.js
return tryLoad('./app.js', 'Fallback Path (same dir)');
})
.catch((err) => {
console.error('[Loaded Models Extension] ❌ All load attempts failed:', err);
throw err;
});
};
loadApp()
.then((appModule) => {
console.log('[Loaded Models Extension] App module loaded successfully');
// Initialize the extension
if (appModule.initLoadedModelsExtension) {
appModule.initLoadedModelsExtension();
} else if (appModule.default && typeof appModule.default === 'function') {
appModule.default();
} else {
console.warn('[Loaded Models Extension] No initialization function found');
}
})
.catch((error) => {
console.error('[Loaded Models Extension] Failed to load extension:', error);
});
} else {
// Legacy browsers: Log error (ES5 is not supported)
console.error('[Loaded Models Extension] ES6 modules required. Browser does not support this feature.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment