Files
LocalAI/core/http/views/model-editor.html
Ettore Di Giacinto bef4c10629 feat(ui): General improvements (#6072)
* wip

* Simplify stop

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Improve UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Show installed backends at the index

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Imporve UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-08-16 07:44:50 +02:00

1196 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="en">
{{template "views/partials/head" .}}
<body class="bg-gradient-to-br from-gray-900 via-gray-950 to-black text-gray-200">
<div class="flex flex-col min-h-screen">
{{template "views/partials/navbar" .}}
<div class="container mx-auto px-4 py-8 flex-grow">
<!-- Hero Header -->
<div class="relative bg-gradient-to-r from-violet-900/40 via-purple-900/30 to-fuchsia-900/40 rounded-3xl shadow-2xl p-8 mb-8 overflow-hidden">
<!-- Background Pattern -->
<div class="absolute inset-0 opacity-10">
<div class="absolute inset-0 bg-gradient-to-r from-violet-500/20 to-fuchsia-500/20"></div>
<div class="absolute top-0 left-0 w-full h-full" style="background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.15) 1px, transparent 0); background-size: 20px 20px;"></div>
</div>
<div class="relative max-w-5xl mx-auto">
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div class="mb-4 md:mb-0">
<h1 class="text-3xl md:text-4xl font-bold text-white mb-2">
<span class="bg-clip-text text-transparent bg-gradient-to-r from-violet-400 via-purple-400 to-fuchsia-400">
{{if .ModelName}}Edit Model: {{.ModelName}}{{else}}Import New Model{{end}}
</span>
</h1>
<p class="text-lg text-gray-300 font-light">Configure your model settings using the form or YAML editor</p>
</div>
<div class="flex gap-3">
<button id="validateBtn" class="group relative inline-flex items-center bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-6 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-xl hover:shadow-blue-500/25">
<i class="fas fa-check mr-2 group-hover:animate-pulse"></i>
<span>Validate</span>
<div class="absolute inset-0 rounded-xl bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</button>
<button id="saveBtn" class="group relative inline-flex items-center bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white py-3 px-6 rounded-xl font-semibold transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-xl hover:shadow-green-500/25">
<i class="fas fa-save mr-2 group-hover:animate-pulse"></i>
<span>{{if .ModelName}}Update{{else}}Create{{end}}</span>
<div class="absolute inset-0 rounded-xl bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</button>
</div>
</div>
</div>
</div>
<!-- Alert Messages -->
<div id="alertContainer" class="mb-6"></div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-250px)]">
<!-- Form Panel (Left) -->
<div class="relative bg-gradient-to-br from-gray-800/90 to-gray-900/90 border border-gray-700/50 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-violet-500/5 to-purple-500/5"></div>
<div class="relative sticky top-0 bg-gray-800/95 border-b border-gray-700/50 p-6 flex items-center justify-between z-10 backdrop-blur-sm">
<h2 class="text-xl font-semibold text-white flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
<i class="fas fa-edit text-violet-400"></i>
</div>
Configuration Form
</h2>
<div class="flex items-center gap-3">
<button id="resetFormBtn" class="group text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded-lg hover:bg-gray-700/50 transition-all duration-200">
<i class="fas fa-undo mr-1.5 group-hover:animate-spin"></i> Reset
</button>
<button id="expandAllBtn" class="group text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded-lg hover:bg-gray-700/50 transition-all duration-200">
<i class="fas fa-expand-alt mr-1.5 group-hover:animate-pulse"></i> Expand All
</button>
</div>
</div>
<div class="relative p-6 overflow-y-auto" style="height: calc(100% - 88px);">
<form id="configForm" class="space-y-6">
<!-- Form will be dynamically generated here -->
</form>
</div>
</div>
<!-- YAML Editor Panel (Right) -->
<div class="relative bg-gradient-to-br from-gray-800/90 to-gray-900/90 border border-gray-700/50 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-fuchsia-500/5 to-purple-500/5"></div>
<div class="relative sticky top-0 bg-gray-800/95 border-b border-gray-700/50 p-6 flex items-center justify-between z-10 backdrop-blur-sm">
<h2 class="text-xl font-semibold text-white flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-fuchsia-500/20 flex items-center justify-center">
<i class="fas fa-code text-fuchsia-400"></i>
</div>
YAML Editor
</h2>
<div class="flex items-center gap-3">
<button id="formatYamlBtn" class="group text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded-lg hover:bg-gray-700/50 transition-all duration-200">
<i class="fas fa-indent mr-1.5 group-hover:animate-pulse"></i> Format
</button>
<button id="copyYamlBtn" class="group text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded-lg hover:bg-gray-700/50 transition-all duration-200">
<i class="fas fa-copy mr-1.5 group-hover:animate-bounce"></i> Copy
</button>
</div>
</div>
<div class="relative" style="height: calc(100% - 88px);">
<div id="yamlCodeMirror" class="h-full"></div>
</div>
</div>
</div>
</div>
{{template "views/partials/footer" .}}
</div>
<!-- Include JS-YAML library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
<!-- Include CodeMirror for syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/addon/display/autorefresh.min.js"></script>
<style>
/* Enhanced CodeMirror styling */
.CodeMirror {
background: linear-gradient(135deg, #111827 0%, #1f2937 100%) !important;
color: #e5e7eb !important;
border: none !important;
height: 100% !important;
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace !important;
font-size: 14px !important;
border-radius: 0 !important;
line-height: 1.5 !important;
}
.CodeMirror-cursor {
border-left: 2px solid #a78bfa !important;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.CodeMirror-gutters {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%) !important;
border-right: 1px solid rgba(75, 85, 99, 0.5) !important;
color: #9ca3af !important;
padding-right: 8px !important;
}
.CodeMirror-linenumber {
color: #6b7280 !important;
padding: 0 8px 0 4px !important;
font-size: 12px !important;
}
.CodeMirror-activeline-background {
background: rgba(139, 92, 246, 0.1) !important;
}
.CodeMirror-selected {
background: rgba(139, 92, 246, 0.25) !important;
}
.CodeMirror-selectedtext {
background: rgba(139, 92, 246, 0.25) !important;
}
.CodeMirror-focused .CodeMirror-selected {
background: rgba(139, 92, 246, 0.3) !important;
}
.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection {
background: rgba(139, 92, 246, 0.3) !important;
}
.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection {
background: rgba(139, 92, 246, 0.3) !important;
}
/* Enhanced YAML Syntax Highlighting */
.cm-keyword { color: #8b5cf6 !important; font-weight: 600 !important; } /* Purple for YAML keys */
.cm-string { color: #10b981 !important; } /* Emerald for strings */
.cm-number { color: #f59e0b !important; } /* Amber for numbers */
.cm-comment { color: #6b7280 !important; font-style: italic !important; } /* Gray for comments */
.cm-property { color: #ec4899 !important; } /* Pink for properties */
.cm-operator { color: #ef4444 !important; } /* Red for operators */
.cm-variable { color: #06b6d4 !important; } /* Cyan for variables */
.cm-tag { color: #8b5cf6 !important; font-weight: 600 !important; } /* Purple for tags */
.cm-attribute { color: #f59e0b !important; } /* Amber for attributes */
.cm-def { color: #ec4899 !important; font-weight: 600 !important; } /* Pink for definitions */
.cm-bracket { color: #d1d5db !important; } /* Light gray for brackets */
.cm-punctuation { color: #d1d5db !important; } /* Light gray for punctuation */
.cm-quote { color: #10b981 !important; } /* Emerald for quotes */
.cm-meta { color: #6b7280 !important; } /* Gray for meta */
.cm-builtin { color: #f472b6 !important; } /* Pink for builtins */
.cm-atom { color: #f59e0b !important; } /* Amber for atoms like true/false/null */
/* Enhanced scrollbar styling */
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
background: #1f2937 !important;
}
.CodeMirror-vscrollbar, .CodeMirror-hscrollbar {
background: #1f2937 !important;
}
.CodeMirror-vscrollbar::-webkit-scrollbar, .CodeMirror-hscrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.CodeMirror-vscrollbar::-webkit-scrollbar-track, .CodeMirror-hscrollbar::-webkit-scrollbar-track {
background: #1f2937;
border-radius: 4px;
}
.CodeMirror-vscrollbar::-webkit-scrollbar-thumb, .CodeMirror-hscrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);
border-radius: 4px;
}
.CodeMirror-vscrollbar::-webkit-scrollbar-thumb:hover, .CodeMirror-hscrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #9ca3af 0%, #d1d5db 100%);
}
/* Focus ring styling */
.CodeMirror-focused {
outline: 2px solid rgba(139, 92, 246, 0.5) !important;
outline-offset: -2px !important;
border-radius: 0.5rem !important;
}
/* Enhanced form styling */
.form-section {
background: linear-gradient(135deg, rgba(55, 65, 81, 0.3) 0%, rgba(75, 85, 99, 0.3) 100%);
border: 1px solid rgba(107, 114, 128, 0.3);
border-radius: 1rem;
backdrop-filter: blur(8px);
transition: all 0.3s ease;
}
.form-section:hover {
border-color: rgba(139, 92, 246, 0.3);
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.1);
}
.form-section.expanded {
border-color: rgba(139, 92, 246, 0.4);
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.15);
}
.section-header {
background: linear-gradient(135deg, rgba(75, 85, 99, 0.4) 0%, rgba(107, 114, 128, 0.4) 100%);
border-bottom: 1px solid rgba(107, 114, 128, 0.3);
padding: 1rem 1.5rem;
border-radius: 1rem 1rem 0 0;
cursor: pointer;
transition: all 0.3s ease;
}
.section-header:hover {
background: linear-gradient(135deg, rgba(107, 114, 128, 0.4) 0%, rgba(139, 92, 246, 0.2) 100%);
}
.form-input {
background: linear-gradient(135deg, rgba(17, 24, 39, 0.8) 0%, rgba(31, 41, 55, 0.8) 100%) !important;
border: 1px solid rgba(107, 114, 128, 0.4) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1rem !important;
color: #e5e7eb !important;
transition: all 0.3s ease !important;
backdrop-filter: blur(4px) !important;
}
.form-input:focus {
border-color: rgba(139, 92, 246, 0.6) !important;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1) !important;
background: linear-gradient(135deg, rgba(31, 41, 55, 0.9) 0%, rgba(55, 65, 81, 0.9) 100%) !important;
}
.form-input:hover {
border-color: rgba(139, 92, 246, 0.4) !important;
}
/* Enhanced button styling */
.action-button {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 0.75rem;
padding: 0.5rem 1rem;
font-weight: 600;
transition: all 0.3s ease;
border: 1px solid rgba(139, 92, 246, 0.3);
}
.action-button:hover {
transform: scale(1.05);
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.3);
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
}
.danger-button {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.danger-button:hover {
background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);
box-shadow: 0 8px 25px rgba(239, 68, 68, 0.3);
}
/* Alert styling */
.alert {
border-radius: 1rem;
padding: 1rem 1.5rem;
backdrop-filter: blur(8px);
border: 1px solid;
animation: slideInFromTop 0.3s ease-out;
}
@keyframes slideInFromTop {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.alert-success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
border-color: rgba(16, 185, 129, 0.3);
color: #10b981;
}
.alert-error {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.alert-warning {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%);
border-color: rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.alert-info {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.1) 100%);
border-color: rgba(59, 130, 246, 0.3);
color: #3b82f6;
}
</style>
<script>
class ModelEditor {
constructor() {
this.config = {};
this.isUpdatingFromYaml = false;
this.isUpdatingFromForm = false;
this.modelName = '{{.ModelName}}';
this.isEditMode = !!this.modelName;
this.yamlEditor = null;
this.init();
}
init() {
{{if .ConfigYAML}}
try {
this.config = jsyaml.load(`{{.ConfigYAML}}`);
} catch (e) {
console.error('Failed to parse initial YAML:', e);
this.config = {};
}
{{else}}
this.config = this.getDefaultConfig();
{{end}}
this.generateForm();
this.initializeCodeMirror();
this.bindEvents();
}
getDefaultConfig() {
return {
name: '',
backend: '',
parameters: {
model: ''
},
template: {},
diffusers: {},
grpc: {
attempts: 1,
attempts_sleep_time: 1000
},
tts: {},
pipeline: {},
function: {},
feature_flags: {},
download_files: [],
known_usecases: [],
options: [],
overrides: [],
stopwords: [],
cutstrings: [],
extract_regex: [],
trimspace: [],
trimsuffix: [],
lora_adapters: [],
lora_scales: [],
roles: {}
};
}
generateForm() {
const form = document.getElementById('configForm');
form.innerHTML = '';
const formSections = [
{
title: 'Basic Configuration',
icon: 'fas fa-cog',
fields: [
{ key: 'name', label: 'Model Name', type: 'text', required: true, description: 'Unique identifier for this model', readonly: this.isEditMode },
{ key: 'backend', label: 'Backend', type: 'text', required: true,
description: 'Backend engine to use (e.g., llama-cpp, transformers, diffusers, whisper, piper, bark-cpp, vllm, rerankers, exllama2, faster-whisper, stablediffusion-ggml)' },
{ key: 'parameters.model', label: 'Model File/Path', type: 'text', required: true, description: 'Path to model file or URL' },
{ key: 'description', label: 'Description', type: 'textarea', description: 'Description of the model' },
{ key: 'usage', label: 'Usage Instructions', type: 'textarea', description: 'How to use this model' }
]
},
{
title: 'Model Parameters',
icon: 'fas fa-sliders-h',
collapsible: true,
fields: [
{ key: 'parameters.temperature', label: 'Temperature', type: 'number', step: 0.01, min: 0, max: 2, description: 'Controls randomness (0-2)' },
{ key: 'parameters.top_p', label: 'Top P', type: 'number', step: 0.01, min: 0, max: 1, description: 'Nucleus sampling (0-1)' },
{ key: 'parameters.top_k', label: 'Top K', type: 'number', min: 1, description: 'Top-k sampling' },
{ key: 'parameters.max_tokens', label: 'Max Tokens', type: 'number', min: 1, description: 'Maximum tokens to generate' },
{ key: 'parameters.seed', label: 'Seed', type: 'number', description: 'Random seed for reproducible results' },
{ key: 'parameters.repeat_penalty', label: 'Repeat Penalty', type: 'number', step: 0.01, min: 0, description: 'Penalty for repetition' },
{ key: 'parameters.frequency_penalty', label: 'Frequency Penalty', type: 'number', step: 0.01, description: 'Frequency-based penalty' },
{ key: 'parameters.presence_penalty', label: 'Presence Penalty', type: 'number', step: 0.01, description: 'Presence-based penalty' },
{ key: 'parameters.n', label: 'N (Number of results)', type: 'number', min: 1, description: 'Number of results to return' },
{ key: 'parameters.echo', label: 'Echo', type: 'checkbox', description: 'Echo back the prompt' },
{ key: 'parameters.batch', label: 'Batch Size', type: 'number', min: 1, description: 'Batch size for processing' },
{ key: 'parameters.ignore_eos', label: 'Ignore EOS', type: 'checkbox', description: 'Ignore end-of-sequence tokens' },
{ key: 'parameters.repeat_last_n', label: 'Repeat Last N', type: 'number', min: 0, description: 'Consider last N tokens for repeat penalty' },
{ key: 'parameters.n_keep', label: 'N Keep', type: 'number', min: 0, description: 'Number of tokens to keep from prompt' },
{ key: 'parameters.tfz', label: 'TFZ (Tail Free Sampling)', type: 'number', step: 0.01, min: 0, max: 1, description: 'Tail free sampling parameter' },
{ key: 'parameters.typical_p', label: 'Typical P', type: 'number', step: 0.01, min: 0, max: 1, description: 'Typical sampling parameter' },
{ key: 'parameters.negative_prompt', label: 'Negative Prompt', type: 'textarea', description: 'Negative prompt for image generation' },
{ key: 'parameters.rope_freq_base', label: 'RoPE Freq Base', type: 'number', step: 0.01, description: 'RoPE frequency base' },
{ key: 'parameters.rope_freq_scale', label: 'RoPE Freq Scale', type: 'number', step: 0.01, description: 'RoPE frequency scale' },
{ key: 'parameters.negative_prompt_scale', label: 'Negative Prompt Scale', type: 'number', step: 0.01, description: 'Scale for negative prompt' },
{ key: 'parameters.clip_skip', label: 'CLIP Skip', type: 'number', min: 0, description: 'CLIP layers to skip' },
{ key: 'parameters.language', label: 'Language', type: 'text', description: 'Language for transcription/TTS' },
{ key: 'parameters.translate', label: 'Translate', type: 'checkbox', description: 'Enable translation for audio transcription' },
{ key: 'parameters.tokenizer', label: 'Tokenizer', type: 'text', description: 'Tokenizer to use' }
]
},
{
title: 'Advanced Configuration',
icon: 'fas fa-tools',
collapsible: true,
fields: [
{ key: 'f16', label: 'Use F16', type: 'checkbox', description: 'Use 16-bit floating point precision' },
{ key: 'threads', label: 'Threads', type: 'number', min: 1, description: 'Number of CPU threads to use' },
{ key: 'debug', label: 'Debug Mode', type: 'checkbox', description: 'Enable debug logging' },
{ key: 'embeddings', label: 'Embeddings Mode', type: 'checkbox', description: 'Enable embeddings generation' },
{ key: 'cuda', label: 'Enable CUDA', type: 'checkbox', description: 'Use CUDA acceleration' },
{ key: 'mmap', label: 'Memory Mapping', type: 'checkbox', description: 'Use memory mapping for model loading' },
{ key: 'mmlock', label: 'Memory Lock', type: 'checkbox', description: 'Lock model in memory' },
{ key: 'low_vram', label: 'Low VRAM Mode', type: 'checkbox', description: 'Optimize for low VRAM usage' },
{ key: 'numa', label: 'NUMA', type: 'checkbox', description: 'Enable NUMA support' },
{ key: 'context_size', label: 'Context Size', type: 'number', min: 1, description: 'Maximum context size' },
{ key: 'gpu_layers', label: 'GPU Layers', type: 'number', min: 0, description: 'Number of layers to offload to GPU' },
{ key: 'reranking', label: 'Reranking', type: 'checkbox', description: 'Enable reranking' }
]
},
{
title: 'Templates',
icon: 'fas fa-file-alt',
collapsible: true,
fields: [
{ key: 'template.chat', label: 'Chat Template', type: 'textarea', description: 'Template for chat completion' },
{ key: 'template.completion', label: 'Completion Template', type: 'textarea', description: 'Template for completion requests' },
{ key: 'template.edit', label: 'Edit Template', type: 'textarea', description: 'Template for edit requests' }
]
},
{
title: 'Use Cases',
icon: 'fas fa-tags',
collapsible: true,
fields: [
{ key: 'known_usecases', label: 'Known Use Cases', type: 'multiselect',
options: ['chat', 'completion', 'edit', 'embeddings', 'rerank', 'image', 'transcript', 'tts', 'sound_generation', 'tokenize', 'vad', 'video', 'detection'],
description: 'Supported model capabilities' }
]
},
{
title: 'LLM Configuration',
icon: 'fas fa-brain',
collapsible: true,
fields: [
{ key: 'system_prompt', label: 'System Prompt', type: 'textarea', description: 'System prompt for the model' },
{ key: 'tensor_split', label: 'Tensor Split', type: 'text', description: 'GPU tensor split configuration' },
{ key: 'main_gpu', label: 'Main GPU', type: 'text', description: 'Primary GPU to use' },
{ key: 'mirostat', label: 'Mirostat', type: 'number', min: 0, max: 2, description: 'Mirostat sampling mode (0=disabled, 1=mirostat, 2=mirostat2)' },
{ key: 'mirostat_eta', label: 'Mirostat ETA', type: 'number', step: 0.01, description: 'Mirostat learning rate' },
{ key: 'mirostat_tau', label: 'Mirostat TAU', type: 'number', step: 0.01, description: 'Mirostat target entropy' },
{ key: 'lora_adapter', label: 'LoRA Adapter', type: 'text', description: 'Path to LoRA adapter' },
{ key: 'lora_base', label: 'LoRA Base', type: 'text', description: 'Base model for LoRA' },
{ key: 'lora_scale', label: 'LoRA Scale', type: 'number', step: 0.01, description: 'LoRA scaling factor' },
{ key: 'lora_adapters', label: 'LoRA Adapters', type: 'array', description: 'Multiple LoRA adapters' },
{ key: 'lora_scales', label: 'LoRA Scales', type: 'array', description: 'Scaling factors for multiple LoRA adapters' },
{ key: 'rope_scaling', label: 'RoPE Scaling', type: 'text', description: 'RoPE position scaling method' },
{ key: 'yarn_ext_factor', label: 'YARN Ext Factor', type: 'number', step: 0.01, description: 'YARN extension factor' },
{ key: 'yarn_attn_factor', label: 'YARN Attn Factor', type: 'number', step: 0.01, description: 'YARN attention factor' },
{ key: 'yarn_beta_fast', label: 'YARN Beta Fast', type: 'number', step: 0.01, description: 'YARN beta fast parameter' },
{ key: 'yarn_beta_slow', label: 'YARN Beta Slow', type: 'number', step: 0.01, description: 'YARN beta slow parameter' },
{ key: 'cfg_scale', label: 'CFG Scale', type: 'number', step: 0.01, description: 'Classifier-free guidance scale' },
{ key: 'grammar', label: 'Grammar', type: 'textarea', description: 'Grammar constraints for generation' },
{ key: 'quantization', label: 'Quantization', type: 'text', description: 'Quantization method (e.g., q4_0, q8_0)' },
{ key: 'load_format', label: 'Load Format', type: 'text', description: 'Model loading format' },
{ key: 'type', label: 'Model Type', type: 'text', description: 'Model architecture type' }
]
},
{
title: 'vLLM Configuration',
icon: 'fas fa-server',
collapsible: true,
fields: [
{ key: 'gpu_memory_utilization', label: 'GPU Memory Utilization', type: 'number', step: 0.01, min: 0, max: 1, description: 'GPU memory utilization ratio (0-1)' },
{ key: 'trust_remote_code', label: 'Trust Remote Code', type: 'checkbox', description: 'Trust remote code execution' },
{ key: 'enforce_eager', label: 'Enforce Eager', type: 'checkbox', description: 'Enforce eager execution' },
{ key: 'swap_space', label: 'Swap Space', type: 'number', min: 0, description: 'Swap space in GB' },
{ key: 'max_model_len', label: 'Max Model Length', type: 'number', min: 1, description: 'Maximum model context length' },
{ key: 'tensor_parallel_size', label: 'Tensor Parallel Size', type: 'number', min: 1, description: 'Number of GPUs for tensor parallelism' },
{ key: 'disable_log_stats', label: 'Disable Log Stats', type: 'checkbox', description: 'Disable logging of statistics' },
{ key: 'dtype', label: 'Data Type', type: 'text', description: 'Model data type (e.g., float16, bfloat16)' }
]
},
{
title: 'Text Processing',
icon: 'fas fa-text-width',
collapsible: true,
fields: [
{ key: 'stopwords', label: 'Stop Words', type: 'array', description: 'List of stop words' },
{ key: 'cutstrings', label: 'Cut Strings', type: 'array', description: 'Strings to cut from output' },
{ key: 'extract_regex', label: 'Extract Regex', type: 'array', description: 'Regex patterns for extraction' },
{ key: 'trimspace', label: 'Trim Space', type: 'array', description: 'Characters to trim' },
{ key: 'trimsuffix', label: 'Trim Suffix', type: 'array', description: 'Suffixes to trim' },
{ key: 'options', label: 'Options', type: 'array', description: 'Additional options' },
{ key: 'roles', label: 'Roles', type: 'keyvalue', description: 'Role mappings' }
]
}
];
formSections.forEach(section => {
const sectionEl = this.createFormSection(section);
form.appendChild(sectionEl);
});
}
createFormSection(section) {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'form-section';
const isCollapsible = section.collapsible;
const sectionId = section.title.toLowerCase().replace(/\s+/g, '-');
sectionDiv.innerHTML = `
<div class="section-header ${isCollapsible ? 'cursor-pointer' : ''}" ${isCollapsible ? `onclick="this.nextElementSibling.classList.toggle('hidden'); this.querySelector('.collapse-icon').classList.toggle('rotate-180'); this.closest('.form-section').classList.toggle('expanded')"` : ''}>
<h3 class="text-lg font-semibold text-white flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
<i class="${section.icon} text-violet-400"></i>
</div>
${section.title}
${isCollapsible ? '<i class="fas fa-chevron-down text-sm ml-auto collapse-icon transition-transform duration-300"></i>' : ''}
</h3>
</div>
<div class="p-6 space-y-6 ${isCollapsible ? 'hidden' : ''}" id="${sectionId}-content">
${section.fields.map(field => this.createFormField(field)).join('')}
</div>
`;
return sectionDiv;
}
createFormField(field) {
const value = this.getNestedValue(this.config, field.key) || '';
const fieldId = field.key.replace(/\./g, '_');
let inputHtml = '';
switch (field.type) {
case 'text':
const readonlyAttr = field.readonly ? 'readonly' : '';
const readonlyClass = field.readonly ? 'bg-gray-700 cursor-not-allowed opacity-60' : '';
inputHtml = `<input type="text" id="${fieldId}" name="${field.key}" value="${value}" class="form-input w-full ${readonlyClass}" ${field.required ? 'required' : ''} ${readonlyAttr}>`;
break;
case 'textarea':
inputHtml = `<textarea id="${fieldId}" name="${field.key}" rows="3" class="form-input w-full resize-vertical">${value}</textarea>`;
break;
case 'number':
const step = field.step || '1';
const min = field.min !== undefined ? `min="${field.min}"` : '';
const max = field.max !== undefined ? `max="${field.max}"` : '';
inputHtml = `<input type="number" id="${fieldId}" name="${field.key}" value="${value}" step="${step}" ${min} ${max} class="form-input w-full">`;
break;
case 'checkbox':
const checked = value === true || value === 'true' ? 'checked' : '';
inputHtml = `
<div class="flex items-center">
<input type="checkbox" id="${fieldId}" name="${field.key}" ${checked} class="w-5 h-5 text-violet-600 bg-gray-800 border-gray-600 rounded focus:ring-violet-500 focus:ring-2">
<label for="${fieldId}" class="ml-3 text-sm text-gray-300 font-medium">Enable</label>
</div>`;
break;
case 'select':
const options = field.options.map(opt => `<option value="${opt}" ${value === opt ? 'selected' : ''}>${opt || '(Select backend)'}</option>`).join('');
inputHtml = `<select id="${fieldId}" name="${field.key}" class="form-input w-full" ${field.required ? 'required' : ''}>${options}</select>`;
break;
case 'multiselect':
const currentValues = Array.isArray(value) ? value : [];
const checkboxes = field.options.map(opt => {
const isChecked = currentValues.includes(opt);
return `
<div class="flex items-center p-2 rounded-lg hover:bg-violet-500/10 transition-colors">
<input type="checkbox" id="${fieldId}_${opt}" name="${field.key}" value="${opt}" ${isChecked ? 'checked' : ''} class="w-4 h-4 text-violet-600 bg-gray-800 border-gray-600 rounded focus:ring-violet-500 focus:ring-2">
<label for="${fieldId}_${opt}" class="ml-3 text-sm text-gray-300 font-medium">${opt}</label>
</div>`;
}).join('');
inputHtml = `<div class="space-y-2 max-h-40 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800 p-2 border border-gray-600/50 rounded-lg bg-gray-800/30">${checkboxes}</div>`;
break;
case 'array':
const arrayValues = Array.isArray(value) ? value : [];
inputHtml = `
<div class="space-y-3">
<div id="${fieldId}_container" class="space-y-2">
${arrayValues.map((item, index) => `
<div class="flex gap-2">
<input type="text" value="${item}" class="form-input flex-1" onchange="modelEditor.updateArrayField('${field.key}', ${index}, this.value)">
<button type="button" onclick="modelEditor.removeArrayItem('${field.key}', ${index})" class="danger-button px-3 py-2 text-white">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('')}
</div>
<button type="button" onclick="modelEditor.addArrayItem('${field.key}')" class="action-button px-4 py-2 text-white text-sm">
<i class="fas fa-plus mr-2"></i> Add Item
</button>
</div>`;
break;
case 'keyvalue':
const kvPairs = typeof value === 'object' && value !== null ? value : {};
const kvEntries = Object.entries(kvPairs);
inputHtml = `
<div class="space-y-3">
<div id="${fieldId}_container" class="space-y-2">
${kvEntries.map(([key, val], index) => `
<div class="flex gap-2">
<input type="text" value="${key}" placeholder="Key" class="form-input flex-1" onchange="modelEditor.updateKeyValueField('${field.key}', ${index}, this.value, this.nextElementSibling.value, 'key')">
<input type="text" value="${val}" placeholder="Value" class="form-input flex-1" onchange="modelEditor.updateKeyValueField('${field.key}', ${index}, this.previousElementSibling.value, this.value, 'value')">
<button type="button" onclick="modelEditor.removeKeyValueItem('${field.key}', ${index})" class="danger-button px-3 py-2 text-white">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('')}
</div>
<button type="button" onclick="modelEditor.addKeyValueItem('${field.key}')" class="action-button px-4 py-2 text-white text-sm">
<i class="fas fa-plus mr-2"></i> Add Pair
</button>
</div>`;
break;
}
return `
<div class="space-y-3">
<label for="${fieldId}" class="block text-sm font-semibold text-gray-300">
${field.label}
${field.required ? '<span class="text-red-400 ml-1">*</span>' : ''}
</label>
${inputHtml}
${field.description ? `<p class="text-xs text-gray-500 leading-relaxed">${field.description}</p>` : ''}
</div>
`;
}
initializeCodeMirror() {
this.yamlEditor = CodeMirror(document.getElementById('yamlCodeMirror'), {
mode: 'yaml',
theme: 'default',
lineNumbers: true,
autoRefresh: true,
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
lineWrapping: false,
styleActiveLine: true,
matchBrackets: true,
autoCloseBrackets: true,
value: {{if .ConfigYAML}}`{{.ConfigYAML}}`{{else}}'# Configuration will appear here...'{{end}}
});
// Update config when YAML changes
this.yamlEditor.on('change', () => {
if (!this.isUpdatingFromForm) {
this.updateConfigFromYaml();
}
});
// If no initial YAML, set the default config
{{if not .ConfigYAML}}
setTimeout(() => {
this.updateYamlEditor();
}, 100);
{{end}}
}
configToYaml() {
try {
return jsyaml.dump(this.config, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false
});
} catch (error) {
console.error('Failed to generate YAML:', error);
return '';
}
}
bindEvents() {
// Form change events
document.getElementById('configForm').addEventListener('change', (e) => {
if (!this.isUpdatingFromYaml) {
this.updateConfigFromForm();
}
});
document.getElementById('configForm').addEventListener('input', (e) => {
if (!this.isUpdatingFromYaml && e.target.type !== 'checkbox') {
this.updateConfigFromForm();
}
});
// Button events
document.getElementById('saveBtn').addEventListener('click', () => this.saveConfig());
document.getElementById('validateBtn').addEventListener('click', () => this.validateConfig());
document.getElementById('resetFormBtn').addEventListener('click', () => this.resetForm());
document.getElementById('expandAllBtn').addEventListener('click', () => this.expandAllSections());
document.getElementById('formatYamlBtn').addEventListener('click', () => this.formatYaml());
document.getElementById('copyYamlBtn').addEventListener('click', () => this.copyYaml());
}
updateConfigFromForm() {
this.isUpdatingFromForm = true;
const formData = new FormData(document.getElementById('configForm'));
const newConfig = {};
for (let [key, value] of formData.entries()) {
this.setNestedValue(newConfig, key, value);
}
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
if (checkbox.name && !checkbox.name.includes('known_usecases')) {
this.setNestedValue(newConfig, checkbox.name, checkbox.checked);
}
});
const multiselectContainers = document.querySelectorAll('input[type="checkbox"][name*="known_usecases"]');
const usecases = [];
multiselectContainers.forEach(checkbox => {
if (checkbox.checked) {
usecases.push(checkbox.value);
}
});
if (usecases.length > 0) {
newConfig.known_usecases = usecases;
}
this.config = this.deepMerge(this.config, newConfig);
this.updateYamlEditor();
this.isUpdatingFromForm = false;
}
updateConfigFromYaml() {
if (this.isUpdatingFromForm) return;
this.isUpdatingFromYaml = true;
try {
const yamlContent = this.yamlEditor.getValue();
this.config = jsyaml.load(yamlContent) || {};
this.updateFormFromConfig();
this.clearAlert();
} catch (error) {
this.showAlert('error', 'YAML Syntax Error: ' + error.message);
}
this.isUpdatingFromYaml = false;
}
updateFormFromConfig() {
const form = document.getElementById('configForm');
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
if (input.name && !input.name.includes('known_usecases')) {
const value = this.getNestedValue(this.config, input.name);
if (input.type === 'checkbox') {
input.checked = value === true || value === 'true';
} else {
input.value = value || '';
}
}
});
this.updateMultiselectFromConfig();
this.updateArraysFromConfig();
this.updateKeyValueFromConfig();
}
updateMultiselectFromConfig() {
const usecases = this.config.known_usecases || [];
const checkboxes = document.querySelectorAll('input[type="checkbox"][name="known_usecases"]');
checkboxes.forEach(checkbox => {
checkbox.checked = usecases.includes(checkbox.value);
});
}
updateArraysFromConfig() {
const arrayFields = ['stopwords', 'cutstrings', 'extract_regex', 'trimspace', 'trimsuffix', 'options', 'lora_adapters', 'lora_scales'];
arrayFields.forEach(field => {
const container = document.getElementById(field.replace(/\./g, '_') + '_container');
if (container) {
const values = this.getNestedValue(this.config, field) || [];
this.regenerateArrayField(field, values);
}
});
}
updateKeyValueFromConfig() {
const kvFields = ['roles'];
kvFields.forEach(field => {
const container = document.getElementById(field.replace(/\./g, '_') + '_container');
if (container) {
const values = this.getNestedValue(this.config, field) || {};
this.regenerateKeyValueField(field, values);
}
});
}
updateYamlEditor() {
if (!this.isUpdatingFromForm) return;
try {
const yamlContent = jsyaml.dump(this.config, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false
});
this.yamlEditor.setValue(yamlContent);
} catch (error) {
console.error('Failed to generate YAML:', error);
}
}
// Array manipulation methods
addArrayItem(fieldKey) {
const values = this.getNestedValue(this.config, fieldKey) || [];
values.push('');
this.setNestedValue(this.config, fieldKey, values);
this.regenerateArrayField(fieldKey, values);
this.updateYamlEditor();
}
removeArrayItem(fieldKey, index) {
const values = this.getNestedValue(this.config, fieldKey) || [];
values.splice(index, 1);
this.setNestedValue(this.config, fieldKey, values);
this.regenerateArrayField(fieldKey, values);
this.updateYamlEditor();
}
updateArrayField(fieldKey, index, value) {
const values = this.getNestedValue(this.config, fieldKey) || [];
values[index] = value;
this.setNestedValue(this.config, fieldKey, values);
this.updateYamlEditor();
}
regenerateArrayField(fieldKey, values) {
const fieldId = fieldKey.replace(/\./g, '_');
const container = document.getElementById(fieldId + '_container');
if (!container) return;
container.innerHTML = values.map((item, index) => `
<div class="flex gap-2">
<input type="text" value="${item}" class="form-input flex-1" onchange="modelEditor.updateArrayField('${fieldKey}', ${index}, this.value)">
<button type="button" onclick="modelEditor.removeArrayItem('${fieldKey}', ${index})" class="danger-button px-3 py-2 text-white">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
}
// Key-value manipulation methods
addKeyValueItem(fieldKey) {
const values = this.getNestedValue(this.config, fieldKey) || {};
const newKey = 'new_key_' + Object.keys(values).length;
values[newKey] = '';
this.setNestedValue(this.config, fieldKey, values);
this.regenerateKeyValueField(fieldKey, values);
this.updateYamlEditor();
}
removeKeyValueItem(fieldKey, index) {
const values = this.getNestedValue(this.config, fieldKey) || {};
const keys = Object.keys(values);
delete values[keys[index]];
this.setNestedValue(this.config, fieldKey, values);
this.regenerateKeyValueField(fieldKey, values);
this.updateYamlEditor();
}
updateKeyValueField(fieldKey, index, key, value, changeType) {
const values = this.getNestedValue(this.config, fieldKey) || {};
const keys = Object.keys(values);
const oldKey = keys[index];
if (changeType === 'key' && key !== oldKey) {
delete values[oldKey];
values[key] = value;
} else {
values[key] = value;
}
this.setNestedValue(this.config, fieldKey, values);
this.updateYamlEditor();
}
regenerateKeyValueField(fieldKey, values) {
const fieldId = fieldKey.replace(/\./g, '_');
const container = document.getElementById(fieldId + '_container');
if (!container) return;
const entries = Object.entries(values);
container.innerHTML = entries.map(([key, val], index) => `
<div class="flex gap-2">
<input type="text" value="${key}" placeholder="Key" class="form-input flex-1" onchange="modelEditor.updateKeyValueField('${fieldKey}', ${index}, this.value, this.nextElementSibling.value, 'key')">
<input type="text" value="${val}" placeholder="Value" class="form-input flex-1" onchange="modelEditor.updateKeyValueField('${fieldKey}', ${index}, this.previousElementSibling.value, this.value, 'value')">
<button type="button" onclick="modelEditor.removeKeyValueItem('${fieldKey}', ${index})" class="danger-button px-3 py-2 text-white">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
}
// Utility methods
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => current && current[key], obj);
}
setNestedValue(obj, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
const target = keys.reduce((current, key) => {
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
return current[key];
}, obj);
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (value === '' || value === null) value = undefined;
else if (!isNaN(value) && !isNaN(parseFloat(value)) && value !== '') {
value = parseFloat(value);
}
if (value !== undefined) {
target[lastKey] = value;
} else {
delete target[lastKey];
}
}
deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = this.deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
// Action methods
validateConfig() {
try {
const yamlContent = this.yamlEditor.getValue();
jsyaml.load(yamlContent);
if (!this.config.name) {
throw new Error('Model name is required');
}
if (!this.config.backend) {
throw new Error('Backend is required');
}
if (!this.config.parameters || !this.config.parameters.model) {
throw new Error('Model file/path is required');
}
this.showAlert('success', 'Configuration is valid!');
} catch (error) {
this.showAlert('error', 'Validation failed: ' + error.message);
}
}
async saveConfig() {
try {
this.validateConfig();
const yamlContent = this.yamlEditor.getValue();
const endpoint = this.isEditMode ? `/models/edit/${this.modelName}` : '/models/import';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-yaml',
},
body: yamlContent
});
const result = await response.json();
if (result.success) {
this.showAlert('success', result.message || (this.isEditMode ? 'Model updated successfully!' : 'Model created successfully!'));
if (!this.isEditMode && this.config.name) {
setTimeout(() => {
window.location.href = `/models/edit/${this.config.name}`;
}, 2000);
}
} else {
this.showAlert('error', result.error || 'Failed to save configuration');
}
} catch (error) {
this.showAlert('error', 'Failed to save: ' + error.message);
}
}
resetForm() {
if (confirm('Are you sure you want to reset the form? All changes will be lost.')) {
// Set the flag to allow YAML editor updates
this.isUpdatingFromForm = true;
{{if .ConfigYAML}}
try {
this.config = jsyaml.load(`{{.ConfigYAML}}`);
} catch (e) {
this.config = this.getDefaultConfig();
}
{{else}}
this.config = this.getDefaultConfig();
{{end}}
this.updateFormFromConfig();
this.updateYamlEditor();
// Reset the flag
this.isUpdatingFromForm = false;
this.showAlert('info', 'Form has been reset');
}
}
expandAllSections() {
const collapsibleSections = document.querySelectorAll('.collapse-icon');
collapsibleSections.forEach(icon => {
const content = icon.closest('div').nextElementSibling;
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
icon.classList.add('rotate-180');
icon.closest('.form-section').classList.add('expanded');
}
});
}
formatYaml() {
try {
const yamlContent = this.yamlEditor.getValue();
const parsed = jsyaml.load(yamlContent);
const formatted = jsyaml.dump(parsed, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: true
});
this.yamlEditor.setValue(formatted);
this.showAlert('success', 'YAML formatted successfully');
} catch (error) {
this.showAlert('error', 'Failed to format YAML: ' + error.message);
}
}
copyYaml() {
const yamlContent = this.yamlEditor.getValue();
navigator.clipboard.writeText(yamlContent).then(() => {
this.showAlert('success', 'YAML copied to clipboard');
}).catch(err => {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = yamlContent;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showAlert('success', 'YAML copied to clipboard');
});
}
showAlert(type, message) {
const container = document.getElementById('alertContainer');
const alertClasses = {
success: 'alert alert-success',
error: 'alert alert-error',
warning: 'alert alert-warning',
info: 'alert alert-info'
};
const alertIcons = {
success: 'fas fa-check-circle',
error: 'fas fa-exclamation-triangle',
warning: 'fas fa-exclamation-circle',
info: 'fas fa-info-circle'
};
container.innerHTML = `
<div class="${alertClasses[type]}">
<div class="flex items-center">
<i class="${alertIcons[type]} mr-3 text-lg"></i>
<span class="flex-1">${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="ml-4 text-current hover:opacity-70 transition-opacity">
<i class="fas fa-times"></i>
</button>
</div>
</div>
`;
if (type === 'success' || type === 'info') {
setTimeout(() => {
const alert = container.querySelector('div');
if (alert) alert.remove();
}, 5000);
}
}
clearAlert() {
document.getElementById('alertContainer').innerHTML = '';
}
}
// Initialize the editor when the page loads
let modelEditor;
document.addEventListener('DOMContentLoaded', () => {
modelEditor = new ModelEditor();
});
</script>
</body>
</html>