Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create a new model from an exiting model #14

Merged
merged 3 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ vendor/
node_modules/
.idea/
.vscode/
Modelfile*
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ gollama
### Key Bindings

- `Space`: Select
- `Enter`: Run model (Ollama run)
- `i`: Inspect model
- `t`: Top (show running models)
- `r`: Run model (Ollama run)
- `D`: Delete model
- `c`: Copy model
- `u`: Update model
- `P`: Push model
- `n`: Sort by name
- `s`: Sort by size
Expand All @@ -85,7 +86,6 @@ gollama
- `l`: Link model to LM Studio
- `L`: Link all models to LM Studio
- `q`: Quit
- `?`: Help

#### Command-line Options

Expand Down
78 changes: 77 additions & 1 deletion app_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.view == TopView || m.inspecting {
m.view = MainView
m.inspecting = false
m.editing = false
return m, nil
} else {
return m, tea.Quit
}
}

if m.view == TopView {
break
}

switch {

case key.Matches(msg, m.keys.Space):
if item, ok := m.list.SelectedItem().(Model); ok {
logging.DebugLogger.Printf("Toggling selection for model: %s (before: %v)\n", item.Name, item.Selected)
Expand All @@ -79,6 +80,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.confirmDeletion = true
}

// case key.Matches(msg, m.keys.Help):
// // TODO: fix this
// help := m.printFullHelp()
// lipgloss.NewStyle().Foreground(lipgloss.Color("129")).Render(help)
// m.message = help
// return m, nil

case key.Matches(msg, m.keys.Top):
m.view = TopView
return m, nil
Expand Down Expand Up @@ -143,6 +151,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.inspecting {
return m.clearScreen(), tea.ClearScreen
}
case key.Matches(msg, m.keys.UpdateModel):
if item, ok := m.list.SelectedItem().(Model); ok {
newModelName := promptForNewName(item.Name)
m.editing = true
if newModelName == "" {
m.message = "Error: name can't be empty"
return m, nil
}

modelfilePath, err := copyModelfile(item.Name, newModelName)
if err != nil {
m.message = fmt.Sprintf("Error copying modelfile: %v", err)
return m, nil
}

return m, openEditor(modelfilePath)
}
case key.Matches(msg, m.keys.LinkModel):
if item, ok := m.list.SelectedItem().(Model); ok {
message, err := linkModel(item.Name, m.lmStudioModelsDir, m.noCleanup)
Expand Down Expand Up @@ -209,6 +234,34 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
return progressMsg{modelName: msg.modelName}
})
case editorFinishedMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error editing modelfile: %v", msg.err)
return m, nil
}
if item, ok := m.list.SelectedItem().(Model); ok {
newModelName := promptForNewName(item.Name)
modelfilePath := fmt.Sprintf("Modelfile-%s", strings.ReplaceAll(newModelName, " ", "_"))
err := createModelFromModelfile(newModelName, modelfilePath)
if err != nil {
m.message = fmt.Sprintf("Error creating model: %v", err)
return m, nil
}
m.message = fmt.Sprintf("Model %s created successfully", newModelName)
}

if item, ok := m.list.SelectedItem().(Model); ok {
newModelName := promptForNewName(item.Name)
modelfilePath := fmt.Sprintf("Modelfile-%s", strings.ReplaceAll(newModelName, " ", "_"))
err := createModelFromModelfile(newModelName, modelfilePath)
if err != nil {
m.message = fmt.Sprintf("Error creating model: %v", err)
return m, nil
}
m.message = fmt.Sprintf("Model %s created successfully", newModelName)
}
m.list, cmd = m.list.Update(msg)
return m, cmd

