feat(ui): use Alpine.js and drop HTMX (#6418)

* feat(ui): use Alpine.js and drop HTMX

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

* Display pending ops

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

* Show in progress ops

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

* more stable sorting

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

* minor fixup

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

* Fix clipboard copy

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

* Cleanup

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-10-09 22:37:06 +02:00
committed by GitHub
parent cb0ed55d89
commit c6f0b44228
16 changed files with 1880 additions and 2255 deletions

View File

@@ -203,6 +203,9 @@ func API(application *application.Application) (*fiber.App, error) {
routes.RegisterLocalAIRoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
routes.RegisterOpenAIRoutes(router, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI {
// Create opcache for tracking UI operations
opcache := services.NewOpCache(application.GalleryService())
routes.RegisterUIAPIRoutes(router, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), opcache)
routes.RegisterUIRoutes(router, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
}
routes.RegisterJINARoutes(router, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

View File

@@ -1,115 +0,0 @@
package elements
import (
"strings"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/mudler/LocalAI/core/gallery"
)
func installButton(galleryName string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg",
"hx-swap": "outerHTML",
// post the Model ID as param
"hx-post": "browse/install/model/" + galleryName,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-download pr-2",
},
),
elem.Text("Install"),
)
}
func reInstallButton(galleryName string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
"hx-target": "#action-div-" + dropBadChars(galleryName),
"hx-swap": "outerHTML",
// post the Model ID as param
"hx-post": "browse/install/model/" + galleryName,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-arrow-rotate-right pr-2",
},
),
elem.Text("Reinstall"),
)
}
func infoButton(m *gallery.GalleryModel) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out",
"data-modal-target": modalName(m),
"data-modal-toggle": modalName(m),
},
elem.P(
attrs.Props{
"class": "flex items-center",
},
elem.I(
attrs.Props{
"class": "fas fa-info-circle pr-2",
},
),
elem.Text("Info"),
),
)
}
func getConfigButton(galleryName string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "float-right ml-2 inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out",
"hx-swap": "outerHTML",
"hx-post": "browse/config/model/" + galleryName,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-download pr-2",
},
),
elem.Text("Get Config"),
)
}
func deleteButton(galleryID string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"hx-confirm": "Are you sure you wish to delete the model?",
"class": "float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2 focus:bg-red-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-red-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
"hx-target": "#action-div-" + dropBadChars(galleryID),
"hx-swap": "outerHTML",
// post the Model ID as param
"hx-post": "browse/delete/model/" + galleryID,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-cancel pr-2",
},
),
elem.Text("Delete"),
)
}
// Javascript/HTMX doesn't like weird IDs
func dropBadChars(s string) string {
return strings.ReplaceAll(s, "@", "__")
}

View File

@@ -1,757 +0,0 @@
package elements
import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/microcosm-cc/bluemonday"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services"
)
const (
noImage = "https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg"
)
func cardSpan(text, icon string) elem.Node {
return elem.Span(
attrs.Props{
"class": "inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2",
},
elem.I(attrs.Props{
"class": icon + " pr-2",
}),
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
)
}
func searchableElement(text, icon string) elem.Node {
return elem.Form(
attrs.Props{},
elem.Input(
attrs.Props{
"type": "hidden",
"name": "search",
"value": text,
},
),
elem.Span(
attrs.Props{
"class": "inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50 hover:bg-gray-600 hover:text-gray-100 transition duration-200 ease-in-out",
},
elem.A(
attrs.Props{
// "name": "search",
// "value": text,
//"class": "inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2",
//"href": "#!",
"href": "browse?term=" + text,
//"hx-post": "browse/search/models",
//"hx-target": "#search-results",
// TODO: this doesn't work
// "hx-vals": `{ \"search\": \"` + text + `\" }`,
//"hx-indicator": ".htmx-indicator",
},
elem.I(attrs.Props{
"class": icon + " pr-2",
}),
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
),
),
)
}
/*
func buttonLink(text, url string) elem.Node {
return elem.A(
attrs.Props{
"class": "inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2",
"href": url,
"target": "_blank",
},
elem.I(attrs.Props{
"class": "fas fa-link pr-2",
}),
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
)
}
*/
func link(text, url string) elem.Node {
return elem.A(
attrs.Props{
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
"href": url,
"target": "_blank",
},
elem.I(attrs.Props{
"class": "fas fa-link pr-2",
}),
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
)
}
type ProcessTracker interface {
Exists(string) bool
Get(string) string
}
func modalName(m *gallery.GalleryModel) string {
return m.Name + "-modal"
}
func modelModal(m *gallery.GalleryModel) elem.Node {
urls := []elem.Node{}
for _, url := range m.URLs {
urls = append(urls,
elem.Li(attrs.Props{}, link(url, url)),
)
}
tagsNodes := []elem.Node{}
for _, tag := range m.Tags {
tagsNodes = append(tagsNodes,
searchableElement(tag, "fas fa-tag"),
)
}
return elem.Div(
attrs.Props{
"id": modalName(m),
"tabindex": "-1",
"aria-hidden": "true",
"class": "hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50",
},
elem.Div(
attrs.Props{
"class": "relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]",
},
elem.Div(
attrs.Props{
"class": "relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col",
},
// header
elem.Div(
attrs.Props{
"class": "flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600",
},
elem.H3(
attrs.Props{
"class": "text-xl font-semibold text-gray-900 dark:text-white",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Name)),
),
elem.Button( // close button
attrs.Props{
"class": "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white",
"data-modal-hide": modalName(m),
},
elem.Raw(
`<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>`,
),
elem.Span(
attrs.Props{
"class": "sr-only",
},
elem.Text("Close modal"),
),
),
),
// body
elem.Div(
attrs.Props{
"class": "p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0",
},
elem.Div(
attrs.Props{
"class": "flex justify-center items-center",
},
elem.Img(attrs.Props{
"class": "lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded",
"src": m.Icon,
"loading": "lazy",
}),
),
elem.P(
attrs.Props{
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Description)),
),
elem.Hr(
attrs.Props{},
),
elem.P(
attrs.Props{
"class": "text-sm font-semibold text-gray-900 dark:text-white",
},
elem.Text("Links"),
),
elem.Ul(
attrs.Props{},
urls...,
),
elem.If(
len(m.Tags) > 0,
elem.Div(
attrs.Props{},
elem.P(
attrs.Props{
"class": "text-sm mb-5 font-semibold text-gray-900 dark:text-white",
},
elem.Text("Tags"),
),
elem.Div(
attrs.Props{
"class": "flex flex-row flex-wrap content-center",
},
tagsNodes...,
),
),
elem.Div(attrs.Props{}),
),
),
// Footer
elem.Div(
attrs.Props{
"class": "flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600",
},
elem.Button(
attrs.Props{
"data-modal-hide": modalName(m),
"class": "py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700",
},
elem.Text("Close"),
),
),
),
),
)
}
func modelDescription(m *gallery.GalleryModel) elem.Node {
return elem.Div(
attrs.Props{
"class": "p-6 text-surface dark:text-white",
},
elem.H5(
attrs.Props{
"class": "mb-2 text-xl font-bold leading-tight",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Name)),
),
elem.Div( // small description
attrs.Props{
"class": "mb-4 text-sm truncate text-base",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Description)),
),
)
}
func modelActionItems(m *gallery.GalleryModel, processTracker ProcessTracker, galleryService *services.GalleryService) elem.Node {
galleryID := fmt.Sprintf("%s@%s", m.Gallery.Name, m.Name)
currentlyProcessing := processTracker.Exists(galleryID)
jobID := ""
isDeletionOp := false
if currentlyProcessing {
status := galleryService.GetStatus(galleryID)
if status != nil && status.Deletion {
isDeletionOp = true
}
jobID = processTracker.Get(galleryID)
// TODO:
// case not handled, if status == nil : "Waiting"
}
nodes := []elem.Node{
cardSpan("Repository: "+m.Gallery.Name, "fa-brands fa-git-alt"),
}
if m.License != "" {
nodes = append(nodes,
cardSpan("License: "+m.License, "fas fa-book"),
)
}
/*
tagsNodes := []elem.Node{}
for _, tag := range m.Tags {
tagsNodes = append(tagsNodes,
searchableElement(tag, "fas fa-tag"),
)
}
nodes = append(nodes,
elem.Div(
attrs.Props{
"class": "flex flex-row flex-wrap content-center",
},
tagsNodes...,
),
)
for i, url := range m.URLs {
nodes = append(nodes,
buttonLink("Link #"+fmt.Sprintf("%d", i+1), url),
)
}
*/
progressMessage := "Installation"
if isDeletionOp {
progressMessage = "Deletion"
}
return elem.Div(
attrs.Props{
"class": "px-6 pt-4 pb-2",
},
elem.P(
attrs.Props{
"class": "mb-4 text-base",
},
nodes...,
),
elem.Div(
attrs.Props{
"id": "action-div-" + dropBadChars(galleryID),
"class": "flow-root", // To order buttons left and right
},
infoButton(m),
elem.Div(
attrs.Props{
"class": "float-right",
},
elem.If(
currentlyProcessing,
elem.Node( // If currently installing, show progress bar
elem.Raw(StartModelProgressBar(jobID, "0", progressMessage)),
), // Otherwise, show install button (if not installed) or display "Installed"
elem.If(m.Installed,
elem.Node(elem.Div(
attrs.Props{},
reInstallButton(m.ID()),
deleteButton(m.ID()),
)),
// otherwise, show the install button, and the get config button
elem.Node(elem.Div(
attrs.Props{},
getConfigButton(m.ID()),
installButton(m.ID()),
)),
),
),
),
),
)
}
func ListModels(models []*gallery.GalleryModel, processTracker ProcessTracker, galleryService *services.GalleryService) string {
modelsElements := []elem.Node{}
for _, m := range models {
elems := []elem.Node{}
if m.Icon == "" {
m.Icon = noImage
}
divProperties := attrs.Props{
"class": "flex justify-center items-center",
}
elems = append(elems,
elem.Div(divProperties,
elem.A(attrs.Props{
"href": "#!",
// "class": "justify-center items-center",
},
elem.Img(attrs.Props{
// "class": "rounded-t-lg object-fit object-center h-96",
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
"src": m.Icon,
"loading": "lazy",
}),
),
),
)
// Special/corner case: if a model sets Trust Remote Code as required, show a warning
// TODO: handle this more generically later
_, trustRemoteCodeExists := m.Overrides["trust_remote_code"]
if trustRemoteCodeExists {
elems = append(elems, elem.Div(
attrs.Props{
"class": "flex justify-center items-center bg-red-500 text-white p-2 rounded-lg mt-2",
},
elem.I(attrs.Props{
"class": "fa-solid fa-circle-exclamation pr-2",
}),
elem.Text("Attention: Trust Remote Code is required for this model"),
))
}
elems = append(elems,
modelDescription(m),
modelActionItems(m, processTracker, galleryService),
)
modelsElements = append(modelsElements,
elem.Div(
attrs.Props{
"class": " me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50",
},
elem.Div(
attrs.Props{
// "class": "p-6",
},
elems...,
),
),
modelModal(m),
)
}
wrapper := elem.Div(attrs.Props{
"class": "dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark",
}, modelsElements...)
return wrapper.Render()
}
func ListBackends(backends []*gallery.GalleryBackend, processTracker ProcessTracker, galleryService *services.GalleryService) string {
backendsElements := []elem.Node{}
for _, b := range backends {
elems := []elem.Node{}
if b.Icon == "" {
b.Icon = noImage
}
divProperties := attrs.Props{
"class": "flex justify-center items-center",
}
elems = append(elems,
elem.Div(divProperties,
elem.A(attrs.Props{
"href": "#!",
},
elem.Img(attrs.Props{
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
"src": b.Icon,
"loading": "lazy",
}),
),
),
)
elems = append(elems,
backendDescription(b),
backendActionItems(b, processTracker, galleryService),
)
backendsElements = append(backendsElements,
elem.Div(
attrs.Props{
"class": "me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50",
},
elem.Div(
attrs.Props{},
elems...,
),
),
backendModal(b),
)
}
wrapper := elem.Div(attrs.Props{
"class": "dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark",
}, backendsElements...)
return wrapper.Render()
}
func backendDescription(b *gallery.GalleryBackend) elem.Node {
return elem.Div(
attrs.Props{
"class": "p-6 text-surface dark:text-white",
},
elem.H5(
attrs.Props{
"class": "mb-2 text-xl font-bold leading-tight",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Name)),
),
elem.Div(
attrs.Props{
"class": "mb-4 text-sm truncate text-base",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Description)),
),
)
}
func backendActionItems(b *gallery.GalleryBackend, processTracker ProcessTracker, galleryService *services.GalleryService) elem.Node {
galleryID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
currentlyProcessing := processTracker.Exists(galleryID)
jobID := ""
isDeletionOp := false
if currentlyProcessing {
status := galleryService.GetStatus(galleryID)
if status != nil && status.Deletion {
isDeletionOp = true
}
jobID = processTracker.Get(galleryID)
}
nodes := []elem.Node{
cardSpan("Repository: "+b.Gallery.Name, "fa-brands fa-git-alt"),
}
if b.License != "" {
nodes = append(nodes,
cardSpan("License: "+b.License, "fas fa-book"),
)
}
progressMessage := "Installation"
if isDeletionOp {
progressMessage = "Deletion"
}
return elem.Div(
attrs.Props{
"class": "px-6 pt-4 pb-2",
},
elem.P(
attrs.Props{
"class": "mb-4 text-base",
},
nodes...,
),
elem.Div(
attrs.Props{
"id": "action-div-" + dropBadChars(galleryID),
"class": "flow-root",
},
backendInfoButton(b),
elem.Div(
attrs.Props{
"class": "float-right",
},
elem.If(
currentlyProcessing,
elem.Node(
elem.Raw(StartModelProgressBar(jobID, "0", progressMessage)),
),
elem.If(b.Installed,
elem.Node(elem.Div(
attrs.Props{},
backendReInstallButton(galleryID),
backendDeleteButton(galleryID),
)),
backendInstallButton(galleryID),
),
),
),
),
)
}
func backendModal(b *gallery.GalleryBackend) elem.Node {
urls := []elem.Node{}
for _, url := range b.URLs {
urls = append(urls,
elem.Li(attrs.Props{}, link(url, url)),
)
}
tagsNodes := []elem.Node{}
for _, tag := range b.Tags {
tagsNodes = append(tagsNodes,
searchableElement(tag, "fas fa-tag"),
)
}
modalID := fmt.Sprintf("modal-%s", dropBadChars(fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)))
return elem.Div(
attrs.Props{
"id": modalID,
"tabindex": "-1",
"aria-hidden": "true",
"class": "hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50",
},
elem.Div(
attrs.Props{
"class": "relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]",
},
elem.Div(
attrs.Props{
"class": "relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col",
},
elem.Div(
attrs.Props{
"class": "flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600",
},
elem.H3(
attrs.Props{
"class": "text-xl font-semibold text-gray-900 dark:text-white",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Name)),
),
elem.Button(
attrs.Props{
"class": "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white",
"data-modal-hide": modalID,
},
elem.Raw(
`<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>`,
),
elem.Span(
attrs.Props{
"class": "sr-only",
},
elem.Text("Close modal"),
),
),
),
elem.Div(
attrs.Props{
"class": "p-4 md:p-5 space-y-4 overflow-y-auto flex-grow",
},
elem.Div(
attrs.Props{
"class": "flex justify-center items-center",
},
elem.Img(attrs.Props{
"src": b.Icon,
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
"loading": "lazy",
}),
),
elem.P(
attrs.Props{
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Description)),
),
elem.Div(
attrs.Props{
"class": "flex flex-wrap gap-2",
},
tagsNodes...,
),
elem.Div(
attrs.Props{
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
},
elem.Ul(attrs.Props{}, urls...),
),
),
elem.Div(
attrs.Props{
"class": "flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600",
},
elem.Button(
attrs.Props{
"data-modal-hide": modalID,
"type": "button",
"class": "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800",
},
elem.Text("Close"),
),
),
),
),
)
}
func backendInfoButton(b *gallery.GalleryBackend) elem.Node {
modalID := fmt.Sprintf("modal-%s", dropBadChars(fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)))
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out",
"data-modal-target": modalID,
"data-modal-toggle": modalID,
},
elem.P(
attrs.Props{
"class": "flex items-center",
},
elem.I(
attrs.Props{
"class": "fas fa-info-circle pr-2",
},
),
elem.Text("Info"),
),
)
}
func backendInstallButton(galleryID string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg",
"hx-swap": "outerHTML",
"hx-post": "browse/install/backend/" + galleryID,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-download pr-2",
},
),
elem.Text("Install"),
)
}
func backendReInstallButton(galleryID string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"class": "float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
"hx-target": "#action-div-" + dropBadChars(galleryID),
"hx-swap": "outerHTML",
"hx-post": "browse/install/backend/" + galleryID,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-arrow-rotate-right pr-2",
},
),
elem.Text("Reinstall"),
)
}
func backendDeleteButton(galleryID string) elem.Node {
return elem.Button(
attrs.Props{
"data-twe-ripple-init": "",
"data-twe-ripple-color": "light",
"hx-confirm": "Are you sure you wish to delete the backend?",
"class": "float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2 focus:bg-red-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-red-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
"hx-target": "#action-div-" + dropBadChars(galleryID),
"hx-swap": "outerHTML",
"hx-post": "browse/delete/backend/" + galleryID,
},
elem.I(
attrs.Props{
"class": "fa-solid fa-cancel pr-2",
},
),
elem.Text("Delete"),
)
}

