mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-30 22:20:20 -06:00
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:
committed by
GitHub
parent
721c3f962b
commit
93cd688f40
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user