case pushSuccessMsg:
m.message = fmt.Sprintf("Successfully pushed model: %s\n", msg.modelName)
Expand Down Expand Up @@ -348,6 +401,8 @@ func (m *AppModel) refreshList() {

func (m *AppModel) clearScreen() tea.Model {
m.inspecting = false
m.editing = false
m.showProgress = false
m.table = table.New()
return m
}
Expand Down Expand Up @@ -395,3 +450,24 @@ func (m *AppModel) topView() string {
// Render the table view
return "\n" + t.View() + "\nPress 'q' or `esc` to return to the main view."
}

// FullHelp returns keybindings for the expanded help view. It's part of the
// key.Map interface.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Space, k.Delete, k.RunModel, k.LinkModel, k.LinkAllModels, k.CopyModel, k.PushModel}, // first column
{k.SortByName, k.SortBySize, k.SortByModified, k.SortByQuant, k.SortByFamily}, // second column
{k.Top, k.UpdateModel, k.InspectModel, k.Quit}, // third column
}
}

// a function that can be called from the man app_model.go file with a hotkey to print the FullHelp as a string
func (m *AppModel) printFullHelp() string {
help := lipgloss.NewStyle().Foreground(lipgloss.Color("129")).Render("Help")
for _, column := range m.keys.FullHelp() {
for _, key := range column {
help += fmt.Sprintf(" %s: %s\n", key.Help().Key, key.Help().Desc)
}
}
return help
}
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Config struct {
SortOrder string `json:"sort_order"` // Current sort order
LastSortSelection string `json:"-"` // Temporary field to hold the last sort selection
StripString string `json:"strip_string"` // Optional string to strip from model names in the TUI (e.g. a private registry URL)
Editor string `json:"editor"`
// ShowTopOnLaunch bool `json:"show_top_on_launch"` // New field to set if 'top' should be shown on launch
}

Expand All @@ -33,6 +34,7 @@ var defaultConfig = Config{
LogFilePath: os.Getenv("HOME") + "/.config/gollama/gollama.log",
SortOrder: "modified", // Default sort order
StripString: "",
Editor: "vim",
// ShowTopOnLaunch: false, // Default to not showing 'top' on launch
}

Expand Down
24 changes: 21 additions & 3 deletions keymap.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import "github.com/charmbracelet/bubbles/key"
import (
"github.com/charmbracelet/bubbles/key"
)

type KeyMap struct {
Space key.Binding
Expand All @@ -22,15 +24,29 @@ type KeyMap struct {
PushModel key.Binding
Top key.Binding
AltScreen key.Binding
UpdateModel key.Binding
Help key.Binding
SortOrder string
}

func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}

// func newModel() model {
// return model{
// keys: *NewKeyMap(),
// help: help.New(),
// inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7")),
// }
// }

func NewKeyMap() *KeyMap {
return &KeyMap{
Space: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "select")),
InspectModel: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "inspect")),
Top: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "top")),
RunModel: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "run")),
RunModel: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "run")),
Delete: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "delete")),
CopyModel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
PushModel: key.NewBinding(key.WithKeys("P"), key.WithHelp("P", "push")),
Expand All @@ -40,12 +56,14 @@ func NewKeyMap() *KeyMap {
SortByQuant: key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "^quant")),
SortByFamily: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "^family")),
LinkModel: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "link (L=all)")),
LinkAllModels: key.NewBinding(key.WithKeys("L")),
UpdateModel: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update model")),
LinkAllModels: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "link all")),
ConfirmYes: key.NewBinding(key.WithKeys("y")),
ConfirmNo: key.NewBinding(key.WithKeys("n")),
ClearScreen: key.NewBinding(key.WithKeys("c")),
Quit: key.NewBinding(key.WithKeys("q")),
AltScreen: key.NewBinding(key.WithKeys("a")),
Help: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "help")),
}
}

