flux-schnell and triposr come with llmgw as presets: their workflow, custom nodes, packages and licence as they were found and tried on the PoC, and a profile that says the card it needs rather than a class, so the entry places on any site's cards. TripoSR's workflow is built in, with the LLMGWPrepareForMesh node that keeps a picture's background out of the mesh, which the copy in the document had left out. `ctl model add PRESET` adds one, and has the nodes install ComfyUI when the cluster's settings name none; `ctl model presets` lists them; the console's add-model dialog offers them beside a definition file. A ComfyUI build is now also checked to keep its order on a Turing card, so a node's installed cu13 is not replaced by the cu126 build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
9.9 KiB
Go
291 lines
9.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
|
)
|
|
|
|
// The mirror, as the control plane reaches it: its management API, with the
|
|
// address and certificate server.yaml gives under mirror.
|
|
|
|
type consoleMirrorMsg struct {
|
|
res mirror.ModelsResponse
|
|
err error
|
|
}
|
|
|
|
func (m consoleModel) mirrorClient() (*mirror.Client, error) {
|
|
cfg, err := config.Load(m.file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.Mirror.API == "" {
|
|
return nil, errors.New("no mirror: mirror.api in server.yaml is empty")
|
|
}
|
|
return mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Second)
|
|
}
|
|
|
|
func (m consoleModel) fetchMirror() tea.Cmd {
|
|
return func() tea.Msg {
|
|
c, err := m.mirrorClient()
|
|
if err != nil {
|
|
return consoleMirrorMsg{err: err}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
res, err := c.List(ctx)
|
|
return consoleMirrorMsg{res, err}
|
|
}
|
|
}
|
|
|
|
// mirrorCall runs one request to the mirror in the background and reports it.
|
|
func (m *consoleModel) mirrorCall(title string, do func(context.Context, *mirror.Client) (string, error)) tea.Cmd {
|
|
m.busy = title
|
|
mm := *m
|
|
return func() tea.Msg {
|
|
c, err := mm.mirrorClient()
|
|
if err != nil {
|
|
return actionDoneMsg{title: title, err: err}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
text, err := do(ctx, c)
|
|
return actionDoneMsg{title: title, text: text, err: err}
|
|
}
|
|
}
|
|
|
|
// mirrorGroup is one version of one model on the mirror: a file, or the set
|
|
// of files a model of several is fetched as.
|
|
type mirrorGroup struct {
|
|
model, version string
|
|
files []mirror.Status
|
|
}
|
|
|
|
func (g mirrorGroup) bytes() (done, total int64) {
|
|
for _, f := range g.files {
|
|
done += f.BytesDone
|
|
total += max(f.BytesTotal, f.BytesDone)
|
|
}
|
|
return done, total
|
|
}
|
|
|
|
// source is what a model's definition names for it: the file, or the
|
|
// directory of a set.
|
|
func (g mirrorGroup) source(base string) string {
|
|
if len(g.files) == 1 {
|
|
return mirror.Entry{Model: g.model, Version: g.version, File: g.files[0].File}.URL(base)
|
|
}
|
|
return strings.TrimSuffix(mirror.Entry{Model: g.model, Version: g.version}.URL(base), "/") + "/"
|
|
}
|
|
|
|
func (m consoleModel) mirrorGroups() []mirrorGroup {
|
|
var out []mirrorGroup
|
|
at := map[string]int{}
|
|
for _, st := range m.mirror.res.Models {
|
|
k := st.Model + "/" + st.Version
|
|
i, ok := at[k]
|
|
if !ok {
|
|
i = len(out)
|
|
at[k] = i
|
|
out = append(out, mirrorGroup{model: st.Model, version: st.Version})
|
|
}
|
|
out[i].files = append(out[i].files, st)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// usedBy names the catalog's models that run from a version's files.
|
|
func (m consoleModel) usedBy(base string, g mirrorGroup) []string {
|
|
dir := strings.TrimSuffix(mirror.Entry{Model: g.model, Version: g.version}.URL(base), "/") + "/"
|
|
var names []string
|
|
for _, md := range m.data.models {
|
|
if strings.HasPrefix(md.Source, dir) {
|
|
names = append(names, md.Name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (m consoleModel) mirrorMenu(g mirrorGroup) *menuDialog {
|
|
what := g.model + "/" + g.version
|
|
used := m.usedBy(m.mirror.res.Base, g)
|
|
items := []menuItem{{"Its files, and where nodes fetch them", func(m *consoleModel) tea.Cmd {
|
|
var b strings.Builder
|
|
for _, f := range g.files {
|
|
sum := ""
|
|
if f.SHA256 != "" {
|
|
sum = sDim.Render(" sha256 " + f.SHA256[:min(12, len(f.SHA256))])
|
|
}
|
|
fmt.Fprintf(&b, "%-50s %9s%s\n", truncate(f.File, 50), bytesShort(max(f.BytesTotal, f.BytesDone)), sum)
|
|
if f.Source != "" {
|
|
b.WriteString(sDim.Render(" from "+truncate(f.Source, 90)) + "\n")
|
|
}
|
|
}
|
|
m.dialog, m.dialogTitle = "error", what
|
|
m.dialogText = b.String() + "\nA model's source: " + g.source(m.mirror.res.Base) + "\n\n" + sDim.Render("any key")
|
|
return nil
|
|
}}}
|
|
items = append(items, menuItem{"Remove from the mirror…", func(m *consoleModel) tea.Cmd {
|
|
text := "Every file of this version is deleted from the mirror."
|
|
if len(used) > 0 {
|
|
text += "\n\n" + sWarn.Render("! ") + "The catalog uses it: " + strings.Join(used, ", ") +
|
|
".\nNodes that do not hold it already cannot start them."
|
|
}
|
|
m.confirmD = &confirmDialog{title: "Remove " + what, text: text,
|
|
yes: func(m *consoleModel) tea.Cmd {
|
|
return m.mirrorCall("Remove "+what, func(ctx context.Context, c *mirror.Client) (string, error) {
|
|
removed, err := c.Remove(ctx, g.model, g.version)
|
|
return fmt.Sprintf("Removed %d file(s).", len(removed)), err
|
|
})
|
|
}}
|
|
return nil
|
|
}})
|
|
return &menuDialog{title: what, items: items}
|
|
}
|
|
|
|
func (m *consoleModel) mirrorFetchForm() {
|
|
m.openForm(&formDialog{title: "Download into the mirror",
|
|
note: "The mirror downloads it once; nodes then fetch it from the mirror.",
|
|
fields: []formField{
|
|
textF("from", "", "an https:// URL, like a Hugging Face resolve link"),
|
|
textF("model", "", "one directory level, like qwen2.5-7b"),
|
|
textF("version", time.Now().Format("2006-01-02"), "one directory level; a date works well"),
|
|
textF("file", "", "optional: taken from the URL"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if !strings.HasPrefix(v[0], "http://") && !strings.HasPrefix(v[0], "https://") {
|
|
return nil, fmt.Errorf("from is a URL")
|
|
}
|
|
if v[1] == "" || v[2] == "" {
|
|
return nil, fmt.Errorf("a model and a version")
|
|
}
|
|
req := mirror.FetchRequest{Source: v[0], Model: v[1], Version: v[2], File: v[3]}
|
|
return m.mirrorCall("Download "+v[1], func(ctx context.Context, c *mirror.Client) (string, error) {
|
|
r, err := c.Fetch(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "Downloading. Once it is here, a model's source is\n" + r.URL, nil
|
|
}), nil
|
|
}})
|
|
}
|
|
|
|
// mirrorImportOllamaForm puts a model from an Ollama on this machine into a
|
|
// mirror on this machine: the control plane's own, when the mirror runs
|
|
// beside it. A mirror elsewhere cannot read this machine's files, and its
|
|
// API is given none; there it is `llmgw mirror import-ollama`, on the mirror.
|
|
func (m *consoleModel) mirrorImportOllamaForm() {
|
|
m.openForm(&formDialog{title: "Import from Ollama",
|
|
note: "From an Ollama on this machine into the mirror's directory on this machine.\n" +
|
|
"A mirror elsewhere: run llmgw mirror import-ollama there.",
|
|
fields: []formField{
|
|
textF("model", "", "as Ollama names it, like llama3.2:3b"),
|
|
textF("ollama dir", "", "optional: found where Ollama keeps it"),
|
|
textF("mirror dir", "/srv/models", "the mirror's directory on this machine"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] == "" {
|
|
return nil, fmt.Errorf("a model, as Ollama names it")
|
|
}
|
|
if st, err := os.Stat(v[2]); err != nil || !st.IsDir() {
|
|
return nil, fmt.Errorf("%s is not a directory on this machine", v[2])
|
|
}
|
|
title := "Import " + v[0]
|
|
m.busy = title
|
|
o := ollamaImport{name: v[0], ollamaDir: v[1], mirrorDir: v[2], base: m.mirror.res.Base, quiet: true}
|
|
return func() tea.Msg {
|
|
var out bytes.Buffer
|
|
err := o.run(context.Background(), &out)
|
|
return actionDoneMsg{title: title, text: strings.TrimSpace(out.String()), err: err}
|
|
}, nil
|
|
}})
|
|
}
|
|
|
|
func (m consoleModel) mirrorPanel(pw int) (string, string, string) {
|
|
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
|
if m.inPanel {
|
|
keys = "↑↓ row · Enter actions · A download one · I import from Ollama · ← back · Q leave"
|
|
}
|
|
if m.mirror.err != nil {
|
|
return "Mirror", "\n " + sBad.Render(m.mirror.err.Error()), keys
|
|
}
|
|
head := "\n " + sDim.Render("Nodes fetch from "+m.mirror.res.Base) + "\n\n"
|
|
var rows [][]string
|
|
for _, g := range m.mirrorGroups() {
|
|
done, total := g.bytes()
|
|
state := "present"
|
|
for _, f := range g.files {
|
|
switch f.State {
|
|
case mirror.Fetching:
|
|
state = sWarn.Render("fetching")
|
|
if total > 0 {
|
|
state = sWarn.Render(fmt.Sprintf("fetching %d%%", done*100/total))
|
|
}
|
|
case mirror.Failed:
|
|
state = sBad.Render("failed")
|
|
}
|
|
if f.State != mirror.Present {
|
|
break
|
|
}
|
|
}
|
|
what := g.files[0].File
|
|
if len(g.files) > 1 {
|
|
what = fmt.Sprintf("%d files", len(g.files))
|
|
}
|
|
used := strings.Join(m.usedBy(m.mirror.res.Base, g), ", ")
|
|
if used == "" {
|
|
used = sDim.Render("unused")
|
|
}
|
|
rows = append(rows, []string{g.model, g.version, what, state, bytesShort(total), used})
|
|
}
|
|
if len(rows) == 0 {
|
|
return "Mirror", head + " " + sDim.Render("Empty. A downloads one; I imports one from Ollama."), keys
|
|
}
|
|
return "Mirror", head + table([]string{"MODEL", "VERSION", "FILE", "STATE", "SIZE", "USED BY"}, rows, m.row, pw, m.inPanel), keys
|
|
}
|
|
|
|
// fromFile is the add-model choice that reads a definition file rather
|
|
// than naming a preset.
|
|
const fromFile = "from a file"
|
|
|
|
// addModelForm adds a model that comes with llmgw, or one from a definition
|
|
// file on this machine: the same YAML a model takes in server.yaml.
|
|
func (m *consoleModel) addModelForm() {
|
|
server := "--server=" + m.client.base
|
|
var note strings.Builder
|
|
note.WriteString("A model that comes with llmgw, tried and with its licence:\n")
|
|
for _, p := range catalog.Presets() {
|
|
fmt.Fprintf(¬e, " %s: %s, %s, %s\n", p.Model.Name, p.Makes, presetCard(p.Model), presetLicence(p.Model))
|
|
}
|
|
note.WriteString("A ComfyUI one has the nodes install ComfyUI if the cluster has none.\n" +
|
|
"Or a definition file: the YAML a model takes in server.yaml's models.")
|
|
m.openForm(&formDialog{title: "Add a model",
|
|
note: note.String(),
|
|
fields: []formField{
|
|
choiceF("model", append(catalog.PresetNames(), fromFile), catalog.PresetNames()[0]),
|
|
textF("file", "", "for "+fromFile+": like /root/qwen.yaml"),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if v[0] != fromFile {
|
|
return m.runCLI("Add "+v[0], "ctl", server, "model", "add", v[0]), nil
|
|
}
|
|
if v[1] == "" {
|
|
return nil, fmt.Errorf("a file, or a model that comes with llmgw")
|
|
}
|
|
if _, err := os.Stat(v[1]); err != nil {
|
|
return nil, err
|
|
}
|
|
return m.runCLI("Add a model", "ctl", server, "model", "add", "-f", v[1]), nil
|
|
}})
|
|
}
|