View File

@@ -1,156 +0,0 @@
package elements
import (
"fmt"
"time"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/microcosm-cc/bluemonday"
"github.com/mudler/LocalAI/core/schema"
)
func renderElements(n []elem.Node) string {
render := ""
for _, r := range n {
render += r.Render()
}
return render
}
func P2PNodeStats(nodes []schema.NodeData) string {
online := 0
for _, n := range nodes {
if n.IsOnline() {
online++
}
}
class := "text-green-400"
if online == 0 {
class = "text-red-400"
} else if online < len(nodes) {
class = "text-yellow-400"
}
nodesElements := []elem.Node{
elem.Span(
attrs.Props{
"class": class + " font-bold text-2xl",
},
elem.Text(fmt.Sprintf("%d", online)),
),
elem.Span(
attrs.Props{
"class": "text-gray-300 text-xl",
},
elem.Text(fmt.Sprintf("/%d", len(nodes))),
),
}
return renderElements(nodesElements)
}
func P2PNodeBoxes(nodes []schema.NodeData) string {
if len(nodes) == 0 {
return `<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
<p class="text-gray-400 text-lg font-medium">No nodes available</p>
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
</div>`
}
render := ""
for _, n := range nodes {
nodeID := bluemonday.StrictPolicy().Sanitize(n.ID)
// Define status-specific classes
statusIconClass := "text-green-400"
statusText := "Online"
statusTextClass := "text-green-400"
cardHoverClass := "hover:shadow-green-500/20 hover:border-green-400/50"
if !n.IsOnline() {
statusIconClass = "text-red-400"
statusText = "Offline"
statusTextClass = "text-red-400"
cardHoverClass = "hover:shadow-red-500/20 hover:border-red-400/50"
}
nodeCard := elem.Div(
attrs.Props{
"class": "bg-gradient-to-br from-gray-800/90 to-gray-900/80 border border-gray-700/50 rounded-xl p-5 shadow-xl transition-all duration-300 " + cardHoverClass + " backdrop-blur-sm",
},
// Header with node icon and status
elem.Div(
attrs.Props{
"class": "flex items-center justify-between mb-4",
},
// Node info
elem.Div(
attrs.Props{
"class": "flex items-center",
},
elem.Div(
attrs.Props{
"class": "w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mr-3",
},
elem.I(
attrs.Props{
"class": "fas fa-server text-blue-400 text-lg",
},
),
),
elem.Div(
attrs.Props{},
elem.H4(
attrs.Props{
"class": "text-white font-semibold text-sm",
},
elem.Text("Node"),
),
elem.P(
attrs.Props{
"class": "text-gray-400 text-xs font-mono break-all",
},
elem.Text(nodeID),
),
),
),
// Status badge
elem.Div(
attrs.Props{
"class": "flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50",
},
elem.I(
attrs.Props{
"class": "fas fa-circle animate-pulse " + statusIconClass + " mr-2 text-xs",
},
),
elem.Span(
attrs.Props{
"class": statusTextClass + " text-xs font-medium",
},
elem.Text(statusText),
),
),
),
// Footer with timestamp
elem.Div(
attrs.Props{
"class": "text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center",
},
elem.I(
attrs.Props{
"class": "fas fa-clock mr-2",
},
),
elem.Text("Updated: "+time.Now().UTC().Format("15:04:05")),
),
)
render += nodeCard.Render()
}
return render
}

View File

@@ -1,115 +0,0 @@
package elements
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/microcosm-cc/bluemonday"
)
func DoneModelProgress(galleryID, text string, showDelete bool) string {
return elem.Div(
attrs.Props{
"id": "action-div-" + dropBadChars(galleryID),
},
elem.H3(
attrs.Props{
"role": "status",
"id": "pblabel",
"tabindex": "-1",
"autofocus": "",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
),
elem.If(showDelete, deleteButton(galleryID), reInstallButton(galleryID)),
).Render()
}
func DoneBackendProgress(galleryID, text string, showDelete bool) string {
return elem.Div(
attrs.Props{
"id": "action-div-" + dropBadChars(galleryID),
},
elem.H3(
attrs.Props{
"role": "status",
"id": "pblabel",
"tabindex": "-1",
"autofocus": "",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
),
elem.If(showDelete, backendDeleteButton(galleryID), reInstallButton(galleryID)),
).Render()
}
func ErrorProgress(err, galleryName string) string {
return elem.Div(
attrs.Props{},
elem.H3(
attrs.Props{
"role": "status",
"id": "pblabel",
"tabindex": "-1",
"autofocus": "",
},
elem.Text("Error "+bluemonday.StrictPolicy().Sanitize(err)),
),
installButton(galleryName),
).Render()
}
func ProgressBar(progress string) string {
return elem.Div(attrs.Props{
"class": "progress",
"role": "progressbar",
"aria-valuemin": "0",
"aria-valuemax": "100",
"aria-valuenow": "0",
"aria-labelledby": "pblabel",
},
elem.Div(attrs.Props{
"id": "pb",
"class": "progress-bar",
"style": "width:" + progress + "%",
}),
).Render()
}
func StartModelProgressBar(uid, progress, text string) string {
return progressBar(uid, "browse/job/", progress, text)
}
func StartBackendProgressBar(uid, progress, text string) string {
return progressBar(uid, "browse/backend/job/", progress, text)
}
func progressBar(uid, url, progress, text string) string {
if progress == "" {
progress = "0"
}
return elem.Div(
attrs.Props{
"hx-trigger": "done",
"hx-get": url + uid,
"hx-swap": "outerHTML",
"hx-target": "this",
},
elem.H3(
attrs.Props{
"role": "status",
"id": "pblabel",
"tabindex": "-1",
"autofocus": "",
},
elem.Text(bluemonday.StrictPolicy().Sanitize(text)), //Perhaps overly defensive
elem.Div(attrs.Props{
"hx-get": url + "progress/" + uid,
"hx-trigger": "every 600ms",
"hx-target": "this",
"hx-swap": "innerHTML",
},
elem.Raw(ProgressBar(progress)),
),
),
).Render()
}

View File

@@ -3,10 +3,8 @@ package routes
import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/elements"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/http/utils"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/model"
@@ -42,20 +40,8 @@ func RegisterUIRoutes(app *fiber.App,
return c.Render("views/p2p", summary)
})
/* show nodes live! */
app.Get("/p2p/ui/workers", func(c *fiber.Ctx) error {
return c.SendString(elements.P2PNodeBoxes(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))))
})
app.Get("/p2p/ui/workers-federation", func(c *fiber.Ctx) error {
return c.SendString(elements.P2PNodeBoxes(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))))
})
app.Get("/p2p/ui/workers-stats", func(c *fiber.Ctx) error {
return c.SendString(elements.P2PNodeStats(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))))
})
app.Get("/p2p/ui/workers-federation-stats", func(c *fiber.Ctx) error {
return c.SendString(elements.P2PNodeStats(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))))
})
// Note: P2P UI fragment routes (/p2p/ui/*) were removed
// P2P nodes are now fetched via JSON API at /api/p2p/workers and /api/p2p/federation
// End P2P

673
core/http/routes/ui_api.go Normal file
View File

