diff --git a/core/http/app.go b/core/http/app.go
index 09f068834..dcd9a2219 100644
--- a/core/http/app.go
+++ b/core/http/app.go
@@ -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())
diff --git a/core/http/elements/buttons.go b/core/http/elements/buttons.go
deleted file mode 100644
index b6ac018c4..000000000
--- a/core/http/elements/buttons.go
+++ /dev/null
@@ -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, "@", "__")
-}
diff --git a/core/http/elements/gallery.go b/core/http/elements/gallery.go
deleted file mode 100644
index 071dad566..000000000
--- a/core/http/elements/gallery.go
+++ /dev/null
@@ -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(
- ``,
- ),
- 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(
- ``,
- ),
- 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"),
- )
-}
diff --git a/core/http/elements/p2p.go b/core/http/elements/p2p.go
deleted file mode 100644
index 4f191772f..000000000
--- a/core/http/elements/p2p.go
+++ /dev/null
@@ -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 `
-
-
No nodes available
-
Start some workers to see them here
-
`
- }
-
- 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
-}
diff --git a/core/http/elements/progressbar.go b/core/http/elements/progressbar.go
deleted file mode 100644
index 64c806fed..000000000
--- a/core/http/elements/progressbar.go
+++ /dev/null
@@ -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()
-}
diff --git a/core/http/routes/ui.go b/core/http/routes/ui.go
index 6e2bda7a9..c781bd88b 100644
--- a/core/http/routes/ui.go
+++ b/core/http/routes/ui.go
@@ -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
diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go
new file mode 100644
index 000000000..edb7a1435
--- /dev/null
+++ b/core/http/routes/ui_api.go
@@ -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),
+ },
+ })
+ })
+}
diff --git a/core/http/routes/ui_backend_gallery.go b/core/http/routes/ui_backend_gallery.go
index 94acd5bcf..ca9c8c765 100644
--- a/core/http/routes/ui_backend_gallery.go
+++ b/core/http/routes/ui_backend_gallery.go
@@ -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))
- })
}
diff --git a/core/http/routes/ui_gallery.go b/core/http/routes/ui_gallery.go
index 3182fea51..f84aa7c37 100644
--- a/core/http/routes/ui_gallery.go
+++ b/core/http/routes/ui_gallery.go
@@ -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))
- })
}
diff --git a/core/http/views/backends.html b/core/http/views/backends.html
index 25d7535cb..32b7cca22 100644
--- a/core/http/views/backends.html
+++ b/core/http/views/backends.html
@@ -3,10 +3,35 @@
{{template "views/partials/head" .}}
-
+
{{template "views/partials/navbar" .}}
- {{ $numBackendsPerPage := 21 }}
+
+
+
+
@@ -29,7 +54,7 @@
-
{{.AvailableBackends}}
+
backends available
-
-
+ placeholder="Search backends by name, description or type...">
+