From c6f0b4422850e841c4b8ddb6ccaee475748af8ac Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 9 Oct 2025 22:37:06 +0200 Subject: [PATCH] feat(ui): use Alpine.js and drop HTMX (#6418) * feat(ui): use Alpine.js and drop HTMX Signed-off-by: Ettore Di Giacinto * Display pending ops Signed-off-by: Ettore Di Giacinto * Show in progress ops Signed-off-by: Ettore Di Giacinto * more stable sorting Signed-off-by: Ettore Di Giacinto * minor fixup Signed-off-by: Ettore Di Giacinto * Fix clipboard copy Signed-off-by: Ettore Di Giacinto * Cleanup Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto --- core/http/app.go | 3 + core/http/elements/buttons.go | 115 ---- core/http/elements/gallery.go | 757 ----------------------- core/http/elements/p2p.go | 156 ----- core/http/elements/progressbar.go | 115 ---- core/http/routes/ui.go | 18 +- core/http/routes/ui_api.go | 673 ++++++++++++++++++++ core/http/routes/ui_backend_gallery.go | 246 +------- core/http/routes/ui_gallery.go | 295 +-------- core/http/views/backends.html | 574 +++++++++++------ core/http/views/index.html | 59 +- core/http/views/models.html | 649 ++++++++++++------- core/http/views/p2p.html | 243 +++++--- core/http/views/partials/head.html | 56 +- core/http/views/partials/inprogress.html | 172 ++++- webui_static.yaml | 4 - 16 files changed, 1880 insertions(+), 2255 deletions(-) delete mode 100644 core/http/elements/buttons.go delete mode 100644 core/http/elements/gallery.go delete mode 100644 core/http/elements/p2p.go delete mode 100644 core/http/elements/progressbar.go create mode 100644 core/http/routes/ui_api.go 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..."> + @@ -86,48 +106,28 @@ Filter by Backend Type
- - - - - @@ -138,34 +138,189 @@
- {{.Backends}} +
+ + + + +

Loading backends...

+
+ +
+ +

No backends found matching your criteria

+
+ +
+ +
- {{ if gt .AvailableBackends $numBackendsPerPage }} -
+
- {{ if .PrevPage }} - - {{ end }}
Page - {{.CurrentPage}} + of - {{.TotalPages}} +
- {{ if .NextPage }} - - {{ end }}
- {{ end }}
{{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; } - \ No newline at end of file + diff --git a/core/http/views/index.html b/core/http/views/index.html index 4c5b0c6c8..26e495e86 100644 --- a/core/http/views/index.html +++ b/core/http/views/index.html @@ -214,12 +214,7 @@ {{ if index $loadedModels .Name }} {{ end }} @@ -233,9 +228,7 @@
@@ -328,10 +321,50 @@
- \ No newline at end of file + diff --git a/core/http/views/p2p.html b/core/http/views/p2p.html index 87fead0a9..35f284362 100644 --- a/core/http/views/p2p.html +++ b/core/http/views/p2p.html @@ -3,10 +3,12 @@ {{template "views/partials/head" .}} -
+
{{template "views/partials/navbar" .}} + {{template "views/partials/inprogress" .}} +
@@ -122,7 +124,7 @@
- {{.P2PToken}} + {{.P2PToken}}

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.

@@ -159,7 +161,10 @@
-
+
+ + / +

nodes

@@ -182,7 +187,10 @@
-
+
+ + / +

workers

@@ -230,7 +238,10 @@
Active Nodes
-
+
+ + / +
@@ -242,8 +253,43 @@ -
- +
+ + +
@@ -348,7 +394,10 @@ docker run -ti --net host -e TOKEN="{{.P2PToken}}" --
Active Workers
-
+
+ + / +
@@ -360,8 +409,43 @@ docker run -ti --net host -e TOKEN="{{.P2PToken}}" -- -
- +
+ + +
@@ -489,95 +573,56 @@ docker run -ti --net host -e TOKEN="{{.P2PToken}}" -- 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; - } + + - \ No newline at end of file + diff --git a/core/http/views/partials/head.html b/core/http/views/partials/head.html index 77b6d34ef..af5b730e0 100644 --- a/core/http/views/partials/head.html +++ b/core/http/views/partials/head.html @@ -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); } @@ -45,7 +77,6 @@ - - - - \ No newline at end of file diff --git a/core/http/views/partials/inprogress.html b/core/http/views/partials/inprogress.html index 48da66d74..7ca71bc46 100644 --- a/core/http/views/partials/inprogress.html +++ b/core/http/views/partials/inprogress.html @@ -1,32 +1,142 @@ - - {{ if .ProcessingModels }} -

Operations in progress

- {{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 }} + +
+ +
+
+
+
+
+ +
+

+ Operations in Progress + +

+
+
+ +
-
-
- {{$modelName}} {{if $repository}} (from the '{{$repository}}' repository) {{end}} -
-
-

{{$op}} -

-
-
- {{ end }} - + +
+ +
+
+
+ + diff --git a/webui_static.yaml b/webui_static.yaml index 8d6912129..fbe640680 100644 --- a/webui_static.yaml +++ b/webui_static.yaml @@ -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"