Expand Down
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type AppModel struct {
selectedForDeletion []Model
confirmDeletion bool
inspecting bool
editing bool
message string
keys KeyMap
client *api.Client
Expand Down Expand Up @@ -88,6 +89,9 @@ func main() {
os.Exit(0)
}

os.Setenv("EDITOR", cfg.Editor)
logging.DebugLogger.Println("EDITOR set to", cfg.Editor)

client, err := api.ClientFromEnvironment()
if err != nil {
logging.ErrorLogger.Println("Error creating API client:", err)
Expand Down Expand Up @@ -198,6 +202,7 @@ func main() {
keys.CopyModel,
keys.PushModel,
keys.Top,
keys.UpdateModel,
}
}

Expand Down
48 changes: 48 additions & 0 deletions operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ func isValidSymlink(symlinkPath, targetPath string) bool {
// Check if the symlink target is a file (not a directory or another symlink)
fileInfo, err := os.Lstat(targetPath)
if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || fileInfo.IsDir() {
logging.DebugLogger.Printf("Symlink target is not a file: %s\n", targetPath)
return false
}

Expand Down Expand Up @@ -333,7 +334,54 @@ func showRunningModels(client *api.Client) ([]table.Row, error) {
until := model.ExpiresAt.Format("2006-01-02 15:04:05")

runningModels = append(runningModels, table.Row{name, fmt.Sprintf("%.2f GB", size), fmt.Sprintf("%.2f GB", vram), until})
logging.DebugLogger.Printf("Running model: %s\n", name)
}

return runningModels, nil
}

func copyModelfile(modelName, newModelName string) (string, error) {
logging.InfoLogger.Printf("Copying modelfile for model: %s\n", modelName)
cmd := exec.Command("ollama", "show", "--modelfile", modelName)
output, err := cmd.Output()
if err != nil {
logging.ErrorLogger.Printf("Error copying modelfile for model %s: %v\n", modelName, err)
return "", err
}

err = os.MkdirAll(filepath.Join(os.Getenv("HOME"), ".config", "gollama", "modelfiles"), os.ModePerm)
if err != nil {
logging.ErrorLogger.Printf("Error creating modelfiles directory: %v\n", err)
return "", err
}

newModelfilePath := filepath.Join(os.Getenv("HOME"), ".config", "gollama", "modelfiles", newModelName+".modelfile")

err = os.WriteFile(newModelfilePath, output, 0644)
if err != nil {
logging.ErrorLogger.Printf("Error writing modelfile for model %s: %v\n", modelName, err)
return "", err
}
logging.InfoLogger.Printf("Copied modelfile to: %s\n", newModelfilePath)

return newModelfilePath, nil
}

type editorFinishedMsg struct{ err error }

func openEditor(filePath string) tea.Cmd {
logging.DebugLogger.Printf("Opening editor for file: %s\n", filePath)
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
c := exec.Command(editor, filePath)
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{err}
})
}

func createModelFromModelfile(modelName, modelfilePath string) error {
cmd := exec.Command("ollama", "create", "-f", modelfilePath, modelName)
return cmd.Run()
}
23 changes: 16 additions & 7 deletions text_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package main
import (
"fmt"

"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sammcj/gollama/logging"
)

Expand All @@ -17,11 +19,18 @@ type textInputModel struct {
// text_input.go (modified)
func promptForNewName(oldName string) string {
ti := textinput.New()
ti.Placeholder = "Enter new name"
ti.ShowSuggestions = true
ti.CharLimit = 300
ti.Width = 60
ti.Placeholder = oldName
ti.SetSuggestions([]string{oldName})
ti.KeyMap.AcceptSuggestion = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "accept suggestion"))
ti.Focus()
ti.Prompt = "New name for model: "
ti.CharLimit = 156
ti.Width = 20
ti.Prompt = "Name for new model: "
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF00FF"))
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#AD00FF"))
ti.Cursor.Style = lipgloss.NewStyle().Background(lipgloss.Color("#AE00FF"))
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#AD00FF"))

m := textInputModel{
textInput: ti,
Expand All @@ -36,7 +45,8 @@ func promptForNewName(oldName string) string {
newName := m.textInput.Value()

if newName == "" {
fmt.Println("Error: New name cannot be empty")
// error handling
logging.ErrorLogger.Println("No new name entered, returning old name")
return oldName
}

Expand Down Expand Up @@ -66,8 +76,7 @@ func (m textInputModel) View() string {
return ""
}
return fmt.Sprintf(
"Old name: %s\n%s\n\n%s",
m.oldName,
"\n%s\n\n%s",
m.textInput.View(),
"(ctrl+c to cancel)",
)
Expand Down
Loading