chore: small ux enhancements (#7290)

* chore: improve chat attachments

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

* chore: display installed backends/models

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-11-17 17:09:42 +01:00
committed by GitHub
parent 721c3f962b
commit 93cd688f40
6 changed files with 216 additions and 58 deletions

View File

@@ -208,7 +208,7 @@ func API(application *application.Application) (*echo.Echo, error) {
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator())
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache)
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

View File

@@ -16,11 +16,12 @@ import (
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/pkg/model"
"github.com/rs/zerolog/log"
)
// RegisterUIAPIRoutes registers JSON API routes for the web UI
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
// Operations API - Get all current operations (models + backends)
app.GET("/api/operations", func(c echo.Context) error {
@@ -257,17 +258,23 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, appConfig
nextPage = totalPages
}
// Calculate installed models count (models with configs + models without configs)
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
installedModelsCount := len(modelConfigs) + len(modelsWithoutConfig)
return c.JSON(200, map[string]interface{}{
"models": modelsJSON,
"repositories": appConfig.Galleries,
"allTags": tags,
"processingModels": processingModelsData,
"taskTypes": taskTypes,
"availableModels": totalModels,
"currentPage": pageNum,
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
"models": modelsJSON,
"repositories": appConfig.Galleries,
"allTags": tags,
"processingModels": processingModelsData,
"taskTypes": taskTypes,
"availableModels": totalModels,
"installedModels": installedModelsCount,
"currentPage": pageNum,
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
})
})
@@ -551,6 +558,13 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, appConfig
nextPage = totalPages
}
// Calculate installed backends count
installedBackends, err := gallery.ListSystemBackends(appConfig.SystemState)
installedBackendsCount := 0
if err == nil {
installedBackendsCount = len(installedBackends)
}
return c.JSON(200, map[string]interface{}{
"backends": backendsJSON,
"repositories": appConfig.BackendGalleries,
@@ -558,6 +572,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, appConfig
"processingBackends": processingBackendsData,
"taskTypes": taskTypes,
"availableBackends": totalBackends,
"installedBackends": installedBackendsCount,
"currentPage": pageNum,
"totalPages": totalPages,
"prevPage": prevPage,

View File

@@ -177,6 +177,9 @@ var images = [];
var audios = [];
var fileContents = [];
var currentFileNames = [];
// Track file names to data URLs for proper removal
var imageFileMap = new Map(); // fileName -> dataURL
var audioFileMap = new Map(); // fileName -> dataURL
async function extractTextFromPDF(pdfData) {
try {
@@ -197,35 +200,119 @@ async function extractTextFromPDF(pdfData) {
}
}
// Global function to handle file selection and update Alpine.js state
window.handleFileSelection = function(event, fileType) {
if (!event.target.files || !event.target.files.length) return;
// Get the Alpine.js component - find the parent div with x-data containing attachedFiles
let inputContainer = event.target.closest('[x-data*="attachedFiles"]');
if (!inputContainer && window.Alpine) {
// Fallback: find any element with attachedFiles in x-data
inputContainer = document.querySelector('[x-data*="attachedFiles"]');
}
if (!inputContainer || !window.Alpine) return;
const alpineData = Alpine.$data(inputContainer);
if (!alpineData || !alpineData.attachedFiles) return;
Array.from(event.target.files).forEach(file => {
// Check if file already exists
const exists = alpineData.attachedFiles.some(f => f.name === file.name && f.type === fileType);
if (!exists) {
alpineData.attachedFiles.push({ name: file.name, type: fileType });
// Process the file based on type
if (fileType === 'image') {
readInputImageFile(file);
} else if (fileType === 'audio') {
readInputAudioFile(file);
} else if (fileType === 'file') {
readInputFileFile(file);
}
}
});
};
// Global function to remove file from input
window.removeFileFromInput = function(fileType, fileName) {
// Remove from arrays
if (fileType === 'image') {
// Remove from images array using the mapping
const dataURL = imageFileMap.get(fileName);
if (dataURL) {
const imageIndex = images.indexOf(dataURL);
if (imageIndex !== -1) {
images.splice(imageIndex, 1);
}
imageFileMap.delete(fileName);
}
} else if (fileType === 'audio') {
// Remove from audios array using the mapping
const dataURL = audioFileMap.get(fileName);
if (dataURL) {
const audioIndex = audios.indexOf(dataURL);
if (audioIndex !== -1) {
audios.splice(audioIndex, 1);
}
audioFileMap.delete(fileName);
}
} else if (fileType === 'file') {
// Remove from fileContents and currentFileNames
const fileIndex = currentFileNames.indexOf(fileName);
if (fileIndex !== -1) {
currentFileNames.splice(fileIndex, 1);
fileContents.splice(fileIndex, 1);
}
}
// Also remove from the actual input element
const inputId = fileType === 'image' ? 'input_image' :
fileType === 'audio' ? 'input_audio' : 'input_file';
const input = document.getElementById(inputId);
if (input && input.files) {
const dt = new DataTransfer();
Array.from(input.files).forEach(file => {
if (file.name !== fileName) {
dt.items.add(file);
}
});
input.files = dt.files;
}
};
function readInputFile() {
if (!this.files || !this.files.length) return;
Array.from(this.files).forEach(file => {
const FR = new FileReader();
currentFileNames.push(file.name);
const fileExtension = file.name.split('.').pop().toLowerCase();
FR.addEventListener("load", async function(evt) {
if (fileExtension === 'pdf') {
try {
const content = await extractTextFromPDF(evt.target.result);
fileContents.push({ name: file.name, content: content });
} catch (error) {
console.error('Error processing PDF:', error);
fileContents.push({ name: file.name, content: "Error processing PDF file" });
}
} else {
// For text and markdown files
fileContents.push({ name: file.name, content: evt.target.result });
}
});
readInputFileFile(file);
});
}
function readInputFileFile(file) {
const FR = new FileReader();
currentFileNames.push(file.name);
const fileExtension = file.name.split('.').pop().toLowerCase();
FR.addEventListener("load", async function(evt) {
if (fileExtension === 'pdf') {
FR.readAsArrayBuffer(file);
try {
const content = await extractTextFromPDF(evt.target.result);
fileContents.push({ name: file.name, content: content });
} catch (error) {
console.error('Error processing PDF:', error);
fileContents.push({ name: file.name, content: "Error processing PDF file" });
}
} else {
FR.readAsText(file);
// For text and markdown files
fileContents.push({ name: file.name, content: evt.target.result });
}
});
if (fileExtension === 'pdf') {
FR.readAsArrayBuffer(file);
} else {
FR.readAsText(file);
}
}
function submitPrompt(event) {
@@ -303,36 +390,66 @@ function processAndSendMessage(inputValue) {
// Reset file contents and names after sending
fileContents = [];
currentFileNames = [];
images = [];
audios = [];
imageFileMap.clear();
audioFileMap.clear();
// Clear Alpine.js attachedFiles array
const inputContainer = document.querySelector('[x-data*="attachedFiles"]');
if (inputContainer && window.Alpine) {
const alpineData = Alpine.$data(inputContainer);
if (alpineData && alpineData.attachedFiles) {
alpineData.attachedFiles = [];
}
}
// Clear file inputs
document.getElementById("input_image").value = null;
document.getElementById("input_audio").value = null;
document.getElementById("input_file").value = null;
}
function readInputImage() {
if (!this.files || !this.files.length) return;
Array.from(this.files).forEach(file => {
const FR = new FileReader();
FR.addEventListener("load", function(evt) {
images.push(evt.target.result);
});
FR.readAsDataURL(file);
readInputImageFile(file);
});
}
function readInputImageFile(file) {
const FR = new FileReader();
FR.addEventListener("load", function(evt) {
const dataURL = evt.target.result;
images.push(dataURL);
imageFileMap.set(file.name, dataURL);
});
FR.readAsDataURL(file);
}
function readInputAudio() {
if (!this.files || !this.files.length) return;
Array.from(this.files).forEach(file => {
const FR = new FileReader();
FR.addEventListener("load", function(evt) {
audios.push(evt.target.result);
});
FR.readAsDataURL(file);
readInputAudioFile(file);
});
}
function readInputAudioFile(file) {
const FR = new FileReader();
FR.addEventListener("load", function(evt) {
const dataURL = evt.target.result;
audios.push(dataURL);
audioFileMap.set(file.name, dataURL);
});
FR.readAsDataURL(file);
}
async function promptGPT(systemPrompt, input) {
const model = document.getElementById("chat-model").value;
const mcpMode = Alpine.store("chat").mcpMode;
@@ -395,13 +512,8 @@ async function promptGPT(systemPrompt, input) {
}
});
// reset the form and the files
images = [];
audios = [];
document.getElementById("input_image").value = null;
document.getElementById("input_audio").value = null;
document.getElementById("input_file").value = null;
document.getElementById("fileName").innerHTML = "";
// reset the form and the files (already done in processAndSendMessage)
// images, audios, and file inputs are cleared after sending
// Choose endpoint based on MCP mode
const endpoint = mcpMode ? "v1/mcp/chat/completions" : "v1/chat/completions";

View File

@@ -51,6 +51,11 @@
<span class="font-semibold text-emerald-300" x-text="availableBackends"></span>
<span class="text-[#94A3B8] ml-1">backends available</span>
</div>
<a href="/manage" class="flex items-center bg-[#101827] hover:bg-[#1E293B] rounded-lg px-4 py-2 transition-colors border border-[#8B5CF6]/30 hover:border-[#8B5CF6]/50">
<div class="w-2 h-2 bg-cyan-400 rounded-full mr-2"></div>
<span class="font-semibold text-cyan-300" x-text="installedBackends"></span>
<span class="text-[#94A3B8] ml-1">installed</span>
</a>
<a href="https://localai.io/backends/" target="_blank"
class="inline-flex items-center bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-info-circle mr-2"></i>
@@ -488,6 +493,7 @@ function backendsGallery() {
currentPage: 1,
totalPages: 1,
availableBackends: 0,
installedBackends: 0,
selectedBackend: null,
jobProgress: {},
notifications: [],
@@ -526,6 +532,7 @@ function backendsGallery() {
this.currentPage = data.currentPage || 1;
this.totalPages = data.totalPages || 1;
this.availableBackends = data.availableBackends || 0;
this.installedBackends = data.installedBackends || 0;
} catch (error) {
console.error('Error fetching backends:', error);
} finally {

View File

@@ -726,8 +726,26 @@ SOFTWARE.
<!-- Chat Input -->
<div class="p-4 border-t border-[#1E293B]" x-data="{ inputValue: '', shiftPressed: false, fileName: '' }">
<div class="p-4 border-t border-[#1E293B]" x-data="{ inputValue: '', shiftPressed: false, attachedFiles: [] }">
<form id="prompt" action="chat/{{.Model}}" method="get" @submit.prevent="submitPrompt" class="max-w-3xl mx-auto">
<!-- Attachment Tags - Show above input when files are attached -->
<div x-show="attachedFiles.length > 0" class="mb-3 flex flex-wrap gap-2 items-center">
<template x-for="(file, index) in attachedFiles" :key="index">
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-[#38BDF8]/20 border border-[#38BDF8]/40 text-[#E5E7EB]">
<i :class="file.type === 'image' ? 'fa-solid fa-image' : file.type === 'audio' ? 'fa-solid fa-microphone' : 'fa-solid fa-file'" class="text-[#38BDF8]"></i>
<span x-text="file.name" class="max-w-[200px] truncate"></span>
<button
type="button"
@click="attachedFiles.splice(index, 1); removeFileFromInput(file.type, file.name)"
class="ml-1 text-[#94A3B8] hover:text-[#E5E7EB] transition-colors"
title="Remove attachment"
>
<i class="fa-solid fa-times text-xs"></i>
</button>
</div>
</template>
</div>
<!-- Token Usage and Context Window - Compact above input -->
<div class="mb-3 flex items-center justify-between gap-4 text-xs">
<!-- Token Usage -->
@@ -792,7 +810,6 @@ SOFTWARE.
@keydown.enter.prevent="if (!shiftPressed) { submitPrompt($event); }"
rows="2"
></textarea>
<span x-text="fileName" id="fileName" class="absolute right-16 top-3 text-[#94A3B8] text-xs mr-2"></span>
<button
type="button"
onclick="document.getElementById('input_image').click()"
@@ -845,7 +862,7 @@ SOFTWARE.
multiple
accept="image/*"
style="display: none;"
@change="fileName = $event.target.files.length + ' image(s) selected'"
@change="handleFileSelection($event, 'image')"
/>
<input
id="input_audio"
@@ -853,7 +870,7 @@ SOFTWARE.
multiple
accept="audio/*"
style="display: none;"
@change="fileName = $event.target.files.length + ' audio file(s) selected'"
@change="handleFileSelection($event, 'audio')"
/>
<input
id="input_file"
@@ -861,7 +878,7 @@ SOFTWARE.
multiple
accept=".txt,.md,.pdf"
style="display: none;"
@change="fileName = $event.target.files.length + ' file(s) selected'"
@change="handleFileSelection($event, 'file')"
/>
</div>
</form>

View File

@@ -51,6 +51,11 @@
<span class="font-semibold text-indigo-300" x-text="availableModels"></span>
<span class="text-[#94A3B8] ml-1">models available</span>
</div>
<a href="/manage" class="flex items-center bg-[#101827] hover:bg-[#1E293B] rounded-lg px-4 py-2 transition-colors border border-[#38BDF8]/30 hover:border-[#38BDF8]/50">
<div class="w-2 h-2 bg-emerald-400 rounded-full mr-2"></div>
<span class="font-semibold text-emerald-300" x-text="installedModels"></span>
<span class="text-[#94A3B8] ml-1">installed</span>
</a>
<div class="flex items-center bg-[#101827] rounded-lg px-4 py-2">
<div class="w-2 h-2 bg-purple-400 rounded-full mr-2"></div>
<span class="font-semibold text-purple-300" x-text="repositories.length"></span>
@@ -559,6 +564,7 @@ function modelsGallery() {
currentPage: 1,
totalPages: 1,
availableModels: 0,
installedModels: 0,
selectedModel: null,
jobProgress: {},
notifications: [],
@@ -597,6 +603,7 @@ function modelsGallery() {
this.currentPage = data.currentPage || 1;
this.totalPages = data.totalPages || 1;
this.availableModels = data.availableModels || 0;
this.installedModels = data.installedModels || 0;
} catch (error) {
console.error('Error fetching models:', error);
} finally {