@@ -0,0 +1,673 @@
package routes
import (
"fmt"
"math"
"net/url"
"sort"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/services"
"github.com/rs/zerolog/log"
)
// RegisterUIAPIRoutes registers JSON API routes for the web UI
func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
// Operations API - Get all current operations (models + backends)
app.Get("/api/operations", func(c *fiber.Ctx) error {
processingData, taskTypes := opcache.GetStatus()
operations := []fiber.Map{}
for galleryID, jobID := range processingData {
taskType := "installation"
if tt, ok := taskTypes[galleryID]; ok {
taskType = tt
}
status := galleryService.GetStatus(jobID)
progress := 0
isDeletion := false
isQueued := false
message := ""
if status != nil {
// Skip completed operations
if status.Processed {
continue
}
progress = int(status.Progress)
isDeletion = status.Deletion
message = status.Message
if isDeletion {
taskType = "deletion"
}
} else {
// Job is queued but hasn't started
isQueued = true
message = "Operation queued"
}
// Determine if it's a model or backend
isBackend := false
backends, _ := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
for _, b := range backends {
backendID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
if backendID == galleryID || b.Name == galleryID {
isBackend = true
break
}
}
// Extract display name (remove repo prefix if exists)
displayName := galleryID
if strings.Contains(galleryID, "@") {
parts := strings.Split(galleryID, "@")
if len(parts) > 1 {
displayName = parts[1]
}
}
operations = append(operations, fiber.Map{
"id": galleryID,
"name": displayName,
"fullName": galleryID,
"jobID": jobID,
"progress": progress,
"taskType": taskType,
"isDeletion": isDeletion,
"isBackend": isBackend,
"isQueued": isQueued,
"message": message,
})
}
// Sort operations by progress (ascending), then by ID for stable display order
sort.Slice(operations, func(i, j int) bool {
progressI := operations[i]["progress"].(int)
progressJ := operations[j]["progress"].(int)
// Primary sort by progress
if progressI != progressJ {
return progressI < progressJ
}
// Secondary sort by ID for stability when progress is the same
return operations[i]["id"].(string) < operations[j]["id"].(string)
})
return c.JSON(fiber.Map{
"operations": operations,
})
})
// Model Gallery APIs
app.Get("/api/models", func(c *fiber.Ctx) error {
term := c.Query("term")
page := c.Query("page", "1")
items := c.Query("items", "21")
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
if err != nil {
log.Error().Err(err).Msg("could not list models from galleries")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
// Get all available tags
allTags := map[string]struct{}{}
tags := []string{}
for _, m := range models {
for _, t := range m.Tags {
allTags[t] = struct{}{}
}
}
for t := range allTags {
tags = append(tags, t)
}
sort.Strings(tags)
if term != "" {
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
}
// Get model statuses
processingModelsData, taskTypes := opcache.GetStatus()
pageNum, err := strconv.Atoi(page)
if err != nil || pageNum < 1 {
pageNum = 1
}
itemsNum, err := strconv.Atoi(items)
if err != nil || itemsNum < 1 {
itemsNum = 21
}
totalPages := int(math.Ceil(float64(len(models)) / float64(itemsNum)))
totalModels := len(models)
if pageNum > 0 {
models = models.Paginate(pageNum, itemsNum)
}
// Convert models to JSON-friendly format
modelsJSON := make([]fiber.Map, 0, len(models))
for _, m := range models {
galleryID := fmt.Sprintf("%s@%s", m.Gallery.Name, m.Name)
currentlyProcessing := opcache.Exists(galleryID)
jobID := ""
isDeletionOp := false
if currentlyProcessing {
jobID = opcache.Get(galleryID)
status := galleryService.GetStatus(jobID)
if status != nil && status.Deletion {
isDeletionOp = true
}
}
_, trustRemoteCodeExists := m.Overrides["trust_remote_code"]
modelsJSON = append(modelsJSON, fiber.Map{
"id": m.ID(),
"name": m.Name,
"description": m.Description,
"icon": m.Icon,
"license": m.License,
"urls": m.URLs,
"tags": m.Tags,
"gallery": m.Gallery.Name,
"installed": m.Installed,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
"trustRemoteCode": trustRemoteCodeExists,
})
}
prevPage := pageNum - 1
nextPage := pageNum + 1
if prevPage < 1 {
prevPage = 1
}
if nextPage > totalPages {
nextPage = totalPages
}
return c.JSON(fiber.Map{
"models": modelsJSON,
"repositories": appConfig.Galleries,
"allTags": tags,
"processingModels": processingModelsData,
"taskTypes": taskTypes,
"availableModels": totalModels,
"currentPage": pageNum,
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
})
})
app.Post("/api/models/install/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id"))
// URL decode the gallery ID (e.g., "localai%40model" -> "localai@model")
galleryID, err := url.QueryUnescape(galleryID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid model ID",
})
}
log.Debug().Msgf("API job submitted to install: %+v\n", galleryID)
id, err := uuid.NewUUID()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
uid := id.String()
opcache.Set(galleryID, uid)
op := services.GalleryOp[gallery.GalleryModel]{
ID: uid,
GalleryElementName: galleryID,
Galleries: appConfig.Galleries,
BackendGalleries: appConfig.BackendGalleries,
}
go func() {
galleryService.ModelGalleryChannel <- op
}()
return c.JSON(fiber.Map{
"jobID": uid,
"message": "Installation started",
})
})
app.Post("/api/models/delete/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id"))
// URL decode the gallery ID
galleryID, err := url.QueryUnescape(galleryID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid model ID",
})
}
log.Debug().Msgf("API job submitted to delete: %+v\n", galleryID)
var galleryName = galleryID
if strings.Contains(galleryID, "@") {
galleryName = strings.Split(galleryID, "@")[1]
}
id, err := uuid.NewUUID()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
uid := id.String()
opcache.Set(galleryID, uid)
op := services.GalleryOp[gallery.GalleryModel]{
ID: uid,
Delete: true,
GalleryElementName: galleryName,
Galleries: appConfig.Galleries,
BackendGalleries: appConfig.BackendGalleries,
}
go func() {
galleryService.ModelGalleryChannel <- op
cl.RemoveModelConfig(galleryName)
}()
return c.JSON(fiber.Map{
"jobID": uid,
"message": "Deletion started",
})
})
app.Post("/api/models/config/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id"))
// URL decode the gallery ID
galleryID, err := url.QueryUnescape(galleryID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid model ID",
})
}
log.Debug().Msgf("API job submitted to get config for: %+v\n", galleryID)
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
model := gallery.FindGalleryElement(models, galleryID)
if model == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "model not found",
})
}
config, err := gallery.GetGalleryConfigFromURL[gallery.ModelConfig](model.URL, appConfig.SystemState.Model.ModelsPath)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
_, err = gallery.InstallModel(appConfig.SystemState, model.Name, &config, model.Overrides, nil, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"message": "Configuration file saved",
})
})
app.Get("/api/models/job/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid"))
status := galleryService.GetStatus(jobUID)
if status == nil {
// Job is queued but hasn't started processing yet
return c.JSON(fiber.Map{
"progress": 0,
"message": "Operation queued",
"galleryElementName": "",
"processed": false,
"deletion": false,
"queued": true,
})
}
response := fiber.Map{
"progress": status.Progress,
"message": status.Message,
"galleryElementName": status.GalleryElementName,
"processed": status.Processed,
"deletion": status.Deletion,
"queued": false,
}
if status.Error != nil {
response["error"] = status.Error.Error()
}
if status.Progress == 100 && status.Processed && status.Message == "completed" {
opcache.DeleteUUID(jobUID)
response["completed"] = true
}
return c.JSON(response)
})
// Backend Gallery APIs
app.Get("/api/backends", func(c *fiber.Ctx) error {
term := c.Query("term")
page := c.Query("page", "1")
items := c.Query("items", "21")
backends, err := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
if err != nil {
log.Error().Err(err).Msg("could not list backends from galleries")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
// Get all available tags
allTags := map[string]struct{}{}
tags := []string{}
for _, b := range backends {
for _, t := range b.Tags {
allTags[t] = struct{}{}
}
}
for t := range allTags {
tags = append(tags, t)
}
sort.Strings(tags)
if term != "" {
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).Search(term)
}
// Get backend statuses
processingBackendsData, taskTypes := opcache.GetStatus()
pageNum, err := strconv.Atoi(page)
if err != nil || pageNum < 1 {
pageNum = 1
}
itemsNum, err := strconv.Atoi(items)
if err != nil || itemsNum < 1 {
itemsNum = 21
}
totalPages := int(math.Ceil(float64(len(backends)) / float64(itemsNum)))
totalBackends := len(backends)
if pageNum > 0 {
backends = backends.Paginate(pageNum, itemsNum)
}
// Convert backends to JSON-friendly format
backendsJSON := make([]fiber.Map, 0, len(backends))
for _, b := range backends {
galleryID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
currentlyProcessing := opcache.Exists(galleryID)
jobID := ""
isDeletionOp := false
if currentlyProcessing {
jobID = opcache.Get(galleryID)
status := galleryService.GetStatus(jobID)
if status != nil && status.Deletion {
isDeletionOp = true
}
}
backendsJSON = append(backendsJSON, fiber.Map{
"id": galleryID,
"name": b.Name,
"description": b.Description,
"icon": b.Icon,
"license": b.License,
"urls": b.URLs,
"tags": b.Tags,
"gallery": b.Gallery.Name,
"installed": b.Installed,
"processing": currentlyProcessing,
"jobID": jobID,
"isDeletion": isDeletionOp,
})
}
prevPage := pageNum - 1
nextPage := pageNum + 1
if prevPage < 1 {
prevPage = 1
}
if nextPage > totalPages {
nextPage = totalPages
}
return c.JSON(fiber.Map{
"backends": backendsJSON,
"repositories": appConfig.BackendGalleries,
"allTags": tags,
"processingBackends": processingBackendsData,
"taskTypes": taskTypes,
"availableBackends": totalBackends,
"currentPage": pageNum,
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
})
})
app.Post("/api/backends/install/:id", func(c *fiber.Ctx) error {
backendID := strings.Clone(c.Params("id"))
// URL decode the backend ID
backendID, err := url.QueryUnescape(backendID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid backend ID",
})
}
log.Debug().Msgf("API job submitted to install backend: %+v\n", backendID)
id, err := uuid.NewUUID()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
uid := id.String()
opcache.Set(backendID, uid)
op := services.GalleryOp[gallery.GalleryBackend]{
ID: uid,
GalleryElementName: backendID,
Galleries: appConfig.BackendGalleries,
}
go func() {
galleryService.BackendGalleryChannel <- op
}()
return c.JSON(fiber.Map{
"jobID": uid,
"message": "Backend installation started",
})
})
app.Post("/api/backends/delete/:id", func(c *fiber.Ctx) error {
backendID := strings.Clone(c.Params("id"))
// URL decode the backend ID
backendID, err := url.QueryUnescape(backendID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid backend ID",
})
}
log.Debug().Msgf("API job submitted to delete backend: %+v\n", backendID)
var backendName = backendID
if strings.Contains(backendID, "@") {
backendName = strings.Split(backendID, "@")[1]
}
id, err := uuid.NewUUID()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
uid := id.String()
opcache.Set(backendID, uid)
op := services.GalleryOp[gallery.GalleryBackend]{
ID: uid,
Delete: true,
GalleryElementName: backendName,
Galleries: appConfig.BackendGalleries,
}
go func() {
galleryService.BackendGalleryChannel <- op
}()
return c.JSON(fiber.Map{
"jobID": uid,
"message": "Backend deletion started",
})
})
app.Get("/api/backends/job/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid"))
status := galleryService.GetStatus(jobUID)
if status == nil {
// Job is queued but hasn't started processing yet
return c.JSON(fiber.Map{
"progress": 0,
"message": "Operation queued",
"galleryElementName": "",
"processed": false,
"deletion": false,
"queued": true,
})
}
response := fiber.Map{
"progress": status.Progress,
"message": status.Message,
"galleryElementName": status.GalleryElementName,
"processed": status.Processed,
"deletion": status.Deletion,
"queued": false,
}
if status.Error != nil {
response["error"] = status.Error.Error()
}
if status.Progress == 100 && status.Processed && status.Message == "completed" {
opcache.DeleteUUID(jobUID)
response["completed"] = true
}
return c.JSON(response)
})
// P2P APIs
app.Get("/api/p2p/workers", func(c *fiber.Ctx) error {
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))
nodesJSON := make([]fiber.Map, 0, len(nodes))
for _, n := range nodes {
nodesJSON = append(nodesJSON, fiber.Map{
"name": n.Name,
"id": n.ID,
"tunnelAddress": n.TunnelAddress,
"serviceID": n.ServiceID,
"lastSeen": n.LastSeen,
"isOnline": n.IsOnline(),
})
}
return c.JSON(fiber.Map{
"nodes": nodesJSON,
})
})
app.Get("/api/p2p/federation", func(c *fiber.Ctx) error {
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
nodesJSON := make([]fiber.Map, 0, len(nodes))
for _, n := range nodes {
nodesJSON = append(nodesJSON, fiber.Map{
"name": n.Name,
"id": n.ID,
"tunnelAddress": n.TunnelAddress,
"serviceID": n.ServiceID,
"lastSeen": n.LastSeen,
"isOnline": n.IsOnline(),
})
}
return c.JSON(fiber.Map{
"nodes": nodesJSON,
})
})
app.Get("/api/p2p/stats", func(c *fiber.Ctx) error {
workerNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))
federatedNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
workersOnline := 0
for _, n := range workerNodes {
if n.IsOnline() {
workersOnline++
}
}
federatedOnline := 0
for _, n := range federatedNodes {
if n.IsOnline() {
federatedOnline++
}
}
return c.JSON(fiber.Map{
"workers": fiber.Map{
"online": workersOnline,
"total": len(workerNodes),
},
"federated": fiber.Map{
"online": federatedOnline,
"total": len(federatedNodes),
},
})
})
}

View File

