This commit is contained in:
Saifeddine ALOUI 2025-04-02 22:01:10 +02:00
parent d274a39bd7
commit 14bd739c37
7 changed files with 347 additions and 193 deletions

26
web/dist/assets/index-BVmgEthl.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View File

@ -6,8 +6,8 @@
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LoLLMS WebUI</title>
<script type="module" crossorigin src="/assets/index-CRNx5s08.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DflngNKB.css">
<script type="module" crossorigin src="/assets/index-CzPKRcU_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVmgEthl.css">
</head>
<body>
<div id="app"></div>

View File

@ -223,7 +223,9 @@ export default {
},
getImgUrl() {
// Prefer model icon, fallback to default
return this.model?.icon || defaultImgPlaceholder;
console.log("model icon:")
console.log(this.model.icon)
return this.model.icon || defaultImgPlaceholder;
},
defaultImg(event) {
this.failedToLoad = true;

View File

@ -1,60 +1,59 @@
<template>
<div v-if="show"
class="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all" style="z-index: 1000;">
<div class="relative w-full mx-4 max-w-2xl">
<!-- Main Container -->
<div class="card flex flex-col rounded-xl shadow-2xl transform transition-all max-h-[90vh]">
class="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity duration-300" style="z-index: 1000;">
<!-- Dialog Container -->
<div class="relative w-full mx-4 max-w-2xl transform transition-all duration-300 ease-out scale-95 opacity-0"
:class="{ 'scale-100 opacity-100': show }">
<!-- Main Panel -->
<div class="flex flex-col rounded-xl panels-color shadow-2xl max-h-[90vh]">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-blue-200 dark:border-blue-700">
<div class="flex items-center justify-between p-5 border-b border-blue-200 dark:border-blue-700">
<div class="flex items-center gap-3">
<i data-feather="sliders" class="w-6 h-6 text-blue-500 dark:text-blue-400"></i>
<h3 class="text-xl font-bold text-blue-800 dark:text-blue-100">{{ title }}</h3>
<i data-feather="sliders" class="w-6 h-6 text-blue-600 dark:text-blue-400"></i>
<h3 class="text-xl font-semibold text-blue-800 dark:text-blue-100">{{ title }}</h3>
</div>
<button @click.stop="hide(false)"
class="svg-button">
<i data-feather="x" class="w-5 h-5"></i> <!-- svg-button class handles text color -->
<i data-feather="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Scrollable Content -->
<div class="overflow-y-auto px-6 py-5 scrollbar"> <!-- Use theme scrollbar -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="overflow-y-auto px-6 py-5 scrollbar">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
<template v-for="(item, index) in controls_array" :key="index">
<div class="group" :class="{'md:col-span-2': item.spanFull || ['btn', 'text', 'list', 'file', 'folder'].includes(item.type)}">
<!-- Common Help Button Structure -->
<div class="flex items-center justify-between mb-2"> <!-- Reduced margin -->
<label :for="'input-' + index" class="label flex items-center gap-2 !mb-0"> <!-- Use theme label, adjusted margin -->
<span>
{{ item.name }}
</span>
<div class="flex flex-col" :class="{'md:col-span-2': item.spanFull || ['btn', 'text', 'list', 'file', 'folder'].includes(item.type)}">
<!-- Label and Help -->
<div class="flex items-center justify-between mb-2">
<label :for="`control-${index}`" class="flex items-center gap-1.5 label">
<span>{{ item.name }}</span>
<button v-if="item.help" @click="item.isHelp = !item.isHelp"
class="text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors">
<i data-feather="help-circle" class="w-4 h-4"></i>
</button>
</label>
<span v-if="item.required" class="text-xs text-red-500 dark:text-red-400">* Required</span>
<span v-if="item.required" class="text-xs text-red-500 dark:text-red-400 font-medium">* Required</span>
</div>
<!-- Help Text -->
<p v-if="item.isHelp" class="text-sm text-blue-600 dark:text-blue-400 mb-3">
<p v-if="item.isHelp" class="text-sm text-blue-600 dark:text-blue-400 mb-3 p bg-blue-100 dark:bg-blue-800 p-2 rounded-md border border-blue-200 dark:border-blue-700">
{{ item.help }}
</p>
<!-- Input Fields -->
<div class="space-y-2">
<div class="mt-1">
<!-- Text/Select Input -->
<div v-if="['str', 'string'].includes(item.type)">
<input v-if="!item.options"
:id="'input-' + index"
:id="`control-${index}`"
type="text"
v-model="item.value"
:placeholder="item.placeholder || 'Enter text'"
class="input w-full text-sm"> <!-- Use theme input -->
class="input w-full">
<select v-else
:id="'input-' + index"
v-model="item.value"
class="input w-full text-sm appearance-none"> <!-- Use theme input, keep appearance-none -->
<select v-else v-model="item.value"
:id="`control-${index}`"
class="input w-full appearance-none">
<option v-for="(op, i) in item.options" :key="i" :value="op">
{{ op }}
</option>
@ -64,92 +63,86 @@
<!-- Button -->
<div v-if="item.type === 'btn'">
<button @click="btn_clicked(item)"
class="btn btn-primary w-full justify-center text-sm"> <!-- Use theme button -->
<i v-if="item.icon" :data-feather="item.icon" class="w-4 h-4 mr-2"></i> <!-- Dynamic feather icon -->
class="btn btn-secondary w-full justify-center">
<i v-if="item.icon" :data-feather="item.icon" class="w-4 h-4 mr-2"></i>
{{ item.name }}
</button>
</div>
<!-- Text Area -->
<div v-if="item.type === 'text'">
<textarea :id="'input-' + index"
v-model="item.value"
<textarea v-model="item.value"
:id="`control-${index}`"
rows="4"
class="input w-full text-sm resize-none"></textarea> <!-- Use theme input -->
class="input w-full resize-y min-h-[80px]"></textarea>
</div>
<!-- Number Inputs -->
<div v-if="['int', 'float'].includes(item.type)" class="space-y-3">
<input :id="'input-' + index"
type="number"
<input type="number"
:id="`control-${index}`"
v-model="item.value"
:step="item.type === 'int' ? 1 : (item.step || 0.1)"
class="input w-full text-sm"> <!-- Use theme input -->
class="input w-full">
<input v-if="item.min !== undefined && item.max !== undefined"
:id="'range-' + index"
type="range"
v-model="item.value"
:min="item.min"
:max="item.max"
:step="item.step || (item.type === 'int' ? 1 : 0.1)"
class="range-input w-full cursor-pointer"> <!-- Use theme range input -->
class="range-input w-full">
</div>
<!-- Boolean Input -->
<!-- Boolean Input (Toggle Switch) -->
<div v-if="item.type === 'bool'" class="flex items-center gap-3">
<label class="relative inline-flex items-center cursor-pointer switch">
<input :id="'input-' + index" type="checkbox" v-model="item.value" class="sr-only peer">
<!-- Keep local switch style but use theme colors -->
<div class="w-11 h-6 bg-blue-200 dark:bg-blue-700 rounded-full peer peer-checked:bg-blue-500 dark:peer-checked:bg-blue-600 transition-colors">
<div class="switch-thumb"></div>
</div>
<label :for="`control-${index}`" class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :id="`control-${index}`" v-model="item.value" class="sr-only peer">
<div class="w-11 h-6 bg-blue-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-blue-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
<span class="text-sm text-blue-700 dark:text-blue-300">{{ item.value ? 'Enabled' : 'Disabled' }}</span>
</div>
<!-- Color Picker -->
<div v-if="item.type === 'color'" class="flex items-center gap-3">
<input :id="'input-' + index"
type="color"
<input type="color"
v-model="item.value"
class="w-10 h-10 rounded-lg border border-blue-300 dark:border-blue-600 cursor-pointer p-0.5 bg-clip-content bg-blue-100 dark:bg-blue-800"> <!-- Adjusted styling -->
class="w-10 h-10 p-0 border-0 rounded-md cursor-pointer bg-transparent appearance-none"
:style="{ backgroundColor: item.value }">
<input type="text"
:id="`control-${index}`"
v-model="item.value"
class="input flex-1 text-sm"> <!-- Use theme input -->
class="input flex-1">
</div>
<!-- File/Folder Input -->
<div v-if="['file', 'folder'].includes(item.type)" class="flex gap-2">
<input :id="'input-' + index"
type="text"
<input type="text"
:id="`control-${index}`"
v-model="item.value"
readonly
class="input flex-1 text-sm bg-blue-50 dark:bg-blue-800"> <!-- Use theme input, adjust readonly bg -->
class="input flex-1 bg-blue-50 dark:bg-blue-800 cursor-not-allowed">
<button @click="openFileDialog(item)"
class="btn btn-secondary text-sm"> <!-- Use theme button -->
class="btn btn-secondary flex-shrink-0">
<i data-feather="folder" class="w-4 h-4 mr-1"></i>
<span>Browse</span>
</button>
</div>
</div>
<!-- Divider (Removed visual divider, relying on grid gap) -->
<!-- <div v-if="index < controls_array.length - 1 && !item.spanFull"
class="h-px bg-blue-200 dark:bg-blue-700 my-6"></div> -->
</div>
</template>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 p-6 border-t border-blue-200 dark:border-blue-700">
<div class="flex justify-end gap-3 p-5 border-t border-blue-200 dark:border-blue-700">
<button @click.stop="hide(false)"
class="btn btn-secondary text-sm"> <!-- Use theme button -->
class="btn btn-secondary">
{{ DenyButtonText }}
</button>
<button @click.stop="hide(true)"
class="btn btn-primary text-sm"> <!-- Use theme button -->
class="btn btn-primary">
<i data-feather="check" class="w-4 h-4 mr-1"></i>
{{ ConfirmButtonText }}
</button>
@ -159,25 +152,176 @@
</div>
</template>
<!-- Keep minimal local styles for elements not covered by the theme, like the switch -->
<style scoped>
/* Keep the switch styles locally, but colors can be adapted from theme vars if needed */
.switch-thumb {
@apply absolute top-0.5 left-0.5 w-5 h-5 bg-white dark:bg-blue-100 rounded-full shadow-sm transform transition-transform duration-200;
<script>
import feather from 'feather-icons'
export default {
name: 'UniversalForm',
data() {
return {
show: false,
resolve: null,
controls_array: [],
title: "Universal Form",
ConfirmButtonText: "Submit",
DenyButtonText: "Cancel",
}
},
mounted() {
feather.replace()
},
methods: {
btn_clicked(item) {
if (item.callback) {
item.callback(item)
} else {
console.log('Button clicked:', item)
}
},
hide(response) {
this.show = false
if (this.resolve) {
if(response){
this.resolve(this.controls_array);
this.resolve = null;
}
}
},
showForm(controls_array, title, ConfirmButtonText, DenyButtonText) {
if (typeof controls_array === 'object' && !Array.isArray(controls_array)) {
return this._newShowForm(controls_array)
}
this.ConfirmButtonText = ConfirmButtonText || this.ConfirmButtonText
this.DenyButtonText = DenyButtonText || this.DenyButtonText
this.controls_array = controls_array.map(item => ({
...item,
isHelp: false,
placeholder: item.placeholder || '',
required: item.required || false,
spanFull: item.spanFull || ['btn', 'text', 'list', 'file', 'folder'].includes(item.type)
}))
return new Promise((resolve) => {
console.log('Resolve')
console.log(resolve)
this.title = title || this.title
this.show = true
this.resolve = resolve
this.$nextTick(() => feather.replace())
})
},
_newShowForm(config) {
this.title = config.title || this.title
this.ConfirmButtonText = config.confirmText || this.ConfirmButtonText
this.DenyButtonText = config.denyText || this.DenyButtonText
this.controls_array = config.fields.map(f => ({
...f,
isHelp: false,
placeholder: f.placeholder || '',
required: f.required || false,
spanFull: f.spanFull || ['btn', 'text', 'list', 'file', 'folder'].includes(f.type)
}))
this.show = true
return new Promise((resolve) => {
this.resolve = resolve
this.$nextTick(() => feather.replace())
})
},
parseValue(item) {
switch(item.type) {
case 'int': return parseInt(item.value) || 0
case 'float': return parseFloat(item.value) || 0.0
case 'bool': return Boolean(item.value)
case 'list': return item.value.split(',').map(i => i.trim())
default: return item.value
}
},
openFileDialog(item) {
const input = document.createElement('input')
input.type = item.type === 'folder' ? 'file' : item.type
if(item.type === 'folder') input.webkitdirectory = true
if(item.accept) input.accept = item.accept
input.onchange = (e) => {
const files = Array.from(e.target.files)
item.value = files.map(f => f.path).join(', ')
}
input.click()
}
},
watch: {
controls_array: {
deep: true,
handler(newArray) {
newArray.forEach(item => {
if(item.type === 'int') item.value = parseInt(item.value) || 0
if(item.type === 'float') item.value = parseFloat(item.value) || 0.0
})
}
}
}
}
.peer:checked + div > .switch-thumb { /* Target child div for thumb positioning */
</script>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
.range-thumb {
@apply appearance-none w-4 h-4 bg-blue-500 rounded-full shadow-sm -mt-1;
}
.dark .range-thumb {
@apply bg-blue-600;
}
.switch-thumb {
@apply absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm transform transition-transform duration-200;
}
.peer:checked ~ .switch-thumb {
@apply translate-x-5;
}
/* Color input background */
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
.peer:checked ~ div {
@apply bg-blue-600;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 6px; /* Slightly less than parent */
}
input[type="color"]::-moz-color-swatch {
border: none;
border-radius: 6px;
.dark .peer:checked ~ div {
@apply bg-blue-700;
}
</style>

View File

@ -105,11 +105,19 @@
/>
</div>
<!-- Loading More Indicator / Trigger -->
<div ref="loadMoreTrigger" class="h-10">
<div v-if="hasMoreModelsToLoad && !isLoadingModels" class="text-center text-blue-500 dark:text-blue-400 py-4">
Loading more models...
</div>
<!-- NEW: Load More Button -->
<div class="mt-6 text-center" v-if="hasMoreModelsToLoad">
<button
@click="loadMoreModels"
:disabled="isLoadingModels || isSearching"
class="btn btn-secondary"
>
<span v-if="isLoadingModels || isSearching">
<svg aria-hidden="true" class="w-4 h-4 mr-1 inline animate-spin text-blue-400 dark:text-blue-500 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> </svg>
Loading...
</span>
<span v-else>Load More Models ({{ filteredModels.length - pagedModels.length }} remaining)</span>
</button>
</div>
<!-- Add Model / Reference Section -->
@ -207,7 +215,6 @@ export default {
itemsPerPage: 15,
currentPage: 1,
searchDebounceTimer: null,
observer: null,
downloadProgress: {
visible: false, name: '', progress: 0, speed: 0, total_size: 0, downloaded_size: 0, details: null
},
@ -483,27 +490,39 @@ export default {
},
loadMoreModels() {
// Prevent loading more if already loading, searching, or no more models exist
// Prevent loading more if explicitly disabled or no more models exist
if (this.isLoadingModels || this.isSearching || !this.hasMoreModelsToLoad) return;
this.isLoadingModels = true; // Set loading state BEFORE potentially heavy operation
console.log(`Loading page ${this.currentPage} for models`);
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
const nextPageItems = this.filteredModels.slice(start, end);
// Prevent adding duplicates if loadMore is triggered rapidly
const newItems = nextPageItems.filter(newItem =>
!this.pagedModels.some(existingItem => (existingItem.id || existingItem.name) === (newItem.id || newItem.name))
);
// Use nextTick to allow the loading state to potentially update UI
// before the potentially blocking push/render cycle starts
nextTick(() => {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
const nextPageItems = this.filteredModels.slice(start, end);
if (newItems.length > 0) {
this.pagedModels.push(...newItems);
this.currentPage++;
nextTick(feather.replace);
} else if (nextPageItems.length === 0 && this.hasMoreModelsToLoad){
// If slice returns empty but calculation says more exist (edge case?)
console.warn("Load more triggered but no new items found in slice.");
}
// Prevent adding duplicates if loadMore is triggered rapidly (unlikely with button)
const newItems = nextPageItems.filter(newItem =>
!this.pagedModels.some(existingItem => (existingItem.id || existingItem.name) === (newItem.id || newItem.name))
);
if (newItems.length > 0) {
this.pagedModels.push(...newItems);
this.currentPage++;
nextTick(() => {
feather.replace(); // Replace icons after new items are rendered
this.isLoadingModels = false; // Reset loading state AFTER rendering cycle (approximately)
});
} else {
// No new items were added, reset loading state
this.isLoadingModels = false;
if (nextPageItems.length === 0 && this.hasMoreModelsToLoad){
console.warn("Load more triggered but no new items found in slice.");
}
}
});
},
// --- Actions ---
@ -918,9 +937,6 @@ export default {
console.warn("Unknown progress status:", response.status);
}
},
setupIntersectionObserver() { /* ... unchanged ... */ },
destroyIntersectionObserver() { /* ... unchanged ... */ }
},
async mounted() {
console.log("updated")
@ -930,7 +946,6 @@ export default {
socket.on('install_progress', this.installProgressListener);
nextTick(() => {
feather.replace();
this.setupIntersectionObserver();
});
// If binding changes *before* mount, the watcher might need 'immediate: true'
// or call the refresh logic here based on initial store state.
@ -955,13 +970,6 @@ export default {
async updated() {
nextTick(() => {
feather.replace();
// Re-setup observer if the trigger element becomes available after an update
if (this.$refs.loadMoreTrigger && !this.observer) {
this.setupIntersectionObserver();
} else if (!this.$refs.loadMoreTrigger && this.observer) {
// Clean up if trigger disappears
this.destroyIntersectionObserver();
}
});
}
};