Spaces:
Configuration error
Configuration error
| package elements | |
| import ( | |
| "fmt" | |
| "strings" | |
| "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/p2p" | |
| "github.com/mudler/LocalAI/core/services" | |
| ) | |
| const ( | |
| noImage = "https://upload.wikimedia.org/wikipedia/commons/6/65/No-Image-Placeholder.svg" | |
| ) | |
| func renderElements(n []elem.Node) string { | |
| render := "" | |
| for _, r := range n { | |
| render += r.Render() | |
| } | |
| return render | |
| } | |
| func DoneProgress(galleryID, text string, showDelete bool) string { | |
| var modelName = galleryID | |
| // Split by @ and grab the name | |
| if strings.Contains(galleryID, "@") { | |
| modelName = strings.Split(galleryID, "@")[1] | |
| } | |
| 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, modelName), 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 P2PNodeStats(nodes []p2p.NodeData) string { | |
| /* | |
| <div class="bg-gray-800 p-6 rounded-lg shadow-lg text-left"> | |
| <p class="text-xl font-semibold text-gray-200">Total Workers Detected: {{ len .Nodes }}</p> | |
| {{ $online := 0 }} | |
| {{ range .Nodes }} | |
| {{ if .IsOnline }} | |
| {{ $online = add $online 1 }} | |
| {{ end }} | |
| {{ end }} | |
| <p class="text-xl font-semibold text-gray-200">Total Online Workers: {{$online}}</p> | |
| </div> | |
| */ | |
| online := 0 | |
| for _, n := range nodes { | |
| if n.IsOnline() { | |
| online++ | |
| } | |
| } | |
| class := "text-green-500" | |
| if online == 0 { | |
| class = "text-red-500" | |
| } | |
| /* | |
| <i class="fas fa-circle animate-pulse text-green-500 ml-2 mr-1"></i> | |
| */ | |
| circle := elem.I(attrs.Props{ | |
| "class": "fas fa-circle animate-pulse " + class + " ml-2 mr-1", | |
| }) | |
| nodesElements := []elem.Node{ | |
| elem.Span( | |
| attrs.Props{ | |
| "class": class, | |
| }, | |
| circle, | |
| elem.Text(fmt.Sprintf("%d", online)), | |
| ), | |
| elem.Span( | |
| attrs.Props{ | |
| "class": "text-gray-200", | |
| }, | |
| elem.Text(fmt.Sprintf("/%d", len(nodes))), | |
| ), | |
| } | |
| return renderElements(nodesElements) | |
| } | |
| func P2PNodeBoxes(nodes []p2p.NodeData) string { | |
| /* | |
| <div class="bg-gray-800 p-4 rounded-lg shadow-lg text-left"> | |
| <div class="flex items-center mb-2"> | |
| <i class="fas fa-desktop text-gray-400 mr-2"></i> | |
| <span class="text-gray-200 font-semibold">{{.ID}}</span> | |
| </div> | |
| <p class="text-sm text-gray-400 mt-2 flex items-center"> | |
| Status: | |
| <i class="fas fa-circle {{ if .IsOnline }}text-green-500{{ else }}text-red-500{{ end }} ml-2 mr-1"></i> | |
| <span class="{{ if .IsOnline }}text-green-400{{ else }}text-red-400{{ end }}"> | |
| {{ if .IsOnline }}Online{{ else }}Offline{{ end }} | |
| </span> | |
| </p> | |
| </div> | |
| */ | |
| nodesElements := []elem.Node{} | |
| for _, n := range nodes { | |
| nodesElements = append(nodesElements, | |
| elem.Div( | |
| attrs.Props{ | |
| "class": "bg-gray-700 p-6 rounded-lg shadow-lg text-left", | |
| }, | |
| elem.P( | |
| attrs.Props{ | |
| "class": "text-sm text-gray-400 mt-2 flex", | |
| }, | |
| elem.I( | |
| attrs.Props{ | |
| "class": "fas fa-desktop text-gray-400 mr-2", | |
| }, | |
| ), | |
| elem.Text("Name: "), | |
| elem.Span( | |
| attrs.Props{ | |
| "class": "text-gray-200 font-semibold ml-2 mr-1", | |
| }, | |
| elem.Text(bluemonday.StrictPolicy().Sanitize(n.ID)), | |
| ), | |
| elem.Text("Status: "), | |
| elem.If( | |
| n.IsOnline(), | |
| elem.I( | |
| attrs.Props{ | |
| "class": "fas fa-circle animate-pulse text-green-500 ml-2 mr-1", | |
| }, | |
| ), | |
| elem.I( | |
| attrs.Props{ | |
| "class": "fas fa-circle animate-pulse text-red-500 ml-2 mr-1", | |
| }, | |
| ), | |
| ), | |
| elem.If( | |
| n.IsOnline(), | |
| elem.Span( | |
| attrs.Props{ | |
| "class": "text-green-400", | |
| }, | |
| elem.Text("Online"), | |
| ), | |
| elem.Span( | |
| attrs.Props{ | |
| "class": "text-red-400", | |
| }, | |
| elem.Text("Offline"), | |
| ), | |
| ), | |
| ), | |
| )) | |
| } | |
| return renderElements(nodesElements) | |
| } | |
| func StartProgressBar(uid, progress, text string) string { | |
| if progress == "" { | |
| progress = "0" | |
| } | |
| return elem.Div( | |
| attrs.Props{ | |
| "hx-trigger": "done", | |
| "hx-get": "/browse/job/" + 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": "/browse/job/progress/" + uid, | |
| "hx-trigger": "every 600ms", | |
| "hx-target": "this", | |
| "hx-swap": "innerHTML", | |
| }, | |
| elem.Raw(ProgressBar(progress)), | |
| ), | |
| ), | |
| ).Render() | |
| } | |
| func cardSpan(text, icon string) elem.Node { | |
| return elem.Span( | |
| 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", | |
| }, | |
| 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-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", | |
| }, | |
| 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": "#!", | |
| "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 link(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 installButton(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 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-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 deleteButton(galleryID, modelName 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, "@", "__") | |
| } | |
| type ProcessTracker interface { | |
| Exists(string) bool | |
| Get(string) string | |
| } | |
| func ListModels(models []*gallery.GalleryModel, processTracker ProcessTracker, galleryService *services.GalleryService) string { | |
| modelsElements := []elem.Node{} | |
| descriptionDiv := func(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.P( | |
| attrs.Props{ | |
| "class": "mb-4 text-sm [&:not(:hover)]:truncate text-base", | |
| }, | |
| elem.Text(bluemonday.StrictPolicy().Sanitize(m.Description)), | |
| ), | |
| ) | |
| } | |
| actionDiv := func(m *gallery.GalleryModel) 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, | |
| link("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), | |
| }, | |
| elem.If( | |
| currentlyProcessing, | |
| elem.Node( // If currently installing, show progress bar | |
| elem.Raw(StartProgressBar(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(), m.Name), | |
| )), | |
| installButton(m.ID()), | |
| ), | |
| ), | |
| ), | |
| ) | |
| } | |
| 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, descriptionDiv(m), actionDiv(m)) | |
| 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", | |
| }, | |
| elem.Div( | |
| attrs.Props{ | |
| // "class": "p-6", | |
| }, | |
| elems..., | |
| ), | |
| ), | |
| ) | |
| } | |
| 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() | |
| } | |