@@ -1,258 +1,24 @@
package routes
import (
"fmt"
"html/template"
"math"
"sort"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/microcosm-cc/bluemonday"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/elements"
"github.com/mudler/LocalAI/core/http/utils"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
"github.com/rs/zerolog/log"
)
func registerBackendGalleryRoutes(app *fiber.App, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
// Show the Backends page (all backends)
// Show the Backends page (all backends are loaded client-side via Alpine.js)
app.Get("/browse/backends", func(c *fiber.Ctx) error {
term := c.Query("term")
page := c.Query("page")
items := c.Query("items")
backends, err := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
if err != nil {
log.Error().Err(err).Msg("could not list backends from galleries")
return c.Status(fiber.StatusInternalServerError).Render("views/error", fiber.Map{
"Title": "LocalAI - Backends",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"ErrorCode": "500",
"ErrorMessage": err.Error(),
})
}
// Get all available tags
allTags := map[string]struct{}{}
tags := []string{}
for _, b := range backends {
for _, t := range b.Tags {
allTags[t] = struct{}{}
}
}
for t := range allTags {
tags = append(tags, t)
}
sort.Strings(tags)
if term != "" {
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).Search(term)
}
// Get backend statuses
processingBackendsData, taskTypes := opcache.GetStatus()
summary := fiber.Map{
"Title": "LocalAI - Backends",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"Backends": template.HTML(elements.ListBackends(backends, opcache, galleryService)),
"Repositories": appConfig.BackendGalleries,
"AllTags": tags,
"ProcessingBackends": processingBackendsData,
"AvailableBackends": len(backends),
"TaskTypes": taskTypes,
"Title": "LocalAI - Backends",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"Repositories": appConfig.BackendGalleries,
}
if page == "" {
page = "1"
}
if page != "" {
// return a subset of the backends
pageNum, err := strconv.Atoi(page)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid page number")
}
if pageNum == 0 {
return c.Render("views/backends", summary)
}
itemsNum, err := strconv.Atoi(items)
if err != nil {
itemsNum = 21
}
totalPages := int(math.Ceil(float64(len(backends)) / float64(itemsNum)))
backends = backends.Paginate(pageNum, itemsNum)
prevPage := pageNum - 1
nextPage := pageNum + 1
if prevPage < 1 {
prevPage = 1
}
if nextPage > totalPages {
nextPage = totalPages
}
if prevPage != pageNum {
summary["PrevPage"] = prevPage
}
summary["NextPage"] = nextPage
summary["TotalPages"] = totalPages
summary["CurrentPage"] = pageNum
summary["Backends"] = template.HTML(elements.ListBackends(backends, opcache, galleryService))
}
// Render index
// Render index - backends are now loaded via Alpine.js from /api/backends
return c.Render("views/backends", summary)
})
// Show the backends, filtered from the user input
app.Post("/browse/search/backends", func(c *fiber.Ctx) error {
page := c.Query("page")
items := c.Query("items")
form := struct {
Search string `form:"search"`
}{}
if err := c.BodyParser(&form); err != nil {
return c.Status(fiber.StatusBadRequest).SendString(bluemonday.StrictPolicy().Sanitize(err.Error()))
}
backends, _ := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
if page != "" {
// return a subset of the backends
pageNum, err := strconv.Atoi(page)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid page number")
}
itemsNum, err := strconv.Atoi(items)
if err != nil {
itemsNum = 21
}
backends = backends.Paginate(pageNum, itemsNum)
}
if form.Search != "" {
backends = backends.Search(form.Search)
}
return c.SendString(elements.ListBackends(backends, opcache, galleryService))
})
// Install backend route
app.Post("/browse/install/backend/:id", func(c *fiber.Ctx) error {
backendID := strings.Clone(c.Params("id")) // note: strings.Clone is required for multiple requests!
log.Debug().Msgf("UI job submitted to install backend: %+v\n", backendID)
id, err := uuid.NewUUID()
if err != nil {
return err
}
uid := id.String()
opcache.Set(backendID, uid)
op := services.GalleryOp[gallery.GalleryBackend]{
ID: uid,
GalleryElementName: backendID,
Galleries: appConfig.BackendGalleries,
}
go func() {
galleryService.BackendGalleryChannel <- op
}()
return c.SendString(elements.StartBackendProgressBar(uid, "0", "Backend Installation"))
})
// Delete backend route
app.Post("/browse/delete/backend/:id", func(c *fiber.Ctx) error {
backendID := strings.Clone(c.Params("id")) // note: strings.Clone is required for multiple requests!
log.Debug().Msgf("UI job submitted to delete backend: %+v\n", backendID)
var backendName = backendID
if strings.Contains(backendID, "@") {
// TODO: this is ugly workaround - we should handle this consistently across the codebase
backendName = strings.Split(backendID, "@")[1]
}
id, err := uuid.NewUUID()
if err != nil {
return err
}
uid := id.String()
opcache.Set(backendName, uid)
opcache.Set(backendID, uid)
op := services.GalleryOp[gallery.GalleryBackend]{
ID: uid,
Delete: true,
GalleryElementName: backendName,
Galleries: appConfig.BackendGalleries,
}
go func() {
galleryService.BackendGalleryChannel <- op
}()
return c.SendString(elements.StartBackendProgressBar(uid, "0", "Backend Deletion"))
})
// Display the job current progress status
app.Get("/browse/backend/job/progress/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid")) // note: strings.Clone is required for multiple requests!
status := galleryService.GetStatus(jobUID)
if status == nil {
return c.SendString(elements.ProgressBar("0"))
}
if status.Progress == 100 && status.Processed && status.Message == "completed" {
c.Set("HX-Trigger", "done") // this triggers /browse/backend/job/:uid
return c.SendString(elements.ProgressBar("100"))
}
if status.Error != nil {
opcache.DeleteUUID(jobUID)
return c.SendString(elements.ErrorProgress(status.Error.Error(), status.GalleryElementName))
}
return c.SendString(elements.ProgressBar(fmt.Sprint(status.Progress)))
})
// Job completion route
app.Get("/browse/backend/job/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid")) // note: strings.Clone is required for multiple requests!
status := galleryService.GetStatus(jobUID)
backendID := status.GalleryElementName
opcache.DeleteUUID(jobUID)
if backendID == "" {
log.Debug().Msgf("no processing backend found for job: %+v\n", jobUID)
}
log.Debug().Msgf("JOB finished: %+v\n", status)
showDelete := true
displayText := "Backend Installation completed"
if status.Deletion {
showDelete = false
displayText = "Backend Deletion completed"
}
return c.SendString(elements.DoneBackendProgress(backendID, displayText, showDelete))
})
}

View File

@@ -1,309 +1,24 @@
package routes
import (
"fmt"
"html/template"
"math"
"sort"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/microcosm-cc/bluemonday"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/elements"
"github.com/mudler/LocalAI/core/http/utils"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
"github.com/rs/zerolog/log"
)
func registerGalleryRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
// Show the Models page (all models)
app.Get("/browse", func(c *fiber.Ctx) error {
term := c.Query("term")
page := c.Query("page")
items := c.Query("items")
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
if err != nil {
log.Error().Err(err).Msg("could not list models from galleries")
return c.Status(fiber.StatusInternalServerError).Render("views/error", fiber.Map{
"Title": "LocalAI - Models",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"ErrorCode": "500",
"ErrorMessage": err.Error(),
})
}
// Get all available tags
allTags := map[string]struct{}{}
tags := []string{}
for _, m := range models {
for _, t := range m.Tags {
allTags[t] = struct{}{}
}
}
for t := range allTags {
tags = append(tags, t)
}
sort.Strings(tags)
if term != "" {
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
}
// Get model statuses
processingModelsData, taskTypes := opcache.GetStatus()
summary := fiber.Map{
"Title": "LocalAI - Models",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"Models": template.HTML(elements.ListModels(models, opcache, galleryService)),
"Repositories": appConfig.Galleries,
"AllTags": tags,
"ProcessingModels": processingModelsData,
"AvailableModels": len(models),
"TaskTypes": taskTypes,
// "ApplicationConfig": appConfig,
"Title": "LocalAI - Models",
"BaseURL": utils.BaseURL(c),
"Version": internal.PrintableVersion(),
"Repositories": appConfig.Galleries,
}
if page == "" {
page = "1"
}
if page != "" {
// return a subset of the models
pageNum, err := strconv.Atoi(page)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid page number")
}
if pageNum == 0 {
return c.Render("views/models", summary)
}
itemsNum, err := strconv.Atoi(items)
if err != nil {
itemsNum = 21
}
totalPages := int(math.Ceil(float64(len(models)) / float64(itemsNum)))
models = models.Paginate(pageNum, itemsNum)
prevPage := pageNum - 1
nextPage := pageNum + 1
if prevPage < 1 {
prevPage = 1
}
if nextPage > totalPages {
nextPage = totalPages
}
if prevPage != pageNum {
summary["PrevPage"] = prevPage
}
summary["NextPage"] = nextPage
summary["TotalPages"] = totalPages
summary["CurrentPage"] = pageNum
summary["Models"] = template.HTML(elements.ListModels(models, opcache, galleryService))
}
// Render index
// Render index - models are now loaded via Alpine.js from /api/models
return c.Render("views/models", summary)
})
// Show the models, filtered from the user input
// https://htmx.org/examples/active-search/
app.Post("/browse/search/models", func(c *fiber.Ctx) error {
page := c.Query("page")
items := c.Query("items")
form := struct {
Search string `form:"search"`
}{}
if err := c.BodyParser(&form); err != nil {
return c.Status(fiber.StatusBadRequest).SendString(bluemonday.StrictPolicy().Sanitize(err.Error()))
}
models, _ := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
if page != "" {
// return a subset of the models
pageNum, err := strconv.Atoi(page)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid page number")
}
itemsNum, err := strconv.Atoi(items)
if err != nil {
itemsNum = 21
}
models = models.Paginate(pageNum, itemsNum)
}
if form.Search != "" {
models = models.Search(form.Search)
}
return c.SendString(elements.ListModels(models, opcache, galleryService))
})
/*
Install routes
*/
// This route is used when the "Install" button is pressed, we submit here a new job to the gallery service
// https://htmx.org/examples/progress-bar/
app.Post("/browse/install/model/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id")) // note: strings.Clone is required for multiple requests!
log.Debug().Msgf("UI job submitted to install : %+v\n", galleryID)
id, err := uuid.NewUUID()
if err != nil {
return err
}
uid := id.String()
opcache.Set(galleryID, uid)
op := services.GalleryOp[gallery.GalleryModel]{
ID: uid,
GalleryElementName: galleryID,
Galleries: appConfig.Galleries,
BackendGalleries: appConfig.BackendGalleries,
}
go func() {
galleryService.ModelGalleryChannel <- op
}()
return c.SendString(elements.StartModelProgressBar(uid, "0", "Installation"))
})
app.Post("/browse/config/model/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id")) // note: strings.Clone is required for multiple requests!
log.Debug().Msgf("UI job submitted to get config for : %+v\n", galleryID)
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
if err != nil {
return err
}
model := gallery.FindGalleryElement(models, galleryID)
if model == nil {
return fmt.Errorf("model not found")
}
config, err := gallery.GetGalleryConfigFromURL[gallery.ModelConfig](model.URL, appConfig.SystemState.Model.ModelsPath)
if err != nil {
return err
}
// Save the config file
_, err = gallery.InstallModel(appConfig.SystemState, model.Name, &config, model.Overrides, nil, false)
if err != nil {
return err
}
return c.SendString("Configuration file saved.")
})
// This route is used when the "Install" button is pressed, we submit here a new job to the gallery service
// https://htmx.org/examples/progress-bar/
app.Post("/browse/delete/model/:id", func(c *fiber.Ctx) error {
galleryID := strings.Clone(c.Params("id")) // note: strings.Clone is required for multiple requests!
log.Debug().Msgf("UI job submitted to delete : %+v\n", galleryID)
var galleryName = galleryID
if strings.Contains(galleryID, "@") {
// if the galleryID contains a @ it means that it's a model from a gallery
// but we want to delete it from the local models which does not need
// a repository ID
galleryName = strings.Split(galleryID, "@")[1]
}
id, err := uuid.NewUUID()
if err != nil {
return err
}
uid := id.String()
// Track the deletion job by galleryID and galleryName
// The GalleryID contains information about the repository,
// while the GalleryName is ONLY the name of the model
opcache.Set(galleryName, uid)
opcache.Set(galleryID, uid)
op := services.GalleryOp[gallery.GalleryModel]{
ID: uid,
Delete: true,
GalleryElementName: galleryName,
Galleries: appConfig.Galleries,
BackendGalleries: appConfig.BackendGalleries,
}
go func() {
galleryService.ModelGalleryChannel <- op
cl.RemoveModelConfig(galleryName)
}()
return c.SendString(elements.StartModelProgressBar(uid, "0", "Deletion"))
})
// Display the job current progress status
// If the job is done, we trigger the /browse/job/:uid route
// https://htmx.org/examples/progress-bar/
app.Get("/browse/job/progress/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid")) // note: strings.Clone is required for multiple requests!
status := galleryService.GetStatus(jobUID)
if status == nil {
//fmt.Errorf("could not find any status for ID")
return c.SendString(elements.ProgressBar("0"))
}
if status.Progress == 100 && status.Processed && status.Message == "completed" {
c.Set("HX-Trigger", "done") // this triggers /browse/job/:uid (which is when the job is done)
return c.SendString(elements.ProgressBar("100"))
}
if status.Error != nil {
// TODO: instead of deleting the job, we should keep it in the cache and make it dismissable by the user
opcache.DeleteUUID(jobUID)
return c.SendString(elements.ErrorProgress(status.Error.Error(), status.GalleryElementName))
}
return c.SendString(elements.ProgressBar(fmt.Sprint(status.Progress)))
})
// this route is hit when the job is done, and we display the
// final state (for now just displays "Installation completed")
app.Get("/browse/job/:uid", func(c *fiber.Ctx) error {
jobUID := strings.Clone(c.Params("uid")) // note: strings.Clone is required for multiple requests!
status := galleryService.GetStatus(jobUID)
galleryID := status.GalleryElementName
opcache.DeleteUUID(jobUID)
if galleryID == "" {
log.Debug().Msgf("no processing model found for job : %+v\n", jobUID)
}
log.Debug().Msgf("JOB finished : %+v\n", status)
showDelete := true
displayText := "Installation completed"
if status.Deletion {
showDelete = false
displayText = "Deletion completed"
}
return c.SendString(elements.DoneModelProgress(galleryID, displayText, showDelete))
})
}

