From 65a57daba66a4ffaa2ac3455a1b34b23e01d0dc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:52:00 +0000 Subject: [PATCH] Convert webui to single-page Alpine.js app - Create SPA container (spa.html) with Alpine.js routing - Create view partials for home, chat, text2image, tts, talk, manage, and browse views - Create spa-router.js for client-side navigation - Create spa-home.js with home view Alpine.js components - Create spa_navbar.html with SPA-aware navigation - Update welcome endpoint to serve SPA instead of separate pages - Update UI routes to serve SPA for chat, text2image, tts, and talk routes Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- core/http/endpoints/localai/welcome.go | 10 +- core/http/routes/ui.go | 209 +----------- core/http/static/spa-home.js | 411 +++++++++++++++++++++++ core/http/static/spa-router.js | 148 ++++++++ core/http/views/partials/spa_navbar.html | 154 +++++++++ core/http/views/spa.html | 94 ++++++ core/http/views/spa/browse.html | 221 ++++++++++++ core/http/views/spa/chat.html | 273 +++++++++++++++ core/http/views/spa/home.html | 329 ++++++++++++++++++ core/http/views/spa/manage.html | 307 +++++++++++++++++ core/http/views/spa/talk.html | 229 +++++++++++++ core/http/views/spa/text2image.html | 155 +++++++++ core/http/views/spa/tts.html | 138 ++++++++ 13 files changed, 2474 insertions(+), 204 deletions(-) create mode 100644 core/http/static/spa-home.js create mode 100644 core/http/static/spa-router.js create mode 100644 core/http/views/partials/spa_navbar.html create mode 100644 core/http/views/spa.html create mode 100644 core/http/views/spa/browse.html create mode 100644 core/http/views/spa/chat.html create mode 100644 core/http/views/spa/home.html create mode 100644 core/http/views/spa/manage.html create mode 100644 core/http/views/spa/talk.html create mode 100644 core/http/views/spa/text2image.html create mode 100644 core/http/views/spa/tts.html diff --git a/core/http/endpoints/localai/welcome.go b/core/http/endpoints/localai/welcome.go index ce197ba05..31b6f1e91 100644 --- a/core/http/endpoints/localai/welcome.go +++ b/core/http/endpoints/localai/welcome.go @@ -65,13 +65,9 @@ func WelcomeEndpoint(appConfig *config.ApplicationConfig, // The client expects a JSON response return c.JSON(200, summary) } else { - // Check if this is the manage route - templateName := "views/index" - if strings.HasSuffix(c.Request().URL.Path, "/manage") || c.Request().URL.Path == "/manage" { - templateName = "views/manage" - } - // Render appropriate template - return c.Render(200, templateName, summary) + // Serve the SPA for both index and manage routes + // The SPA handles routing client-side via Alpine.js + return c.Render(200, "views/spa", summary) } } } diff --git a/core/http/routes/ui.go b/core/http/routes/ui.go index da6f5d1ee..d5fa2167d 100644 --- a/core/http/routes/ui.go +++ b/core/http/routes/ui.go @@ -3,7 +3,6 @@ package routes import ( "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/config" - "github.com/mudler/LocalAI/core/gallery" "github.com/mudler/LocalAI/core/http/endpoints/localai" "github.com/mudler/LocalAI/core/http/middleware" "github.com/mudler/LocalAI/core/services" @@ -115,208 +114,24 @@ func RegisterUIRoutes(app *echo.Echo, registerBackendGalleryRoutes(app, appConfig, galleryService, processingOps) } - app.GET("/talk", func(c echo.Context) error { - modelConfigs, _ := services.ListModels(cl, ml, config.NoFilterFn, services.SKIP_IF_CONFIGURED) + // Talk route - now served by SPA + app.GET("/talk", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - if len(modelConfigs) == 0 { - // If no model is available redirect to the index which suggests how to install models - return c.Redirect(302, middleware.BaseURL(c)) - } + // Chat routes - now served by SPA + app.GET("/chat", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - summary := map[string]interface{}{ - "Title": "LocalAI - Talk", - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "Model": modelConfigs[0], + // Show the Chat page with specific model + app.GET("/chat/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - "Version": internal.PrintableVersion(), - } + // Text2Image routes - now served by SPA + app.GET("/text2image/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - // Render index - return c.Render(200, "views/talk", summary) - }) + app.GET("/text2image", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - app.GET("/chat", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) + // TTS routes - now served by SPA + app.GET("/tts/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) - if len(modelConfigs)+len(modelsWithoutConfig) == 0 { - // If no model is available redirect to the index which suggests how to install models - return c.Redirect(302, middleware.BaseURL(c)) - } - modelThatCanBeUsed := "" - galleryConfigs := map[string]*gallery.ModelConfig{} - - for _, m := range modelConfigs { - cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name) - if err != nil { - continue - } - galleryConfigs[m.Name] = cfg - } - - title := "LocalAI - Chat" - var modelContextSize *int - - for _, b := range modelConfigs { - if b.HasUsecases(config.FLAG_CHAT) { - modelThatCanBeUsed = b.Name - title = "LocalAI - Chat with " + modelThatCanBeUsed - if b.LLMConfig.ContextSize != nil { - modelContextSize = b.LLMConfig.ContextSize - } - break - } - } - - summary := map[string]interface{}{ - "Title": title, - "BaseURL": middleware.BaseURL(c), - "ModelsWithoutConfig": modelsWithoutConfig, - "GalleryConfig": galleryConfigs, - "ModelsConfig": modelConfigs, - "Model": modelThatCanBeUsed, - "ContextSize": modelContextSize, - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/chat", summary) - }) - - // Show the Chat page - app.GET("/chat/:model", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) - - galleryConfigs := map[string]*gallery.ModelConfig{} - modelName := c.Param("model") - var modelContextSize *int - - for _, m := range modelConfigs { - cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name) - if err != nil { - continue - } - galleryConfigs[m.Name] = cfg - if m.Name == modelName && m.LLMConfig.ContextSize != nil { - modelContextSize = m.LLMConfig.ContextSize - } - } - - summary := map[string]interface{}{ - "Title": "LocalAI - Chat with " + modelName, - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "GalleryConfig": galleryConfigs, - "ModelsWithoutConfig": modelsWithoutConfig, - "Model": modelName, - "ContextSize": modelContextSize, - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/chat", summary) - }) - - app.GET("/text2image/:model", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) - - summary := map[string]interface{}{ - "Title": "LocalAI - Generate images with " + c.Param("model"), - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "ModelsWithoutConfig": modelsWithoutConfig, - "Model": c.Param("model"), - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/text2image", summary) - }) - - app.GET("/text2image", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) - - if len(modelConfigs)+len(modelsWithoutConfig) == 0 { - // If no model is available redirect to the index which suggests how to install models - return c.Redirect(302, middleware.BaseURL(c)) - } - - modelThatCanBeUsed := "" - title := "LocalAI - Generate images" - - for _, b := range modelConfigs { - if b.HasUsecases(config.FLAG_IMAGE) { - modelThatCanBeUsed = b.Name - title = "LocalAI - Generate images with " + modelThatCanBeUsed - break - } - } - - summary := map[string]interface{}{ - "Title": title, - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "ModelsWithoutConfig": modelsWithoutConfig, - "Model": modelThatCanBeUsed, - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/text2image", summary) - }) - - app.GET("/tts/:model", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) - - summary := map[string]interface{}{ - "Title": "LocalAI - Generate images with " + c.Param("model"), - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "ModelsWithoutConfig": modelsWithoutConfig, - "Model": c.Param("model"), - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/tts", summary) - }) - - app.GET("/tts", func(c echo.Context) error { - modelConfigs := cl.GetAllModelsConfigs() - modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY) - - if len(modelConfigs)+len(modelsWithoutConfig) == 0 { - // If no model is available redirect to the index which suggests how to install models - return c.Redirect(302, middleware.BaseURL(c)) - } - - modelThatCanBeUsed := "" - title := "LocalAI - Generate audio" - - for _, b := range modelConfigs { - if b.HasUsecases(config.FLAG_TTS) { - modelThatCanBeUsed = b.Name - title = "LocalAI - Generate audio with " + modelThatCanBeUsed - break - } - } - summary := map[string]interface{}{ - "Title": title, - "BaseURL": middleware.BaseURL(c), - "ModelsConfig": modelConfigs, - "ModelsWithoutConfig": modelsWithoutConfig, - "Model": modelThatCanBeUsed, - "Version": internal.PrintableVersion(), - } - - // Render index - return c.Render(200, "views/tts", summary) - }) + app.GET("/tts", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps)) // Traces UI app.GET("/traces", func(c echo.Context) error { diff --git a/core/http/static/spa-home.js b/core/http/static/spa-home.js new file mode 100644 index 000000000..6d3481a5b --- /dev/null +++ b/core/http/static/spa-home.js @@ -0,0 +1,411 @@ +/** + * SPA Home View JavaScript + * Contains Alpine.js components and functions for the home view + */ + +// 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?', + '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(); + // Build MCP models map from data attributes + this.buildMCPModelsMap(); + // Select first model by default + 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(); + } + } + }); + // Watch for changes to selectedModel to update MCP availability + 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 - navigates to chat view instead of full page redirect +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; + } + + // Get MCP mode from checkbox + let mcpMode = false; + const mcpToggle = document.getElementById('spa_home_mcp_toggle'); + if (mcpToggle && mcpToggle.checked) { + mcpMode = true; + } + + // Store message and files in localStorage for chat view to pick up + const chatData = { + message: message, + imageFiles: [], + audioFiles: [], + textFiles: [], + mcpMode: mcpMode + }; + + // Convert files to base64 for storage + 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 = () => { + // Store in localStorage + localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData)); + + // Use SPA router to navigate to chat + if (window.Alpine && Alpine.store('router')) { + Alpine.store('router').navigate('chat', { model: selectedModel }); + } else { + // Fallback to full page redirect if router not available + 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(err => { + console.error('Error processing files:', err); + navigateToChat(); + }); + } else { + navigateToChat(); + } +} + +// 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); + } + } + }; +} + +// 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) { + // Get loaded models from DOM + 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 functions available globally +window.homeInputForm = homeInputForm; +window.startChatSPA = startChatSPA; +window.resourceMonitor = resourceMonitor; +window.stopModel = stopModel; +window.stopAllModels = stopAllModels; diff --git a/core/http/static/spa-router.js b/core/http/static/spa-router.js new file mode 100644 index 000000000..d24171e20 --- /dev/null +++ b/core/http/static/spa-router.js @@ -0,0 +1,148 @@ +/** + * LocalAI SPA Router + * Client-side routing for the single-page application + */ + +// 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) { + // Remove trailing slash + pathname = pathname.replace(/\/$/, '') || '/'; + + // Check for hash-based routes first (for SPA navigation) + 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: {} }; + } + // Check for parameterized routes like /chat/:model + if (pathname.startsWith(path + '/')) { + const param = pathname.slice(path.length + 1); + if (param) { + return { route, params: { model: param } }; + } + } + } + } + + // Default to home + return { route: 'home', params: {} }; +} + +// Initialize the router store for Alpine.js +document.addEventListener('alpine:init', () => { + // Parse initial route from URL + const initialRoute = parseUrlPath(window.location.pathname); + + Alpine.store('router', { + currentRoute: initialRoute.route, + routeParams: initialRoute.params, + previousRoute: null, + + /** + * Navigate to a route + * @param {string} route - The route name to navigate to + * @param {Object} params - Optional parameters for the route + */ + navigate(route, params = {}) { + if (!SPA_ROUTES[route]) { + console.warn(`Unknown route: ${route}`); + return; + } + + this.previousRoute = this.currentRoute; + this.currentRoute = route; + this.routeParams = params; + + // Update document title + document.title = SPA_ROUTES[route].title; + + // Update URL without page reload using history API + const url = route === 'home' ? '/' : `/#${route}`; + if (params.model) { + window.history.pushState({ route, params }, '', `/#${route}/${params.model}`); + } else { + window.history.pushState({ route, params }, '', url); + } + + // Scroll to top on navigation + window.scrollTo(0, 0); + + // Emit custom event for route change listeners + window.dispatchEvent(new CustomEvent('spa:navigate', { + detail: { route, params, previousRoute: this.previousRoute } + })); + }, + + /** + * Check if the current route matches + * @param {string} route - The route to check + * @returns {boolean} + */ + isRoute(route) { + return this.currentRoute === route; + }, + + /** + * Navigate to chat with a specific model + * @param {string} model - The model name + */ + navigateToChat(model) { + this.navigate('chat', { model }); + }, + + /** + * Navigate to text2image with a specific model + * @param {string} model - The model name + */ + navigateToText2Image(model) { + this.navigate('text2image', { model }); + }, + + /** + * Navigate to TTS with a specific model + * @param {string} model - The model name + */ + navigateToTTS(model) { + this.navigate('tts', { model }); + } + }); +}); + +// Handle browser back/forward buttons +window.addEventListener('popstate', (event) => { + if (event.state && event.state.route) { + Alpine.store('router').currentRoute = event.state.route; + Alpine.store('router').routeParams = event.state.params || {}; + } else { + // Parse URL for route + 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; diff --git a/core/http/views/partials/spa_navbar.html b/core/http/views/partials/spa_navbar.html new file mode 100644 index 000000000..e40328341 --- /dev/null +++ b/core/http/views/partials/spa_navbar.html @@ -0,0 +1,154 @@ + diff --git a/core/http/views/spa.html b/core/http/views/spa.html new file mode 100644 index 000000000..5889373a1 --- /dev/null +++ b/core/http/views/spa.html @@ -0,0 +1,94 @@ + + +{{template "views/partials/head" .}} + + + + + + + + + + + + +
+ + {{template "views/partials/spa_navbar" .}} + + +
+ + +
+ {{template "views/spa/home" .}} +
+ + +
+ {{template "views/spa/chat" .}} +
+ + +
+ {{template "views/spa/text2image" .}} +
+ + +
+ {{template "views/spa/tts" .}} +
+ + +
+ {{template "views/spa/talk" .}} +
+ + +
+ {{template "views/spa/manage" .}} +
+ + +
+ {{template "views/spa/browse" .}} +
+ +
+ + {{template "views/partials/footer" .}} +
+ + + + + diff --git a/core/http/views/spa/browse.html b/core/http/views/spa/browse.html new file mode 100644 index 000000000..da425d1a6 --- /dev/null +++ b/core/http/views/spa/browse.html @@ -0,0 +1,221 @@ + + +
+ + +
+
+

+ Model Gallery +

+

Browse and install AI models

+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ + +
+ +
+ + +
+ +

No models found

+

Try adjusting your search or filters

+
+ + +
+ + + View Full Model Gallery + +
+
+ + diff --git a/core/http/views/spa/chat.html b/core/http/views/spa/chat.html new file mode 100644 index 000000000..7ae56bceb --- /dev/null +++ b/core/http/views/spa/chat.html @@ -0,0 +1,273 @@ + + +
+ + +
+ + + + +
+ +
+
+ +
+ +
+
+
+ - + + + +
+
+ + +
+ + + +
+ +

Start a conversation

+

Select a model and send a message to begin

+
+
+ + +
+
+
+
+ +
+ + + +
+
+ + +
+ + + + +
+ + + + +
+
+
+ + +
+ +
+ + diff --git a/core/http/views/spa/home.html b/core/http/views/spa/home.html new file mode 100644 index 000000000..9e27203a5 --- /dev/null +++ b/core/http/views/spa/home.html @@ -0,0 +1,329 @@ + + +
+
+ {{ if eq (len .ModelsConfig) 0 }} + +
+
+

+ No Models Installed +

+

+ Get started with LocalAI by installing your first model. Choose from our gallery, import your own, or use the API to download models. +

+
+
+ + +
+
+
+ +
+

Model Gallery

+

Browse and install pre-configured models

+
+
+
+ +
+

Import Models

+

Upload your own model files

+
+
+
+ +
+

API Download

+

Use the API to download models programmatically

+
+
+ + +
+

+ + How to Get Started +

+
+
+
+ 1 +
+
+

Browse the Model Gallery

+

Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.

+
+
+
+
+ 2 +
+
+

Install a Model

+

Click on a model from the gallery to install it, or use the import feature to upload your own model files.

+
+
+
+
+ 3 +
+
+

Start Chatting

+

Once installed, return to this page to start chatting with your model or use the API to interact programmatically.

+
+
+
+
+ + + {{ else }} + +
+
+
+ LocalAI Logo +
+

How can I help you today?

+

Ask me anything, and I'll do my best to assist you.

+
+
+ + +
+ +
+ +
+ + + +
+ + MCP + +
+
+ + +
+
+ +

Non-streaming mode active. Responses may take longer to process.

+
+
+
+ + +
+ +
+ +
+ +
+ + + + + + + + + +
+
+ + + + + +
+ + + + + +
+ +
+ + + {{ $loadedModels := .LoadedModels }} + + {{ end }} +
+
diff --git a/core/http/views/spa/manage.html b/core/http/views/spa/manage.html new file mode 100644 index 000000000..1415a7144 --- /dev/null +++ b/core/http/views/spa/manage.html @@ -0,0 +1,307 @@ + +
+ + +
+ +
+ + +
+
+

+ Model & Backend Management +

+

Manage your installed models and backends

+ + +
+ + + Model Gallery + + + + + Import Model + + + + + + + Backend Gallery + + + {{ if not .DisableRuntimeSettings }} + + + Settings + + {{ end }} +
+
+
+ + +
+ +
+ + +
+

+ + Installed Models +

+ +
+
+ + + + + + + + + + + {{ $loadedModels := .LoadedModels }} + {{ range .ModelsConfig }} + + + + + + + {{ end }} + {{ range .Models }} + + + + + + + {{ end }} + +
ModelStatusBackendActions
+
+ {{.Name}} +
+
+ {{ if index $loadedModels .Name }} + + Loaded + + {{ else }} + + Idle + + {{ end }} + + {{.Backend}} + +
+ {{ $hasChat := false }} + {{ range .KnownUsecaseStrings }} + {{ if eq . "FLAG_CHAT" }}{{ $hasChat = true }}{{ end }} + {{ end }} + {{ if $hasChat }} + + {{ end }} + {{ if index $loadedModels .Name }} + + {{ end }} + + + +
+
+
+ {{.}} + (no config) +
+
+ + Idle + + + - + + +
+
+ + {{ if and (eq (len .ModelsConfig) 0) (eq (len .Models) 0) }} +
+ +

No models installed yet

+

+ Browse the gallery to get started +

+
+ {{ end }} +
+
+ + +
+

+ + Installed Backends +

+ +
+ {{ range .InstalledBackends }} +
+
+
+
+ +
+
+

{{.Name}}

+

{{.Version}}

+
+
+ + Installed + +
+
+ {{ else }} +
+ +

No backends installed yet

+

+ Browse the backend gallery +

+
+ {{ end }} +
+
+
+ + diff --git a/core/http/views/spa/talk.html b/core/http/views/spa/talk.html new file mode 100644 index 000000000..74272fa37 --- /dev/null +++ b/core/http/views/spa/talk.html @@ -0,0 +1,229 @@ + +
+ +
+
+

+ Talk Interface +

+

Speak with your AI models using voice interaction

+
+
+ + +
+
+ +
+ + + + + + + +
Press the record button to start recording.
+ + +
+
+ +

+ Note: You need an LLM, an audio-transcription (whisper), and a TTS model installed for this to work. Select the appropriate models below and click 'Talk' to start recording. +

+
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+
+
+
+
+ + diff --git a/core/http/views/spa/text2image.html b/core/http/views/spa/text2image.html new file mode 100644 index 000000000..24c50d72a --- /dev/null +++ b/core/http/views/spa/text2image.html @@ -0,0 +1,155 @@ + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + +
+ +
+ + +
+ + +
+
+ + +
+ +
+
+
+
+
+ + +
+
+ + + +
+

Your generated images will appear here

+
+ +
+
+
+
+
+
+ + diff --git a/core/http/views/spa/tts.html b/core/http/views/spa/tts.html new file mode 100644 index 000000000..abc190c3f --- /dev/null +++ b/core/http/views/spa/tts.html @@ -0,0 +1,138 @@ + +
+ +
+
+

+ Text to Speech +

+

Convert your text into natural-sounding speech

+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+
+ +

+ Enter your text below and submit to generate speech with the selected TTS model. + The generated audio will appear below the input field. +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+

Generated audio will appear here

+
+
+
+
+
+
+ +