mirror of
https://github.com/mudler/LocalAI.git
synced 2026-02-20 18:28:54 -06:00
feat(ui): use Alpine.js and drop HTMX (#6418)
* feat(ui): use Alpine.js and drop HTMX Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Display pending ops Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Show in progress ops Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * more stable sorting Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * minor fixup Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fix clipboard copy Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Cleanup Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
cb0ed55d89
commit
c6f0b44228
@@ -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())
|
||||
|
||||
@@ -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, "@", "__")
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
package elements
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/chasefleming/elem-go"
|
||||
"github.com/chasefleming/elem-go/attrs"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/services"
|
||||
)
|
||||
|
||||
const (
|
||||
noImage = "https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg"
|
||||
)
|
||||
|
||||
func cardSpan(text, icon string) elem.Node {
|
||||
return elem.Span(
|
||||
attrs.Props{
|
||||
"class": "inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2",
|
||||
},
|
||||
elem.I(attrs.Props{
|
||||
"class": icon + " pr-2",
|
||||
}),
|
||||
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
|
||||
)
|
||||
}
|
||||
|
||||
func searchableElement(text, icon string) elem.Node {
|
||||
return elem.Form(
|
||||
attrs.Props{},
|
||||
elem.Input(
|
||||
attrs.Props{
|
||||
"type": "hidden",
|
||||
"name": "search",
|
||||
"value": text,
|
||||
},
|
||||
),
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": "inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50 hover:bg-gray-600 hover:text-gray-100 transition duration-200 ease-in-out",
|
||||
},
|
||||
elem.A(
|
||||
attrs.Props{
|
||||
// "name": "search",
|
||||
// "value": text,
|
||||
//"class": "inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2",
|
||||
//"href": "#!",
|
||||
"href": "browse?term=" + text,
|
||||
//"hx-post": "browse/search/models",
|
||||
//"hx-target": "#search-results",
|
||||
// TODO: this doesn't work
|
||||
// "hx-vals": `{ \"search\": \"` + text + `\" }`,
|
||||
//"hx-indicator": ".htmx-indicator",
|
||||
},
|
||||
elem.I(attrs.Props{
|
||||
"class": icon + " pr-2",
|
||||
}),
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
func buttonLink(text, url string) elem.Node {
|
||||
return elem.A(
|
||||
attrs.Props{
|
||||
"class": "inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2",
|
||||
"href": url,
|
||||
"target": "_blank",
|
||||
},
|
||||
elem.I(attrs.Props{
|
||||
"class": "fas fa-link pr-2",
|
||||
}),
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
func link(text, url string) elem.Node {
|
||||
return elem.A(
|
||||
attrs.Props{
|
||||
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
|
||||
"href": url,
|
||||
"target": "_blank",
|
||||
},
|
||||
elem.I(attrs.Props{
|
||||
"class": "fas fa-link pr-2",
|
||||
}),
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(text)),
|
||||
)
|
||||
}
|
||||
|
||||
type ProcessTracker interface {
|
||||
Exists(string) bool
|
||||
Get(string) string
|
||||
}
|
||||
|
||||
func modalName(m *gallery.GalleryModel) string {
|
||||
return m.Name + "-modal"
|
||||
}
|
||||
|
||||
func modelModal(m *gallery.GalleryModel) elem.Node {
|
||||
urls := []elem.Node{}
|
||||
for _, url := range m.URLs {
|
||||
urls = append(urls,
|
||||
elem.Li(attrs.Props{}, link(url, url)),
|
||||
)
|
||||
}
|
||||
|
||||
tagsNodes := []elem.Node{}
|
||||
for _, tag := range m.Tags {
|
||||
tagsNodes = append(tagsNodes,
|
||||
searchableElement(tag, "fas fa-tag"),
|
||||
)
|
||||
}
|
||||
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"id": modalName(m),
|
||||
"tabindex": "-1",
|
||||
"aria-hidden": "true",
|
||||
"class": "hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col",
|
||||
},
|
||||
// header
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600",
|
||||
},
|
||||
elem.H3(
|
||||
attrs.Props{
|
||||
"class": "text-xl font-semibold text-gray-900 dark:text-white",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Name)),
|
||||
),
|
||||
elem.Button( // close button
|
||||
attrs.Props{
|
||||
"class": "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white",
|
||||
"data-modal-hide": modalName(m),
|
||||
},
|
||||
elem.Raw(
|
||||
`<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>`,
|
||||
),
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": "sr-only",
|
||||
},
|
||||
elem.Text("Close modal"),
|
||||
),
|
||||
),
|
||||
),
|
||||
// body
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex justify-center items-center",
|
||||
},
|
||||
elem.Img(attrs.Props{
|
||||
"class": "lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded",
|
||||
"src": m.Icon,
|
||||
"loading": "lazy",
|
||||
}),
|
||||
),
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Description)),
|
||||
),
|
||||
elem.Hr(
|
||||
attrs.Props{},
|
||||
),
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "text-sm font-semibold text-gray-900 dark:text-white",
|
||||
},
|
||||
elem.Text("Links"),
|
||||
),
|
||||
elem.Ul(
|
||||
attrs.Props{},
|
||||
urls...,
|
||||
),
|
||||
elem.If(
|
||||
len(m.Tags) > 0,
|
||||
elem.Div(
|
||||
attrs.Props{},
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "text-sm mb-5 font-semibold text-gray-900 dark:text-white",
|
||||
},
|
||||
elem.Text("Tags"),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex flex-row flex-wrap content-center",
|
||||
},
|
||||
tagsNodes...,
|
||||
),
|
||||
),
|
||||
elem.Div(attrs.Props{}),
|
||||
),
|
||||
),
|
||||
// Footer
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600",
|
||||
},
|
||||
elem.Button(
|
||||
attrs.Props{
|
||||
"data-modal-hide": modalName(m),
|
||||
"class": "py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700",
|
||||
},
|
||||
elem.Text("Close"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func modelDescription(m *gallery.GalleryModel) elem.Node {
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"class": "p-6 text-surface dark:text-white",
|
||||
},
|
||||
elem.H5(
|
||||
attrs.Props{
|
||||
"class": "mb-2 text-xl font-bold leading-tight",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Name)),
|
||||
),
|
||||
elem.Div( // small description
|
||||
attrs.Props{
|
||||
"class": "mb-4 text-sm truncate text-base",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(m.Description)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func modelActionItems(m *gallery.GalleryModel, processTracker ProcessTracker, galleryService *services.GalleryService) elem.Node {
|
||||
galleryID := fmt.Sprintf("%s@%s", m.Gallery.Name, m.Name)
|
||||
currentlyProcessing := processTracker.Exists(galleryID)
|
||||
jobID := ""
|
||||
isDeletionOp := false
|
||||
if currentlyProcessing {
|
||||
status := galleryService.GetStatus(galleryID)
|
||||
if status != nil && status.Deletion {
|
||||
isDeletionOp = true
|
||||
}
|
||||
jobID = processTracker.Get(galleryID)
|
||||
// TODO:
|
||||
// case not handled, if status == nil : "Waiting"
|
||||
}
|
||||
|
||||
nodes := []elem.Node{
|
||||
cardSpan("Repository: "+m.Gallery.Name, "fa-brands fa-git-alt"),
|
||||
}
|
||||
|
||||
if m.License != "" {
|
||||
nodes = append(nodes,
|
||||
cardSpan("License: "+m.License, "fas fa-book"),
|
||||
)
|
||||
}
|
||||
/*
|
||||
tagsNodes := []elem.Node{}
|
||||
|
||||
for _, tag := range m.Tags {
|
||||
tagsNodes = append(tagsNodes,
|
||||
searchableElement(tag, "fas fa-tag"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
nodes = append(nodes,
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex flex-row flex-wrap content-center",
|
||||
},
|
||||
tagsNodes...,
|
||||
),
|
||||
)
|
||||
|
||||
for i, url := range m.URLs {
|
||||
nodes = append(nodes,
|
||||
buttonLink("Link #"+fmt.Sprintf("%d", i+1), url),
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
progressMessage := "Installation"
|
||||
if isDeletionOp {
|
||||
progressMessage = "Deletion"
|
||||
}
|
||||
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"class": "px-6 pt-4 pb-2",
|
||||
},
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "mb-4 text-base",
|
||||
},
|
||||
nodes...,
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"id": "action-div-" + dropBadChars(galleryID),
|
||||
"class": "flow-root", // To order buttons left and right
|
||||
},
|
||||
infoButton(m),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "float-right",
|
||||
},
|
||||
elem.If(
|
||||
currentlyProcessing,
|
||||
elem.Node( // If currently installing, show progress bar
|
||||
elem.Raw(StartModelProgressBar(jobID, "0", progressMessage)),
|
||||
), // Otherwise, show install button (if not installed) or display "Installed"
|
||||
elem.If(m.Installed,
|
||||
elem.Node(elem.Div(
|
||||
attrs.Props{},
|
||||
reInstallButton(m.ID()),
|
||||
deleteButton(m.ID()),
|
||||
)),
|
||||
// otherwise, show the install button, and the get config button
|
||||
elem.Node(elem.Div(
|
||||
attrs.Props{},
|
||||
getConfigButton(m.ID()),
|
||||
installButton(m.ID()),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func ListModels(models []*gallery.GalleryModel, processTracker ProcessTracker, galleryService *services.GalleryService) string {
|
||||
modelsElements := []elem.Node{}
|
||||
|
||||
for _, m := range models {
|
||||
elems := []elem.Node{}
|
||||
|
||||
if m.Icon == "" {
|
||||
m.Icon = noImage
|
||||
}
|
||||
|
||||
divProperties := attrs.Props{
|
||||
"class": "flex justify-center items-center",
|
||||
}
|
||||
|
||||
elems = append(elems,
|
||||
elem.Div(divProperties,
|
||||
elem.A(attrs.Props{
|
||||
"href": "#!",
|
||||
// "class": "justify-center items-center",
|
||||
},
|
||||
elem.Img(attrs.Props{
|
||||
// "class": "rounded-t-lg object-fit object-center h-96",
|
||||
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
|
||||
"src": m.Icon,
|
||||
"loading": "lazy",
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Special/corner case: if a model sets Trust Remote Code as required, show a warning
|
||||
// TODO: handle this more generically later
|
||||
_, trustRemoteCodeExists := m.Overrides["trust_remote_code"]
|
||||
if trustRemoteCodeExists {
|
||||
elems = append(elems, elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex justify-center items-center bg-red-500 text-white p-2 rounded-lg mt-2",
|
||||
},
|
||||
elem.I(attrs.Props{
|
||||
"class": "fa-solid fa-circle-exclamation pr-2",
|
||||
}),
|
||||
elem.Text("Attention: Trust Remote Code is required for this model"),
|
||||
))
|
||||
}
|
||||
|
||||
elems = append(elems,
|
||||
modelDescription(m),
|
||||
modelActionItems(m, processTracker, galleryService),
|
||||
)
|
||||
modelsElements = append(modelsElements,
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": " me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
// "class": "p-6",
|
||||
},
|
||||
elems...,
|
||||
),
|
||||
),
|
||||
modelModal(m),
|
||||
)
|
||||
}
|
||||
|
||||
wrapper := elem.Div(attrs.Props{
|
||||
"class": "dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark",
|
||||
}, modelsElements...)
|
||||
|
||||
return wrapper.Render()
|
||||
}
|
||||
|
||||
func ListBackends(backends []*gallery.GalleryBackend, processTracker ProcessTracker, galleryService *services.GalleryService) string {
|
||||
backendsElements := []elem.Node{}
|
||||
|
||||
for _, b := range backends {
|
||||
elems := []elem.Node{}
|
||||
|
||||
if b.Icon == "" {
|
||||
b.Icon = noImage
|
||||
}
|
||||
|
||||
divProperties := attrs.Props{
|
||||
"class": "flex justify-center items-center",
|
||||
}
|
||||
|
||||
elems = append(elems,
|
||||
elem.Div(divProperties,
|
||||
elem.A(attrs.Props{
|
||||
"href": "#!",
|
||||
},
|
||||
elem.Img(attrs.Props{
|
||||
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
|
||||
"src": b.Icon,
|
||||
"loading": "lazy",
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
elems = append(elems,
|
||||
backendDescription(b),
|
||||
backendActionItems(b, processTracker, galleryService),
|
||||
)
|
||||
backendsElements = append(backendsElements,
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{},
|
||||
elems...,
|
||||
),
|
||||
),
|
||||
backendModal(b),
|
||||
)
|
||||
}
|
||||
|
||||
wrapper := elem.Div(attrs.Props{
|
||||
"class": "dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark",
|
||||
}, backendsElements...)
|
||||
|
||||
return wrapper.Render()
|
||||
}
|
||||
|
||||
func backendDescription(b *gallery.GalleryBackend) elem.Node {
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"class": "p-6 text-surface dark:text-white",
|
||||
},
|
||||
elem.H5(
|
||||
attrs.Props{
|
||||
"class": "mb-2 text-xl font-bold leading-tight",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Name)),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "mb-4 text-sm truncate text-base",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Description)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func backendActionItems(b *gallery.GalleryBackend, processTracker ProcessTracker, galleryService *services.GalleryService) elem.Node {
|
||||
galleryID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
|
||||
currentlyProcessing := processTracker.Exists(galleryID)
|
||||
jobID := ""
|
||||
isDeletionOp := false
|
||||
if currentlyProcessing {
|
||||
status := galleryService.GetStatus(galleryID)
|
||||
if status != nil && status.Deletion {
|
||||
isDeletionOp = true
|
||||
}
|
||||
jobID = processTracker.Get(galleryID)
|
||||
}
|
||||
|
||||
nodes := []elem.Node{
|
||||
cardSpan("Repository: "+b.Gallery.Name, "fa-brands fa-git-alt"),
|
||||
}
|
||||
|
||||
if b.License != "" {
|
||||
nodes = append(nodes,
|
||||
cardSpan("License: "+b.License, "fas fa-book"),
|
||||
)
|
||||
}
|
||||
|
||||
progressMessage := "Installation"
|
||||
if isDeletionOp {
|
||||
progressMessage = "Deletion"
|
||||
}
|
||||
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"class": "px-6 pt-4 pb-2",
|
||||
},
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "mb-4 text-base",
|
||||
},
|
||||
nodes...,
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"id": "action-div-" + dropBadChars(galleryID),
|
||||
"class": "flow-root",
|
||||
},
|
||||
backendInfoButton(b),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "float-right",
|
||||
},
|
||||
elem.If(
|
||||
currentlyProcessing,
|
||||
elem.Node(
|
||||
elem.Raw(StartModelProgressBar(jobID, "0", progressMessage)),
|
||||
),
|
||||
elem.If(b.Installed,
|
||||
elem.Node(elem.Div(
|
||||
attrs.Props{},
|
||||
backendReInstallButton(galleryID),
|
||||
backendDeleteButton(galleryID),
|
||||
)),
|
||||
backendInstallButton(galleryID),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func backendModal(b *gallery.GalleryBackend) elem.Node {
|
||||
urls := []elem.Node{}
|
||||
for _, url := range b.URLs {
|
||||
urls = append(urls,
|
||||
elem.Li(attrs.Props{}, link(url, url)),
|
||||
)
|
||||
}
|
||||
|
||||
tagsNodes := []elem.Node{}
|
||||
for _, tag := range b.Tags {
|
||||
tagsNodes = append(tagsNodes,
|
||||
searchableElement(tag, "fas fa-tag"),
|
||||
)
|
||||
}
|
||||
|
||||
modalID := fmt.Sprintf("modal-%s", dropBadChars(fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)))
|
||||
|
||||
return elem.Div(
|
||||
attrs.Props{
|
||||
"id": modalID,
|
||||
"tabindex": "-1",
|
||||
"aria-hidden": "true",
|
||||
"class": "hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600",
|
||||
},
|
||||
elem.H3(
|
||||
attrs.Props{
|
||||
"class": "text-xl font-semibold text-gray-900 dark:text-white",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Name)),
|
||||
),
|
||||
elem.Button(
|
||||
attrs.Props{
|
||||
"class": "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white",
|
||||
"data-modal-hide": modalID,
|
||||
},
|
||||
elem.Raw(
|
||||
`<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>`,
|
||||
),
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": "sr-only",
|
||||
},
|
||||
elem.Text("Close modal"),
|
||||
),
|
||||
),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "p-4 md:p-5 space-y-4 overflow-y-auto flex-grow",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex justify-center items-center",
|
||||
},
|
||||
elem.Img(attrs.Props{
|
||||
"src": b.Icon,
|
||||
"class": "rounded-t-lg max-h-48 max-w-96 object-cover mt-3",
|
||||
"loading": "lazy",
|
||||
}),
|
||||
),
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
|
||||
},
|
||||
elem.Text(bluemonday.StrictPolicy().Sanitize(b.Description)),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex flex-wrap gap-2",
|
||||
},
|
||||
tagsNodes...,
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "text-base leading-relaxed text-gray-500 dark:text-gray-400",
|
||||
},
|
||||
elem.Ul(attrs.Props{}, urls...),
|
||||
),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600",
|
||||
},
|
||||
elem.Button(
|
||||
attrs.Props{
|
||||
"data-modal-hide": modalID,
|
||||
"type": "button",
|
||||
"class": "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800",
|
||||
},
|
||||
elem.Text("Close"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func backendInfoButton(b *gallery.GalleryBackend) elem.Node {
|
||||
modalID := fmt.Sprintf("modal-%s", dropBadChars(fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)))
|
||||
return elem.Button(
|
||||
attrs.Props{
|
||||
"data-twe-ripple-init": "",
|
||||
"data-twe-ripple-color": "light",
|
||||
"class": "inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out",
|
||||
"data-modal-target": modalID,
|
||||
"data-modal-toggle": modalID,
|
||||
},
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "flex items-center",
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fas fa-info-circle pr-2",
|
||||
},
|
||||
),
|
||||
elem.Text("Info"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func backendInstallButton(galleryID string) elem.Node {
|
||||
return elem.Button(
|
||||
attrs.Props{
|
||||
"data-twe-ripple-init": "",
|
||||
"data-twe-ripple-color": "light",
|
||||
"class": "float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-post": "browse/install/backend/" + galleryID,
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fa-solid fa-download pr-2",
|
||||
},
|
||||
),
|
||||
elem.Text("Install"),
|
||||
)
|
||||
}
|
||||
|
||||
func backendReInstallButton(galleryID string) elem.Node {
|
||||
return elem.Button(
|
||||
attrs.Props{
|
||||
"data-twe-ripple-init": "",
|
||||
"data-twe-ripple-color": "light",
|
||||
"class": "float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2 focus:bg-primary-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-primary-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
|
||||
"hx-target": "#action-div-" + dropBadChars(galleryID),
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-post": "browse/install/backend/" + galleryID,
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fa-solid fa-arrow-rotate-right pr-2",
|
||||
},
|
||||
),
|
||||
elem.Text("Reinstall"),
|
||||
)
|
||||
}
|
||||
|
||||
func backendDeleteButton(galleryID string) elem.Node {
|
||||
return elem.Button(
|
||||
attrs.Props{
|
||||
"data-twe-ripple-init": "",
|
||||
"data-twe-ripple-color": "light",
|
||||
"hx-confirm": "Are you sure you wish to delete the backend?",
|
||||
"class": "float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2 focus:bg-red-accent-300 focus:shadow-primary-2 focus:outline-none focus:ring-0 active:bg-red-600 active:shadow-primary-2 dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong",
|
||||
"hx-target": "#action-div-" + dropBadChars(galleryID),
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-post": "browse/delete/backend/" + galleryID,
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fa-solid fa-cancel pr-2",
|
||||
},
|
||||
),
|
||||
elem.Text("Delete"),
|
||||
)
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package elements
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/chasefleming/elem-go"
|
||||
"github.com/chasefleming/elem-go/attrs"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
)
|
||||
|
||||
func renderElements(n []elem.Node) string {
|
||||
render := ""
|
||||
for _, r := range n {
|
||||
render += r.Render()
|
||||
}
|
||||
return render
|
||||
}
|
||||
|
||||
func P2PNodeStats(nodes []schema.NodeData) string {
|
||||
online := 0
|
||||
for _, n := range nodes {
|
||||
if n.IsOnline() {
|
||||
online++
|
||||
}
|
||||
}
|
||||
|
||||
class := "text-green-400"
|
||||
if online == 0 {
|
||||
class = "text-red-400"
|
||||
} else if online < len(nodes) {
|
||||
class = "text-yellow-400"
|
||||
}
|
||||
|
||||
nodesElements := []elem.Node{
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": class + " font-bold text-2xl",
|
||||
},
|
||||
elem.Text(fmt.Sprintf("%d", online)),
|
||||
),
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": "text-gray-300 text-xl",
|
||||
},
|
||||
elem.Text(fmt.Sprintf("/%d", len(nodes))),
|
||||
),
|
||||
}
|
||||
|
||||
return renderElements(nodesElements)
|
||||
}
|
||||
|
||||
func P2PNodeBoxes(nodes []schema.NodeData) string {
|
||||
if len(nodes) == 0 {
|
||||
return `<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
|
||||
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
|
||||
<p class="text-gray-400 text-lg font-medium">No nodes available</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
render := ""
|
||||
for _, n := range nodes {
|
||||
nodeID := bluemonday.StrictPolicy().Sanitize(n.ID)
|
||||
|
||||
// Define status-specific classes
|
||||
statusIconClass := "text-green-400"
|
||||
statusText := "Online"
|
||||
statusTextClass := "text-green-400"
|
||||
cardHoverClass := "hover:shadow-green-500/20 hover:border-green-400/50"
|
||||
|
||||
if !n.IsOnline() {
|
||||
statusIconClass = "text-red-400"
|
||||
statusText = "Offline"
|
||||
statusTextClass = "text-red-400"
|
||||
cardHoverClass = "hover:shadow-red-500/20 hover:border-red-400/50"
|
||||
}
|
||||
|
||||
nodeCard := elem.Div(
|
||||
attrs.Props{
|
||||
"class": "bg-gradient-to-br from-gray-800/90 to-gray-900/80 border border-gray-700/50 rounded-xl p-5 shadow-xl transition-all duration-300 " + cardHoverClass + " backdrop-blur-sm",
|
||||
},
|
||||
// Header with node icon and status
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center justify-between mb-4",
|
||||
},
|
||||
// Node info
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center",
|
||||
},
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mr-3",
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fas fa-server text-blue-400 text-lg",
|
||||
},
|
||||
),
|
||||
),
|
||||
elem.Div(
|
||||
attrs.Props{},
|
||||
elem.H4(
|
||||
attrs.Props{
|
||||
"class": "text-white font-semibold text-sm",
|
||||
},
|
||||
elem.Text("Node"),
|
||||
),
|
||||
elem.P(
|
||||
attrs.Props{
|
||||
"class": "text-gray-400 text-xs font-mono break-all",
|
||||
},
|
||||
elem.Text(nodeID),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Status badge
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50",
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fas fa-circle animate-pulse " + statusIconClass + " mr-2 text-xs",
|
||||
},
|
||||
),
|
||||
elem.Span(
|
||||
attrs.Props{
|
||||
"class": statusTextClass + " text-xs font-medium",
|
||||
},
|
||||
elem.Text(statusText),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Footer with timestamp
|
||||
elem.Div(
|
||||
attrs.Props{
|
||||
"class": "text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center",
|
||||
},
|
||||
elem.I(
|
||||
attrs.Props{
|
||||
"class": "fas fa-clock mr-2",
|
||||
},
|
||||
),
|
||||
elem.Text("Updated: "+time.Now().UTC().Format("15:04:05")),
|
||||
),
|
||||
)
|
||||
|
||||
render += nodeCard.Render()
|
||||
}
|
||||
|
||||
return render
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -3,10 +3,8 @@ package routes
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/http/elements"
|
||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||
"github.com/mudler/LocalAI/core/http/utils"
|
||||
"github.com/mudler/LocalAI/core/p2p"
|
||||
"github.com/mudler/LocalAI/core/services"
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
@@ -42,20 +40,8 @@ func RegisterUIRoutes(app *fiber.App,
|
||||
return c.Render("views/p2p", summary)
|
||||
})
|
||||
|
||||
/* show nodes live! */
|
||||
app.Get("/p2p/ui/workers", func(c *fiber.Ctx) error {
|
||||
return c.SendString(elements.P2PNodeBoxes(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))))
|
||||
})
|
||||
app.Get("/p2p/ui/workers-federation", func(c *fiber.Ctx) error {
|
||||
return c.SendString(elements.P2PNodeBoxes(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))))
|
||||
})
|
||||
|
||||
app.Get("/p2p/ui/workers-stats", func(c *fiber.Ctx) error {
|
||||
return c.SendString(elements.P2PNodeStats(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))))
|
||||
})
|
||||
app.Get("/p2p/ui/workers-federation-stats", func(c *fiber.Ctx) error {
|
||||
return c.SendString(elements.P2PNodeStats(p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))))
|
||||
})
|
||||
// Note: P2P UI fragment routes (/p2p/ui/*) were removed
|
||||
// P2P nodes are now fetched via JSON API at /api/p2p/workers and /api/p2p/federation
|
||||
|
||||
// End P2P
|
||||
|
||||
|
||||
673
core/http/routes/ui_api.go
Normal file
673
core/http/routes/ui_api.go
Normal file
@@ -0,0 +1,673 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/p2p"
|
||||
"github.com/mudler/LocalAI/core/services"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// RegisterUIAPIRoutes registers JSON API routes for the web UI
|
||||
func RegisterUIAPIRoutes(app *fiber.App, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
|
||||
|
||||
// Operations API - Get all current operations (models + backends)
|
||||
app.Get("/api/operations", func(c *fiber.Ctx) error {
|
||||
processingData, taskTypes := opcache.GetStatus()
|
||||
|
||||
operations := []fiber.Map{}
|
||||
for galleryID, jobID := range processingData {
|
||||
taskType := "installation"
|
||||
if tt, ok := taskTypes[galleryID]; ok {
|
||||
taskType = tt
|
||||
}
|
||||
|
||||
status := galleryService.GetStatus(jobID)
|
||||
progress := 0
|
||||
isDeletion := false
|
||||
isQueued := false
|
||||
message := ""
|
||||
|
||||
if status != nil {
|
||||
// Skip completed operations
|
||||
if status.Processed {
|
||||
continue
|
||||
}
|
||||
|
||||
progress = int(status.Progress)
|
||||
isDeletion = status.Deletion
|
||||
message = status.Message
|
||||
if isDeletion {
|
||||
taskType = "deletion"
|
||||
}
|
||||
} else {
|
||||
// Job is queued but hasn't started
|
||||
isQueued = true
|
||||
message = "Operation queued"
|
||||
}
|
||||
|
||||
// Determine if it's a model or backend
|
||||
isBackend := false
|
||||
backends, _ := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
|
||||
for _, b := range backends {
|
||||
backendID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
|
||||
if backendID == galleryID || b.Name == galleryID {
|
||||
isBackend = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract display name (remove repo prefix if exists)
|
||||
displayName := galleryID
|
||||
if strings.Contains(galleryID, "@") {
|
||||
parts := strings.Split(galleryID, "@")
|
||||
if len(parts) > 1 {
|
||||
displayName = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
operations = append(operations, fiber.Map{
|
||||
"id": galleryID,
|
||||
"name": displayName,
|
||||
"fullName": galleryID,
|
||||
"jobID": jobID,
|
||||
"progress": progress,
|
||||
"taskType": taskType,
|
||||
"isDeletion": isDeletion,
|
||||
"isBackend": isBackend,
|
||||
"isQueued": isQueued,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort operations by progress (ascending), then by ID for stable display order
|
||||
sort.Slice(operations, func(i, j int) bool {
|
||||
progressI := operations[i]["progress"].(int)
|
||||
progressJ := operations[j]["progress"].(int)
|
||||
|
||||
// Primary sort by progress
|
||||
if progressI != progressJ {
|
||||
return progressI < progressJ
|
||||
}
|
||||
|
||||
// Secondary sort by ID for stability when progress is the same
|
||||
return operations[i]["id"].(string) < operations[j]["id"].(string)
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"operations": operations,
|
||||
})
|
||||
})
|
||||
|
||||
// Model Gallery APIs
|
||||
app.Get("/api/models", func(c *fiber.Ctx) error {
|
||||
term := c.Query("term")
|
||||
page := c.Query("page", "1")
|
||||
items := c.Query("items", "21")
|
||||
|
||||
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("could not list models from galleries")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get all available tags
|
||||
allTags := map[string]struct{}{}
|
||||
tags := []string{}
|
||||
for _, m := range models {
|
||||
for _, t := range m.Tags {
|
||||
allTags[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
for t := range allTags {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
|
||||
if term != "" {
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
|
||||
}
|
||||
|
||||
// Get model statuses
|
||||
processingModelsData, taskTypes := opcache.GetStatus()
|
||||
|
||||
pageNum, err := strconv.Atoi(page)
|
||||
if err != nil || pageNum < 1 {
|
||||
pageNum = 1
|
||||
}
|
||||
|
||||
itemsNum, err := strconv.Atoi(items)
|
||||
if err != nil || itemsNum < 1 {
|
||||
itemsNum = 21
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(len(models)) / float64(itemsNum)))
|
||||
totalModels := len(models)
|
||||
|
||||
if pageNum > 0 {
|
||||
models = models.Paginate(pageNum, itemsNum)
|
||||
}
|
||||
|
||||
// Convert models to JSON-friendly format
|
||||
modelsJSON := make([]fiber.Map, 0, len(models))
|
||||
for _, m := range models {
|
||||
galleryID := fmt.Sprintf("%s@%s", m.Gallery.Name, m.Name)
|
||||
currentlyProcessing := opcache.Exists(galleryID)
|
||||
jobID := ""
|
||||
isDeletionOp := false
|
||||
if currentlyProcessing {
|
||||
jobID = opcache.Get(galleryID)
|
||||
status := galleryService.GetStatus(jobID)
|
||||
if status != nil && status.Deletion {
|
||||
isDeletionOp = true
|
||||
}
|
||||
}
|
||||
|
||||
_, trustRemoteCodeExists := m.Overrides["trust_remote_code"]
|
||||
|
||||
modelsJSON = append(modelsJSON, fiber.Map{
|
||||
"id": m.ID(),
|
||||
"name": m.Name,
|
||||
"description": m.Description,
|
||||
"icon": m.Icon,
|
||||
"license": m.License,
|
||||
"urls": m.URLs,
|
||||
"tags": m.Tags,
|
||||
"gallery": m.Gallery.Name,
|
||||
"installed": m.Installed,
|
||||
"processing": currentlyProcessing,
|
||||
"jobID": jobID,
|
||||
"isDeletion": isDeletionOp,
|
||||
"trustRemoteCode": trustRemoteCodeExists,
|
||||
})
|
||||
}
|
||||
|
||||
prevPage := pageNum - 1
|
||||
nextPage := pageNum + 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"models": modelsJSON,
|
||||
"repositories": appConfig.Galleries,
|
||||
"allTags": tags,
|
||||
"processingModels": processingModelsData,
|
||||
"taskTypes": taskTypes,
|
||||
"availableModels": totalModels,
|
||||
"currentPage": pageNum,
|
||||
"totalPages": totalPages,
|
||||
"prevPage": prevPage,
|
||||
"nextPage": nextPage,
|
||||
})
|
||||
})
|
||||
|
||||
app.Post("/api/models/install/:id", func(c *fiber.Ctx) error {
|
||||
galleryID := strings.Clone(c.Params("id"))
|
||||
// URL decode the gallery ID (e.g., "localai%40model" -> "localai@model")
|
||||
galleryID, err := url.QueryUnescape(galleryID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid model ID",
|
||||
})
|
||||
}
|
||||
log.Debug().Msgf("API job submitted to install: %+v\n", galleryID)
|
||||
|
||||
id, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
uid := id.String()
|
||||
opcache.Set(galleryID, uid)
|
||||
|
||||
op := services.GalleryOp[gallery.GalleryModel]{
|
||||
ID: uid,
|
||||
GalleryElementName: galleryID,
|
||||
Galleries: appConfig.Galleries,
|
||||
BackendGalleries: appConfig.BackendGalleries,
|
||||
}
|
||||
go func() {
|
||||
galleryService.ModelGalleryChannel <- op
|
||||
}()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"jobID": uid,
|
||||
"message": "Installation started",
|
||||
})
|
||||
})
|
||||
|
||||
app.Post("/api/models/delete/:id", func(c *fiber.Ctx) error {
|
||||
galleryID := strings.Clone(c.Params("id"))
|
||||
// URL decode the gallery ID
|
||||
galleryID, err := url.QueryUnescape(galleryID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid model ID",
|
||||
})
|
||||
}
|
||||
log.Debug().Msgf("API job submitted to delete: %+v\n", galleryID)
|
||||
|
||||
var galleryName = galleryID
|
||||
if strings.Contains(galleryID, "@") {
|
||||
galleryName = strings.Split(galleryID, "@")[1]
|
||||
}
|
||||
|
||||
id, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
uid := id.String()
|
||||
|
||||
opcache.Set(galleryID, uid)
|
||||
|
||||
op := services.GalleryOp[gallery.GalleryModel]{
|
||||
ID: uid,
|
||||
Delete: true,
|
||||
GalleryElementName: galleryName,
|
||||
Galleries: appConfig.Galleries,
|
||||
BackendGalleries: appConfig.BackendGalleries,
|
||||
}
|
||||
go func() {
|
||||
galleryService.ModelGalleryChannel <- op
|
||||
cl.RemoveModelConfig(galleryName)
|
||||
}()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"jobID": uid,
|
||||
"message": "Deletion started",
|
||||
})
|
||||
})
|
||||
|
||||
app.Post("/api/models/config/:id", func(c *fiber.Ctx) error {
|
||||
galleryID := strings.Clone(c.Params("id"))
|
||||
// URL decode the gallery ID
|
||||
galleryID, err := url.QueryUnescape(galleryID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid model ID",
|
||||
})
|
||||
}
|
||||
log.Debug().Msgf("API job submitted to get config for: %+v\n", galleryID)
|
||||
|
||||
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
model := gallery.FindGalleryElement(models, galleryID)
|
||||
if model == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "model not found",
|
||||
})
|
||||
}
|
||||
|
||||
config, err := gallery.GetGalleryConfigFromURL[gallery.ModelConfig](model.URL, appConfig.SystemState.Model.ModelsPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
_, err = gallery.InstallModel(appConfig.SystemState, model.Name, &config, model.Overrides, nil, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Configuration file saved",
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/models/job/:uid", func(c *fiber.Ctx) error {
|
||||
jobUID := strings.Clone(c.Params("uid"))
|
||||
|
||||
status := galleryService.GetStatus(jobUID)
|
||||
if status == nil {
|
||||
// Job is queued but hasn't started processing yet
|
||||
return c.JSON(fiber.Map{
|
||||
"progress": 0,
|
||||
"message": "Operation queued",
|
||||
"galleryElementName": "",
|
||||
"processed": false,
|
||||
"deletion": false,
|
||||
"queued": true,
|
||||
})
|
||||
}
|
||||
|
||||
response := fiber.Map{
|
||||
"progress": status.Progress,
|
||||
"message": status.Message,
|
||||
"galleryElementName": status.GalleryElementName,
|
||||
"processed": status.Processed,
|
||||
"deletion": status.Deletion,
|
||||
"queued": false,
|
||||
}
|
||||
|
||||
if status.Error != nil {
|
||||
response["error"] = status.Error.Error()
|
||||
}
|
||||
|
||||
if status.Progress == 100 && status.Processed && status.Message == "completed" {
|
||||
opcache.DeleteUUID(jobUID)
|
||||
response["completed"] = true
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
})
|
||||
|
||||
// Backend Gallery APIs
|
||||
app.Get("/api/backends", func(c *fiber.Ctx) error {
|
||||
term := c.Query("term")
|
||||
page := c.Query("page", "1")
|
||||
items := c.Query("items", "21")
|
||||
|
||||
backends, err := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("could not list backends from galleries")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get all available tags
|
||||
allTags := map[string]struct{}{}
|
||||
tags := []string{}
|
||||
for _, b := range backends {
|
||||
for _, t := range b.Tags {
|
||||
allTags[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
for t := range allTags {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
|
||||
if term != "" {
|
||||
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).Search(term)
|
||||
}
|
||||
|
||||
// Get backend statuses
|
||||
processingBackendsData, taskTypes := opcache.GetStatus()
|
||||
|
||||
pageNum, err := strconv.Atoi(page)
|
||||
if err != nil || pageNum < 1 {
|
||||
pageNum = 1
|
||||
}
|
||||
|
||||
itemsNum, err := strconv.Atoi(items)
|
||||
if err != nil || itemsNum < 1 {
|
||||
itemsNum = 21
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(len(backends)) / float64(itemsNum)))
|
||||
totalBackends := len(backends)
|
||||
|
||||
if pageNum > 0 {
|
||||
backends = backends.Paginate(pageNum, itemsNum)
|
||||
}
|
||||
|
||||
// Convert backends to JSON-friendly format
|
||||
backendsJSON := make([]fiber.Map, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
galleryID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
|
||||
currentlyProcessing := opcache.Exists(galleryID)
|
||||
jobID := ""
|
||||
isDeletionOp := false
|
||||
if currentlyProcessing {
|
||||
jobID = opcache.Get(galleryID)
|
||||
status := galleryService.GetStatus(jobID)
|
||||
if status != nil && status.Deletion {
|
||||
isDeletionOp = true
|
||||
}
|
||||
}
|
||||
|
||||
backendsJSON = append(backendsJSON, fiber.Map{
|
||||
"id": galleryID,
|
||||
"name": b.Name,
|
||||
"description": b.Description,
|
||||
"icon": b.Icon,
|
||||
"license": b.License,
|
||||
"urls": b.URLs,
|
||||
"tags": b.Tags,
|
||||
"gallery": b.Gallery.Name,
|
||||
"installed": b.Installed,
|
||||
"processing": currentlyProcessing,
|
||||
"jobID": jobID,
|
||||
"isDeletion": isDeletionOp,
|
||||
})
|
||||
}
|
||||
|
||||
prevPage := pageNum - 1
|
||||
nextPage := pageNum + 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"backends": backendsJSON,
|
||||
"repositories": appConfig.BackendGalleries,
|
||||
"allTags": tags,
|
||||
"processingBackends": processingBackendsData,
|
||||
"taskTypes": taskTypes,
|
||||
"availableBackends": totalBackends,
|
||||
"currentPage": pageNum,
|
||||
"totalPages": totalPages,
|
||||
"prevPage": prevPage,
|
||||
"nextPage": nextPage,
|
||||
})
|
||||
})
|
||||
|
||||
app.Post("/api/backends/install/:id", func(c *fiber.Ctx) error {
|
||||
backendID := strings.Clone(c.Params("id"))
|
||||
// URL decode the backend ID
|
||||
backendID, err := url.QueryUnescape(backendID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid backend ID",
|
||||
})
|
||||
}
|
||||
log.Debug().Msgf("API job submitted to install backend: %+v\n", backendID)
|
||||
|
||||
id, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
uid := id.String()
|
||||
opcache.Set(backendID, uid)
|
||||
|
||||
op := services.GalleryOp[gallery.GalleryBackend]{
|
||||
ID: uid,
|
||||
GalleryElementName: backendID,
|
||||
Galleries: appConfig.BackendGalleries,
|
||||
}
|
||||
go func() {
|
||||
galleryService.BackendGalleryChannel <- op
|
||||
}()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"jobID": uid,
|
||||
"message": "Backend installation started",
|
||||
})
|
||||
})
|
||||
|
||||
app.Post("/api/backends/delete/:id", func(c *fiber.Ctx) error {
|
||||
backendID := strings.Clone(c.Params("id"))
|
||||
// URL decode the backend ID
|
||||
backendID, err := url.QueryUnescape(backendID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid backend ID",
|
||||
})
|
||||
}
|
||||
log.Debug().Msgf("API job submitted to delete backend: %+v\n", backendID)
|
||||
|
||||
var backendName = backendID
|
||||
if strings.Contains(backendID, "@") {
|
||||
backendName = strings.Split(backendID, "@")[1]
|
||||
}
|
||||
|
||||
id, err := uuid.NewUUID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
uid := id.String()
|
||||
|
||||
opcache.Set(backendID, uid)
|
||||
|
||||
op := services.GalleryOp[gallery.GalleryBackend]{
|
||||
ID: uid,
|
||||
Delete: true,
|
||||
GalleryElementName: backendName,
|
||||
Galleries: appConfig.BackendGalleries,
|
||||
}
|
||||
go func() {
|
||||
galleryService.BackendGalleryChannel <- op
|
||||
}()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"jobID": uid,
|
||||
"message": "Backend deletion started",
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/backends/job/:uid", func(c *fiber.Ctx) error {
|
||||
jobUID := strings.Clone(c.Params("uid"))
|
||||
|
||||
status := galleryService.GetStatus(jobUID)
|
||||
if status == nil {
|
||||
// Job is queued but hasn't started processing yet
|
||||
return c.JSON(fiber.Map{
|
||||
"progress": 0,
|
||||
"message": "Operation queued",
|
||||
"galleryElementName": "",
|
||||
"processed": false,
|
||||
"deletion": false,
|
||||
"queued": true,
|
||||
})
|
||||
}
|
||||
|
||||
response := fiber.Map{
|
||||
"progress": status.Progress,
|
||||
"message": status.Message,
|
||||
"galleryElementName": status.GalleryElementName,
|
||||
"processed": status.Processed,
|
||||
"deletion": status.Deletion,
|
||||
"queued": false,
|
||||
}
|
||||
|
||||
if status.Error != nil {
|
||||
response["error"] = status.Error.Error()
|
||||
}
|
||||
|
||||
if status.Progress == 100 && status.Processed && status.Message == "completed" {
|
||||
opcache.DeleteUUID(jobUID)
|
||||
response["completed"] = true
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
})
|
||||
|
||||
// P2P APIs
|
||||
app.Get("/api/p2p/workers", func(c *fiber.Ctx) error {
|
||||
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))
|
||||
|
||||
nodesJSON := make([]fiber.Map, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodesJSON = append(nodesJSON, fiber.Map{
|
||||
"name": n.Name,
|
||||
"id": n.ID,
|
||||
"tunnelAddress": n.TunnelAddress,
|
||||
"serviceID": n.ServiceID,
|
||||
"lastSeen": n.LastSeen,
|
||||
"isOnline": n.IsOnline(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"nodes": nodesJSON,
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/p2p/federation", func(c *fiber.Ctx) error {
|
||||
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
||||
|
||||
nodesJSON := make([]fiber.Map, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodesJSON = append(nodesJSON, fiber.Map{
|
||||
"name": n.Name,
|
||||
"id": n.ID,
|
||||
"tunnelAddress": n.TunnelAddress,
|
||||
"serviceID": n.ServiceID,
|
||||
"lastSeen": n.LastSeen,
|
||||
"isOnline": n.IsOnline(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"nodes": nodesJSON,
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/p2p/stats", func(c *fiber.Ctx) error {
|
||||
workerNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.WorkerID))
|
||||
federatedNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
||||
|
||||
workersOnline := 0
|
||||
for _, n := range workerNodes {
|
||||
if n.IsOnline() {
|
||||
workersOnline++
|
||||
}
|
||||
}
|
||||
|
||||
federatedOnline := 0
|
||||
for _, n := range federatedNodes {
|
||||
if n.IsOnline() {
|
||||
federatedOnline++
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"workers": fiber.Map{
|
||||
"online": workersOnline,
|
||||
"total": len(workerNodes),
|
||||
},
|
||||
"federated": fiber.Map{
|
||||
"online": federatedOnline,
|
||||
"total": len(federatedNodes),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,10 +3,35 @@
|
||||
{{template "views/partials/head" .}}
|
||||
|
||||
<body class="bg-gradient-to-br from-gray-900 via-gray-950 to-black text-gray-200">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="flex flex-col min-h-screen" x-data="backendsGallery()">
|
||||
|
||||
{{template "views/partials/navbar" .}}
|
||||
{{ $numBackendsPerPage := 21 }}
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<div x-show="true"
|
||||
x-transition:enter="transform ease-out duration-300 transition"
|
||||
x-transition:enter-start="translate-x-full opacity-0"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transform ease-in duration-200 transition"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="translate-x-full opacity-0"
|
||||
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
|
||||
class="rounded-lg shadow-xl p-4 text-white flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:text-gray-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
|
||||
<!-- Hero Header -->
|
||||
@@ -29,7 +54,7 @@
|
||||
<div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base">
|
||||
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="font-semibold text-emerald-300">{{.AvailableBackends}}</span>
|
||||
<span class="font-semibold text-emerald-300" x-text="availableBackends"></span>
|
||||
<span class="text-gray-300 ml-1">backends available</span>
|
||||
</div>
|
||||
<a href="https://localai.io/backends/" target="_blank"
|
||||
@@ -59,18 +84,13 @@
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
<input class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/50 focus:outline-none"
|
||||
<input
|
||||
x-model="searchTerm"
|
||||
@input.debounce.500ms="fetchBackends()"
|
||||
class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/50 focus:outline-none"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search backends by name, description or type..."
|
||||
hx-post="browse/search/backends"
|
||||
hx-trigger="input changed delay:500ms, search"
|
||||
hx-target="#search-results"
|
||||
oninput="hidePagination()"
|
||||
onchange="hidePagination()"
|
||||
onsearch="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<span class="htmx-indicator absolute right-4 top-4">
|
||||
placeholder="Search backends by name, description or type...">
|
||||
<span class="absolute right-4 top-4" x-show="loading">
|
||||
<svg class="animate-spin h-6 w-6 text-emerald-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -86,48 +106,28 @@
|
||||
Filter by Backend Type
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<button hx-post="browse/search/backends"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "llm"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('llm')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25">
|
||||
<i class="fas fa-brain mr-2 group-hover:animate-pulse"></i>
|
||||
<span>LLM</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/backends"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "diffusion"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('diffusion')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25">
|
||||
<i class="fas fa-image mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Diffusion</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/backends"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "tts"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('tts')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25">
|
||||
<i class="fas fa-microphone mr-2 group-hover:animate-pulse"></i>
|
||||
<span>TTS</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/backends"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "whisper"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('whisper')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25">
|
||||
<i class="fas fa-headphones mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Whisper</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/backends"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "object-detection"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('object-detection')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25">
|
||||
<i class="fas fa-eye mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Vision</span>
|
||||
</button>
|
||||
@@ -138,34 +138,189 @@
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="search-results" class="transition-all duration-300">
|
||||
{{.Backends}}
|
||||
<div x-show="loading && backends.length === 0" class="text-center py-12">
|
||||
<svg class="animate-spin h-12 w-12 text-emerald-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-400">Loading backends...</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && backends.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-search text-gray-500 text-4xl mb-4"></i>
|
||||
<p class="text-gray-400">No backends found matching your criteria</p>
|
||||
</div>
|
||||
|
||||
<div class="dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark">
|
||||
<template x-for="backend in backends" :key="backend.id">
|
||||
<div>
|
||||
<!-- Backend Card -->
|
||||
<div class="me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50">
|
||||
<div>
|
||||
<!-- Backend Image -->
|
||||
<div class="flex justify-center items-center">
|
||||
<a href="#!">
|
||||
<img :src="backend.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
|
||||
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Backend Description -->
|
||||
<div class="p-6 text-surface dark:text-white">
|
||||
<h5 class="mb-2 text-xl font-bold leading-tight" x-text="backend.name"></h5>
|
||||
<div class="mb-4 text-sm truncate text-base" x-text="backend.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- Backend Actions -->
|
||||
<div class="px-6 pt-4 pb-2">
|
||||
<p class="mb-4 text-base">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
|
||||
<i class="fa-brands fa-git-alt pr-2"></i>
|
||||
<span>Repository: <span x-text="backend.gallery"></span></span>
|
||||
</span>
|
||||
<span x-show="backend.license" class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
|
||||
<i class="fas fa-book pr-2"></i>
|
||||
<span>License: <span x-text="backend.license"></span></span>
|
||||
</span>
|
||||
</p>
|
||||
<div :id="'action-div-' + backend.id.replace('@', '__')" class="flow-root">
|
||||
<!-- Info Button -->
|
||||
<button @click="openModal(backend)"
|
||||
class="inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
|
||||
<i class="fas fa-info-circle pr-2"></i>
|
||||
Info
|
||||
</button>
|
||||
|
||||
<div class="float-right">
|
||||
<!-- Processing State -->
|
||||
<div x-show="backend.processing">
|
||||
<div class="text-sm font-medium text-gray-300 mb-2">
|
||||
<span x-text="backend.isDeletion ? 'Deletion' : 'Installation'"></span>
|
||||
<!-- Show queued message when progress is 0 -->
|
||||
<div x-show="(jobProgress[backend.jobID] || 0) === 0" class="text-xs text-blue-400 mt-1">
|
||||
<i class="fas fa-clock mr-1"></i>Operation queued
|
||||
</div>
|
||||
<div class="progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="progress-bar" :style="'width:' + (jobProgress[backend.jobID] || 0) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed State -->
|
||||
<div x-show="!backend.processing && backend.installed">
|
||||
<button @click="reinstallBackend(backend.id)"
|
||||
class="float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2">
|
||||
<i class="fa-solid fa-arrow-rotate-right pr-2"></i>
|
||||
Reinstall
|
||||
</button>
|
||||
<button @click="deleteBackend(backend.id)"
|
||||
class="float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2">
|
||||
<i class="fa-solid fa-cancel pr-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not Installed State -->
|
||||
<div x-show="!backend.processing && !backend.installed">
|
||||
<button @click="installBackend(backend.id)"
|
||||
class="float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg">
|
||||
<i class="fa-solid fa-download pr-2"></i>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div x-show="selectedBackend && selectedBackend.id === backend.id"
|
||||
x-transition
|
||||
@click.away="closeModal()"
|
||||
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50"
|
||||
style="display: none;">
|
||||
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white" x-text="backend.name"></h3>
|
||||
<button @click="closeModal()"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal Body -->
|
||||
<div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-grow">
|
||||
<div class="flex justify-center items-center">
|
||||
<img :src="backend.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
|
||||
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="backend.description"></p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="tag in backend.tags" :key="tag">
|
||||
<span class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50">
|
||||
<i class="fas fa-tag pr-2"></i>
|
||||
<span x-text="tag"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-base leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
<ul>
|
||||
<template x-for="url in backend.urls" :key="url">
|
||||
<li>
|
||||
<a :href="url" target="_blank" class="text-blue-500 hover:underline">
|
||||
<i class="fas fa-link pr-2"></i>
|
||||
<span x-text="url"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
|
||||
<button @click="closeModal()"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ if gt .AvailableBackends $numBackendsPerPage }}
|
||||
<div id="paginate" class="flex justify-center mt-12">
|
||||
<div x-show="totalPages > 1" class="flex justify-center mt-12">
|
||||
<div class="flex items-center gap-4 bg-gray-800/60 rounded-2xl p-4 backdrop-blur-sm border border-gray-700/50">
|
||||
{{ if .PrevPage }}
|
||||
<button onclick="window.location.href='browse/backends?page={{.PrevPage}}'"
|
||||
<button @click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
:class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-emerald-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
|
||||
<i class="fas fa-chevron-left group-hover:animate-pulse"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
<div class="text-gray-300 text-sm font-medium px-4">
|
||||
<span class="text-gray-400">Page</span>
|
||||
<span class="text-white font-bold text-lg mx-2">{{.CurrentPage}}</span>
|
||||
<span class="text-white font-bold text-lg mx-2" x-text="currentPage"></span>
|
||||
<span class="text-gray-400">of</span>
|
||||
<span class="text-white font-bold text-lg mx-2">{{.TotalPages}}</span>
|
||||
<span class="text-white font-bold text-lg mx-2" x-text="totalPages"></span>
|
||||
</div>
|
||||
{{ if .NextPage }}
|
||||
<button onclick="window.location.href='browse/backends?page={{.NextPage}}'"
|
||||
<button @click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
:class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-emerald-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
|
||||
<i class="fas fa-chevron-right group-hover:animate-pulse"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
{{template "views/partials/footer" .}}
|
||||
@@ -191,145 +346,190 @@
|
||||
background: rgba(107, 114, 128, 0.8);
|
||||
}
|
||||
|
||||
/* Add some custom CSS for backend cards to match our theme */
|
||||
#search-results .dark\:bg-gray-800 {
|
||||
background: linear-gradient(135deg, rgba(31, 41, 55, 0.9) 0%, rgba(17, 24, 39, 0.9) 100%) !important;
|
||||
border: 1px solid rgba(75, 85, 99, 0.5) !important;
|
||||
border-radius: 1rem !important;
|
||||
transition: all 0.5s ease !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
/* Progress bar styling */
|
||||
.progress {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(20, 184, 166, 0.2) 100%);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#search-results .dark\:bg-gray-800:hover {
|
||||
transform: translateY(-8px) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(16, 185, 129, 0.1) !important;
|
||||
border-color: rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Style the install buttons */
|
||||
#search-results .bg-blue-600 {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-blue-600:hover {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the reinstall buttons specifically */
|
||||
#search-results .bg-primary {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-primary:hover {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(6, 182, 212, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the delete buttons */
|
||||
#search-results .bg-red-800 {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-red-800:hover {
|
||||
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(220, 38, 38, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the info buttons */
|
||||
#search-results .bg-gray-700 {
|
||||
background: linear-gradient(135deg, #374151 0%, #1f2937 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(55, 65, 81, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-gray-700:hover {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(55, 65, 81, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the backend images */
|
||||
#search-results img.rounded-t-lg {
|
||||
border-radius: 1rem !important;
|
||||
transition: transform 0.3s ease !important;
|
||||
}
|
||||
|
||||
#search-results .dark\:bg-gray-800:hover img.rounded-t-lg {
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
/* Style the progress bars */
|
||||
#search-results .progress {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(6, 182, 212, 0.2) 100%) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Style action buttons */
|
||||
#search-results button[class*="primary"] {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
#search-results button[class*="primary"]:hover {
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.3) !important;
|
||||
.progress-bar {
|
||||
background: linear-gradient(135deg, #10b981 0%, #14b8a6 100%);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function hidePagination() {
|
||||
const paginateDiv = document.getElementById('paginate');
|
||||
if (paginateDiv) {
|
||||
paginateDiv.style.display = 'none';
|
||||
function backendsGallery() {
|
||||
return {
|
||||
backends: [],
|
||||
allTags: [],
|
||||
repositories: [],
|
||||
searchTerm: '',
|
||||
loading: false,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
availableBackends: 0,
|
||||
selectedBackend: null,
|
||||
jobProgress: {},
|
||||
notifications: [],
|
||||
|
||||
init() {
|
||||
this.fetchBackends();
|
||||
// Poll for job progress every 600ms
|
||||
setInterval(() => this.pollJobs(), 600);
|
||||
},
|
||||
|
||||
addNotification(message, type = 'error') {
|
||||
const id = Date.now();
|
||||
this.notifications.push({ id, message, type });
|
||||
// Auto-dismiss after 10 seconds
|
||||
setTimeout(() => this.dismissNotification(id), 10000);
|
||||
},
|
||||
|
||||
dismissNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
async fetchBackends() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
items: 21,
|
||||
term: this.searchTerm
|
||||
});
|
||||
const response = await fetch(`/api/backends?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
this.backends = data.backends || [];
|
||||
this.allTags = data.allTags || [];
|
||||
this.repositories = data.repositories || [];
|
||||
this.currentPage = data.currentPage || 1;
|
||||
this.totalPages = data.totalPages || 1;
|
||||
this.availableBackends = data.availableBackends || 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching backends:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterByTerm(term) {
|
||||
this.searchTerm = term;
|
||||
this.currentPage = 1;
|
||||
this.fetchBackends();
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.fetchBackends();
|
||||
}
|
||||
},
|
||||
|
||||
async installBackend(backendId) {
|
||||
try {
|
||||
const response = await fetch(`/api/backends/install/${encodeURIComponent(backendId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.jobID) {
|
||||
const backend = this.backends.find(b => b.id === backendId);
|
||||
if (backend) {
|
||||
backend.processing = true;
|
||||
backend.jobID = data.jobID;
|
||||
backend.isDeletion = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing backend:', error);
|
||||
alert('Failed to start installation');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteBackend(backendId) {
|
||||
if (!confirm('Are you sure you wish to delete the backend?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/backends/delete/${encodeURIComponent(backendId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.jobID) {
|
||||
const backend = this.backends.find(b => b.id === backendId);
|
||||
if (backend) {
|
||||
backend.processing = true;
|
||||
backend.jobID = data.jobID;
|
||||
backend.isDeletion = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting backend:', error);
|
||||
alert('Failed to start deletion');
|
||||
}
|
||||
},
|
||||
|
||||
async reinstallBackend(backendId) {
|
||||
this.installBackend(backendId);
|
||||
},
|
||||
|
||||
async pollJobs() {
|
||||
const processingBackends = this.backends.filter(b => b.processing && b.jobID);
|
||||
|
||||
for (const backend of processingBackends) {
|
||||
try {
|
||||
const response = await fetch(`/api/backends/job/${backend.jobID}`);
|
||||
const jobData = await response.json();
|
||||
|
||||
// Handle queued status
|
||||
if (jobData.queued) {
|
||||
this.jobProgress[backend.jobID] = 0;
|
||||
// Keep processing state but don't show error
|
||||
continue;
|
||||
}
|
||||
|
||||
this.jobProgress[backend.jobID] = jobData.progress || 0;
|
||||
|
||||
if (jobData.completed) {
|
||||
backend.processing = false;
|
||||
backend.installed = !jobData.deletion;
|
||||
delete this.jobProgress[backend.jobID];
|
||||
// Show success notification
|
||||
const action = jobData.deletion ? 'deleted' : 'installed';
|
||||
this.addNotification(`Backend "${backend.name}" ${action} successfully!`, 'success');
|
||||
// Refresh the backends list to get updated state
|
||||
this.fetchBackends();
|
||||
}
|
||||
|
||||
if (jobData.error) {
|
||||
backend.processing = false;
|
||||
delete this.jobProgress[backend.jobID];
|
||||
const action = backend.isDeletion ? 'deleting' : 'installing';
|
||||
this.addNotification(`Error ${action} backend "${backend.name}": ${jobData.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling job:', error);
|
||||
// Don't show notification for every polling error, only if backend is stuck
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
openModal(backend) {
|
||||
this.selectedBackend = backend;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.selectedBackend = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the htmx:afterSwap event to handle cases when the search results are updated
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'search-results') {
|
||||
hidePagination();
|
||||
}
|
||||
});
|
||||
|
||||
// Add loading state animation
|
||||
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput && event.detail.elt === searchInput) {
|
||||
searchInput.classList.add('animate-pulse');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
searchInput.classList.remove('animate-pulse');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -214,12 +214,7 @@
|
||||
{{ if index $loadedModels .Name }}
|
||||
<button class="group/stop inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
data-twe-ripple-init=""
|
||||
hx-confirm="Are you sure you wish to stop this model?"
|
||||
hx-post="/backend/shutdown"
|
||||
hx-vals='{"model": "{{.Name}}"}'
|
||||
hx-target="this"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleShutdownResponse(event, '{{.Name}}')">
|
||||
onclick="handleStopModel('{{.Name}}')">
|
||||
<i class="fas fa-stop mr-2 group-hover/stop:animate-pulse"></i>Stop
|
||||
</button>
|
||||
{{ end }}
|
||||
@@ -233,9 +228,7 @@
|
||||
<button
|
||||
class="group/delete inline-flex items-center text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
data-twe-ripple-init=""
|
||||
hx-confirm="Are you sure you wish to delete this model?"
|
||||
hx-post="browse/delete/model/{{.Name}}"
|
||||
hx-swap="outerHTML">
|
||||
onclick="handleDeleteModel('{{.Name}}')">
|
||||
<i class="fas fa-trash-alt mr-2 group-hover/delete:animate-bounce"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,10 +321,50 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleShutdownResponse(event, modelName) {
|
||||
const button = event.target;
|
||||
const response = event.detail.xhr;
|
||||
window.location.reload();
|
||||
async function handleStopModel(modelName) {
|
||||
if (!confirm('Are you sure you wish to stop this model?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteModel(modelName) {
|
||||
if (!confirm('Are you sure you wish to delete this model?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelName)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting model:', error);
|
||||
alert('Failed to delete model');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reload models button
|
||||
|
||||
@@ -3,10 +3,35 @@
|
||||
{{template "views/partials/head" .}}
|
||||
|
||||
<body class="bg-gradient-to-br from-gray-900 via-gray-950 to-black text-gray-200">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="flex flex-col min-h-screen" x-data="modelsGallery()">
|
||||
|
||||
{{template "views/partials/navbar" .}}
|
||||
{{ $numModelsPerPage := 21 }}
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<div x-show="true"
|
||||
x-transition:enter="transform ease-out duration-300 transition"
|
||||
x-transition:enter-start="translate-x-full opacity-0"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transform ease-in duration-200 transition"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="translate-x-full opacity-0"
|
||||
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
|
||||
class="rounded-lg shadow-xl p-4 text-white flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:text-gray-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
|
||||
<!-- Hero Header -->
|
||||
@@ -29,12 +54,12 @@
|
||||
<div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base">
|
||||
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
|
||||
<div class="w-2 h-2 bg-indigo-400 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="font-semibold text-indigo-300">{{.AvailableModels}}</span>
|
||||
<span class="font-semibold text-indigo-300" x-text="availableModels"></span>
|
||||
<span class="text-gray-300 ml-1">models available</span>
|
||||
</div>
|
||||
<div class="flex items-center bg-white/10 rounded-full px-4 py-2">
|
||||
<div class="w-2 h-2 bg-purple-400 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="font-semibold text-purple-300">{{ len .Repositories }}</span>
|
||||
<span class="font-semibold text-purple-300" x-text="repositories.length"></span>
|
||||
<span class="text-gray-300 ml-1">repositories</span>
|
||||
</div>
|
||||
<a href="https://localai.io/models/" target="_blank"
|
||||
@@ -64,18 +89,13 @@
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
<input class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
<input
|
||||
x-model="searchTerm"
|
||||
@input.debounce.500ms="fetchModels()"
|
||||
class="w-full pl-12 pr-16 py-4 text-base font-normal text-gray-300 bg-gray-900/90 border border-gray-700/70 rounded-xl transition-all duration-300 focus:text-gray-200 focus:bg-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search models by name, tag, or description..."
|
||||
hx-post="browse/search/models"
|
||||
hx-trigger="input changed delay:500ms, search"
|
||||
hx-target="#search-results"
|
||||
oninput="hidePagination()"
|
||||
onchange="hidePagination()"
|
||||
onsearch="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<span class="htmx-indicator absolute right-4 top-4">
|
||||
placeholder="Search models by name, tag, or description...">
|
||||
<span class="absolute right-4 top-4" x-show="loading">
|
||||
<svg class="animate-spin h-6 w-6 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -91,75 +111,43 @@
|
||||
Filter by Model Type
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-3">
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "tts"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('tts')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-indigo-600/80 to-indigo-700/80 hover:from-indigo-600 hover:to-indigo-700 text-indigo-100 border border-indigo-500/30 hover:border-indigo-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-indigo-500/25">
|
||||
<i class="fas fa-microphone mr-2 group-hover:animate-pulse"></i>
|
||||
<span>TTS</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "stablediffusion"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('stablediffusion')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-purple-600/80 to-purple-700/80 hover:from-purple-600 hover:to-purple-700 text-purple-100 border border-purple-500/30 hover:border-purple-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-purple-500/25">
|
||||
<i class="fas fa-image mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "llm"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('llm')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-blue-600/80 to-blue-700/80 hover:from-blue-600 hover:to-blue-700 text-blue-100 border border-blue-500/30 hover:border-blue-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25">
|
||||
<i class="fas fa-comment-alt mr-2 group-hover:animate-bounce"></i>
|
||||
<span>LLM</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "multimodal"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('multimodal')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-green-600/80 to-green-700/80 hover:from-green-600 hover:to-green-700 text-green-100 border border-green-500/30 hover:border-green-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-green-500/25">
|
||||
<i class="fas fa-object-group mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Multimodal</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-cyan-600/80 to-cyan-700/80 hover:from-cyan-600 hover:to-cyan-700 text-cyan-100 border border-cyan-500/30 hover:border-cyan-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-cyan-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "embedding"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('embedding')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-cyan-600/80 to-cyan-700/80 hover:from-cyan-600 hover:to-cyan-700 text-cyan-100 border border-cyan-500/30 hover:border-cyan-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-cyan-500/25">
|
||||
<i class="fas fa-vector-square mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Embedding</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-amber-600/80 to-amber-700/80 hover:from-amber-600 hover:to-amber-700 text-amber-100 border border-amber-500/30 hover:border-amber-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-amber-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "rerank"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('rerank')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-amber-600/80 to-amber-700/80 hover:from-amber-600 hover:to-amber-700 text-amber-100 border border-amber-500/30 hover:border-amber-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-amber-500/25">
|
||||
<i class="fas fa-sort-amount-up mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Rerank</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-teal-600/80 to-teal-700/80 hover:from-teal-600 hover:to-teal-700 text-teal-100 border border-teal-500/30 hover:border-teal-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-teal-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "whisper"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('whisper')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-teal-600/80 to-teal-700/80 hover:from-teal-600 hover:to-teal-700 text-teal-100 border border-teal-500/30 hover:border-teal-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-teal-500/25">
|
||||
<i class="fas fa-headphones mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Whisper</span>
|
||||
</button>
|
||||
<button hx-post="browse/search/models"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "object-detection"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<button @click="filterByTerm('object-detection')"
|
||||
class="group flex items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold bg-gradient-to-r from-red-600/80 to-red-700/80 hover:from-red-600 hover:to-red-700 text-red-100 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg hover:shadow-red-500/25">
|
||||
<i class="fas fa-eye mr-2 group-hover:animate-pulse"></i>
|
||||
<span>Vision</span>
|
||||
</button>
|
||||
@@ -167,24 +155,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Filter by Tags -->
|
||||
<div>
|
||||
<div x-show="allTags.length > 0">
|
||||
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<i class="fas fa-tags mr-3 text-pink-400"></i>
|
||||
Browse by Tags
|
||||
</h3>
|
||||
<div class="max-h-32 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600/50 scrollbar-track-gray-800/50 pr-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{ range .AllTags }}
|
||||
<button hx-post="browse/search/models"
|
||||
class="group inline-flex items-center text-xs px-3 py-2 rounded-full bg-gray-700/60 hover:bg-gray-600/80 text-gray-300 hover:text-white border border-gray-600/50 hover:border-gray-500/70 transition-all duration-200 ease-in-out transform hover:scale-105"
|
||||
hx-target="#search-results"
|
||||
hx-vals='{"search": "{{.}}"}'
|
||||
onclick="hidePagination()"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<template x-for="tag in allTags" :key="tag">
|
||||
<button @click="filterByTerm(tag)"
|
||||
class="group inline-flex items-center text-xs px-3 py-2 rounded-full bg-gray-700/60 hover:bg-gray-600/80 text-gray-300 hover:text-white border border-gray-600/50 hover:border-gray-500/70 transition-all duration-200 ease-in-out transform hover:scale-105">
|
||||
<i class="fas fa-tag text-xs mr-2 group-hover:animate-pulse"></i>
|
||||
<span>{{.}}</span>
|
||||
<span x-text="tag"></span>
|
||||
</button>
|
||||
{{ end }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,31 +177,205 @@
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="search-results" class="transition-all duration-300">
|
||||
{{.Models}}
|
||||
<div x-show="loading && models.length === 0" class="text-center py-12">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-400">Loading models...</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && models.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-search text-gray-500 text-4xl mb-4"></i>
|
||||
<p class="text-gray-400">No models found matching your criteria</p>
|
||||
</div>
|
||||
|
||||
<div class="dark grid grid-cols-1 grid-rows-1 md:grid-cols-3 block rounded-lg shadow-secondary-1 dark:bg-surface-dark">
|
||||
<template x-for="model in models" :key="model.id">
|
||||
<div>
|
||||
<!-- Model Card -->
|
||||
<div class="me-4 mb-2 block rounded-lg bg-white shadow-secondary-1 dark:bg-gray-800 dark:bg-surface-dark dark:text-white text-surface pb-2 bg-gray-800/90 border border-gray-700/50 rounded-xl overflow-hidden transition-all duration-300 hover:shadow-lg hover:shadow-blue-900/20 hover:-translate-y-1 hover:border-blue-700/50">
|
||||
<div>
|
||||
<!-- Model Image -->
|
||||
<div class="flex justify-center items-center">
|
||||
<a href="#!">
|
||||
<img :src="model.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
|
||||
class="rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust Remote Code Warning -->
|
||||
<div x-show="model.trustRemoteCode"
|
||||
class="flex justify-center items-center bg-red-500 text-white p-2 rounded-lg mt-2">
|
||||
<i class="fa-solid fa-circle-exclamation pr-2"></i>
|
||||
<span>Attention: Trust Remote Code is required for this model</span>
|
||||
</div>
|
||||
|
||||
<!-- Model Description -->
|
||||
<div class="p-6 text-surface dark:text-white">
|
||||
<h5 class="mb-2 text-xl font-bold leading-tight" x-text="model.name"></h5>
|
||||
<div class="mb-4 text-sm truncate text-base" x-text="model.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- Model Actions -->
|
||||
<div class="px-6 pt-4 pb-2">
|
||||
<p class="mb-4 text-base">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
|
||||
<i class="fa-brands fa-git-alt pr-2"></i>
|
||||
<span>Repository: <span x-text="model.gallery"></span></span>
|
||||
</span>
|
||||
<span x-show="model.license" class="inline-flex items-center px-3 py-1 rounded-lg text-xs font-medium bg-gray-700/70 text-gray-300 border border-gray-600/50 mr-2 mb-2">
|
||||
<i class="fas fa-book pr-2"></i>
|
||||
<span>License: <span x-text="model.license"></span></span>
|
||||
</span>
|
||||
</p>
|
||||
<div :id="'action-div-' + model.id.replace('@', '__')" class="flow-root">
|
||||
<!-- Info Button -->
|
||||
<button @click="openModal(model)"
|
||||
class="inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
|
||||
<i class="fas fa-info-circle pr-2"></i>
|
||||
Info
|
||||
</button>
|
||||
|
||||
<div class="float-right">
|
||||
<!-- Processing State -->
|
||||
<div x-show="model.processing">
|
||||
<div class="text-sm font-medium text-gray-300 mb-2">
|
||||
<span x-text="model.isDeletion ? 'Deletion' : 'Installation'"></span>
|
||||
<!-- Show queued message when progress is 0 -->
|
||||
<div x-show="(jobProgress[model.jobID] || 0) === 0" class="text-xs text-blue-400 mt-1">
|
||||
<i class="fas fa-clock mr-1"></i>Operation queued
|
||||
</div>
|
||||
<div class="progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="progress-bar" :style="'width:' + (jobProgress[model.jobID] || 0) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed State -->
|
||||
<div x-show="!model.processing && model.installed">
|
||||
<button @click="reinstallModel(model.id)"
|
||||
class="float-right inline-block rounded bg-primary ml-2 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-primary-accent-300 hover:shadow-primary-2">
|
||||
<i class="fa-solid fa-arrow-rotate-right pr-2"></i>
|
||||
Reinstall
|
||||
</button>
|
||||
<button @click="deleteModel(model.id)"
|
||||
class="float-right inline-block rounded bg-red-800 px-6 pb-2.5 mb-3 pt-2.5 text-xs font-medium uppercase leading-normal text-white shadow-primary-3 transition duration-150 ease-in-out hover:bg-red-accent-300 hover:shadow-red-2">
|
||||
<i class="fa-solid fa-cancel pr-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not Installed State -->
|
||||
<div x-show="!model.processing && !model.installed">
|
||||
<button @click="getConfig(model.id)"
|
||||
class="float-right ml-2 inline-flex items-center rounded-lg bg-gray-700 hover:bg-gray-600 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out">
|
||||
<i class="fa-solid fa-download pr-2"></i>
|
||||
Get Config
|
||||
</button>
|
||||
<button @click="installModel(model.id)"
|
||||
class="float-right inline-flex items-center rounded-lg bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-medium text-white transition duration-300 ease-in-out shadow hover:shadow-lg">
|
||||
<i class="fa-solid fa-download pr-2"></i>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div x-show="selectedModel && selectedModel.id === model.id"
|
||||
x-transition
|
||||
@click.away="closeModal()"
|
||||
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-gray-900/50"
|
||||
style="display: none;">
|
||||
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 h-full flex flex-col">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white" x-text="model.name"></h3>
|
||||
<button @click="closeModal()"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modal Body -->
|
||||
<div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div class="flex justify-center items-center">
|
||||
<img :src="model.icon || 'https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg'"
|
||||
class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400" x-text="model.description"></p>
|
||||
<hr>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
|
||||
<ul>
|
||||
<template x-for="url in model.urls" :key="url">
|
||||
<li>
|
||||
<a :href="url" target="_blank" class="text-base leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-link pr-2"></i>
|
||||
<span x-text="url"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div x-show="model.tags && model.tags.length > 0">
|
||||
<p class="text-sm mb-5 font-semibold text-gray-900 dark:text-white">Tags</p>
|
||||
<div class="flex flex-row flex-wrap content-center">
|
||||
<template x-for="tag in model.tags" :key="tag">
|
||||
<a :href="'browse?term=' + tag"
|
||||
class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-gray-700/60 text-gray-300 border border-gray-600/50 hover:bg-gray-600 hover:text-gray-100 transition duration-200 ease-in-out mr-2 mb-2">
|
||||
<i class="fas fa-tag pr-2"></i>
|
||||
<span x-text="tag"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
|
||||
<button @click="closeModal()"
|
||||
class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ if gt .AvailableModels $numModelsPerPage }}
|
||||
<div id="paginate" class="flex justify-center mt-12">
|
||||
<div x-show="totalPages > 1" class="flex justify-center mt-12">
|
||||
<div class="flex items-center gap-4 bg-gray-800/60 rounded-2xl p-4 backdrop-blur-sm border border-gray-700/50">
|
||||
<button onclick="window.location.href='browse?page={{.PrevPage}}'"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 {{if not .PrevPage}}opacity-50 cursor-not-allowed{{end}}"
|
||||
{{if not .PrevPage}}disabled{{end}}>
|
||||
<button @click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
:class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
|
||||
<i class="fas fa-chevron-left group-hover:animate-pulse"></i>
|
||||
</button>
|
||||
<div class="text-gray-300 text-sm font-medium px-4">
|
||||
<span class="text-gray-400">Page</span>
|
||||
<span class="text-white font-bold text-lg mx-2">{{add .PrevPage 1}}</span>
|
||||
<span class="text-gray-400">of many</span>
|
||||
<span class="text-white font-bold text-lg mx-2" x-text="currentPage"></span>
|
||||
<span class="text-gray-400">of</span>
|
||||
<span class="text-white font-bold text-lg mx-2" x-text="totalPages"></span>
|
||||
</div>
|
||||
<button onclick="window.location.href='browse?page={{.NextPage}}'"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 {{if not .NextPage}}opacity-50 cursor-not-allowed{{end}}"
|
||||
{{if not .NextPage}}disabled{{end}}>
|
||||
<button @click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
:class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="group flex items-center justify-center h-12 w-12 bg-gray-700/80 hover:bg-indigo-600 text-gray-300 hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
|
||||
<i class="fas fa-chevron-right group-hover:animate-pulse"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
{{template "views/partials/footer" .}}
|
||||
@@ -243,145 +401,204 @@
|
||||
background: rgba(107, 114, 128, 0.8);
|
||||
}
|
||||
|
||||
/* Add some custom CSS for gallery model cards to match our theme */
|
||||
#search-results .dark\:bg-gray-800 {
|
||||
background: linear-gradient(135deg, rgba(31, 41, 55, 0.9) 0%, rgba(17, 24, 39, 0.9) 100%) !important;
|
||||
border: 1px solid rgba(75, 85, 99, 0.5) !important;
|
||||
border-radius: 1rem !important;
|
||||
transition: all 0.5s ease !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
/* Progress bar styling */
|
||||
.progress {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#search-results .dark\:bg-gray-800:hover {
|
||||
transform: translateY(-8px) !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(59, 130, 246, 0.1) !important;
|
||||
border-color: rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Style the install buttons */
|
||||
#search-results .bg-blue-600 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-blue-600:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the model images */
|
||||
#search-results img.rounded-t-lg {
|
||||
border-radius: 1rem !important;
|
||||
transition: transform 0.3s ease !important;
|
||||
}
|
||||
|
||||
#search-results .dark\:bg-gray-800:hover img.rounded-t-lg {
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
/* Style the progress bars */
|
||||
#search-results .progress {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Style action buttons */
|
||||
#search-results button[class*="primary"] {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
#search-results button[class*="primary"]:hover {
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Style the reinstall buttons specifically */
|
||||
#search-results .bg-primary {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-primary:hover {
|
||||
background: linear-gradient(135deg, #047857 0%, #065f46 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(5, 150, 105, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the delete buttons */
|
||||
#search-results .bg-red-800 {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-red-800:hover {
|
||||
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(220, 38, 38, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Style the info buttons */
|
||||
#search-results .bg-gray-700 {
|
||||
background: linear-gradient(135deg, #374151 0%, #1f2937 100%) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: 0 4px 15px rgba(55, 65, 81, 0.25) !important;
|
||||
}
|
||||
|
||||
#search-results .bg-gray-700:hover {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #111827 100%) !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(55, 65, 81, 0.4) !important;
|
||||
.progress-bar {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function hidePagination() {
|
||||
const paginateDiv = document.getElementById('paginate');
|
||||
if (paginateDiv) {
|
||||
paginateDiv.style.display = 'none';
|
||||
function modelsGallery() {
|
||||
return {
|
||||
models: [],
|
||||
allTags: [],
|
||||
repositories: [],
|
||||
searchTerm: '',
|
||||
loading: false,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
availableModels: 0,
|
||||
selectedModel: null,
|
||||
jobProgress: {},
|
||||
notifications: [],
|
||||
|
||||
init() {
|
||||
this.fetchModels();
|
||||
// Poll for job progress every 600ms
|
||||
setInterval(() => this.pollJobs(), 600);
|
||||
},
|
||||
|
||||
addNotification(message, type = 'error') {
|
||||
const id = Date.now();
|
||||
this.notifications.push({ id, message, type });
|
||||
// Auto-dismiss after 10 seconds
|
||||
setTimeout(() => this.dismissNotification(id), 10000);
|
||||
},
|
||||
|
||||
dismissNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
async fetchModels() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
items: 21,
|
||||
term: this.searchTerm
|
||||
});
|
||||
const response = await fetch(`/api/models?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
this.models = data.models || [];
|
||||
this.allTags = data.allTags || [];
|
||||
this.repositories = data.repositories || [];
|
||||
this.currentPage = data.currentPage || 1;
|
||||
this.totalPages = data.totalPages || 1;
|
||||
this.availableModels = data.availableModels || 0;
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterByTerm(term) {
|
||||
this.searchTerm = term;
|
||||
this.currentPage = 1;
|
||||
this.fetchModels();
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.fetchModels();
|
||||
}
|
||||
},
|
||||
|
||||
async installModel(modelId) {
|
||||
try {
|
||||
const response = await fetch(`/api/models/install/${encodeURIComponent(modelId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.jobID) {
|
||||
// Update model state
|
||||
const model = this.models.find(m => m.id === modelId);
|
||||
if (model) {
|
||||
model.processing = true;
|
||||
model.jobID = data.jobID;
|
||||
model.isDeletion = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing model:', error);
|
||||
alert('Failed to start installation');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteModel(modelId) {
|
||||
if (!confirm('Are you sure you wish to delete the model?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.jobID) {
|
||||
const model = this.models.find(m => m.id === modelId);
|
||||
if (model) {
|
||||
model.processing = true;
|
||||
model.jobID = data.jobID;
|
||||
model.isDeletion = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting model:', error);
|
||||
alert('Failed to start deletion');
|
||||
}
|
||||
},
|
||||
|
||||
async reinstallModel(modelId) {
|
||||
this.installModel(modelId);
|
||||
},
|
||||
|
||||
async getConfig(modelId) {
|
||||
try {
|
||||
const response = await fetch(`/api/models/config/${encodeURIComponent(modelId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message || 'Configuration saved');
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
alert('Failed to get configuration');
|
||||
}
|
||||
},
|
||||
|
||||
async pollJobs() {
|
||||
const processingModels = this.models.filter(m => m.processing && m.jobID);
|
||||
|
||||
for (const model of processingModels) {
|
||||
try {
|
||||
const response = await fetch(`/api/models/job/${model.jobID}`);
|
||||
const jobData = await response.json();
|
||||
|
||||
// Handle queued status
|
||||
if (jobData.queued) {
|
||||
this.jobProgress[model.jobID] = 0;
|
||||
// Keep processing state but don't show error
|
||||
continue;
|
||||
}
|
||||
|
||||
this.jobProgress[model.jobID] = jobData.progress || 0;
|
||||
|
||||
if (jobData.completed) {
|
||||
model.processing = false;
|
||||
model.installed = !jobData.deletion;
|
||||
delete this.jobProgress[model.jobID];
|
||||
// Show success notification
|
||||
const action = jobData.deletion ? 'deleted' : 'installed';
|
||||
this.addNotification(`Model "${model.name}" ${action} successfully!`, 'success');
|
||||
// Refresh the models list to get updated state
|
||||
this.fetchModels();
|
||||
}
|
||||
|
||||
if (jobData.error) {
|
||||
model.processing = false;
|
||||
delete this.jobProgress[model.jobID];
|
||||
const action = model.isDeletion ? 'deleting' : 'installing';
|
||||
this.addNotification(`Error ${action} model "${model.name}": ${jobData.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling job:', error);
|
||||
// Don't show notification for every polling error, only if model is stuck
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
openModal(model) {
|
||||
this.selectedModel = model;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.selectedModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the htmx:afterSwap event to handle cases when the search results are updated
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'search-results') {
|
||||
hidePagination();
|
||||
}
|
||||
});
|
||||
|
||||
// Add loading state animation
|
||||
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput && event.detail.elt === searchInput) {
|
||||
searchInput.classList.add('animate-pulse');
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput) {
|
||||
searchInput.classList.remove('animate-pulse');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
{{template "views/partials/head" .}}
|
||||
|
||||
<body class="bg-gradient-to-br from-gray-900 to-gray-950 text-gray-200">
|
||||
<div class="flex flex-col min-h-screen" x-data="{}">
|
||||
<div class="flex flex-col min-h-screen" x-data="p2pNetwork()">
|
||||
|
||||
{{template "views/partials/navbar" .}}
|
||||
|
||||
{{template "views/partials/inprogress" .}}
|
||||
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
<div class="workers mt-8">
|
||||
<!-- Hero Section with Network Animation -->
|
||||
@@ -122,7 +124,7 @@
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<code class="block bg-gray-900/80 text-yellow-300 p-4 rounded-lg break-words mb-4 border border-gray-700/50" @click="copyClipboard($el.textContent)">{{.P2PToken}}</code>
|
||||
<code class="block bg-gray-900/80 text-yellow-300 p-4 rounded-lg break-words mb-4 border border-gray-700/50 cursor-pointer hover:bg-gray-900" @click="copyClipboard($el.textContent.trim())">{{.P2PToken}}</code>
|
||||
<p class="text-gray-300">
|
||||
The network token can be used to either share the instance or join a federation or a worker network. Below you will find examples on how to start a new instance or a worker with this token.
|
||||
</p>
|
||||
@@ -159,7 +161,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold" hx-get="p2p/ui/workers-federation-stats" hx-trigger="every 1s"></div>
|
||||
<div class="text-2xl font-bold">
|
||||
<span :class="stats.federated.online > 0 ? 'text-green-400' : 'text-red-400'" x-text="stats.federated.online"></span>
|
||||
<span class="text-gray-300 text-xl">/<span x-text="stats.federated.total"></span></span>
|
||||
</div>
|
||||
<p class="text-blue-300 text-sm">nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +187,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold" hx-get="p2p/ui/workers-stats" hx-trigger="every 1s"></div>
|
||||
<div class="text-2xl font-bold">
|
||||
<span :class="stats.workers.online > 0 ? 'text-green-400' : 'text-red-400'" x-text="stats.workers.online"></span>
|
||||
<span class="text-gray-300 text-xl">/<span x-text="stats.workers.total"></span></span>
|
||||
</div>
|
||||
<p class="text-purple-300 text-sm">workers</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +238,10 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-400 mb-1">Active Nodes</div>
|
||||
<div class="text-3xl font-bold text-blue-400" hx-get="p2p/ui/workers-federation-stats" hx-trigger="every 1s"></div>
|
||||
<div class="text-3xl font-bold">
|
||||
<span :class="stats.federated.online > 0 ? 'text-blue-400' : 'text-red-400'" x-text="stats.federated.online"></span>
|
||||
<span class="text-gray-400 text-xl">/<span x-text="stats.federated.total"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,8 +253,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Federation Nodes Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" hx-get="p2p/ui/workers-federation" hx-trigger="every 1s">
|
||||
<!-- Nodes will be loaded here -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<template x-if="federationNodes.length === 0">
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
|
||||
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
|
||||
<p class="text-gray-400 text-lg font-medium">No nodes available</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="node in federationNodes" :key="node.id">
|
||||
<div :class="node.isOnline ? 'border-green-400/50 hover:shadow-green-500/20' : 'border-red-400/50 hover:shadow-red-500/20'"
|
||||
class="bg-gradient-to-br from-gray-800/90 to-gray-900/80 border rounded-xl p-5 shadow-xl transition-all duration-300 hover:border-opacity-100 backdrop-blur-sm">
|
||||
<!-- Header with node icon and status -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Node info -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-server text-blue-400 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-white font-semibold text-sm">Node</h4>
|
||||
<p class="text-gray-400 text-xs font-mono break-all" x-text="node.id"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status badge -->
|
||||
<div class="flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50">
|
||||
<i :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="fas fa-circle animate-pulse mr-2 text-xs"></i>
|
||||
<span :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="text-xs font-medium" x-text="node.isOnline ? 'Online' : 'Offline'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer with timestamp -->
|
||||
<div class="text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center">
|
||||
<i class="fas fa-clock mr-2"></i>
|
||||
<span x-text="'Updated: ' + new Date().toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -348,7 +394,10 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-400 mb-1">Active Workers</div>
|
||||
<div class="text-3xl font-bold text-purple-400" hx-get="p2p/ui/workers-stats" hx-trigger="every 1s"></div>
|
||||
<div class="text-3xl font-bold">
|
||||
<span :class="stats.workers.online > 0 ? 'text-purple-400' : 'text-red-400'" x-text="stats.workers.online"></span>
|
||||
<span class="text-gray-400 text-xl">/<span x-text="stats.workers.total"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,8 +409,43 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
|
||||
</div>
|
||||
|
||||
<!-- Workers Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" hx-get="p2p/ui/workers" hx-trigger="every 1s">
|
||||
<!-- Workers will be loaded here -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<template x-if="workerNodes.length === 0">
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center bg-gray-800/50 border border-gray-700/50 rounded-xl">
|
||||
<i class="fas fa-server text-gray-500 text-4xl mb-4"></i>
|
||||
<p class="text-gray-400 text-lg font-medium">No workers available</p>
|
||||
<p class="text-gray-500 text-sm mt-2">Start some workers to see them here</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="node in workerNodes" :key="node.id">
|
||||
<div :class="node.isOnline ? 'border-green-400/50 hover:shadow-green-500/20' : 'border-red-400/50 hover:shadow-red-500/20'"
|
||||
class="bg-gradient-to-br from-gray-800/90 to-gray-900/80 border rounded-xl p-5 shadow-xl transition-all duration-300 hover:border-opacity-100 backdrop-blur-sm">
|
||||
<!-- Header with node icon and status -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Node info -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-server text-purple-400 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-white font-semibold text-sm">Worker</h4>
|
||||
<p class="text-gray-400 text-xs font-mono break-all" x-text="node.id"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status badge -->
|
||||
<div class="flex items-center bg-gray-900/50 rounded-full px-3 py-1.5 border border-gray-700/50">
|
||||
<i :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="fas fa-circle animate-pulse mr-2 text-xs"></i>
|
||||
<span :class="node.isOnline ? 'text-green-400' : 'text-red-400'" class="text-xs font-medium" x-text="node.isOnline ? 'Online' : 'Offline'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer with timestamp -->
|
||||
<div class="text-xs text-gray-500 pt-3 border-t border-gray-700/30 flex items-center">
|
||||
<i class="fas fa-clock mr-2"></i>
|
||||
<span x-text="'Updated: ' + new Date().toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -489,95 +573,56 @@ docker run -ti --net host -e TOKEN="<span class="token">{{.P2PToken}}</span>" --
|
||||
transform: rotate(-5deg) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* Copy button enhancements */
|
||||
.copy-icon:hover, button:hover .fa-copy {
|
||||
color: #60a5fa;
|
||||
transform: scale(1.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Node card hover effects */
|
||||
.workers .grid > div {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.workers .grid > div:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator animations */
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced tab styling */
|
||||
.tablink {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tablink::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.tablink:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Loading spinner for HTMX */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Card gradient overlays */
|
||||
.card-overlay {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(99, 102, 241, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* Enhanced button styles */
|
||||
button[onclick*="copyClipboard"] {
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
button[onclick*="copyClipboard"]:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Code block enhancements */
|
||||
code {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
code:hover {
|
||||
box-shadow: 0 4px 12px rgba(234, 179, 8, 0.2);
|
||||
border-color: rgba(234, 179, 8, 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function p2pNetwork() {
|
||||
return {
|
||||
workerNodes: [],
|
||||
federationNodes: [],
|
||||
stats: {
|
||||
workers: { online: 0, total: 0 },
|
||||
federated: { online: 0, total: 0 }
|
||||
},
|
||||
|
||||
init() {
|
||||
this.fetchNodes();
|
||||
this.fetchStats();
|
||||
// Poll every 1 second
|
||||
setInterval(() => {
|
||||
this.fetchNodes();
|
||||
this.fetchStats();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
async fetchNodes() {
|
||||
try {
|
||||
// Fetch worker nodes
|
||||
const workersResponse = await fetch('/api/p2p/workers');
|
||||
const workersData = await workersResponse.json();
|
||||
this.workerNodes = workersData.nodes || [];
|
||||
|
||||
// Fetch federation nodes
|
||||
const federationResponse = await fetch('/api/p2p/federation');
|
||||
const federationData = await federationResponse.json();
|
||||
this.federationNodes = federationData.nodes || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching P2P nodes:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const response = await fetch('/api/p2p/stats');
|
||||
const data = await response.json();
|
||||
this.stats = data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching P2P stats:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -30,14 +30,46 @@
|
||||
},
|
||||
};
|
||||
function copyClipboard(token) {
|
||||
navigator.clipboard.writeText(token)
|
||||
.then(() => {
|
||||
console.log('Text copied to clipboard:', token);
|
||||
alert('Text copied to clipboard!');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy token:', err);
|
||||
});
|
||||
// Try modern Clipboard API first (requires secure context)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(token)
|
||||
.then(() => {
|
||||
console.log('Text copied to clipboard:', token);
|
||||
alert('Text copied to clipboard!');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy token:', err);
|
||||
fallbackCopy(token);
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-secure contexts
|
||||
fallbackCopy(token);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
console.log('Text copied to clipboard (fallback):', text);
|
||||
alert('Text copied to clipboard!');
|
||||
} else {
|
||||
console.error('Fallback copy failed');
|
||||
alert('Failed to copy text. Please copy manually.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy error:', err);
|
||||
alert('Failed to copy text. Please copy manually.');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -45,7 +77,6 @@
|
||||
<link href="static/assets/fontawesome/css/brands.css" rel="stylesheet" />
|
||||
<link href="static/assets/fontawesome/css/solid.css" rel="stylesheet" />
|
||||
<script src="static/assets/flowbite.min.js"></script>
|
||||
<script src="static/assets/htmx.js" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Example responsive styling improvements -->
|
||||
<style>
|
||||
@@ -119,11 +150,4 @@
|
||||
100% { left: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Initialize Flowbite on HTMX content load -->
|
||||
<script>
|
||||
htmx.onLoad(function(content) {
|
||||
initFlowbite();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
@@ -1,32 +1,142 @@
|
||||
<!-- Show in progress operations-->
|
||||
{{ if .ProcessingModels }}
|
||||
<h2
|
||||
class="mt-4 mb-4 text-center text-3xl font-semibold text-gray-100">Operations in progress</h2>
|
||||
{{end}}
|
||||
{{$taskType:=.TaskTypes}}
|
||||
|
||||
{{ range $key,$value:=.ProcessingModels }}
|
||||
{{ $op := index $taskType $key}}
|
||||
{{$parts := split "@" $key}}
|
||||
{{$modelName:=$parts._1}}
|
||||
{{$repository:=$parts._0}}
|
||||
{{ if not (contains "@" $key)}}
|
||||
{{$repository = ""}}
|
||||
{{$modelName = $key}}
|
||||
{{ end }}
|
||||
<!-- Global Operations Status Bar -->
|
||||
<div x-data="operationsStatus()" x-init="init()" x-show="operations.length > 0"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-4"
|
||||
class="sticky top-0 left-0 right-0 z-40 bg-gradient-to-r from-blue-900/95 to-purple-900/95 backdrop-blur-sm shadow-lg border-b border-blue-700/50">
|
||||
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative">
|
||||
<i class="fas fa-spinner fa-spin text-blue-300 text-lg"></i>
|
||||
</div>
|
||||
<h3 class="text-white font-semibold text-sm">
|
||||
Operations in Progress
|
||||
<span class="ml-2 bg-blue-700/50 px-2 py-1 rounded-full text-xs" x-text="operations.length"></span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="collapsed = !collapsed"
|
||||
class="text-white/80 hover:text-white transition-colors">
|
||||
<i class="fas" :class="collapsed ? 'fa-chevron-down' : 'fa-chevron-up'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-slate-600 p-2 mb-2 rounded-md">
|
||||
<div class="flex items center">
|
||||
<span class="text-gray-300"><a href="browse?term={{$parts._1}}"
|
||||
class="text-white-500 inline-block bg-blue-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2 hover:bg-gray-300 hover:shadow-gray-2"
|
||||
>{{$modelName}}</a> {{if $repository}} (from the '{{$repository}}' repository) {{end}}</span>
|
||||
</div>
|
||||
<div hx-get="browse/job/{{$value}}" hx-swap="outerHTML" hx-target="this" hx-trigger="done">
|
||||
<h3 role="status" id="pblabel" >{{$op}}
|
||||
<div hx-get="browse/job/progress/{{$value}}" hx-trigger="every 600ms"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML" ></div></h3>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<!-- END Show in progress operations-->
|
||||
<!-- Operations List -->
|
||||
<div x-show="!collapsed"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-96"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 max-h-96"
|
||||
x-transition:leave-end="opacity-0 max-h-0"
|
||||
class="space-y-2 overflow-y-auto max-h-96">
|
||||
<template x-for="operation in operations" :key="operation.id">
|
||||
<div class="bg-gray-800/60 rounded-lg p-3 border border-gray-700/50 hover:border-blue-600/50 transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<!-- Icon based on type -->
|
||||
<div class="flex-shrink-0">
|
||||
<i class="text-lg"
|
||||
:class="{
|
||||
'fas fa-cube text-blue-400': !operation.isBackend && !operation.isDeletion,
|
||||
'fas fa-cubes text-purple-400': operation.isBackend && !operation.isDeletion,
|
||||
'fas fa-trash text-red-400': operation.isDeletion
|
||||
}"></i>
|
||||
</div>
|
||||
|
||||
<!-- Operation details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white font-medium text-sm truncate" x-text="operation.name"></span>
|
||||
<span class="flex-shrink-0 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-blue-700/50 text-blue-200': !operation.isDeletion && !operation.isBackend,
|
||||
'bg-purple-700/50 text-purple-200': !operation.isDeletion && operation.isBackend,
|
||||
'bg-red-700/50 text-red-200': operation.isDeletion
|
||||
}"
|
||||
x-text="operation.isBackend ? 'Backend' : 'Model'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<template x-if="operation.isQueued">
|
||||
<span class="text-xs text-blue-300 flex items-center">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
Queued
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!operation.isQueued && operation.message">
|
||||
<span class="text-xs text-gray-300 truncate" x-text="operation.message"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress percentage -->
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<span class="text-white font-bold text-lg" x-text="operation.progress + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-gray-700/50 rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300 ease-out"
|
||||
:class="{
|
||||
'bg-gradient-to-r from-blue-500 to-blue-600': !operation.isDeletion,
|
||||
'bg-gradient-to-r from-red-500 to-red-600': operation.isDeletion
|
||||
}"
|
||||
:style="'width: ' + operation.progress + '%'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function operationsStatus() {
|
||||
return {
|
||||
operations: [],
|
||||
collapsed: false,
|
||||
pollInterval: null,
|
||||
|
||||
init() {
|
||||
this.fetchOperations();
|
||||
// Poll every 500ms for smooth updates
|
||||
this.pollInterval = setInterval(() => this.fetchOperations(), 500);
|
||||
},
|
||||
|
||||
async fetchOperations() {
|
||||
try {
|
||||
const response = await fetch('/api/operations');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch operations');
|
||||
}
|
||||
const data = await response.json();
|
||||
this.operations = data.operations || [];
|
||||
|
||||
// Auto-collapse if there are many operations
|
||||
if (this.operations.length > 5 && !this.collapsed) {
|
||||
// Don't auto-collapse, let user control it
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching operations:', error);
|
||||
// Don't clear operations on error, just keep showing last known state
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user