View File

@@ -3,10 +3,35 @@
{{template "views/partials/head" .}}
<body class="bg-gradient-to-br from-gray-900 via-gray-950 to-black text-gray-200">
<div class="flex flex-col min-h-screen">
<div class="flex flex-col min-h-screen" x-data="backendsGallery()">
{{template "views/partials/navbar" .}}
{{ $numBackendsPerPage := 21 }}
<!-- Notifications -->
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
<template x-for="notification in notifications" :key="notification.id">
<div x-show="true"
x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform ease-in duration-200 transition"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
class="rounded-lg shadow-xl p-4 text-white flex items-start space-x-3">
<div class="flex-shrink-0">
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
</div>
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</template>
</div>
<div class="container mx-auto px-4 py-8 flex-grow">
<!-- Hero Header -->
@@ -29,7 +54,7 @@
<div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base">
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full mr-2 animate-pulse"></div>
<span class="font-semibold text-emerald-300">{{.AvailableBackends}}</span>
<span class="font-semibold text-emerald-300" x-text="availableBackends"></span>
<span class="text-gray-300 ml-1">backends available</span>
</div>
<a href="https://localai.io/backends/" target="_blank"
@@ -59,18 +84,13 @@
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
<i class="fas fa-search text-gray-400"></i>
</div>
<input class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/50 focus:outline-none"
<input
x-model="searchTerm"
@input.debounce.500ms="fetchBackends()"
class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/50 focus:outline-none"
type="search"
name="search"
placeholder="Search backends by name, description or type..."
hx-post="browse/search/backends"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
oninput="hidePagination()"
onchange="hidePagination()"
onsearch="hidePagination()"
hx-indicator=".htmx-indicator">
<span class="htmx-indicator absolute right-4 top-4">
placeholder="Search backends by name, description or type...">
<span class="absolute right-4 top-4" x-show="loading">
<svg class="animate-spin h-6 w-6 text-emerald-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -86,48 +106,28 @@
Filter by Backend Type
</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<button hx-post="browse/search/backends"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25"
hx-target="#search-results"
hx-vals='{"search": "llm"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('llm')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25">
<i class="fas fa-brain mr-2 group-hover:animate-pulse"></i>
<span>LLM</span>
</button>
<button hx-post="browse/search/backends"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25"
hx-target="#search-results"
hx-vals='{"search": "diffusion"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('diffusion')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25">
<i class="fas fa-image mr-2 group-hover:animate-pulse"></i>
<span>Diffusion</span>
</button>
<button hx-post="browse/search/backends"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25"
hx-target="#search-results"
hx-vals='{"search": "tts"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('tts')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25">
<i class="fas fa-microphone mr-2 group-hover:animate-pulse"></i>
<span>TTS</span>
</button>
<button hx-post="browse/search/backends"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25"
hx-target="#search-results"
hx-vals='{"search": "whisper"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('whisper')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25">
<i class="fas fa-headphones mr-2 group-hover:animate-pulse"></i>
<span>Whisper</span>
</button>
<button hx-post="browse/search/backends"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25"
hx-target="#search-results"
hx-vals='{"search": "object-detection"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('object-detection')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25">
<i class="fas fa-eye mr-2 group-hover:animate-pulse"></i>
<span>Vision</span>
</button>
@@ -138,34 +138,189 @@
<!-- Results Section -->
<div id="search-results" class="transition-all duration-300">
{{.Backends}}
<div x-show="loading && backends.length === 0" class="text-center py-12">
<svg class="animate-spin h-12 w-12 text-emerald-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-400">Loading backends...</p>
</div>
<div x-show="!loading && backends.length === 0" class="text-center py-12">
<i class="fas fa-search text-gray-500 text-4xl mb-4"></i>
<p class="text-gray-400">No backends found matching your criteria</p>
</div>
<div class="dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark">
<template x-for="backend in backends" :key="backend.id">
<div>
<!-- Backend Card -->
<div class="me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50">
<div>
<!-- Backend Image -->
<div class="flex justify-center items-center">
<a href="#!">
<img :src="backend.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</a>
</div>
<!-- Backend Description -->
<div class="p-6 text-surface dark:text-white">
<h5 class="mb-2 text-xl font-bold leading-tight" x-text="backend.name"></h5>
<div class="mb-4 text-sm truncate text-base" x-text="backend.description"></div>
</div>
<!-- Backend Actions -->
<div class="px-6 pt-4 pb-2">
<p class="mb-4 text-base">
<span class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
<i class="fa-brands fa-git-alt pr-2"></i>
<span>Repository: <span x-text="backend.gallery"></span></span>
</span>
<span x-show="backend.license" class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
<i class="fas fa-book pr-2"></i>
<span>License: <span x-text="backend.license"></span></span>
</span>
</p>
<div :id="'action-div-' + backend.id.replace('@', '__')" class="flow-root">
<!-- Info Button -->
<button @click="openModal(backend)"
class="inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
<i class="fas fa-info-circle pr-2"></i>
Info
</button>
<div class="float-right">
<!-- Processing State -->
<div x-show="backend.processing">
<div class="text-sm font-medium text-gray-300 mb-2">
<span x-text="backend.isDeletion ? 'Deletion' : 'Installation'"></span>
<!-- Show queued message when progress is 0 -->
<div x-show="(jobProgress[backend.jobID] || 0) === 0" class="text-xs text-blue-400 mt-1">
<i class="fas fa-clock mr-1"></i>Operation queued
</div>
<div class="progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="progress-bar" :style="'width:' + (jobProgress[backend.jobID] || 0) + '%'"></div>
</div>
</div>
</div>
<!-- Installed State -->
<div x-show="!backend.processing && backend.installed">
<button @click="reinstallBackend(backend.id)"
class="float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2">
<i class="fa-solid fa-arrow-rotate-right pr-2"></i>
Reinstall
</button>
<button @click="deleteBackend(backend.id)"
class="float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2">
<i class="fa-solid fa-cancel pr-2"></i>
Delete
</button>
</div>
<!-- Not Installed State -->
<div x-show="!backend.processing && !backend.installed">
<button @click="installBackend(backend.id)"
class="float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg">
<i class="fa-solid fa-download pr-2"></i>
Install
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div x-show="selectedBackend && selectedBackend.id === backend.id"
x-transition
@click.away="closeModal()"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50"
style="display: none;">
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col">
<!-- Modal Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white" x-text="backend.name"></h3>
<button @click="closeModal()"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal Body -->
<div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-grow">
<div class="flex justify-center items-center">
<img :src="backend.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</div>
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="backend.description"></p>
<div class="flex flex-wrap gap-2">
<template x-for="tag in backend.tags" :key="tag">
<span class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50">
<i class="fas fa-tag pr-2"></i>
<span x-text="tag"></span>
</span>
</template>
</div>
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400">
<ul>
<template x-for="url in backend.urls" :key="url">
<li>
<a :href="url" target="_blank" class="text-blue-500 hover:underline">
<i class="fas fa-link pr-2"></i>
<span x-text="url"></span>
</a>
</li>
</template>
</ul>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button @click="closeModal()"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Pagination -->
{{ if gt .AvailableBackends $numBackendsPerPage }}
<div id="paginate" class="flex justify-center mt-12">
<div x-show="totalPages > 1" class="flex justify-center mt-12">
<div class="flex items-center gap-4 bg-gray-800/60 rounded-2xl p-4 backdrop-blur-sm border border-gray-700/50">
{{ if .PrevPage }}
<button onclick="window.location.href='browse/backends?page={{.PrevPage}}'"
<button @click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
:class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-emerald-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
<i class="fas fa-chevron-left group-hover:animate-pulse"></i>
</button>
{{ end }}
<div class="text-gray-300 text-sm font-medium px-4">
<span class="text-gray-400">Page</span>
<span class="text-white font-bold text-lg mx-2">{{.CurrentPage}}</span>
<span class="text-white font-bold text-lg mx-2" x-text="currentPage"></span>
<span class="text-gray-400">of</span>
<span class="text-white font-bold text-lg mx-2">{{.TotalPages}}</span>
<span class="text-white font-bold text-lg mx-2" x-text="totalPages"></span>
</div>
{{ if .NextPage }}
<button onclick="window.location.href='browse/backends?page={{.NextPage}}'"
<button @click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
:class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-emerald-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
<i class="fas fa-chevron-right group-hover:animate-pulse"></i>
</button>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{template "views/partials/footer" .}}
@@ -191,145 +346,190 @@
background: rgba(107, 114, 128, 0.8);
}
/* Add some custom CSS for backend cards to match our theme */
#search-results .dark\:bg-gray-800 {
background: linear-gradient(135deg, rgba(31, 41, 55, 0.9) 0%, rgba(17, 24, 39, 0.9) 100%) !important;
border: 1px solid rgba(75, 85, 99, 0.5) !important;
border-radius: 1rem !important;
transition: all 0.5s ease !important;
backdrop-filter: blur(8px) !important;
/* Progress bar styling */
.progress {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(20, 184, 166, 0.2) 100%);
border-radius: 0.5rem;
border: 1px solid rgba(16, 185, 129, 0.3);
height: 24px;
overflow: hidden;
}
#search-results .dark\:bg-gray-800:hover {
transform: translateY(-8px) !important;
box-shadow: 0 25px 50px -12px rgba(16, 185, 129, 0.1) !important;
border-color: rgba(16, 185, 129, 0.3) !important;
}
/* Style the install buttons */
#search-results .bg-blue-600 {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.25) !important;
}
#search-results .bg-blue-600:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4) !important;
}
/* Style the reinstall buttons specifically */
#search-results .bg-primary {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.25) !important;
}
#search-results .bg-primary:hover {
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(6, 182, 212, 0.4) !important;
}
/* Style the delete buttons */
#search-results .bg-red-800 {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.25) !important;
}
#search-results .bg-red-800:hover {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(220, 38, 38, 0.4) !important;
}
/* Style the info buttons */
#search-results .bg-gray-700 {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(55, 65, 81, 0.25) !important;
}
#search-results .bg-gray-700:hover {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(55, 65, 81, 0.4) !important;
}
/* Style the backend images */
#search-results img.rounded-t-lg {
border-radius: 1rem !important;
transition: transform 0.3s ease !important;
}
#search-results .dark\:bg-gray-800:hover img.rounded-t-lg {
transform: scale(1.05) !important;
}
/* Style the progress bars */
#search-results .progress {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(6, 182, 212, 0.2) 100%) !important;
border-radius: 0.5rem !important;
border: 1px solid rgba(16, 185, 129, 0.3) !important;
}
/* Style action buttons */
#search-results button[class*="primary"] {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
border-radius: 0.5rem !important;
transition: all 0.2s ease !important;
}
#search-results button[class*="primary"]:hover {
transform: scale(1.05) !important;
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.3) !important;
.progress-bar {
background: linear-gradient(135deg, #10b981 0%, #14b8a6 100%);
height: 100%;
transition: width 0.3s ease;
}
</style>
<script>
function hidePagination() {
const paginateDiv = document.getElementById('paginate');
if (paginateDiv) {
paginateDiv.style.display = 'none';
function backendsGallery() {
return {
backends: [],
allTags: [],
repositories: [],
searchTerm: '',
loading: false,
currentPage: 1,
totalPages: 1,
availableBackends: 0,
selectedBackend: null,
jobProgress: {},
notifications: [],
init() {
this.fetchBackends();
// Poll for job progress every 600ms
setInterval(() => this.pollJobs(), 600);
},
addNotification(message, type = 'error') {
const id = Date.now();
this.notifications.push({ id, message, type });
// Auto-dismiss after 10 seconds
setTimeout(() => this.dismissNotification(id), 10000);
},
dismissNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
},
async fetchBackends() {
this.loading = true;
try {
const params = new URLSearchParams({
page: this.currentPage,
items: 21,
term: this.searchTerm
});
const response = await fetch(`/api/backends?${params}`);
const data = await response.json();
this.backends = data.backends || [];
this.allTags = data.allTags || [];
this.repositories = data.repositories || [];
this.currentPage = data.currentPage || 1;
this.totalPages = data.totalPages || 1;
this.availableBackends = data.availableBackends || 0;
} catch (error) {
console.error('Error fetching backends:', error);
} finally {
this.loading = false;
}
},
filterByTerm(term) {
this.searchTerm = term;
this.currentPage = 1;
this.fetchBackends();
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
this.fetchBackends();
}
},
async installBackend(backendId) {
try {
const response = await fetch(`/api/backends/install/${encodeURIComponent(backendId)}`, {
method: 'POST'
});
const data = await response.json();
if (data.jobID) {
const backend = this.backends.find(b => b.id === backendId);
if (backend) {
backend.processing = true;
backend.jobID = data.jobID;
backend.isDeletion = false;
}
}
} catch (error) {
console.error('Error installing backend:', error);
alert('Failed to start installation');
}
},
async deleteBackend(backendId) {
if (!confirm('Are you sure you wish to delete the backend?')) {
return;
}
try {
const response = await fetch(`/api/backends/delete/${encodeURIComponent(backendId)}`, {
method: 'POST'
});
const data = await response.json();
if (data.jobID) {
const backend = this.backends.find(b => b.id === backendId);
if (backend) {
backend.processing = true;
backend.jobID = data.jobID;
backend.isDeletion = true;
}
}
} catch (error) {
console.error('Error deleting backend:', error);
alert('Failed to start deletion');
}
},
async reinstallBackend(backendId) {
this.installBackend(backendId);
},
async pollJobs() {
const processingBackends = this.backends.filter(b => b.processing && b.jobID);
for (const backend of processingBackends) {
try {
const response = await fetch(`/api/backends/job/${backend.jobID}`);
const jobData = await response.json();
// Handle queued status
if (jobData.queued) {
this.jobProgress[backend.jobID] = 0;
// Keep processing state but don't show error
continue;
}
this.jobProgress[backend.jobID] = jobData.progress || 0;
if (jobData.completed) {
backend.processing = false;
backend.installed = !jobData.deletion;
delete this.jobProgress[backend.jobID];
// Show success notification
const action = jobData.deletion ? 'deleted' : 'installed';
this.addNotification(`Backend "${backend.name}" ${action} successfully!`, 'success');
// Refresh the backends list to get updated state
this.fetchBackends();
}
if (jobData.error) {
backend.processing = false;
delete this.jobProgress[backend.jobID];
const action = backend.isDeletion ? 'deleting' : 'installing';
this.addNotification(`Error ${action} backend "${backend.name}": ${jobData.error}`, 'error');
}
} catch (error) {
console.error('Error polling job:', error);
// Don't show notification for every polling error, only if backend is stuck
}
}
},
openModal(backend) {
this.selectedBackend = backend;
},
closeModal() {
this.selectedBackend = null;
}
}
// Listen for the htmx:afterSwap event to handle cases when the search results are updated
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'search-results') {
hidePagination();
}
});
// Add loading state animation
document.body.addEventListener('htmx:beforeRequest', function(event) {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput && event.detail.elt === searchInput) {
searchInput.classList.add('animate-pulse');
}
});
document.body.addEventListener('htmx:afterRequest', function(event) {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
searchInput.classList.remove('animate-pulse');
}
});
}
</script>
</body>
</html>
</html>

