Files
lollms_hub/app/templates/admin/models_manager.html
Saifeddine ALOUI 7b6233349e feat: Implement core bot management, admin routes, and model metadata CRUD
This commit introduces several related updates across the application, focusing on establishing core functionality for bot management, admin routing, and metadata handling.

Key changes include:
- Updating API routes for admin and proxy functionality.
- Refactoring the `BotManager` to handle the starting of active bots.
- Implementing necessary CRUD operations for model metadata and server information.
- Updating database migration and session management files.
- Adjusting relevant template files for the admin dashboard display.
2026-04-19 04:05:00 +02:00

626 lines
38 KiB
HTML

{% extends "admin/base.html" %}
{% block title %}Models Manager{% endblock %}
{% block header_title %}Models Manager & Auto-Routing{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- AI Auto-Config Section -->
<div class="card-style border-l-4 border-purple-600 bg-purple-600/5 mb-8">
<div class="flex flex-col md:flex-row justify-between items-center gap-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-purple-600 rounded-2xl shadow-xl shadow-purple-500/20">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<div>
<h2 class="text-xl font-black text-white uppercase tracking-tighter">AI Auto-Config</h2>
<p class="text-xs text-purple-300 font-bold uppercase tracking-widest">Ground model metadata in industry standard scorecards</p>
</div>
</div>
<div class="flex flex-grow max-w-xl gap-2">
<input type="text" id="ai-config-url" value="https://grumpified-oggvct.github.io/model-trust-scorecard/" class="flex-grow bg-black/40 border border-purple-500/30 rounded-xl px-4 py-2 text-sm text-white outline-none focus:border-purple-500">
<button onclick="runAIConfig()" id="ai-config-btn" class="bg-purple-600 hover:bg-purple-500 text-white px-6 py-2 rounded-xl font-black text-xs uppercase tracking-widest transition-all transform active:scale-95 shadow-lg shadow-purple-900/20">Sync Metadata</button>
<button onclick="stopAIConfig()" id="ai-stop-btn" class="hidden bg-red-600 hover:bg-red-500 text-white px-4 py-2 rounded-xl font-black text-xs uppercase transition-all shadow-lg">STOP</button>
</div>
</div>
<div id="ai-config-progress-container" class="hidden mt-6">
<div class="flex justify-between text-[10px] font-black text-purple-400 uppercase tracking-widest mb-1">
<span id="ai-progress-label">Syncing Cluster Intelligence...</span>
<span id="ai-progress-percent">0%</span>
</div>
<div class="h-1.5 w-full bg-black/40 rounded-full border border-white/5 overflow-hidden">
<div id="ai-progress-bar" class="h-full bg-purple-600 transition-all duration-500 shadow-[0_0_10px_rgba(168,85,247,0.5)]" style="width: 0%"></div>
</div>
</div>
<div id="ai-config-console" class="hidden mt-4 bg-black/60 border border-white/5 rounded-xl p-4 font-mono text-[10px] text-purple-300 space-y-1 h-32 overflow-y-auto custom-scrollbar">
<div class="text-gray-600 italic">Waiting for architect...</div>
</div>
</div>
<!-- Server Filter Bar -->
<div class="card-style mb-6">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-black text-gray-500 uppercase tracking-widest">Filter by Infrastructure</h3>
<button onclick="clearServerFilters()" class="text-[10px] font-bold text-indigo-400 hover:text-white uppercase">Reset Filters</button>
</div>
<div class="flex flex-wrap gap-2" id="server-filter-container">
<button onclick="toggleServerFilter('all', this)" class="server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-indigo-500/30 bg-indigo-600 text-white shadow-lg shadow-indigo-900/20 transition-all" data-server-id="all">
All Servers
</button>
{% for server in servers %}
<button onclick="toggleServerFilter({{ server.id }}, this)" class="server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-white/5 bg-white/5 text-gray-500 hover:text-indigo-300 transition-all" data-server-id="{{ server.id }}">
{{ server.name }}
</button>
{% endfor %}
</div>
</div>
</div>
<div class="card-style">
<div class="flex justify-between items-center">
<div class="flex items-center gap-4 cursor-pointer" onclick="document.getElementById('doc-body').classList.toggle('hidden'); this.querySelector('.arrow').classList.toggle('rotate-180')">
<h2 class="text-2xl font-bold">Auto-Routing Logic & Metadata</h2>
<svg class="arrow w-6 h-6 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
<button onclick="window.startCustomTour(MODELS_TOUR, 'tour_models_v1', true, true)" class="flex items-center gap-2 text-[10px] font-black uppercase text-indigo-400 hover:text-white transition-colors bg-white/5 px-3 py-1 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.79 4 4 0 2.21-1.79 4-4 4-1.742 0-3.223-.835-3.772-2M12 18.5v.01M12 5.5v.01"></path></svg>
Replay Tour
</button>
</div>
<div id="doc-body" class="mt-4 border-t border-white/5 pt-4">
<p class="text-current">
The Models Manager defines the "Intelligence DNA" for the virtual <code class="inline-code">auto</code> model.
Instead of managing server hardware here (which has moved to **Workflows**), this page focuses on tagging models so the Gateway knows how to route requests based on prompt content.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-indigo-400 font-bold text-xs uppercase mb-1">👁️ Supports Images</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Required for vision tasks. If a user attaches an image, the 'auto' router filters for these models only.</p>
</div>
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-amber-400 font-bold text-xs uppercase mb-1">🧠 Think (CoT)</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Enable for models supporting internal reasoning (DeepSeek R1, etc). Prevents 'think' parameter stripping.</p>
</div>
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-emerald-400 font-bold text-xs uppercase mb-1">💻 Code</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Tagged as programming experts. Used when the router detects code keywords (e.g. 'def', 'class', 'import').</p>
</div>
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-sky-400 font-bold text-xs uppercase mb-1">⚡ Fast</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Small, optimized models. Used when 'fast_model' option is set in the request for low-latency tasks.</p>
</div>
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-purple-400 font-bold text-xs uppercase mb-1">🧩 Reasoning</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Complex logic models. Triggered by keywords like 'solve', 'math', 'why', or 'step by step'.</p>
</div>
<div class="p-3 bg-white/5 rounded border border-white/10">
<span class="block text-rose-400 font-bold text-xs uppercase mb-1">📏 Max Context</span>
<p class="text-[11px] text-gray-400 leading-relaxed">Token limit. The router checks if the current chat history fits before selecting the model.</p>
</div>
</div>
</div>
</div>
<form id="metadata-form" action="{{ url_for('admin_update_model_metadata') }}" method="post" class="card-style flex flex-col h-[70vh]">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6 flex-shrink-0">
<h2 class="text-2xl font-bold">Configure Model Capabilities</h2>
<div class="flex items-center gap-4 w-full md:w-auto">
<!-- Search Bar -->
<div class="relative flex-grow md:w-80">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</span>
<input type="text" id="model-search" onkeyup="filterModels()" placeholder="Search names, tags, servers..." class="w-full pl-10 pr-4 py-2 bg-black/40 border border-white/10 rounded-xl text-sm focus:border-indigo-500 outline-none transition-all">
</div>
<button type="button" onclick="window.location.reload()" class="p-2 bg-white/5 hover:bg-white/10 rounded-xl text-indigo-400 transition-colors" title="Refresh list from Database">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
</button>
</div>
</div>
<div class="flex-grow overflow-auto border border-white/5 rounded-xl bg-black/20 custom-scrollbar">
<table class="min-w-full" id="models-table">
<thead class="sticky top-0 bg-[#0f0f15] z-10 shadow-md">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Model Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Servers</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Description</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Embedding</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('is_embedding_model_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('is_embedding_model_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Supports Images</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('supports_images_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('supports_images_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Think (CoT)</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('supports_thinking_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('supports_thinking_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Code</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('is_code_model_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('is_code_model_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Fast</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('is_fast_model_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('is_fast_model_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<div class="mb-1">Reasoning</div>
<div class="flex justify-center gap-2 text-[9px] font-bold">
<button type="button" onclick="toggleColumn('is_reasoning_model_', true)" class="text-indigo-400 hover:text-white uppercase">All</button>
<span class="text-gray-600">|</span>
<button type="button" onclick="toggleColumn('is_reasoning_model_', false)" class="text-gray-500 hover:text-white uppercase">None</button>
</div>
</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Max Context</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Size (B)</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Scale</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Priority</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10" id="metadata-tbody">
{% for meta in metadata_list %}
<tr class="align-top metadata-row">
<td class="px-4 py-4 whitespace-nowrap text-sm font-medium text-current font-mono">{{ meta.model_name }}</td>
<td class="px-4 py-4 whitespace-nowrap text-xs">
<div class="flex flex-wrap gap-1">
{% for srv in meta.origin_servers %}
<button type="button"
onclick="toggleModelOnServer(event, {{ srv.id }}, '{{ meta.model_name }}')"
class="server-badge px-2 py-1 rounded-full border transition-all hover:scale-105 active:scale-95
{% if srv.is_allowed %}
bg-emerald-500/10 text-emerald-400 border-emerald-500/30
{% else %}
bg-red-500/10 text-red-400 border-red-500/30
{% endif %}"
title="{{ 'Active: Click to Deactivate' if srv.is_allowed else 'Blocked: Click to Activate' }} on {{ srv.name }}">
{{ srv.name }}
</button>
{% endfor %}
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap">
<input type="text" name="description_{{ meta.id }}" value="{{ meta.description or '' }}" placeholder="A short description..." class="w-full min-w-48 px-2 py-1 rounded-md shadow-sm sm:text-sm">
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="is_embedding_model_{{ meta.id }}" value="true" class="embedding-toggle h-5 w-5 rounded text-amber-500" data-meta-id="{{ meta.id }}" {% if meta.is_embedding_model %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="supports_images_{{ meta.id }}" value="true" class="capability-toggle h-5 w-5 rounded text-[var(--color-primary-600)]" {% if meta.supports_images %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="supports_images_{{ meta.id }}" value="true" class="capability-toggle h-5 w-5 rounded text-[var(--color-primary-600)]" data-meta-id="{{ meta.id }}" {% if meta.supports_images %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="supports_thinking_{{ meta.id }}" value="true" class="h-5 w-5 rounded text-[var(--color-primary-600)]" {% if meta.supports_thinking %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="is_code_model_{{ meta.id }}" value="true" class="h-5 w-5 rounded text-[var(--color-primary-600)]" {% if meta.is_code_model %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="is_fast_model_{{ meta.id }}" value="true" class="h-5 w-5 rounded text-[var(--color-primary-600)]" {% if meta.is_fast_model %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="checkbox" name="is_reasoning_model_{{ meta.id }}" value="true" class="h-5 w-5 rounded text-[var(--color-primary-600)]" {% if meta.is_reasoning_model %}checked{% endif %}>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-1">
<input type="number" id="ctx_input_{{ meta.id }}" name="max_context_{{ meta.id }}" value="{{ meta.max_context }}" step="1024" class="w-24 px-2 py-1 rounded-md shadow-sm sm:text-sm text-center">
<button type="button" onclick="refreshContext('{{ meta.model_name }}', 'ctx_input_{{ meta.id }}')" class="p-1.5 hover:bg-white/10 rounded-md text-gray-500 hover:text-indigo-400 transition-colors" title="Sync from server">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
</button>
</div>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="number" step="0.1" name="model_size_{{ meta.id }}" value="{{ meta.model_size }}"
class="w-16 px-2 py-1 rounded-md shadow-sm sm:text-sm text-center {% if meta.model_size < 0 %}text-red-400 font-bold{% endif %}"
title="{{ 'Size Unknown - AI config or manual entry needed' if meta.model_size < 0 else 'Parameter count in Billions' }}">
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<select name="model_scale_{{ meta.id }}" class="bg-black/40 border border-white/10 rounded p-1 text-[10px] font-bold uppercase">
<option value="1" {% if meta.model_scale == 1 %}selected{% endif %}>Small</option>
<option value="2" {% if meta.model_scale == 2 %}selected{% endif %}>Medium</option>
<option value="3" {% if meta.model_scale == 3 %}selected{% endif %}>Large</option>
</select>
</td>
<td class="px-4 py-4 whitespace-nowrap text-center">
<input type="number" name="priority_{{ meta.id }}" value="{{ meta.priority }}" class="w-16 px-2 py-1 rounded-md shadow-sm sm:text-sm text-center">
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-6 py-10 text-center text-gray-400">
No models found across any active servers. Go to the <a href="{{ url_for('admin_servers') }}" class="font-semibold text-[var(--color-primary-500)] hover:underline">Server Management</a> page and refresh your servers to discover models.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex justify-end pt-6 border-t border-white/10 mt-6">
<button type="submit" class="w-full md:w-auto justify-center py-2 px-6 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[var(--color-primary-600)] hover:bg-[var(--color-primary-700)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--color-primary-500)]">
Save All Changes
</button>
</div>
</form>
</div>
<script>
let activeServerFilters = new Set(['all']);
function toggleServerFilter(serverId, btn) {
if (serverId === 'all') {
activeServerFilters.clear();
activeServerFilters.add('all');
document.querySelectorAll('.server-filter-btn').forEach(b => {
b.className = "server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-white/5 bg-white/5 text-gray-500 hover:text-indigo-300 transition-all";
});
btn.className = "server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-indigo-500/30 bg-indigo-600 text-white shadow-lg shadow-indigo-900/20 transition-all";
} else {
// Remove 'all' if present
if (activeServerFilters.has('all')) {
activeServerFilters.delete('all');
const allBtn = document.querySelector('[data-server-id="all"]');
allBtn.className = "server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-white/5 bg-white/5 text-gray-500 hover:text-indigo-300 transition-all";
}
if (activeServerFilters.has(serverId)) {
activeServerFilters.delete(serverId);
btn.className = "server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-white/5 bg-white/5 text-gray-500 hover:text-indigo-300 transition-all";
} else {
activeServerFilters.add(serverId);
btn.className = "server-filter-btn px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border border-indigo-500/30 bg-indigo-600 text-white shadow-lg shadow-indigo-900/20 transition-all";
}
// If nothing selected, reset to all
if (activeServerFilters.size === 0) {
toggleServerFilter('all', document.querySelector('[data-server-id="all"]'));
return;
}
}
filterModels();
}
function clearServerFilters() {
toggleServerFilter('all', document.querySelector('[data-server-id="all"]'));
}
/**
* Filters the table rows based on search input and server selection.
*/
function filterModels() {
const input = document.getElementById('model-search');
const searchFilter = input.value.toLowerCase();
const rows = document.querySelectorAll('#metadata-tbody tr');
rows.forEach(row => {
const name = row.cells[0].textContent.toLowerCase();
const desc = row.querySelector('input[name^="description_"]').value.toLowerCase();
// Server matching
let serverMatch = activeServerFilters.has('all');
if (!serverMatch) {
// Get server IDs from badges in the second cell
const badges = row.cells[1].querySelectorAll('.server-badge');
badges.forEach(badge => {
// Extract ID from the onclick attribute: toggleModelOnServer(event, ID, 'name')
const match = badge.getAttribute('onclick').match(/toggleModelOnServer\(event,\s*(\d+),/);
if (match && activeServerFilters.has(parseInt(match[1]))) {
serverMatch = true;
}
});
}
// Capability search tags
const isVision = row.querySelector('input[name^="supports_images_"]').checked;
const isCode = row.querySelector('input[name^="is_code_model_"]').checked;
const isFast = row.querySelector('input[name^="is_fast_model_"]').checked;
const isReasoning = row.querySelector('input[name^="is_reasoning_model_"]').checked;
const tags = [];
if(isVision) tags.push("vision", "images");
if(isCode) tags.push("code", "programming");
if(isFast) tags.push("fast", "small");
if(isReasoning) tags.push("reasoning", "logic");
const tagStr = tags.join(" ");
const textMatch = name.includes(searchFilter) || desc.includes(searchFilter) || tagStr.includes(searchFilter);
if (serverMatch && textMatch) {
row.style.display = "";
} else {
row.style.display = "none";
}
});
}
/**
* Calls the backend to fetch current context length for a model.
*/
async function refreshContext(modelName, inputId) {
const input = document.getElementById(inputId);
const btn = input.nextElementSibling;
// Visual feedback
btn.classList.add('animate-spin', 'text-indigo-400');
try {
const response = await fetch(`{{ url_for('admin_refresh_model_context') }}?model_name=${encodeURIComponent(modelName)}`);
const data = await response.json();
if (data.success) {
input.value = data.context_length;
input.classList.add('ring-2', 'ring-green-500');
setTimeout(() => input.classList.remove('ring-2', 'ring-green-500'), 2000);
window.showToast(`Context for ${modelName} updated.`, 'success');
} else {
window.showErrorModal("Sync Failed", data);
}
} catch (e) {
window.showErrorModal("Connection Fault", { error: e.message });
} finally {
btn.classList.remove('animate-spin', 'text-indigo-400');
}
}
/**
* Handle Row Selection/Highlighting
*/
document.getElementById('metadata-tbody').addEventListener('click', (e) => {
const row = e.target.closest('.metadata-row');
if (!row) return;
// Optional: Clear other highlights if you want "Single Selection" mode
// document.querySelectorAll('.metadata-row').forEach(r => r.classList.remove('active-row'));
row.classList.toggle('active-row');
});
/**
* Toggles all checkboxes in a column based on the name prefix.
*/
function toggleColumn(prefix, value) {
const checkboxes = document.querySelectorAll(`input[name^="${prefix}"]`);
checkboxes.forEach(cb => {
cb.checked = value;
// Trigger change event to run the exclusivity logic
cb.dispatchEvent(new Event('change', { bubbles: true }));
});
}
/**
* Mutual Exclusivity: If Embedding is selected, disable and clear other capabilities.
*/
document.addEventListener('change', function(e) {
if (e.target.classList.contains('embedding-toggle')) {
const row = e.target.closest('tr');
const otherCheckboxes = row.querySelectorAll('input[type="checkbox"]:not(.embedding-toggle)');
if (e.target.checked) {
otherCheckboxes.forEach(cb => {
cb.checked = false;
cb.disabled = true;
cb.parentElement.classList.add('opacity-30');
});
} else {
otherCheckboxes.forEach(cb => {
cb.disabled = false;
cb.parentElement.classList.remove('opacity-30');
});
}
}
});
// Run on load for existing state
document.querySelectorAll('.embedding-toggle').forEach(el => {
if (el.checked) el.dispatchEvent(new Event('change', { bubbles: true }));
});
/**
* Toggles administrative allowed status of a model on a specific server via AJAX.
*/
async function toggleModelOnServer(event, serverId, modelName) {
const btn = event.currentTarget;
const originalClasses = btn.className;
// Visual feedback for processing
btn.classList.add('opacity-50', 'animate-pulse');
btn.disabled = true;
try {
const response = await fetch("{{ url_for('admin_toggle_model_server') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": "{{ csrf_token }}"
},
body: JSON.stringify({
server_id: serverId,
model_name: modelName
})
});
const data = await response.json();
if (data.success) {
// Update styling based on new state
if (data.new_state) {
btn.className = "server-badge px-2 py-1 rounded-full border transition-all hover:scale-105 active:scale-95 bg-emerald-500/10 text-emerald-400 border-emerald-500/30";
btn.title = "Active: Click to Deactivate on " + btn.innerText;
window.showToast(`${modelName} enabled on server.`, 'success');
} else {
btn.className = "server-badge px-2 py-1 rounded-full border transition-all hover:scale-105 active:scale-95 bg-red-500/10 text-red-400 border-red-500/30";
btn.title = "Blocked: Click to Activate on " + btn.innerText;
window.showToast(`${modelName} blocked on server.`, 'info');
}
} else {
window.showErrorModal("Toggle Failed", data);
btn.className = originalClasses;
}
} catch (e) {
window.showErrorModal("Network Fault", { error: e.message });
btn.className = originalClasses;
} finally {
btn.classList.remove('opacity-50', 'animate-pulse');
btn.disabled = false;
}
}
// --- Page Tour Logic ---
const MODELS_TOUR = [
{
target: '#metadata-tbody tr:first-child td:first-child',
text: "This is your <b>Hub Identity</b>. Every discovered model across your cluster is listed here uniquely."
},
{
target: '.capability-toggle',
text: "<b>Supports Images</b>: Vital for VLM routing. If a user attaches a file, the Hub only considers models with this checked."
},
{
target: 'input[name^="supports_thinking_"]',
text: "<b>Think (CoT)</b>: Enable this for Reasoning models. It prevents the Gateway from stripping the 'think' parameter."
},
{
target: 'input[name^="max_context_"]',
text: "<b>Max Context</b>: The token ceiling. The 'auto' router will bypass small models if the chat history is too long."
},
{
target: 'input[name^="priority_"]',
text: "<b>Priority</b>: Tie-breaker. If two models match the request, the one with the <b>lowest value</b> (e.g., 1 vs 10) wins."
},
{
target: '#btn-save-all',
text: "Don't forget to <b>Save All Changes</b> to update the cluster metadata."
}
];
// Ensure tour triggers after all components are settled
window.addEventListener('load', () => {
if (window.startCustomTour) {
window.startCustomTour(MODELS_TOUR, 'tour_models_v1', {{ settings.tour_models | tojson }});
}
});
let currentConfigTaskId = null;
window.stopAIConfig = async () => {
if (!currentConfigTaskId) return;
const resp = await fetch(`/admin/system/stop-task/${currentConfigTaskId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': "{{ csrf_token }}" }
});
document.getElementById('ai-stop-btn').classList.add('hidden');
};
window.runAIConfig = async () => {
const btn = document.getElementById('ai-config-btn');
const stopBtn = document.getElementById('ai-stop-btn');
const console = document.getElementById('ai-config-console');
const url = document.getElementById('ai-config-url').value;
if(!url) return alert("Scorecard URL required.");
btn.disabled = true;
btn.innerText = "CONFIGURING...";
console.classList.remove('hidden');
console.innerHTML = '<div class="text-purple-400 font-bold">[Architect] Handshaking with cluster...</div>';
const formData = new FormData();
formData.append('url', url);
formData.append('csrf_token', "{{ csrf_token }}");
try {
const resp = await fetch("{{ url_for('admin_ai_configure_models') }}", {
method: 'POST',
body: formData
});
const data = await resp.json();
if(data.success) {
currentConfigTaskId = data.task_id;
stopBtn.classList.remove('hidden');
console.innerHTML += `<div class="text-indigo-400">[System] Task ${data.task_id} shifted to background. You can navigate away.</div>`;
} else {
console.innerHTML += `<div class="text-red-400">[Error] ${data.error}</div>`;
btn.disabled = false;
btn.innerText = "Sync Metadata";
}
} catch(e) {
console.innerHTML += `<div class="text-red-500">[Network Error] ${e.message}</div>`;
btn.disabled = false;
}
};
// SSE Listener for Config Trace & Progress
window.showNotification = (text) => {
const toast = document.getElementById('global-notification');
document.getElementById('notif-text').innerText = text;
toast.classList.remove('translate-x-full');
setTimeout(hideNotification, 6000);
};
window.hideNotification = () => {
document.getElementById('global-notification').classList.add('translate-x-full');
};
const configSource = new EventSource("{{ url_for('sse_events') }}");
configSource.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'completed' && msg.id.startsWith('sys_')) {
window.showNotification(msg.error || "System background task finished.");
}
if(msg.id && msg.id.startsWith('sys_build_config_')) {
const consoleEl = document.getElementById('ai-config-console');
const progressContainer = document.getElementById('ai-config-progress-container');
const progressBar = document.getElementById('ai-progress-bar');
const progressPercent = document.getElementById('ai-progress-percent');
progressContainer.classList.remove('hidden');
// Update Progress Bar using the 'tokens' field mapped in Python
if (msg.tokens !== undefined) {
const pct = msg.tokens + '%';
progressBar.style.width = pct;
progressPercent.innerText = pct;
}
const color = msg.type === 'error' ? 'text-red-400' : 'text-purple-300';
consoleEl.innerHTML += `<div class="${color} opacity-80">${msg.error || 'Analyzing node data...'}</div>`;
consoleEl.scrollTop = consoleEl.scrollHeight;
if (msg.type === 'completed') {
progressBar.style.width = '100%';
progressPercent.innerText = '100%';
}
}
};
</script>
{% endblock %}