Files
LocalAI/core/http/views/index.html
Ettore Di Giacinto 721c3f962b chore: scroll in thinking mode, better buttons placement (#7289)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-11-17 16:21:14 +01:00

588 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
{{template "views/partials/head" .}}
<body class="bg-[#101827] text-[#E5E7EB]">
<div class="flex flex-col min-h-screen">
{{template "views/partials/navbar" .}}
<!-- Main Content - ChatGPT-style minimal interface -->
<div class="flex-1 flex flex-col items-center justify-center px-4 py-12">
<div class="w-full max-w-3xl mx-auto">
{{ if eq (len .ModelsConfig) 0 }}
<!-- No Models - Wizard Guide -->
<div class="bg-[#1E293B] border border-[#8B5CF6]/20 rounded-xl p-12">
<div class="text-center max-w-4xl mx-auto">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#8B5CF6]/10 border border-[#8B5CF6]/20 mb-6">
<i class="text-[#8B5CF6] text-2xl fas fa-robot"></i>
</div>
<h2 class="text-3xl md:text-4xl font-bold text-[#E5E7EB] mb-4">
<span class="bg-clip-text text-transparent bg-gradient-to-r from-[#38BDF8] to-[#8B5CF6]">
No Models Installed
</span>
</h2>
<p class="text-xl text-[#94A3B8] mb-8">
Get started with LocalAI by installing your first model. Choose from our gallery, import your own, or use the API to download models.
</p>
<!-- Features Preview -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
<div class="bg-[#101827] border border-[#38BDF8]/20 rounded-lg p-4">
<div class="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center mx-auto mb-3">
<i class="fas fa-images text-[#38BDF8] text-xl"></i>
</div>
<h3 class="text-sm font-semibold text-[#E5E7EB] mb-2">Model Gallery</h3>
<p class="text-xs text-[#94A3B8]">Browse and install pre-configured models</p>
</div>
<div class="bg-[#101827] border border-[#8B5CF6]/20 rounded-lg p-4">
<div class="w-10 h-10 bg-purple-500/10 rounded-lg flex items-center justify-center mx-auto mb-3">
<i class="fas fa-upload text-[#8B5CF6] text-xl"></i>
</div>
<h3 class="text-sm font-semibold text-[#E5E7EB] mb-2">Import Models</h3>
<p class="text-xs text-[#94A3B8]">Upload your own model files</p>
</div>
<div class="bg-[#101827] border border-green-500/20 rounded-lg p-4">
<div class="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center mx-auto mb-3">
<i class="fas fa-code text-green-400 text-xl"></i>
</div>
<h3 class="text-sm font-semibold text-[#E5E7EB] mb-2">API Download</h3>
<p class="text-xs text-[#94A3B8]">Use the API to download models programmatically</p>
</div>
</div>
<!-- Setup Instructions -->
<div class="bg-[#101827] border border-[#8B5CF6]/20 rounded-xl p-6 mb-8 text-left">
<h3 class="text-lg font-bold text-[#E5E7EB] mb-4 flex items-center">
<i class="fas fa-rocket text-[#8B5CF6] mr-2"></i>
How to Get Started
</h3>
<div class="space-y-4">
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[#8B5CF6]/20 flex items-center justify-center mr-3 mt-0.5">
<span class="text-[#8B5CF6] font-bold text-sm">1</span>
</div>
<div class="flex-1">
<p class="text-[#E5E7EB] font-medium mb-2">Browse the Model Gallery</p>
<p class="text-[#94A3B8] text-sm">Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[#8B5CF6]/20 flex items-center justify-center mr-3 mt-0.5">
<span class="text-[#8B5CF6] font-bold text-sm">2</span>
</div>
<div class="flex-1">
<p class="text-[#E5E7EB] font-medium mb-2">Install a Model</p>
<p class="text-[#94A3B8] text-sm">Click on a model from the gallery to install it, or use the import feature to upload your own model files.</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[#8B5CF6]/20 flex items-center justify-center mr-3 mt-0.5">
<span class="text-[#8B5CF6] font-bold text-sm">3</span>
</div>
<div class="flex-1">
<p class="text-[#E5E7EB] font-medium mb-2">Start Chatting</p>
<p class="text-[#94A3B8] text-sm">Once installed, return to this page to start chatting with your model or use the API to interact programmatically.</p>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap justify-center gap-4">
<a href="/browse/"
class="inline-flex items-center bg-[#8B5CF6] hover:bg-[#8B5CF6]/90 text-white py-3 px-6 rounded-lg font-semibold transition-colors">
<i class="fas fa-images mr-2"></i>
Browse Model Gallery
</a>
<a href="/import-model"
class="inline-flex items-center bg-[#38BDF8] hover:bg-[#38BDF8]/90 text-white py-3 px-6 rounded-lg font-semibold transition-colors">
<i class="fas fa-upload mr-2"></i>
Import Model
</a>
<a href="https://localai.io/basics/getting_started/" target="_blank"
class="inline-flex items-center bg-[#1E293B] hover:bg-[#1E293B]/80 border border-[#8B5CF6]/20 text-[#E5E7EB] py-3 px-6 rounded-lg font-semibold transition-colors">
<i class="fas fa-graduation-cap mr-2"></i>
Getting Started
<i class="fas fa-external-link-alt ml-2 text-sm"></i>
</a>
</div>
</div>
</div>
{{ else }}
<!-- Welcome Message -->
<div class="text-center mb-12">
<div class="mb-6 flex justify-center">
<img src="static/logo.png" alt="LocalAI Logo" class="h-24 md:h-32 drop-shadow-[0_0_15px_rgba(56,189,248,0.3)]">
</div>
<p class="text-lg text-[#94A3B8]">How can I help you today?</p>
</div>
<!-- Chat Input Form -->
<div class="mb-8" x-data="{
selectedModel: '',
inputValue: '',
shiftPressed: false,
fileName: '',
imageFiles: [],
audioFiles: [],
textFiles: [],
currentPlaceholder: 'Send a message...',
placeholderIndex: 0,
charIndex: 0,
isTyping: false,
typingTimeout: null,
displayTimeout: null,
placeholderMessages: [
'What is Nuclear fusion?',
'How does a combustion engine work?',
'Explain quantum computing',
'What causes climate change?',
'How do neural networks learn?',
'What is the theory of relativity?',
'How does photosynthesis work?',
'Explain the water cycle',
'What is machine learning?',
'How do black holes form?',
'What is DNA and how does it work?',
'Explain the greenhouse effect',
'How does the immune system work?',
'What is artificial intelligence?',
'How do solar panels generate electricity?',
'Explain the process of evolution',
'What is the difference between weather and climate?',
'How does the human brain process information?',
'What is the structure of an atom?',
'How do vaccines work?',
'Explain the concept of entropy',
'What is the speed of light?',
'How does gravity work?',
'What is the difference between mass and weight?'
],
init() {
window.currentPlaceholderText = this.currentPlaceholder;
this.startTypingAnimation();
// Select first model by default
this.$nextTick(() => {
const select = this.$el.querySelector('select');
if (select && select.options.length > 1) {
// Skip the first option (disabled placeholder) and select the first real option
const firstModelOption = select.options[1];
if (firstModelOption && firstModelOption.value) {
this.selectedModel = firstModelOption.value;
}
}
});
},
startTypingAnimation() {
if (this.isTyping) return;
this.typeNextPlaceholder();
},
typeNextPlaceholder() {
if (this.isTyping) return;
this.isTyping = true;
this.charIndex = 0;
const message = this.placeholderMessages[this.placeholderIndex];
this.currentPlaceholder = '';
window.currentPlaceholderText = '';
const typeChar = () => {
if (this.charIndex < message.length) {
this.currentPlaceholder = message.substring(0, this.charIndex + 1);
window.currentPlaceholderText = this.currentPlaceholder;
this.charIndex++;
this.typingTimeout = setTimeout(typeChar, 30);
} else {
// Finished typing, wait 2 seconds then move to next
this.isTyping = false;
window.currentPlaceholderText = this.currentPlaceholder;
this.displayTimeout = setTimeout(() => {
this.placeholderIndex = (this.placeholderIndex + 1) % this.placeholderMessages.length;
this.typeNextPlaceholder();
}, 2000);
}
};
typeChar();
},
pauseTyping() {
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
this.typingTimeout = null;
}
if (this.displayTimeout) {
clearTimeout(this.displayTimeout);
this.displayTimeout = null;
}
this.isTyping = false;
},
resumeTyping() {
if (!this.inputValue.trim() && !this.isTyping) {
this.startTypingAnimation();
}
},
handleFocus() {
// Complete the current placeholder instantly if typing
if (this.isTyping && this.placeholderIndex < this.placeholderMessages.length) {
const fullMessage = this.placeholderMessages[this.placeholderIndex];
this.currentPlaceholder = fullMessage;
window.currentPlaceholderText = fullMessage;
}
this.pauseTyping();
},
handleBlur() {
if (!this.inputValue.trim()) {
this.resumeTyping();
}
},
handleInput() {
if (this.inputValue.trim()) {
this.pauseTyping();
} else {
this.resumeTyping();
}
}
}">
<!-- Model Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-[#94A3B8] mb-2">Select Model</label>
<select
x-model="selectedModel"
class="w-full bg-[#1E293B] text-[#E5E7EB] border border-[#38BDF8]/20 focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50 rounded-lg p-3 appearance-none"
required
>
<option value="" disabled class="text-[#94A3B8]">Select a model to chat with...</option>
{{ range .ModelsConfig }}
{{ $cfg := . }}
{{ range .KnownUsecaseStrings }}
{{ if eq . "FLAG_CHAT" }}
<option value="{{$cfg.Name}}" class="bg-[#1E293B] text-[#E5E7EB]">{{$cfg.Name}}</option>
{{ end }}
{{ end }}
{{ end }}
</select>
</div>
<!-- Input Bar -->
<form @submit.prevent="startChat($event)" class="relative w-full">
<div class="relative w-full bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl focus-within:ring-2 focus-within:ring-[#38BDF8]/50 focus-within:border-[#38BDF8] transition-all duration-200">
<textarea
x-model="inputValue"
:placeholder="currentPlaceholder"
class="p-3 pr-16 w-full bg-[#1E293B] text-[#E5E7EB] placeholder-[#94A3B8] focus:outline-none resize-none border-0 rounded-xl transition-colors duration-200"
required
@keydown.shift="shiftPressed = true"
@keyup.shift="shiftPressed = false"
@keydown.enter.prevent="if (!shiftPressed && selectedModel && (inputValue.trim() || currentPlaceholder.trim())) { startChat($event); }"
@focus="handleFocus()"
@blur="handleBlur()"
@input="handleInput()"
rows="2"
></textarea>
<span x-show="fileName" x-text="fileName" class="absolute right-16 top-3 text-[#94A3B8] text-xs mr-2"></span>
<!-- Attachment Buttons -->
<button
type="button"
@click="document.getElementById('index_input_image').click()"
class="fa-solid fa-image text-[#94A3B8] absolute right-12 top-3 text-base p-1.5 hover:text-[#38BDF8] transition-colors duration-200"
title="Attach images"
></button>
<button
type="button"
@click="document.getElementById('index_input_audio').click()"
class="fa-solid fa-microphone text-[#94A3B8] absolute right-20 top-3 text-base p-1.5 hover:text-[#38BDF8] transition-colors duration-200"
title="Attach an audio file"
></button>
<button
type="button"
@click="document.getElementById('index_input_file').click()"
class="fa-solid fa-file text-[#94A3B8] absolute right-28 top-3 text-base p-1.5 hover:text-[#38BDF8] transition-colors duration-200"
title="Upload text, markdown or PDF file"
></button>
<!-- Send Button -->
<button
type="submit"
:disabled="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim())"
:class="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim()) ? 'opacity-50 cursor-not-allowed' : ''"
class="text-lg p-2 text-[#94A3B8] hover:text-[#38BDF8] transition-colors duration-200 absolute right-3 top-3"
title="Send message (Enter)"
>
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
</form>
<!-- Hidden File Inputs -->
<input
id="index_input_image"
type="file"
multiple
accept="image/*"
style="display: none;"
@change="imageFiles = Array.from($event.target.files); fileName = imageFiles.length > 0 ? imageFiles.length + ' image(s) selected' : ''"
/>
<input
id="index_input_audio"
type="file"
multiple
accept="audio/*"
style="display: none;"
@change="audioFiles = Array.from($event.target.files); fileName = audioFiles.length > 0 ? audioFiles.length + ' audio file(s) selected' : ''"
/>
<input
id="index_input_file"
type="file"
multiple
accept=".txt,.md,.pdf"
style="display: none;"
@change="textFiles = Array.from($event.target.files); fileName = textFiles.length > 0 ? textFiles.length + ' file(s) selected' : ''"
/>
</div>
<!-- Quick Links -->
<div class="flex flex-wrap justify-center gap-3 mb-8">
<a href="/manage"
class="inline-flex items-center text-sm text-[#94A3B8] hover:text-[#E5E7EB] px-4 py-2 rounded-lg hover:bg-[#1E293B] transition-colors">
<i class="fas fa-cog mr-2"></i>
Installed Models and Backends
</a>
<a href="/import-model" class="inline-flex items-center text-sm text-[#94A3B8] hover:text-[#E5E7EB] px-4 py-2 rounded-lg hover:bg-[#1E293B] transition-colors">
<i class="fas fa-upload mr-2"></i>
Import Model
</a>
<a href="/browse/"
class="inline-flex items-center text-sm text-[#94A3B8] hover:text-[#E5E7EB] px-4 py-2 rounded-lg hover:bg-[#1E293B] transition-colors">
<i class="fas fa-images mr-2"></i>
Browse Gallery
</a>
<a href="https://localai.io" target="_blank"
class="inline-flex items-center text-sm text-[#94A3B8] hover:text-[#E5E7EB] px-4 py-2 rounded-lg hover:bg-[#1E293B] transition-colors">
<i class="fas fa-book mr-2"></i>
Documentation
</a>
</div>
<!-- Model Status Summary - Subtle -->
{{ $loadedModels := .LoadedModels }}
<div class="mb-8 flex items-center justify-center gap-2 text-xs text-[#94A3B8]"
x-data="{ stoppingAll: false, stopAllModels() { window.stopAllModels(this); }, stopModel(name) { window.stopModel(name); }, getLoadedCount() { return document.querySelectorAll('[data-loaded-model]').length; } }"
x-show="getLoadedCount() > 0"
style="display: none;">
<span class="flex items-center gap-1.5">
<i class="fas fa-circle text-green-500 text-[10px]"></i>
<span x-text="`${getLoadedCount()} model(s) loaded`"></span>
</span>
<span class="text-[#38BDF8]/40"></span>
{{ range .ModelsConfig }}
{{ if index $loadedModels .Name }}
<span class="inline-flex items-center gap-1 text-[#94A3B8] hover:text-[#E5E7EB] transition-colors" data-loaded-model>
<span class="truncate max-w-[100px]">{{.Name}}</span>
<button
@click="stopModel('{{.Name}}')"
class="text-red-400/60 hover:text-red-400 transition-colors ml-0.5"
title="Stop {{.Name}}"
>
<i class="fas fa-times text-[10px]"></i>
</button>
</span>
{{ end }}
{{ end }}
<span class="text-[#38BDF8]/40"></span>
<button
@click="stopAllModels()"
:disabled="stoppingAll"
:class="stoppingAll ? 'opacity-50 cursor-not-allowed' : ''"
class="text-red-400/60 hover:text-red-400 transition-colors text-xs"
title="Stop all loaded models"
>
<span x-text="stoppingAll ? 'Stopping...' : 'Stop all'"></span>
</button>
</div>
{{ end }}
</div>
</div>
{{template "views/partials/footer" .}}
</div>
<script>
// Handle form submission - redirect to chat with message
function startChat(event) {
if (event) {
event.preventDefault();
}
// Get form data directly from form elements (Alpine x-model binds to value)
const form = event ? event.target.closest('form') : document.querySelector('form');
if (!form) return;
const alpineComponent = form.closest('[x-data]');
const select = alpineComponent ? alpineComponent.querySelector('select') : null;
const textarea = form.querySelector('textarea');
const selectedModel = select ? select.value : '';
let message = textarea ? textarea.value : '';
// If message is empty, use the current placeholder text
if (!message.trim() && window.currentPlaceholderText) {
message = window.currentPlaceholderText;
}
if (!selectedModel || !message.trim()) {
return;
}
// Store message and files in localStorage for chat page to pick up
const chatData = {
message: message,
imageFiles: [],
audioFiles: [],
textFiles: []
};
// Convert files to base64 for storage
const imageInput = document.getElementById('index_input_image');
const audioInput = document.getElementById('index_input_audio');
const fileInput = document.getElementById('index_input_file');
const filePromises = [
...Array.from(imageInput.files || []).map(file =>
new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
reader.readAsDataURL(file);
})
),
...Array.from(audioInput.files || []).map(file =>
new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
reader.readAsDataURL(file);
})
),
...Array.from(fileInput.files || []).map(file =>
new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
reader.readAsText(file);
})
)
];
if (filePromises.length > 0) {
Promise.all(filePromises).then(files => {
files.forEach(file => {
if (file.type.startsWith('image/')) {
chatData.imageFiles.push(file);
} else if (file.type.startsWith('audio/')) {
chatData.audioFiles.push(file);
} else {
chatData.textFiles.push(file);
}
});
// Store in localStorage
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
// Redirect to chat page
window.location.href = `/chat/${selectedModel}`;
}).catch(err => {
console.error('Error processing files:', err);
// Still redirect even if file processing fails
localStorage.setItem('localai_index_chat_data', JSON.stringify({ message: message, imageFiles: [], audioFiles: [], textFiles: [] }));
window.location.href = `/chat/${selectedModel}`;
});
} else {
// No files, just store message and redirect
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
window.location.href = `/chat/${selectedModel}`;
}
}
// Make startChat available globally
window.startChat = startChat;
// Stop individual model
async function stopModel(modelName) {
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) {
return;
}
try {
const response = await fetch('/backend/shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: modelName })
});
if (response.ok) {
// Reload page after short delay to reflect changes
setTimeout(() => {
window.location.reload();
}, 500);
} else {
alert('Failed to stop model');
}
} catch (error) {
console.error('Error stopping model:', error);
alert('Failed to stop model');
}
}
// Stop all loaded models
async function stopAllModels(component) {
const loadedModelNamesStr = '{{ $loadedModels := .LoadedModels }}{{ range .ModelsConfig }}{{ if index $loadedModels .Name }}{{.Name}},{{ end }}{{ end }}';
const loadedModelNames = loadedModelNamesStr.split(',').filter(name => name.length > 0);
if (loadedModelNames.length === 0) {
return;
}
if (!confirm(`Are you sure you want to stop all ${loadedModelNames.length} loaded model(s)?`)) {
return;
}
// Set loading state
if (component) {
component.stoppingAll = true;
}
try {
// Stop all models in parallel
const stopPromises = loadedModelNames.map(modelName =>
fetch('/backend/shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: modelName })
})
);
await Promise.all(stopPromises);
// Reload page after short delay to reflect changes
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
console.error('Error stopping models:', error);
alert('Failed to stop some models');
if (component) {
component.stoppingAll = false;
}
}
}
// Make functions available globally for Alpine.js
window.stopModel = stopModel;
window.stopAllModels = stopAllModels;
</script>
</body>
</html>