`).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 = '
Loading models...
';
try {
const response = await fetch('/api/models');
const data = await response.json();
if (data.error) {
container.innerHTML = `
Error: ${escapeHtml(data.error)}
`;
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 = `
`;
}
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 = '
Modelfile saved! Recreating model...
';
// 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 = '
`;
resultsBox.innerHTML = tableHtml;
} else {
// Single result - show all details
resultsBox.innerHTML = `
VRAM Test Results for ${escapeHtml(data.model)}
Parameters: ${data.params || 'N/A'}
Quantization: ${data.quant || 'N/A'}
Context Size: ${data.num_ctx || 'N/A'}
Total Size: ${data.size_gb} GB
VRAM Usage: ${data.vram_gb} GB
CPU Offload: ${data.offload_pct}%
${data.offload_pct > 0 ? ' ⚠️ Model is using CPU offloading. Consider reducing num_ctx or using smaller quantization.' : '✓ Model fits entirely in VRAM'}
Context Optimization Results for ${escapeHtml(data.model)}
Max Context: ${data.max_context}
Current Context: ${data.current_context || 'Default'} Recommended Context: ${data.optimal_context}
Available VRAM: ${data.available_vram_gb} GB
Context Size
VRAM Usage
CPU Offload
Fits in GPU?
`;
for (const result of data.results) {
const fits = result.fits ? '✓ Yes' : '✗ No';
tableHtml += `
${result.context_size}
${result.vram_gb} GB
${result.offload_pct}%
${fits}
`;
}
tableHtml += `
To apply the recommended context size, edit the model's Modelfile and set: PARAMETER num_ctx ${data.optimal_context}