mirror of
https://github.com/mudler/LocalAI.git
synced 2026-02-05 12:12:39 -05:00
The spa-router.js was loaded with defer but registered the Alpine.js store using the 'alpine:init' event. Since Alpine.js also loads with defer, there was a race condition where Alpine could initialize before the event listener was registered, causing $store.router to be undefined. Moved the entire router store definition and registration inline in spa.html so it's guaranteed to be registered before Alpine.js initializes. Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
566 lines
18 KiB
HTML
566 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
{{template "views/partials/head" .}}
|
|
|
|
<!-- Critical Alpine.js component functions must be defined before Alpine loads -->
|
|
<script>
|
|
// Resource Monitor component (GPU if available, otherwise RAM)
|
|
function resourceMonitor() {
|
|
return {
|
|
resourceData: null,
|
|
pollInterval: null,
|
|
|
|
async fetchResourceData() {
|
|
try {
|
|
const response = await fetch('/api/resources');
|
|
if (response.ok) {
|
|
this.resourceData = await response.json();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching resource data:', error);
|
|
}
|
|
},
|
|
|
|
startPolling() {
|
|
this.fetchResourceData();
|
|
this.pollInterval = setInterval(() => this.fetchResourceData(), 5000);
|
|
},
|
|
|
|
stopPolling() {
|
|
if (this.pollInterval) {
|
|
clearInterval(this.pollInterval);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Format bytes helper
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
// Home input form component
|
|
function homeInputForm() {
|
|
return {
|
|
selectedModel: '',
|
|
inputValue: '',
|
|
shiftPressed: false,
|
|
fileName: '',
|
|
imageFiles: [],
|
|
audioFiles: [],
|
|
textFiles: [],
|
|
attachedFiles: [],
|
|
mcpMode: false,
|
|
mcpAvailable: false,
|
|
mcpModels: {},
|
|
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?'
|
|
],
|
|
|
|
init() {
|
|
window.currentPlaceholderText = this.currentPlaceholder;
|
|
this.startTypingAnimation();
|
|
this.buildMCPModelsMap();
|
|
this.$nextTick(() => {
|
|
const select = this.$el.querySelector('select');
|
|
if (select && select.options.length > 1) {
|
|
const firstModelOption = select.options[1];
|
|
if (firstModelOption && firstModelOption.value) {
|
|
this.selectedModel = firstModelOption.value;
|
|
this.checkMCPAvailability();
|
|
}
|
|
}
|
|
});
|
|
this.$watch('selectedModel', () => {
|
|
this.checkMCPAvailability();
|
|
});
|
|
},
|
|
|
|
buildMCPModelsMap() {
|
|
const select = this.$el.querySelector('select');
|
|
if (!select) return;
|
|
this.mcpModels = {};
|
|
for (let i = 0; i < select.options.length; i++) {
|
|
const option = select.options[i];
|
|
if (option.value) {
|
|
const hasMcpAttr = option.getAttribute('data-has-mcp');
|
|
this.mcpModels[option.value] = hasMcpAttr === 'true';
|
|
}
|
|
}
|
|
},
|
|
|
|
checkMCPAvailability() {
|
|
if (!this.selectedModel) {
|
|
this.mcpAvailable = false;
|
|
this.mcpMode = false;
|
|
return;
|
|
}
|
|
const hasMCP = this.mcpModels[this.selectedModel] === true;
|
|
this.mcpAvailable = hasMCP;
|
|
if (!hasMCP) {
|
|
this.mcpMode = false;
|
|
}
|
|
},
|
|
|
|
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 {
|
|
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() {
|
|
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();
|
|
}
|
|
},
|
|
|
|
handleFileSelection(files, fileType) {
|
|
Array.from(files).forEach(file => {
|
|
const exists = this.attachedFiles.some(f => f.name === file.name && f.type === fileType);
|
|
if (!exists) {
|
|
this.attachedFiles.push({ name: file.name, type: fileType });
|
|
}
|
|
});
|
|
},
|
|
|
|
removeAttachedFile(fileType, fileName) {
|
|
const index = this.attachedFiles.findIndex(f => f.name === fileName && f.type === fileType);
|
|
if (index !== -1) {
|
|
this.attachedFiles.splice(index, 1);
|
|
}
|
|
if (fileType === 'image') {
|
|
this.imageFiles = this.imageFiles.filter(f => f.name !== fileName);
|
|
} else if (fileType === 'audio') {
|
|
this.audioFiles = this.audioFiles.filter(f => f.name !== fileName);
|
|
} else if (fileType === 'file') {
|
|
this.textFiles = this.textFiles.filter(f => f.name !== fileName);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Start chat function for SPA
|
|
function startChatSPA(event) {
|
|
if (event) event.preventDefault();
|
|
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.trim() && window.currentPlaceholderText) {
|
|
message = window.currentPlaceholderText;
|
|
}
|
|
|
|
if (!selectedModel || !message.trim()) return;
|
|
|
|
let mcpMode = false;
|
|
const mcpToggle = document.getElementById('spa_home_mcp_toggle');
|
|
if (mcpToggle && mcpToggle.checked) mcpMode = true;
|
|
|
|
const chatData = { message, imageFiles: [], audioFiles: [], textFiles: [], mcpMode };
|
|
const imageInput = document.getElementById('spa_home_input_image');
|
|
const audioInput = document.getElementById('spa_home_input_audio');
|
|
const fileInput = document.getElementById('spa_home_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);
|
|
})
|
|
)
|
|
];
|
|
|
|
const navigateToChat = () => {
|
|
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
|
|
if (window.Alpine && Alpine.store('router')) {
|
|
Alpine.store('router').navigate('chat', { model: selectedModel });
|
|
} else {
|
|
window.location.href = `/chat/${selectedModel}`;
|
|
}
|
|
};
|
|
|
|
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);
|
|
});
|
|
navigateToChat();
|
|
}).catch(() => navigateToChat());
|
|
} else {
|
|
navigateToChat();
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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 loadedModelElements = document.querySelectorAll('[data-loaded-model]');
|
|
const loadedModelNames = Array.from(loadedModelElements).map(el => {
|
|
const span = el.querySelector('span.truncate');
|
|
return span ? span.textContent.trim() : '';
|
|
}).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;
|
|
|
|
if (component) component.stoppingAll = true;
|
|
|
|
try {
|
|
const stopPromises = loadedModelNames.map(modelName =>
|
|
fetch('/backend/shutdown', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: modelName })
|
|
})
|
|
);
|
|
await Promise.all(stopPromises);
|
|
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 available globally
|
|
window.resourceMonitor = resourceMonitor;
|
|
window.formatBytes = formatBytes;
|
|
window.homeInputForm = homeInputForm;
|
|
window.startChatSPA = startChatSPA;
|
|
window.stopModel = stopModel;
|
|
window.stopAllModels = stopAllModels;
|
|
|
|
// ========================================
|
|
// SPA Router - Alpine.js Store Definition
|
|
// Must be defined before Alpine.js initializes
|
|
// ========================================
|
|
|
|
// Define routes and their corresponding view IDs
|
|
const SPA_ROUTES = {
|
|
'home': { title: 'LocalAI', viewId: 'view-home', paths: ['/', ''] },
|
|
'chat': { title: 'LocalAI - Chat', viewId: 'view-chat', paths: ['/chat'] },
|
|
'text2image': { title: 'LocalAI - Images', viewId: 'view-text2image', paths: ['/text2image'] },
|
|
'tts': { title: 'LocalAI - TTS', viewId: 'view-tts', paths: ['/tts'] },
|
|
'talk': { title: 'LocalAI - Talk', viewId: 'view-talk', paths: ['/talk'] },
|
|
'manage': { title: 'LocalAI - System', viewId: 'view-manage', paths: ['/manage'] },
|
|
'browse': { title: 'LocalAI - Model Gallery', viewId: 'view-browse', paths: ['/browse'] }
|
|
};
|
|
|
|
// Parse URL path to determine route
|
|
function parseUrlPath(pathname) {
|
|
pathname = pathname.replace(/\/$/, '') || '/';
|
|
|
|
// Check for hash-based routes first
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash) {
|
|
const hashParts = hash.split('/');
|
|
const route = hashParts[0];
|
|
const model = hashParts[1] || null;
|
|
if (SPA_ROUTES[route]) {
|
|
return { route, params: model ? { model } : {} };
|
|
}
|
|
}
|
|
|
|
// Check path-based routes
|
|
for (const [route, config] of Object.entries(SPA_ROUTES)) {
|
|
for (const path of config.paths) {
|
|
if (pathname === path) {
|
|
return { route, params: {} };
|
|
}
|
|
if (pathname.startsWith(path + '/')) {
|
|
const param = pathname.slice(path.length + 1);
|
|
if (param) {
|
|
return { route, params: { model: param } };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { route: 'home', params: {} };
|
|
}
|
|
|
|
// Register the router store with Alpine.js on init event
|
|
document.addEventListener('alpine:init', () => {
|
|
const initialRoute = parseUrlPath(window.location.pathname);
|
|
|
|
Alpine.store('router', {
|
|
currentRoute: initialRoute.route,
|
|
routeParams: initialRoute.params,
|
|
previousRoute: null,
|
|
|
|
navigate(route, params = {}) {
|
|
if (!SPA_ROUTES[route]) {
|
|
console.warn('Unknown route:', route);
|
|
return;
|
|
}
|
|
|
|
this.previousRoute = this.currentRoute;
|
|
this.currentRoute = route;
|
|
this.routeParams = params;
|
|
|
|
document.title = SPA_ROUTES[route].title;
|
|
|
|
const url = route === 'home' ? '/' : '/#' + route;
|
|
if (params.model) {
|
|
window.history.pushState({ route, params }, '', '/#' + route + '/' + params.model);
|
|
} else {
|
|
window.history.pushState({ route, params }, '', url);
|
|
}
|
|
|
|
window.scrollTo(0, 0);
|
|
window.dispatchEvent(new CustomEvent('spa:navigate', {
|
|
detail: { route, params, previousRoute: this.previousRoute }
|
|
}));
|
|
},
|
|
|
|
isRoute(route) {
|
|
return this.currentRoute === route;
|
|
},
|
|
|
|
navigateToChat(model) {
|
|
this.navigate('chat', { model });
|
|
},
|
|
|
|
navigateToText2Image(model) {
|
|
this.navigate('text2image', { model });
|
|
},
|
|
|
|
navigateToTTS(model) {
|
|
this.navigate('tts', { model });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle browser back/forward buttons
|
|
window.addEventListener('popstate', (event) => {
|
|
if (window.Alpine && Alpine.store('router')) {
|
|
if (event.state && event.state.route) {
|
|
Alpine.store('router').currentRoute = event.state.route;
|
|
Alpine.store('router').routeParams = event.state.params || {};
|
|
} else {
|
|
const parsed = parseUrlPath(window.location.pathname);
|
|
Alpine.store('router').currentRoute = parsed.route;
|
|
Alpine.store('router').routeParams = parsed.params;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Export for use in other scripts
|
|
window.SPA_ROUTES = SPA_ROUTES;
|
|
window.parseUrlPath = parseUrlPath;
|
|
</script>
|
|
|
|
<!-- SPA Scripts -->
|
|
<script defer src="static/spa-router.js"></script>
|
|
<script defer src="static/spa-home.js"></script>
|
|
<script defer src="static/chat.js"></script>
|
|
<script defer src="static/image.js"></script>
|
|
<script defer src="static/tts.js"></script>
|
|
<!-- Note: talk.js is NOT included here because it has global-scope DOM access that
|
|
conflicts with the SPA architecture. The SPA talk view has its own inline JS. -->
|
|
<script src="static/assets/pdf.min.js"></script>
|
|
<script>
|
|
// Initialize PDF.js worker
|
|
if (typeof pdfjsLib !== 'undefined') {
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'static/assets/pdf.worker.min.js';
|
|
}
|
|
|
|
// Store gallery configs for header icon display and model info modal
|
|
window.__galleryConfigs = {};
|
|
{{ $allGalleryConfigs:=.GalleryConfig }}
|
|
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
|
|
window.__galleryConfigs["{{$modelName}}"] = {};
|
|
{{ if $galleryConfig.Icon }}
|
|
window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}";
|
|
{{ end }}
|
|
{{ if $galleryConfig.Description }}
|
|
window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }};
|
|
{{ end }}
|
|
{{ if $galleryConfig.URLs }}
|
|
window.__galleryConfigs["{{$modelName}}"].URLs = [
|
|
{{ range $idx, $url := $galleryConfig.URLs }}
|
|
{{ if $idx }},{{ end }}{{ printf "%q" $url }}
|
|
{{ end }}
|
|
];
|
|
{{ end }}
|
|
{{ end }}
|
|
</script>
|
|
|
|
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
|
|
<div class="flex flex-col min-h-screen" x-data="{ mobileMenuOpen: false, settingsOpen: false, mobileSettingsOpen: false }">
|
|
|
|
{{template "views/partials/spa_navbar" .}}
|
|
|
|
<!-- SPA View Container -->
|
|
<div class="flex-1 flex flex-col">
|
|
|
|
<!-- Home View -->
|
|
<div x-show="$store.router.currentRoute === 'home'" x-cloak>
|
|
{{template "views/spa/home" .}}
|
|
</div>
|
|
|
|
<!-- Chat View -->
|
|
<div x-show="$store.router.currentRoute === 'chat'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/chat" .}}
|
|
</div>
|
|
|
|
<!-- Text2Image View -->
|
|
<div x-show="$store.router.currentRoute === 'text2image'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/text2image" .}}
|
|
</div>
|
|
|
|
<!-- TTS View -->
|
|
<div x-show="$store.router.currentRoute === 'tts'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/tts" .}}
|
|
</div>
|
|
|
|
<!-- Talk View -->
|
|
<div x-show="$store.router.currentRoute === 'talk'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/talk" .}}
|
|
</div>
|
|
|
|
<!-- Manage View -->
|
|
<div x-show="$store.router.currentRoute === 'manage'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/manage" .}}
|
|
</div>
|
|
|
|
<!-- Browse View (Model Gallery) -->
|
|
<div x-show="$store.router.currentRoute === 'browse'" x-cloak class="flex-1 flex flex-col">
|
|
{{template "views/spa/browse" .}}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{{template "views/partials/footer" .}}
|
|
</div>
|
|
|
|
<style>
|
|
/* Hide elements until Alpine.js initializes */
|
|
[x-cloak] { display: none !important; }
|
|
</style>
|
|
|
|
</body>
|
|
</html>
|