View File

@@ -214,12 +214,7 @@
{{ if index $loadedModels .Name }}
<button class="group/stop inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
data-twe-ripple-init=""
hx-confirm="Are you sure you wish to stop this model?"
hx-post="/backend/shutdown"
hx-vals='{"model": "{{.Name}}"}'
hx-target="this"
hx-swap="none"
hx-on::after-request="handleShutdownResponse(event, '{{.Name}}')">
onclick="handleStopModel('{{.Name}}')">
<i class="fas fa-stop mr-2 group-hover/stop:animate-pulse"></i>Stop
</button>
{{ end }}
@@ -233,9 +228,7 @@
<button
class="group/delete inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
data-twe-ripple-init=""
hx-confirm="Are you sure you wish to delete this model?"
hx-post="browse/delete/model/{{.Name}}"
hx-swap="outerHTML">
onclick="handleDeleteModel('{{.Name}}')">
<i class="fas fa-trash-alt mr-2 group-hover/delete:animate-bounce"></i>Delete
</button>
</div>
@@ -328,10 +321,50 @@
</div>
<script>
function handleShutdownResponse(event, modelName) {
const button = event.target;
const response = event.detail.xhr;
window.location.reload();
async function handleStopModel(modelName) {
if (!confirm('Are you sure you wish to stop this model?')) {
return;
}
try {
const response = await fetch('/backend/shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: modelName })
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to stop model');
}
} catch (error) {
console.error('Error stopping model:', error);
alert('Failed to stop model');
}
}
async function handleDeleteModel(modelName) {
if (!confirm('Are you sure you wish to delete this model?')) {
return;
}
try {
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelName)}`, {
method: 'POST'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to delete model');
}
} catch (error) {
console.error('Error deleting model:', error);
alert('Failed to delete model');
}
}
// Handle reload models button

View File

@@ -3,10 +3,35 @@
{{template "views/partials/head" .}}
<body class="bg-gradient-to-br from-gray-900 via-gray-950 to-black text-gray-200">
<div class="flex flex-col min-h-screen">
<div class="flex flex-col min-h-screen" x-data="modelsGallery()">
{{template "views/partials/navbar" .}}
{{ $numModelsPerPage := 21 }}
<!-- Notifications -->
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
<template x-for="notification in notifications" :key="notification.id">
<div x-show="true"
x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform ease-in duration-200 transition"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
class="rounded-lg shadow-xl p-4 text-white flex items-start space-x-3">
<div class="flex-shrink-0">
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
</div>
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</template>
</div>
<div class="container mx-auto px-4 py-8 flex-grow">
<!-- Hero Header -->
@@ -29,12 +54,12 @@
<div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base">
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
<div class="w-2 h-2 bg-indigo-400 rounded-full mr-2 animate-pulse"></div>
<span class="font-semibold text-indigo-300">{{.AvailableModels}}</span>
<span class="font-semibold text-indigo-300" x-text="availableModels"></span>
<span class="text-gray-300 ml-1">models available</span>
</div>
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
<div class="w-2 h-2 bg-purple-400 rounded-full mr-2 animate-pulse"></div>
<span class="font-semibold text-purple-300">{{ len .Repositories }}</span>
<span class="font-semibold text-purple-300" x-text="repositories.length"></span>
<span class="text-gray-300 ml-1">repositories</span>
</div>
<a href="https://localai.io/models/" target="_blank"
@@ -64,18 +89,13 @@
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
<i class="fas fa-search text-gray-400"></i>
</div>
<input class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
<input
x-model="searchTerm"
@input.debounce.500ms="fetchModels()"
class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
type="search"
name="search"
placeholder="Search models by name, tag, or description..."
hx-post="browse/search/models"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
oninput="hidePagination()"
onchange="hidePagination()"
onsearch="hidePagination()"
hx-indicator=".htmx-indicator">
<span class="htmx-indicator absolute right-4 top-4">
placeholder="Search models by name, tag, or description...">
<span class="absolute right-4 top-4" x-show="loading">
<svg class="animate-spin h-6 w-6 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -91,75 +111,43 @@
Filter by Model Type
</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-3">
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25"
hx-target="#search-results"
hx-vals='{"search": "tts"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('tts')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25">
<i class="fas fa-microphone mr-2 group-hover:animate-pulse"></i>
<span>TTS</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25"
hx-target="#search-results"
hx-vals='{"search": "stablediffusion"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('stablediffusion')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25">
<i class="fas fa-image mr-2 group-hover:animate-pulse"></i>
<span>Image</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25"
hx-target="#search-results"
hx-vals='{"search": "llm"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('llm')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25">
<i class="fas fa-comment-alt mr-2 group-hover:animate-bounce"></i>
<span>LLM</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25"
hx-target="#search-results"
hx-vals='{"search": "multimodal"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('multimodal')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25">
<i class="fas fa-object-group mr-2 group-hover:animate-pulse"></i>
<span>Multimodal</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-cyan-600/80 to-cyan-700/80 hover:from-cyan-600 hover:to-cyan-700 text-cyan-100 border border-cyan-500/30 hover:border-cyan-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-cyan-500/25"
hx-target="#search-results"
hx-vals='{"search": "embedding"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('embedding')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-cyan-600/80 to-cyan-700/80 hover:from-cyan-600 hover:to-cyan-700 text-cyan-100 border border-cyan-500/30 hover:border-cyan-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-cyan-500/25">
<i class="fas fa-vector-square mr-2 group-hover:animate-pulse"></i>
<span>Embedding</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-amber-600/80 to-amber-700/80 hover:from-amber-600 hover:to-amber-700 text-amber-100 border border-amber-500/30 hover:border-amber-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-amber-500/25"
hx-target="#search-results"
hx-vals='{"search": "rerank"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('rerank')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-amber-600/80 to-amber-700/80 hover:from-amber-600 hover:to-amber-700 text-amber-100 border border-amber-500/30 hover:border-amber-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-amber-500/25">
<i class="fas fa-sort-amount-up mr-2 group-hover:animate-pulse"></i>
<span>Rerank</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-teal-600/80 to-teal-700/80 hover:from-teal-600 hover:to-teal-700 text-teal-100 border border-teal-500/30 hover:border-teal-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-teal-500/25"
hx-target="#search-results"
hx-vals='{"search": "whisper"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('whisper')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-teal-600/80 to-teal-700/80 hover:from-teal-600 hover:to-teal-700 text-teal-100 border border-teal-500/30 hover:border-teal-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-teal-500/25">
<i class="fas fa-headphones mr-2 group-hover:animate-pulse"></i>
<span>Whisper</span>
</button>
<button hx-post="browse/search/models"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25"
hx-target="#search-results"
hx-vals='{"search": "object-detection"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<button @click="filterByTerm('object-detection')"
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25">
<i class="fas fa-eye mr-2 group-hover:animate-pulse"></i>
<span>Vision</span>
</button>
@@ -167,24 +155,20 @@
</div>
<!-- Filter by Tags -->
<div>
<div x-show="allTags.length > 0">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<i class="fas fa-tags mr-3 text-pink-400"></i>
Browse by Tags
</h3>
<div class="max-h-32 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600/50 scrollbar-track-gray-800/50 pr-2">
<div class="flex flex-wrap gap-2">
{{ range .AllTags }}
<button hx-post="browse/search/models"
class="group inline-flex items-center text-xs px-3 py-2 rounded-full bg-gray-700/60 hover:bg-gray-600/80 text-gray-300 hover:text-white border border-gray-600/50 hover:border-gray-500/70 transition-all duration-200 ease-in-out transform hover:scale-105"
hx-target="#search-results"
hx-vals='{"search": "{{.}}"}'
onclick="hidePagination()"
hx-indicator=".htmx-indicator">
<template x-for="tag in allTags" :key="tag">
<button @click="filterByTerm(tag)"
class="group inline-flex items-center text-xs px-3 py-2 rounded-full bg-gray-700/60 hover:bg-gray-600/80 text-gray-300 hover:text-white border border-gray-600/50 hover:border-gray-500/70 transition-all duration-200 ease-in-out transform hover:scale-105">
<i class="fas fa-tag text-xs mr-2 group-hover:animate-pulse"></i>
<span>{{.}}</span>
<span x-text="tag"></span>
</button>
{{ end }}
</template>
</div>
</div>
</div>
@@ -193,31 +177,205 @@
<!-- Results Section -->
<div id="search-results" class="transition-all duration-300">
{{.Models}}
<div x-show="loading && models.length === 0" class="text-center py-12">
<svg class="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-400">Loading models...</p>
</div>
<div x-show="!loading && models.length === 0" class="text-center py-12">
<i class="fas fa-search text-gray-500 text-4xl mb-4"></i>
<p class="text-gray-400">No models found matching your criteria</p>
</div>
<div class="dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark">
<template x-for="model in models" :key="model.id">
<div>
<!-- Model Card -->
<div class="me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50">
<div>
<!-- Model Image -->
<div class="flex justify-center items-center">
<a href="#!">
<img :src="model.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</a>
</div>
<!-- Trust Remote Code Warning -->
<div x-show="model.trustRemoteCode"
class="flex justify-center items-center bg-red-500 text-white p-2 rounded-lg mt-2">
<i class="fa-solid fa-circle-exclamation pr-2"></i>
<span>Attention: Trust Remote Code is required for this model</span>
</div>
<!-- Model Description -->
<div class="p-6 text-surface dark:text-white">
<h5 class="mb-2 text-xl font-bold leading-tight" x-text="model.name"></h5>
<div class="mb-4 text-sm truncate text-base" x-text="model.description"></div>
</div>
<!-- Model Actions -->
<div class="px-6 pt-4 pb-2">
<p class="mb-4 text-base">
<span class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
<i class="fa-brands fa-git-alt pr-2"></i>
<span>Repository: <span x-text="model.gallery"></span></span>
</span>
<span x-show="model.license" class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
<i class="fas fa-book pr-2"></i>
<span>License: <span x-text="model.license"></span></span>
</span>
</p>
<div :id="'action-div-' + model.id.replace('@', '__')" class="flow-root">
<!-- Info Button -->
<button @click="openModal(model)"
class="inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
<i class="fas fa-info-circle pr-2"></i>
Info
</button>
<div class="float-right">
<!-- Processing State -->
<div x-show="model.processing">
<div class="text-sm font-medium text-gray-300 mb-2">
<span x-text="model.isDeletion ? 'Deletion' : 'Installation'"></span>
<!-- Show queued message when progress is 0 -->
<div x-show="(jobProgress[model.jobID] || 0) === 0" class="text-xs text-blue-400 mt-1">
<i class="fas fa-clock mr-1"></i>Operation queued
</div>
<div class="progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="progress-bar" :style="'width:' + (jobProgress[model.jobID] || 0) + '%'"></div>
</div>
</div>
</div>
<!-- Installed State -->
<div x-show="!model.processing && model.installed">
<button @click="reinstallModel(model.id)"
class="float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2">
<i class="fa-solid fa-arrow-rotate-right pr-2"></i>
Reinstall
</button>
<button @click="deleteModel(model.id)"
class="float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2">
<i class="fa-solid fa-cancel pr-2"></i>
Delete
</button>
</div>
<!-- Not Installed State -->
<div x-show="!model.processing && !model.installed">
<button @click="getConfig(model.id)"
class="float-right ml-2 inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
<i class="fa-solid fa-download pr-2"></i>
Get Config
</button>
<button @click="installModel(model.id)"
class="float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg">
<i class="fa-solid fa-download pr-2"></i>
Install
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div x-show="selectedModel && selectedModel.id === model.id"
x-transition
@click.away="closeModal()"
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50"
style="display: none;">
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col">
<!-- Modal Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white" x-text="model.name"></h3>
<button @click="closeModal()"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal Body -->
<div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0">
<div class="flex justify-center items-center">
<img :src="model.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
loading="lazy">
</div>
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="model.description"></p>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul>
<template x-for="url in model.urls" :key="url">
<li>
<a :href="url" target="_blank" class="text-base leading-relaxed text-gray-500 dark:text-gray-400">
<i class="fas fa-link pr-2"></i>
<span x-text="url"></span>
</a>
</li>
</template>
</ul>
<div x-show="model.tags && model.tags.length > 0">
<p class="text-sm mb-5 font-semibold text-gray-900 dark:text-white">Tags</p>
<div class="flex flex-row flex-wrap content-center">
<template x-for="tag in model.tags" :key="tag">
<a :href="'browse?term=' + tag"
class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50 hover:bg-gray-600 hover:text-gray-100 transition duration-200 ease-in-out mr-2 mb-2">
<i class="fas fa-tag pr-2"></i>
<span x-text="tag"></span>
</a>
</template>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button @click="closeModal()"
class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Pagination -->
{{ if gt .AvailableModels $numModelsPerPage }}
<div id="paginate" class="flex justify-center mt-12">
<div x-show="totalPages > 1" class="flex justify-center mt-12">
<div class="flex items-center gap-4 bg-gray-800/60 rounded-2xl p-4 backdrop-blur-sm border border-gray-700/50">
<button onclick="window.location.href='browse?page={{.PrevPage}}'"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 {{if not .PrevPage}}opacity-50 cursor-not-allowed{{end}}"
{{if not .PrevPage}}disabled{{end}}>
<button @click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
:class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
<i class="fas fa-chevron-left group-hover:animate-pulse"></i>
</button>
<div class="text-gray-300 text-sm font-medium px-4">
<span class="text-gray-400">Page</span>
<span class="text-white font-bold text-lg mx-2">{{add .PrevPage 1}}</span>
<span class="text-gray-400">of many</span>
<span class="text-white font-bold text-lg mx-2" x-text="currentPage"></span>
<span class="text-gray-400">of</span>
<span class="text-white font-bold text-lg mx-2" x-text="totalPages"></span>
</div>
<button onclick="window.location.href='browse?page={{.NextPage}}'"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 {{if not .NextPage}}opacity-50 cursor-not-allowed{{end}}"
{{if not .NextPage}}disabled{{end}}>
<button @click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
:class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''"
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
<i class="fas fa-chevron-right group-hover:animate-pulse"></i>
</button>
</div>
</div>
{{ end }}
</div>
{{template "views/partials/footer" .}}
@@ -243,145 +401,204 @@
background: rgba(107, 114, 128, 0.8);
}
/* Add some custom CSS for gallery model cards to match our theme */
#search-results .dark\:bg-gray-800 {
background: linear-gradient(135deg, rgba(31, 41, 55, 0.9) 0%, rgba(17, 24, 39, 0.9) 100%) !important;
border: 1px solid rgba(75, 85, 99, 0.5) !important;
border-radius: 1rem !important;
transition: all 0.5s ease !important;
backdrop-filter: blur(8px) !important;
/* Progress bar styling */
.progress {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%);
border-radius: 0.5rem;
border: 1px solid rgba(59, 130, 246, 0.3);
height: 24px;
overflow: hidden;
}
#search-results .dark\:bg-gray-800:hover {
transform: translateY(-8px) !important;
box-shadow: 0 25px 50px -12px rgba(59, 130, 246, 0.1) !important;
border-color: rgba(59, 130, 246, 0.3) !important;
}
/* Style the install buttons */
#search-results .bg-blue-600 {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.25) !important;
}
#search-results .bg-blue-600:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4) !important;
}
/* Style the model images */
#search-results img.rounded-t-lg {
border-radius: 1rem !important;
transition: transform 0.3s ease !important;
}
#search-results .dark\:bg-gray-800:hover img.rounded-t-lg {
transform: scale(1.05) !important;
}
/* Style the progress bars */
#search-results .progress {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%) !important;
border-radius: 0.5rem !important;
border: 1px solid rgba(59, 130, 246, 0.3) !important;
}
/* Style action buttons */
#search-results button[class*="primary"] {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
border-radius: 0.5rem !important;
transition: all 0.2s ease !important;
}
#search-results button[class*="primary"]:hover {
transform: scale(1.05) !important;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3) !important;
}
/* Style the reinstall buttons specifically */
#search-results .bg-primary {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.25) !important;
}
#search-results .bg-primary:hover {
background: linear-gradient(135deg, #047857 0%, #065f46 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(5, 150, 105, 0.4) !important;
}
/* Style the delete buttons */
#search-results .bg-red-800 {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.25) !important;
}
#search-results .bg-red-800:hover {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(220, 38, 38, 0.4) !important;
}
/* Style the info buttons */
#search-results .bg-gray-700 {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%) !important;
border-radius: 0.75rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(55, 65, 81, 0.25) !important;
}
#search-results .bg-gray-700:hover {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
transform: scale(1.05) !important;
box-shadow: 0 8px 25px rgba(55, 65, 81, 0.4) !important;
.progress-bar {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
height: 100%;
transition: width 0.3s ease;
}
</style>
<script>
function hidePagination() {
const paginateDiv = document.getElementById('paginate');
if (paginateDiv) {
paginateDiv.style.display = 'none';
function modelsGallery() {
return {
models: [],
allTags: [],
repositories: [],
searchTerm: '',
loading: false,
currentPage: 1,
totalPages: 1,
availableModels: 0,
selectedModel: null,
jobProgress: {},
notifications: [],
init() {
this.fetchModels();
// Poll for job progress every 600ms
setInterval(() => this.pollJobs(), 600);
},
addNotification(message, type = 'error') {
const id = Date.now();
this.notifications.push({ id, message, type });
// Auto-dismiss after 10 seconds
setTimeout(() => this.dismissNotification(id), 10000);
},
dismissNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
},
async fetchModels() {
this.loading = true;
try {
const params = new URLSearchParams({
page: this.currentPage,
items: 21,
term: this.searchTerm
});
const response = await fetch(`/api/models?${params}`);
const data = await response.json();
this.models = data.models || [];
this.allTags = data.allTags || [];
this.repositories = data.repositories || [];
this.currentPage = data.currentPage || 1;
this.totalPages = data.totalPages || 1;
this.availableModels = data.availableModels || 0;
} catch (error) {
console.error('Error fetching models:', error);
} finally {
this.loading = false;
}
},
filterByTerm(term) {
this.searchTerm = term;
this.currentPage = 1;
this.fetchModels();
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
this.fetchModels();
}
},
async installModel(modelId) {
try {
const response = await fetch(`/api/models/install/${encodeURIComponent(modelId)}`, {
method: 'POST'
});
const data = await response.json();
if (data.jobID) {
// Update model state
const model = this.models.find(m => m.id === modelId);
if (model) {
model.processing = true;
model.jobID = data.jobID;
model.isDeletion = false;
}
}
} catch (error) {
console.error('Error installing model:', error);
alert('Failed to start installation');
}
},
async deleteModel(modelId) {
if (!confirm('Are you sure you wish to delete the model?')) {
return;
}
try {
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelId)}`, {
method: 'POST'
});
const data = await response.json();
if (data.jobID) {
const model = this.models.find(m => m.id === modelId);
if (model) {
model.processing = true;
model.jobID = data.jobID;
model.isDeletion = true;
}
}
} catch (error) {
console.error('Error deleting model:', error);
alert('Failed to start deletion');
}
},
async reinstallModel(modelId) {
this.installModel(modelId);
},
async getConfig(modelId) {
try {
const response = await fetch(`/api/models/config/${encodeURIComponent(modelId)}`, {
method: 'POST'
});
const data = await response.json();
alert(data.message || 'Configuration saved');
} catch (error) {
console.error('Error getting config:', error);
alert('Failed to get configuration');
}
},
async pollJobs() {
const processingModels = this.models.filter(m => m.processing && m.jobID);
for (const model of processingModels) {
try {
const response = await fetch(`/api/models/job/${model.jobID}`);
const jobData = await response.json();
// Handle queued status
if (jobData.queued) {
this.jobProgress[model.jobID] = 0;
// Keep processing state but don't show error
continue;
}
this.jobProgress[model.jobID] = jobData.progress || 0;
if (jobData.completed) {
model.processing = false;
model.installed = !jobData.deletion;
delete this.jobProgress[model.jobID];
// Show success notification
const action = jobData.deletion ? 'deleted' : 'installed';
this.addNotification(`Model "${model.name}" ${action} successfully!`, 'success');
// Refresh the models list to get updated state
this.fetchModels();
}
if (jobData.error) {
model.processing = false;
delete this.jobProgress[model.jobID];
const action = model.isDeletion ? 'deleting' : 'installing';
this.addNotification(`Error ${action} model "${model.name}": ${jobData.error}`, 'error');
}
} catch (error) {
console.error('Error polling job:', error);
// Don't show notification for every polling error, only if model is stuck
}
}
},
openModal(model) {
this.selectedModel = model;
},
closeModal() {
this.selectedModel = null;
}
}
// Listen for the htmx:afterSwap event to handle cases when the search results are updated
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'search-results') {
hidePagination();
}
});
// Add loading state animation
document.body.addEventListener('htmx:beforeRequest', function(event) {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput && event.detail.elt === searchInput) {
searchInput.classList.add('animate-pulse');
}
});
document.body.addEventListener('htmx:afterRequest', function(event) {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
searchInput.classList.remove('animate-pulse');
}
});
}
</script>
</body>
</html>
</html>

