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" .}} + + + + + + + + + + + +
+Browse and install AI models
+ + +No models found
+Try adjusting your search or filters
+Start a conversation
+Select a model and send a message to begin
++ Get started with LocalAI by installing your first model. Choose from our gallery, import your own, or use the API to download models. +
+Browse and install pre-configured models
+Upload your own model files
+Use the API to download models programmatically
+Browse the Model Gallery
+Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.
+Install a Model
+Click on a model from the gallery to install it, or use the import feature to upload your own model files.
+Start Chatting
+Once installed, return to this page to start chatting with your model or use the API to interact programmatically.
+
+ Ask me anything, and I'll do my best to assist you.
+Non-streaming mode active. Responses may take longer to process.
+Manage your installed models and backends
+ + +| Model | +Status | +Backend | +Actions | +
|---|---|---|---|
|
+
+ {{.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 + + | ++ - + | ++ + | +
No models installed yet
++ Browse the gallery to get started +
+{{.Version}}
+No backends installed yet
+ +Speak with your AI models using voice interaction
++ 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. +
+Generating image...
+Your generated images will appear here
+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
+