Files
ollama-utils/static/script.js
2026-01-21 12:31:30 +01:00

1212 lines
48 KiB
JavaScript

// Ollama Utils Web Interface - JavaScript
// Global state
let currentModels = [];
let statusUpdateInterval = null;
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
initializeTabs();
initializeModals();
initializeEventListeners();
// Start real-time updates
updateStatus();
statusUpdateInterval = setInterval(updateStatus, 2000); // Update every 2 seconds
// Load initial data
loadModels();
});
// ===== TAB MANAGEMENT =====
function initializeTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.dataset.tab;
switchTab(tabName);
});
});
}
function switchTab(tabName) {
// Update buttons
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Update content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `tab-${tabName}`);
});
// Load tab-specific data
if (tabName === 'performance') {
populatePerformanceSelects();
}
}
// ===== MODAL MANAGEMENT =====
function initializeModals() {
// Install modal
document.getElementById('modal-close').addEventListener('click', () => {
closeModal('install-modal');
});
// Model details modal
document.getElementById('model-details-close').addEventListener('click', () => {
closeModal('model-details-modal');
});
// Modelfile editor modal
document.getElementById('modelfile-editor-close').addEventListener('click', () => {
closeModal('modelfile-editor-modal');
});
// Close modals on background click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal(modal.id);
}
});
});
}
function openModal(modalId) {
document.getElementById(modalId).classList.add('active');
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
// ===== EVENT LISTENERS =====
function initializeEventListeners() {
// Model management
document.getElementById('btn-install').addEventListener('click', openInstallModal);
document.getElementById('btn-refresh-models').addEventListener('click', loadModels);
document.getElementById('btn-collapse-all').addEventListener('click', toggleAllFamilies);
// Install options
document.querySelectorAll('.install-option').forEach(option => {
option.addEventListener('click', () => {
switchInstallOption(option.dataset.option);
});
});
// Ollama install
document.getElementById('btn-install-ollama').addEventListener('click', installOllamaModel);
// HuggingFace install
document.getElementById('btn-fetch-hf').addEventListener('click', fetchHuggingFaceInfo);
document.getElementById('btn-create-hf-model').addEventListener('click', createHuggingFaceModel);
// Modelfile editor
document.getElementById('btn-save-modelfile').addEventListener('click', saveModelfile);
document.getElementById('btn-cancel-modelfile').addEventListener('click', () => {
closeModal('modelfile-editor-modal');
});
// Performance tools
document.getElementById('btn-run-vram-test').addEventListener('click', runVramTest);
document.getElementById('btn-run-optimizer').addEventListener('click', runOptimizer);
document.getElementById('btn-stop-optimizer').addEventListener('click', stopOptimizer);
}
function switchInstallOption(option) {
// Update buttons
document.querySelectorAll('.install-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.option === option);
});
// Update forms
document.querySelectorAll('.install-form').forEach(form => {
form.classList.toggle('active', form.id === `install-${option}`);
});
}
// ===== STATUS UPDATES =====
async function updateStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
// Update CPU
document.getElementById('cpu-load').textContent = data.cpu_load.toFixed(2);
// Update RAM
const ramPct = data.ram_used_pct;
document.getElementById('ram-usage').textContent =
`${data.ram_used_mb} / ${data.ram_total_mb} MB (${ramPct}%)`;
document.getElementById('ram-progress').style.width = `${ramPct}%`;
// Update VRAM
if (data.vram_used_mb !== null && data.vram_total_mb !== null) {
const vramPct = data.vram_used_pct;
document.getElementById('vram-usage').textContent =
`${data.vram_used_mb} / ${data.vram_total_mb} MB (${vramPct}%)`;
document.getElementById('vram-progress').style.width = `${vramPct}%`;
} else {
document.getElementById('vram-usage').textContent = 'N/A';
document.getElementById('vram-progress').style.width = '0%';
}
// Update GPU load
if (data.gpu_load !== null) {
document.getElementById('gpu-load').textContent = `${data.gpu_load}%`;
} else {
document.getElementById('gpu-load').textContent = 'N/A';
}
// Update running models
updateRunningModels(data.running_models);
} catch (error) {
console.error('Error updating status:', error);
}
}
function updateRunningModels(models) {
const container = document.getElementById('running-models-list');
if (!models || models.length === 0) {
container.innerHTML = '<div class="no-models">No models loaded</div>';
return;
}
container.innerHTML = models.map(model => {
const gpuLayers = model.gpu_layers || 0;
const totalLayers = model.total_layers || 0;
const cpuLayers = totalLayers > 0 ? totalLayers - gpuLayers : 0;
return `
<div class="running-model">
<div class="running-model-name">${escapeHtml(model.name)}</div>
<div class="running-model-stats">
VRAM: ${model.vram_gb.toFixed(2)} GB
${model.offload_pct > 0 ? ` | CPU: ${model.offload_pct.toFixed(1)}%` : ''}
</div>
<div class="running-model-layers">
GPU: ${gpuLayers} layers | CPU: ${cpuLayers} layers
</div>
</div>
`;
}).join('');
}
// ===== MODEL MANAGEMENT =====
async function loadModels() {
const container = document.getElementById('models-list');
// Save active installation job IDs before refreshing
const activeInstalls = new Map();
document.querySelectorAll('.model-card[data-model-name]').forEach(card => {
const modelName = card.dataset.modelName;
const progressDiv = card.querySelector('.install-progress');
if (progressDiv && progressDiv.style.display !== 'none') {
const jobIdMatch = progressDiv.dataset.jobId;
if (jobIdMatch) {
activeInstalls.set(modelName, jobIdMatch);
}
}
});
container.innerHTML = '<div class="loading">Loading models...</div>';
try {
const response = await fetch('/api/models');
const data = await response.json();
if (data.error) {
container.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
return;
}
currentModels = data.models;
renderModels(data.models);
// Check for active jobs from server (for page reloads)
const activeJobsResponse = await fetch('/api/install/active');
const activeJobs = await activeJobsResponse.json();
// Combine saved jobs with server jobs
for (const [jobId, jobInfo] of Object.entries(activeJobs)) {
// Find model name from modelfile path
const model = data.models.find(m => m.modelfile_path === jobInfo.modelfile_path);
if (model && !activeInstalls.has(model.name)) {
activeInstalls.set(model.name, jobId);
}
}
// Restart polling for active installations
activeInstalls.forEach((jobId, modelName) => {
showInstallProgressInCard(jobId, modelName);
});
} catch (error) {
container.innerHTML = `<div class="error-message">Error loading models: ${escapeHtml(error.message)}</div>`;
}
}
function renderModels(models) {
const container = document.getElementById('models-list');
if (models.length === 0) {
container.innerHTML = '<div class="loading">No models installed</div>';
return;
}
// Group models by family (everything before the colon)
const grouped = {};
models.forEach(model => {
const family = model.name.includes(':') ? model.name.split(':')[0] : 'other';
if (!grouped[family]) {
grouped[family] = [];
}
grouped[family].push(model);
});
// Sort families alphabetically
const sortedFamilies = Object.keys(grouped).sort();
// Render grouped models
container.innerHTML = sortedFamilies.map(family => {
const familyModels = grouped[family];
const installedCount = familyModels.filter(m => m.installed).length;
const notInstalledCount = familyModels.length - installedCount;
const familyId = `family-${family.replace(/[^a-zA-Z0-9]/g, '-')}`;
return `
<div class="model-family-group">
<div class="model-family-header" onclick="toggleModelFamily('${familyId}')">
<div class="model-family-title">
<span class="model-family-icon">▼</span>
<span class="model-family-name">${escapeHtml(family)}</span>
<span class="model-family-count">(<span class="count-installed">${installedCount} installed</span> / <span class="count-not-installed">${notInstalledCount} not installed</span>)</span>
</div>
</div>
<div class="model-family-content" id="${familyId}">
${familyModels.map(model => renderModelCard(model)).join('')}
</div>
</div>
`;
}).join('');
}
function renderModelCard(model) {
return `
<div class="model-card ${!model.installed ? 'model-not-installed' : ''}" data-model-name="${escapeHtml(model.name)}">
<div class="model-header">
<div class="model-name">
${escapeHtml(model.name)}
${!model.installed ? '<span class="badge badge-warning">Not Installed</span>' : ''}
</div>
<div class="model-actions">
${!model.installed && model.has_modelfile ? `
<button class="btn btn-small btn-primary install-btn" onclick="installFromModelfile('${escapeHtml(model.modelfile_path)}', '${escapeHtml(model.name)}')">
Install
</button>
<button class="btn btn-small btn-danger cancel-install-btn" style="display: none;">Cancel</button>
<button class="btn btn-small btn-secondary" onclick="editModelfile('${escapeHtml(model.name)}')">
Edit Modelfile
</button>
` : ''}
${model.installed && model.has_modelfile ? `
<button class="btn btn-small btn-secondary" onclick="editModelfile('${escapeHtml(model.name)}')">
Edit Modelfile
</button>
` : ''}
${model.installed ? `
<button class="btn btn-small btn-danger" onclick="deleteModel('${escapeHtml(model.name)}')">
Delete
</button>
` : ''}
</div>
</div>
<!-- Installation progress container -->
<div class="install-progress" style="display: none;">
<div class="install-progress-header">
<span class="install-status-icon">⏳</span>
<span class="install-status-text">Installing...</span>
</div>
<div class="install-progress-bar-container">
<div class="install-progress-bar">
<div class="install-progress-bar-fill" style="width: 0%;"></div>
</div>
<span class="install-progress-percent">0%</span>
</div>
</div>
<div class="model-info">
<div class="model-info-item">
<div class="model-info-label">Size</div>
<div class="model-info-value">${escapeHtml(model.size)}</div>
</div>
<div class="model-info-item">
<div class="model-info-label">Parameters</div>
<div class="model-info-value">${escapeHtml(model.params)}</div>
</div>
<div class="model-info-item">
<div class="model-info-label">Quantization</div>
<div class="model-info-value">${escapeHtml(model.quant)}</div>
</div>
<div class="model-info-item">
<div class="model-info-label">Architecture</div>
<div class="model-info-value">${escapeHtml(model.family)}</div>
</div>
${model.installed ? `
<div class="model-info-item">
<div class="model-info-label">Context</div>
<div class="model-info-value">${model.context_used > 0 ? `${model.context_used}/${model.max_context}` : model.max_context}</div>
</div>
<div class="model-info-item">
<div class="model-info-label">Est. VRAM</div>
<div class="model-info-value">${escapeHtml(model.vram_estimate)}</div>
</div>
` : ''}
${!model.installed && model.hf_upstream ? `
<div class="model-info-item" style="grid-column: 1 / -1;">
<div class="model-info-label">Source</div>
<div class="model-info-value"><a href="${escapeHtml(model.hf_upstream)}" target="_blank" style="color: var(--accent-primary);">${escapeHtml(model.hf_upstream)}</a></div>
</div>
` : ''}
</div>
<div class="model-badges">
${model.has_modelfile ? '<span class="badge badge-modelfile">Has Modelfile</span>' : '<span class="badge badge-ollama">Ollama Library</span>'}
${model.capabilities.map(cap => `<span class="badge badge-capability">${escapeHtml(cap)}</span>`).join('')}
</div>
</div>
`;
}
function toggleModelFamily(familyId) {
const content = document.getElementById(familyId);
const header = content.previousElementSibling;
const icon = header.querySelector('.model-family-icon');
if (content.classList.contains('collapsed')) {
content.classList.remove('collapsed');
icon.textContent = '▼';
} else {
content.classList.add('collapsed');
icon.textContent = '▶';
}
}
function toggleAllFamilies() {
const allContents = document.querySelectorAll('.model-family-content');
const button = document.getElementById('btn-collapse-all');
// Check if any are expanded
const anyExpanded = Array.from(allContents).some(content => !content.classList.contains('collapsed'));
allContents.forEach(content => {
const header = content.previousElementSibling;
const icon = header.querySelector('.model-family-icon');
if (anyExpanded) {
content.classList.add('collapsed');
icon.textContent = '▶';
} else {
content.classList.remove('collapsed');
icon.textContent = '▼';
}
});
// Update button text
button.textContent = anyExpanded ? 'Expand All' : 'Collapse All';
}
async function deleteModel(modelName) {
if (!confirm(`Are you sure you want to delete "${modelName}"?`)) {
return;
}
try {
const response = await fetch(`/api/model/${encodeURIComponent(modelName)}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
loadModels(); // Reload the list
} else {
alert(`Error: ${data.error}`);
}
} catch (error) {
alert(`Error deleting model: ${error.message}`);
}
}
async function installFromModelfile(modelfilePath, modelName) {
try {
// Start installation
const response = await fetch('/api/install/modelfile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
modelfile_path: modelfilePath
})
});
const data = await response.json();
if (data.success && data.job_id) {
// Show progress in the model card
showInstallProgressInCard(data.job_id, modelName);
} else {
alert(`Error: ${data.error}`);
}
} catch (error) {
alert(`Error starting installation: ${error.message}`);
}
}
async function showInstallProgressInCard(jobId, modelName) {
// Find the model card
const modelCard = document.querySelector(`.model-card[data-model-name="${modelName}"]`);
if (!modelCard) return;
const progressContainer = modelCard.querySelector('.install-progress');
const progressBarFill = modelCard.querySelector('.install-progress-bar-fill');
const progressPercent = modelCard.querySelector('.install-progress-percent');
const statusIcon = modelCard.querySelector('.install-status-icon');
const statusText = modelCard.querySelector('.install-status-text');
const cancelBtn = modelCard.querySelector('.cancel-install-btn');
const installBtn = modelCard.querySelector('.install-btn');
// Store job ID in both cancel button and progress container
cancelBtn.dataset.jobId = jobId;
progressContainer.dataset.jobId = jobId;
// If already polling this job, don't start a new interval
if (progressContainer.dataset.polling === 'true') {
return;
}
progressContainer.dataset.polling = 'true';
// Hide install button, show cancel button and progress
if (installBtn) installBtn.style.display = 'none';
cancelBtn.style.display = 'inline-block';
progressContainer.style.display = 'block';
// Set up cancel button handler
cancelBtn.onclick = async function() {
const btnJobId = this.dataset.jobId;
if (confirm(`Cancel installation?`)) {
try {
await fetch(`/api/install/cancel/${btnJobId}`, { method: 'POST' });
this.disabled = true;
} catch (error) {
alert(`Error cancelling: ${error.message}`);
}
}
};
// Poll for progress
const pollInterval = setInterval(async () => {
try {
const statusResponse = await fetch(`/api/install/status/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.progress) {
// Extract percentage from progress text (e.g., "Progress: 45.2% (1024/2048 MB)")
const percentMatch = statusData.progress.match(/([0-9.]+)%/);
if (percentMatch) {
const percent = parseFloat(percentMatch[1]);
progressBarFill.style.width = `${percent}%`;
progressPercent.textContent = `${percent.toFixed(1)}%`;
}
}
if (statusData.status === 'completed') {
clearInterval(pollInterval);
progressContainer.dataset.polling = 'false';
statusIcon.textContent = '✓';
statusText.textContent = 'Installation completed!';
progressBarFill.style.width = '100%';
progressPercent.textContent = '100%';
cancelBtn.style.display = 'none';
// Reload models after short delay
setTimeout(() => loadModels(), 2000);
} else if (statusData.status === 'failed') {
clearInterval(pollInterval);
progressContainer.dataset.polling = 'false';
statusIcon.textContent = '✗';
statusText.textContent = 'Installation failed';
cancelBtn.style.display = 'none';
// Show install button again after delay
setTimeout(() => {
progressContainer.style.display = 'none';
if (installBtn) installBtn.style.display = 'inline-block';
}, 5000);
} else if (statusData.status === 'cancelled') {
clearInterval(pollInterval);
progressContainer.dataset.polling = 'false';
statusIcon.textContent = '⊘';
statusText.textContent = 'Installation cancelled';
cancelBtn.style.display = 'none';
// Show install button again after delay
setTimeout(() => {
progressContainer.style.display = 'none';
if (installBtn) installBtn.style.display = 'inline-block';
}, 3000);
}
} catch (error) {
clearInterval(pollInterval);
statusIcon.textContent = '✗';
statusText.textContent = 'Error';
cancelBtn.style.display = 'none';
}
}, 1000); // Poll every second
}
async function editModelfile(modelName) {
try {
const response = await fetch(`/api/modelfile/${encodeURIComponent(modelName)}`);
const data = await response.json();
if (data.error) {
alert(`Error: ${data.error}`);
return;
}
// Open editor modal
document.getElementById('modelfile-editor-title').textContent = `Edit Modelfile - ${modelName}`;
const editorContent = document.getElementById('modelfile-editor-content');
editorContent.value = data.content;
editorContent.dataset.modelName = modelName;
// Store original content to detect changes
editorContent.dataset.originalContent = data.content;
openModal('modelfile-editor-modal');
} catch (error) {
alert(`Error loading Modelfile: ${error.message}`);
}
}
async function saveModelfile() {
const editorContent = document.getElementById('modelfile-editor-content');
const content = editorContent.value;
const modelName = editorContent.dataset.modelName;
const originalContent = editorContent.dataset.originalContent;
const outputBox = document.getElementById('modelfile-save-output');
// Check if content has changed
const hasChanges = content !== originalContent;
try {
const response = await fetch(`/api/modelfile/${encodeURIComponent(modelName)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
content,
recreate_model: hasChanges
})
});
const data = await response.json();
if (data.success) {
if (data.recreating && data.job_id) {
outputBox.innerHTML = '<div class="success-message">Modelfile saved! Recreating model...</div>';
// Close modal and show progress in the model card
setTimeout(async () => {
closeModal('modelfile-editor-modal');
outputBox.innerHTML = '';
// Wait for models to load before showing progress
await loadModels();
// Start showing progress in the model card
showInstallProgressInCard(data.job_id, modelName);
}, 1000);
} else {
outputBox.innerHTML = '<div class="success-message">Modelfile saved successfully!</div>';
setTimeout(() => {
closeModal('modelfile-editor-modal');
outputBox.innerHTML = '';
}, 2000);
}
} else {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
outputBox.innerHTML = `<div class="error-message">Error saving Modelfile: ${escapeHtml(error.message)}</div>`;
}
}
// ===== INSTALL MODELS =====
function openInstallModal() {
// Reset the form
document.getElementById('ollama-model-name').value = '';
document.getElementById('hf-url').value = '';
document.getElementById('ollama-install-output').innerHTML = '';
document.getElementById('hf-install-output').innerHTML = '';
document.getElementById('hf-modelfile-section').style.display = 'none';
openModal('install-modal');
}
async function installOllamaModel() {
const modelName = document.getElementById('ollama-model-name').value.trim();
const outputBox = document.getElementById('ollama-install-output');
if (!modelName) {
outputBox.innerHTML = '<div class="error-message">Please enter a model name</div>';
return;
}
outputBox.innerHTML = '<div class="loading">Pulling model... This may take a while.</div>';
try {
const response = await fetch('/api/install/ollama', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ model_name: modelName })
});
const data = await response.json();
if (data.success) {
outputBox.innerHTML = '<div class="success-message">Model installed successfully!</div>';
loadModels(); // Refresh the models list
} else {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
}
async function fetchHuggingFaceInfo() {
const url = document.getElementById('hf-url').value.trim();
const outputBox = document.getElementById('hf-install-output');
const fileSelectSection = document.getElementById('hf-file-select-section');
if (!url) {
outputBox.innerHTML = '<div class="error-message">Please enter a HuggingFace URL</div>';
return;
}
outputBox.innerHTML = '<div class="loading">Fetching model information...</div>';
try {
const response = await fetch('/api/install/huggingface', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url })
});
const data = await response.json();
if (data.success && data.requires_selection) {
// Show dropdown for file selection
let selectHtml = `
<div class="success-message">
Found ${data.gguf_files.length} GGUF file(s) in repository. Please select one:
</div>
<div style="margin: 15px 0;">
<label for="hf-file-select" style="display: block; margin-bottom: 5px;">Select GGUF File:</label>
<select id="hf-file-select" class="input" style="width: 100%; padding: 8px;">
<option value="">-- Select a file --</option>
`;
for (const file of data.gguf_files) {
selectHtml += `<option value="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</option>`;
}
selectHtml += `
</select>
</div>
<button class="btn btn-primary" onclick="generateModelfileFromSelection()">Generate Modelfile</button>
`;
fileSelectSection.innerHTML = selectHtml;
fileSelectSection.style.display = 'block';
outputBox.innerHTML = '';
// Store org/repo for later use
fileSelectSection.dataset.org = data.org;
fileSelectSection.dataset.repo = data.repo;
fileSelectSection.dataset.baseUrl = url;
} else if (data.success && !data.requires_selection) {
// Direct file - populate the Modelfile editor immediately
fileSelectSection.style.display = 'none';
document.getElementById('hf-model-name').value = data.full_name;
document.getElementById('hf-modelfile').value = data.modelfile_content;
document.getElementById('hf-repo-link').href = data.repo_url;
// Store file_url and gguf_filename for later
const modelfileSection = document.getElementById('hf-modelfile-section');
modelfileSection.dataset.fileUrl = data.file_url;
modelfileSection.dataset.ggufFilename = data.gguf_filename;
modelfileSection.style.display = 'block';
// Show mmproj info if modelfile includes mmproj configuration
const mmprojInfo = document.getElementById('mmproj-info');
if (data.modelfile_content && data.modelfile_content.includes('# mmproj_url:')) {
mmprojInfo.style.display = 'block';
} else {
mmprojInfo.style.display = 'none';
}
outputBox.innerHTML = '<div class="success-message">Model information fetched! Please review and customize the Modelfile below.</div>';
} else {
fileSelectSection.style.display = 'none';
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
fileSelectSection.style.display = 'none';
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
}
async function generateModelfileFromSelection() {
const fileSelectSection = document.getElementById('hf-file-select-section');
const selectedFile = document.getElementById('hf-file-select').value;
const outputBox = document.getElementById('hf-install-output');
if (!selectedFile) {
outputBox.innerHTML = '<div class="error-message">Please select a file</div>';
return;
}
const baseUrl = fileSelectSection.dataset.baseUrl;
outputBox.innerHTML = '<div class="loading">Generating Modelfile...</div>';
try {
const response = await fetch('/api/install/huggingface', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: baseUrl,
selected_file: selectedFile
})
});
const data = await response.json();
if (data.success) {
// Populate the Modelfile editor
document.getElementById('hf-model-name').value = data.full_name;
document.getElementById('hf-modelfile').value = data.modelfile_content;
document.getElementById('hf-repo-link').href = data.repo_url;
// Store file_url and gguf_filename for later
const modelfileSection = document.getElementById('hf-modelfile-section');
modelfileSection.dataset.fileUrl = data.file_url;
modelfileSection.dataset.ggufFilename = data.gguf_filename;
modelfileSection.style.display = 'block';
// Show mmproj info if modelfile includes mmproj configuration
const mmprojInfo = document.getElementById('mmproj-info');
if (data.modelfile_content && data.modelfile_content.includes('# mmproj_url:')) {
mmprojInfo.style.display = 'block';
} else {
mmprojInfo.style.display = 'none';
}
fileSelectSection.style.display = 'none';
outputBox.innerHTML = '<div class="success-message">Modelfile generated! Please review and customize below.</div>';
} else {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
}
async function createHuggingFaceModel() {
const modelName = document.getElementById('hf-model-name').value.trim();
const modelfileContent = document.getElementById('hf-modelfile').value.trim();
const outputBox = document.getElementById('hf-install-output');
const modelfileSection = document.getElementById('hf-modelfile-section');
const fileUrl = modelfileSection.dataset.fileUrl;
const ggufFilename = modelfileSection.dataset.ggufFilename;
if (!modelName || !modelfileContent || !fileUrl) {
outputBox.innerHTML = '<div class="error-message">Missing required information. Please fetch a model first.</div>';
return;
}
// Parse mmproj info from modelfile content
let mmprojUrl = null;
let mmprojFilename = null;
const mmprojUrlMatch = modelfileContent.match(/#\s*mmproj_url:\s*([^\s]+)/);
const mmprojQuantMatch = modelfileContent.match(/#\s*mmproj_quant:\s*([^\s]+)/);
if (mmprojUrlMatch) {
mmprojUrl = mmprojUrlMatch[1];
const mmprojQuant = mmprojQuantMatch ? mmprojQuantMatch[1] : 'BF16';
// Determine mmproj filename based on repo pattern
if (mmprojUrl.includes('/unsloth/')) {
mmprojFilename = `mmproj-${mmprojQuant}.gguf`;
} else {
// Try to extract base name from modelfile content or gguf filename
const baseMatch = ggufFilename.match(/^(.+?)-Q[0-9]/i);
if (baseMatch) {
mmprojFilename = `${baseMatch[1]}-${mmprojQuant}-mmproj.gguf`;
} else {
mmprojFilename = `mmproj-${mmprojQuant}.gguf`;
}
}
// Convert to resolve URL if needed
if (!mmprojUrl.includes('/resolve/')) {
mmprojUrl = `${mmprojUrl}/resolve/main/${mmprojFilename}`;
}
}
try {
const response = await fetch('/api/install/huggingface/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_name: modelName,
modelfile_content: modelfileContent,
file_url: fileUrl,
gguf_filename: ggufFilename,
mmproj_url: mmprojUrl,
mmproj_filename: mmprojFilename
})
});
const data = await response.json();
if (data.success && data.job_id) {
// Show progress bar with same structure as model card progress
outputBox.innerHTML = `
<div class="install-progress">
<div class="install-progress-bar-container">
<div class="install-progress-bar">
<div class="install-progress-bar-fill" style="width: 0%"></div>
</div>
<span class="install-progress-percent">0%</span>
</div>
<div class="install-status-text">Starting...</div>
</div>
`;
// Poll for status
pollHuggingFaceJobStatus(data.job_id, outputBox);
} else {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
}
async function pollHuggingFaceJobStatus(jobId, outputBox) {
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/install/status/${jobId}`);
const data = await response.json();
if (data.status === 'running') {
const statusText = outputBox.querySelector('.install-status-text');
if (statusText) {
statusText.textContent = data.progress || 'Processing...';
}
// Extract percentage if present in progress text
const percentMatch = data.progress?.match(/([0-9.]+)%/);
if (percentMatch) {
const percent = parseFloat(percentMatch[1]);
const progressBarFill = outputBox.querySelector('.install-progress-bar-fill');
const progressPercent = outputBox.querySelector('.install-progress-percent');
if (progressBarFill) {
progressBarFill.style.width = `${percent}%`;
}
if (progressPercent) {
progressPercent.textContent = `${percent.toFixed(1)}%`;
}
}
} else if (data.status === 'completed') {
clearInterval(pollInterval);
outputBox.innerHTML = `<div class="success-message">Successfully created ${escapeHtml(data.model_name)}!</div>`;
loadModels(); // Refresh models list
} else if (data.status === 'failed') {
clearInterval(pollInterval);
outputBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Installation failed')}</div>`;
} else if (data.status === 'cancelled') {
clearInterval(pollInterval);
outputBox.innerHTML = `<div class="error-message">Installation cancelled</div>`;
}
} catch (error) {
clearInterval(pollInterval);
outputBox.innerHTML = `<div class="error-message">Error polling status: ${escapeHtml(error.message)}</div>`;
}
}, 1000);
}
// ===== PERFORMANCE TOOLS =====
function populatePerformanceSelects() {
const vramSelect = document.getElementById('vram-test-model');
const optimizerSelect = document.getElementById('optimizer-model');
// Filter only installed models
const installedModels = currentModels.filter(m => m.installed);
const options = installedModels.map(model =>
`<option value="${escapeHtml(model.name)}">${escapeHtml(model.name)}</option>`
).join('');
vramSelect.innerHTML = '<option value="">-- Select a model --</option><option value="_all_">Test All Models</option>' + options;
optimizerSelect.innerHTML = '<option value="">-- Select a model --</option>' + options;
}
async function runVramTest() {
const modelName = document.getElementById('vram-test-model').value;
const resultsBox = document.getElementById('vram-test-results');
if (!modelName) {
resultsBox.innerHTML = '<div class="error-message">Please select a model</div>';
return;
}
if (modelName === '_all_') {
resultsBox.innerHTML = '<div class="loading">Testing all models... This may take a while.</div>';
} else {
resultsBox.innerHTML = '<div class="loading">Testing VRAM usage... Please wait.</div>';
}
try {
const response = await fetch(`/api/performance/vram-test/${encodeURIComponent(modelName)}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
if (data.results) {
// Batch results - display as table
let tableHtml = `
<div class="success-message">
<strong>VRAM Test Results - All Models</strong>
</div>
<table class="results-table">
<thead>
<tr>
<th>Model</th>
<th>Params</th>
<th>Quant</th>
<th>num_ctx</th>
<th>Total Size</th>
<th>VRAM Usage</th>
<th>CPU Offload</th>
<th>Status</th>
</tr>
</thead>
<tbody>
`;
for (const result of data.results) {
const status = result.success
? (result.offload_pct > 0 ? '⚠️ Offloading' : '✓ GPU Only')
: '✗ Failed';
tableHtml += `
<tr>
<td>${escapeHtml(result.model)}</td>
<td>${result.params || 'N/A'}</td>
<td>${result.quant || 'N/A'}</td>
<td>${result.num_ctx || 'N/A'}</td>
<td>${result.success ? result.size_gb + ' GB' : 'N/A'}</td>
<td>${result.success ? result.vram_gb + ' GB' : 'N/A'}</td>
<td>${result.success ? result.offload_pct + '%' : 'N/A'}</td>
<td>${status}</td>
</tr>
`;
}
tableHtml += `
</tbody>
</table>
`;
resultsBox.innerHTML = tableHtml;
} else {
// Single result - show all details
resultsBox.innerHTML = `
<div class="success-message">
<strong>VRAM Test Results for ${escapeHtml(data.model)}</strong><br>
Parameters: ${data.params || 'N/A'}<br>
Quantization: ${data.quant || 'N/A'}<br>
Context Size: ${data.num_ctx || 'N/A'}<br>
Total Size: ${data.size_gb} GB<br>
VRAM Usage: ${data.vram_gb} GB<br>
CPU Offload: ${data.offload_pct}%<br>
${data.offload_pct > 0 ? '<br>⚠️ Model is using CPU offloading. Consider reducing num_ctx or using smaller quantization.' : '✓ Model fits entirely in VRAM'}
</div>
`;
}
} else {
resultsBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
resultsBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
}
let optimizerAbortController = null;
async function runOptimizer() {
const modelName = document.getElementById('optimizer-model').value;
const overheadGb = parseFloat(document.getElementById('optimizer-overhead').value);
const maxTurns = parseInt(document.getElementById('optimizer-max-turns').value);
const resultsBox = document.getElementById('optimizer-results');
const runButton = document.getElementById('btn-run-optimizer');
const stopButton = document.getElementById('btn-stop-optimizer');
if (!modelName) {
resultsBox.innerHTML = '<div class="error-message">Please select a model</div>';
return;
}
// Show stop button, hide run button
runButton.style.display = 'none';
stopButton.style.display = 'inline-block';
const iterations = maxTurns === 0 ? 'unlimited' : maxTurns;
resultsBox.innerHTML = `<div class="loading">Running context optimizer (${iterations} iterations, ${overheadGb}GB overhead)... This may take several minutes.</div>`;
// Create abort controller
optimizerAbortController = new AbortController();
try {
const response = await fetch(`/api/performance/optimize/${encodeURIComponent(modelName)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
overhead_gb: overheadGb,
max_turns: maxTurns
}),
signal: optimizerAbortController.signal
});
const data = await response.json();
if (data.success) {
let tableHtml = `
<div class="success-message">
<strong>Context Optimization Results for ${escapeHtml(data.model)}</strong><br>
Max Context: ${data.max_context}<br>
Current Context: ${data.current_context || 'Default'}<br>
<strong>Recommended Context: ${data.optimal_context}</strong><br>
Available VRAM: ${data.available_vram_gb} GB
</div>
<table class="results-table">
<thead>
<tr>
<th>Context Size</th>
<th>VRAM Usage</th>
<th>CPU Offload</th>
<th>Fits in GPU?</th>
</tr>
</thead>
<tbody>
`;
for (const result of data.results) {
const fits = result.fits ? '✓ Yes' : '✗ No';
tableHtml += `
<tr ${result.context_size === data.optimal_context ? 'style="background-color: rgba(76, 175, 80, 0.1);"' : ''}>
<td>${result.context_size}</td>
<td>${result.vram_gb} GB</td>
<td>${result.offload_pct}%</td>
<td>${fits}</td>
</tr>
`;
}
tableHtml += `
</tbody>
</table>
<div class="info-text" style="margin-top: 15px;">
To apply the recommended context size, edit the model's Modelfile and set:<br>
<code>PARAMETER num_ctx ${data.optimal_context}</code>
</div>
`;
resultsBox.innerHTML = tableHtml;
} else {
resultsBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
if (error.name === 'AbortError') {
resultsBox.innerHTML = '<div class="error-message">Optimization stopped by user.</div>';
} else {
resultsBox.innerHTML = `<div class="error-message">Error: ${escapeHtml(error.message)}</div>`;
}
} finally {
// Reset buttons
runButton.style.display = 'inline-block';
stopButton.style.display = 'none';
optimizerAbortController = null;
}
}
function stopOptimizer() {
if (optimizerAbortController) {
optimizerAbortController.abort();
}
}
// ===== UTILITY FUNCTIONS =====
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (statusUpdateInterval) {
clearInterval(statusUpdateInterval);
}
});