View File

@@ -3,10 +3,12 @@
{{template "views/partials/head" .}}
<body class="bg-gradient-to-br from-gray-900 to-gray-950 text-gray-200">
<div class="flex flex-col min-h-screen" x-data="{}">
<div class="flex flex-col min-h-screen" x-data="p2pNetwork()">
{{template "views/partials/navbar" .}}
{{template "views/partials/inprogress" .}}
<div class="container mx-auto px-4 py-8 flex-grow">
<div class="workers mt-8">
<!-- Hero Section with Network Animation -->
@@ -122,7 +124,7 @@
<i class="fa-solid fa-copy"></i>
</button>
</div>
<code class="block bg-gray-900/80 text-yellow-300 p-4 rounded-lg break-words mb-4 border border-gray-700/50" @click="copyClipboard($el.textContent)">{{.P2PToken}}</code>
<code class="block bg-gray-900/80 text-yellow-300 p-4 rounded-lg break-words mb-4 border border-gray-700/50 cursor-pointer hover:bg-gray-900" @click="copyClipboard($el.textContent.trim())">{{.P2PToken}}</code>
<p class="text-gray-300">
The network token can be used to either share the instance or join a federation or a worker network. Below you will find examples on how to start a new instance or a worker with this token.
</p>
@@ -159,7 +161,10 @@
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold" hx-get="p2p/ui/workers-federation-stats" hx-trigger="every 1s"></div>
<div class="text-2xl font-bold">
<span :class="stats.federated.online > 0 ? 'text-green-400' : 'text-red-400'" x-text="stats.federated.online"></span>
<span class="text-gray-300 text-xl">/<span x-text="stats.federated.total"></span></span>
</div>
<p class="text-blue-300 text-sm">nodes</p>
</div>
</div>
@@ -182,7 +187,10 @@
</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold" hx-get="p2p/ui/workers-stats" hx-trigger="every 1s"></div>
<div class="text-2xl font-bold">
<span :class="stats.workers.online > 0 ? 'text-green-400' : 'text-red-400'" x-text="stats.workers.online"></span>
<span class="text-gray-300 text-xl">/<span x-text="stats.workers.total"></span></span>
</div>
<p class="text-purple-300 text-sm">workers</p>
</div>
</div>
@@ -230,7 +238,10 @@
</div>
<div class="text-right">
<div class="text-sm text-gray-400 mb-1">Active Nodes</div>
<div class="text-3xl font-bold text-blue-400" hx-get="p2p/ui/workers-federation-stats" hx-trigger="every 1s"></div>
<div class="text-3xl font-bold">
<span :class="stats.federated.online > 0 ? 'text-blue-400' : 'text-red-400'" x-text="stats.federated.online"></span>
<span class="text-gray-400 text-xl">/<span x-text="stats.federated.total"></span></span>
</div>
</div>
</div>
@@ -242,8 +253,43 @@
</div>
<!-- Federation Nodes Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" hx-get="p2p/ui/workers-federation" hx-trigger="every 1s">
<!-- Nodes will be loaded here -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<template x-if="federationNodes.length === 0">
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
<p class="text-gray-400 text-lg font-medium">No nodes available</p>
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
</div>
</template>
<template x-for="node in federationNodes" :key="node.id">
<div :class="node.isOnline ? 'border-green-400/50 hover:shadow-green-500/20' : 'border-red-400/50 hover:shadow-red-500/20'"
class="bg-gradient-to-br from-gray-800/90 to-gray-900/80 border rounded-xl p-5 shadow-xl transition-all duration-300 hover:border-opacity-100 backdrop-blur-sm">
<!-- Header with node icon and status -->
<div class="flex items-center justify-between mb-4">
<!-- Node info -->
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-server text-blue-400 text-lg"></i>
</div>
<div>
<h4 class="text-white font-semibold text-sm">Node</h4>
<p class="text-gray-400 text-xs font-mono break-all" x-text="node.id"></p>
</div>
</div>
<!-- Status badge -->
<div class="flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50">
<i :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="fas fa-circle animate-pulse mr-2 text-xs"></i>
<span :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="text-xs font-medium" x-text="node.isOnline ? 'Online' : 'Offline'"></span>
</div>
</div>
<!-- Footer with timestamp -->
<div class="text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center">
<i class="fas fa-clock mr-2"></i>
<span x-text="'Updated: ' + new Date().toLocaleTimeString()"></span>
</div>
</div>
</template>
</div>
</div>
@@ -348,7 +394,10 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
</div>
<div class="text-right">
<div class="text-sm text-gray-400 mb-1">Active Workers</div>
<div class="text-3xl font-bold text-purple-400" hx-get="p2p/ui/workers-stats" hx-trigger="every 1s"></div>
<div class="text-3xl font-bold">
<span :class="stats.workers.online > 0 ? 'text-purple-400' : 'text-red-400'" x-text="stats.workers.online"></span>
<span class="text-gray-400 text-xl">/<span x-text="stats.workers.total"></span></span>
</div>
</div>
</div>
@@ -360,8 +409,43 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
</div>
<!-- Workers Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" hx-get="p2p/ui/workers" hx-trigger="every 1s">
<!-- Workers will be loaded here -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<template x-if="workerNodes.length === 0">
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
<p class="text-gray-400 text-lg font-medium">No workers available</p>
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
</div>
</template>
<template x-for="node in workerNodes" :key="node.id">
<div :class="node.isOnline ? 'border-green-400/50 hover:shadow-green-500/20' : 'border-red-400/50 hover:shadow-red-500/20'"
class="bg-gradient-to-br from-gray-800/90 to-gray-900/80 border rounded-xl p-5 shadow-xl transition-all duration-300 hover:border-opacity-100 backdrop-blur-sm">
<!-- Header with node icon and status -->
<div class="flex items-center justify-between mb-4">
<!-- Node info -->
<div class="flex items-center">
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-server text-purple-400 text-lg"></i>
</div>
<div>
<h4 class="text-white font-semibold text-sm">Worker</h4>
<p class="text-gray-400 text-xs font-mono break-all" x-text="node.id"></p>
</div>
</div>
<!-- Status badge -->
<div class="flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50">
<i :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="fas fa-circle animate-pulse mr-2 text-xs"></i>
<span :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="text-xs font-medium" x-text="node.isOnline ? 'Online' : 'Offline'"></span>
</div>
</div>
<!-- Footer with timestamp -->
<div class="text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center">
<i class="fas fa-clock mr-2"></i>
<span x-text="'Updated: ' + new Date().toLocaleTimeString()"></span>
</div>
</div>
</template>
</div>
</div>
@@ -489,95 +573,56 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
transform: rotate(-5deg) scale(1.02);
}
}
/* Copy button enhancements */
.copy-icon:hover, button:hover .fa-copy {
color: #60a5fa;
transform: scale(1.1);
transition: all 0.2s ease;
}
/* Node card hover effects */
.workers .grid > div {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.workers .grid > div:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
/* Status indicator animations */
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
/* Enhanced tab styling */
.tablink {
position: relative;
overflow: hidden;
}
.tablink::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: left 0.5s ease;
}
.tablink:hover::before {
left: 100%;
}
/* Loading spinner for HTMX */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
/* Card gradient overlays */
.card-overlay {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(99, 102, 241, 0.1) 100%);
}
/* Enhanced button styles */
button[onclick*="copyClipboard"] {
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}
button[onclick*="copyClipboard"]:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* Code block enhancements */
code {
position: relative;
transition: all 0.2s ease;
}
code:hover {
box-shadow: 0 4px 12px rgba(234, 179, 8, 0.2);
border-color: rgba(234, 179, 8, 0.3) !important;
}
</style>
<script>
function p2pNetwork() {
return {
workerNodes: [],
federationNodes: [],
stats: {
workers: { online: 0, total: 0 },
federated: { online: 0, total: 0 }
},
init() {
this.fetchNodes();
this.fetchStats();
// Poll every 1 second
setInterval(() => {
this.fetchNodes();
this.fetchStats();
}, 1000);
},
async fetchNodes() {
try {
// Fetch worker nodes
const workersResponse = await fetch('/api/p2p/workers');
const workersData = await workersResponse.json();
this.workerNodes = workersData.nodes || [];
// Fetch federation nodes
const federationResponse = await fetch('/api/p2p/federation');
const federationData = await federationResponse.json();
this.federationNodes = federationData.nodes || [];
} catch (error) {
console.error('Error fetching P2P nodes:', error);
}
},
async fetchStats() {
try {
const response = await fetch('/api/p2p/stats');
const data = await response.json();
this.stats = data;
} catch (error) {
console.error('Error fetching P2P stats:', error);
}
}
}
}
</script>
</body>
</html>
</html>

View File

@@ -30,14 +30,46 @@
},
};
function copyClipboard(token) {
navigator.clipboard.writeText(token)
.then(() => {
console.log('Text copied to clipboard:', token);
alert('Text copied to clipboard!');
})
.catch(err => {
console.error('Failed to copy token:', err);
});
// Try modern Clipboard API first (requires secure context)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(token)
.then(() => {
console.log('Text copied to clipboard:', token);
alert('Text copied to clipboard!');
})
.catch(err => {
console.error('Failed to copy token:', err);
fallbackCopy(token);
});
} else {
// Fallback for non-secure contexts
fallbackCopy(token);
}
}
function fallbackCopy(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
console.log('Text copied to clipboard (fallback):', text);
alert('Text copied to clipboard!');
} else {
console.error('Fallback copy failed');
alert('Failed to copy text. Please copy manually.');
}
} catch (err) {
console.error('Fallback copy error:', err);
alert('Failed to copy text. Please copy manually.');
}
document.body.removeChild(textArea);
}
</script>
@@ -45,7 +77,6 @@
<link href="static/assets/fontawesome/css/brands.css" rel="stylesheet" />
<link href="static/assets/fontawesome/css/solid.css" rel="stylesheet" />
<script src="static/assets/flowbite.min.js"></script>
<script src="static/assets/htmx.js" crossorigin="anonymous"></script>
<!-- Example responsive styling improvements -->
<style>
@@ -119,11 +150,4 @@
100% { left: 100%; }
}
</style>
<!-- Initialize Flowbite on HTMX content load -->
<script>
htmx.onLoad(function(content) {
initFlowbite();
});
</script>
</head>

View File

@@ -1,32 +1,142 @@
<!-- Show in progress operations-->
{{ if .ProcessingModels }}
<h2
class="mt-4 mb-4 text-center text-3xl font-semibold text-gray-100">Operations in progress</h2>
{{end}}
{{$taskType:=.TaskTypes}}
{{ range $key,$value:=.ProcessingModels }}
{{ $op := index $taskType $key}}
{{$parts := split "@" $key}}
{{$modelName:=$parts._1}}
{{$repository:=$parts._0}}
{{ if not (contains "@" $key)}}
{{$repository = ""}}
{{$modelName = $key}}
{{ end }}
<!-- Global Operations Status Bar -->
<div x-data="operationsStatus()" x-init="init()" x-show="operations.length > 0"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-4"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-4"
class="sticky top-0 left-0 right-0 z-40 bg-gradient-to-r from-blue-900/95 to-purple-900/95 backdrop-blur-sm shadow-lg border-b border-blue-700/50">
<div class="container mx-auto px-4 py-3">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2">
<div class="relative">
<i class="fas fa-spinner fa-spin text-blue-300 text-lg"></i>
</div>
<h3 class="text-white font-semibold text-sm">
Operations in Progress
<span class="ml-2 bg-blue-700/50 px-2 py-1 rounded-full text-xs" x-text="operations.length"></span>
</h3>
</div>
</div>
<button @click="collapsed = !collapsed"
class="text-white/80 hover:text-white transition-colors">
<i class="fas" :class="collapsed ? 'fa-chevron-down' : 'fa-chevron-up'"></i>
</button>
</div>
<div class="flex items-center justify-between bg-slate-600 p-2 mb-2 rounded-md">
<div class="flex items center">
<span class="text-gray-300"><a href="browse?term={{$parts._1}}"
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
>{{$modelName}}</a> {{if $repository}} (from the '{{$repository}}' repository) {{end}}</span>
</div>
<div hx-get="browse/job/{{$value}}" hx-swap="outerHTML" hx-target="this" hx-trigger="done">
<h3 role="status" id="pblabel" >{{$op}}
<div hx-get="browse/job/progress/{{$value}}" hx-trigger="every 600ms"
hx-target="this"
hx-swap="innerHTML" ></div></h3>
</div>
</div>
{{ end }}
<!-- END Show in progress operations-->
<!-- Operations List -->
<div x-show="!collapsed"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 max-h-0"
x-transition:enter-end="opacity-100 max-h-96"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 max-h-96"
x-transition:leave-end="opacity-0 max-h-0"
class="space-y-2 overflow-y-auto max-h-96">
<template x-for="operation in operations" :key="operation.id">
<div class="bg-gray-800/60 rounded-lg p-3 border border-gray-700/50 hover:border-blue-600/50 transition-all">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1 min-w-0">
<!-- Icon based on type -->
<div class="flex-shrink-0">
<i class="text-lg"
:class="{
'fas fa-cube text-blue-400': !operation.isBackend && !operation.isDeletion,
'fas fa-cubes text-purple-400': operation.isBackend && !operation.isDeletion,
'fas fa-trash text-red-400': operation.isDeletion
}"></i>
</div>
<!-- Operation details -->
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2">
<span class="text-white font-medium text-sm truncate" x-text="operation.name"></span>
<span class="flex-shrink-0 text-xs px-2 py-0.5 rounded-full"
:class="{
'bg-blue-700/50 text-blue-200': !operation.isDeletion && !operation.isBackend,
'bg-purple-700/50 text-purple-200': !operation.isDeletion && operation.isBackend,
'bg-red-700/50 text-red-200': operation.isDeletion
}"
x-text="operation.isBackend ? 'Backend' : 'Model'"></span>
</div>
<!-- Status message -->
<div class="flex items-center space-x-2 mt-1">
<template x-if="operation.isQueued">
<span class="text-xs text-blue-300 flex items-center">
<i class="fas fa-clock mr-1"></i>
Queued
</span>
</template>
<template x-if="!operation.isQueued && operation.message">
<span class="text-xs text-gray-300 truncate" x-text="operation.message"></span>
</template>
</div>
</div>
<!-- Progress percentage -->
<div class="flex-shrink-0 text-right">
<span class="text-white font-bold text-lg" x-text="operation.progress + '%'"></span>
</div>
</div>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-700/50 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full transition-all duration-300 ease-out"
:class="{
'bg-gradient-to-r from-blue-500 to-blue-600': !operation.isDeletion,
'bg-gradient-to-r from-red-500 to-red-600': operation.isDeletion
}"
:style="'width: ' + operation.progress + '%'">
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<script>
function operationsStatus() {
return {
operations: [],
collapsed: false,
pollInterval: null,
init() {
this.fetchOperations();
// Poll every 500ms for smooth updates
this.pollInterval = setInterval(() => this.fetchOperations(), 500);
},
async fetchOperations() {
try {
const response = await fetch('/api/operations');
if (!response.ok) {
throw new Error('Failed to fetch operations');
}
const data = await response.json();
this.operations = data.operations || [];
// Auto-collapse if there are many operations
if (this.operations.length > 5 && !this.collapsed) {
// Don't auto-collapse, let user control it
}
} catch (error) {
console.error('Error fetching operations:', error);
// Don't clear operations on error, just keep showing last known state
}
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
}
}
}
</script>

View File

@@ -22,10 +22,6 @@
- filename: "tailwindcss.js"
url: "https://cdn.tailwindcss.com/3.3.0"
sha: "dbff048aa4581e6eae7f1cb2c641f72655ea833b3bb82923c4a59822e11ca594"
- filename: "htmx.js"
url: "https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"
sha: "449317ade7881e949510db614991e195c3a099c4c791c24dacec55f9f4a2a452"
- filename: "UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfMZg.ttf"
url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfMZg.ttf"
sha: "02c6d2ce3eb535653060cf6105c31551ba740750a7fd8a3e084d8864d82b888d"