diff --git a/.github/workflows/build-and-release.yaml b/.github/workflows/build-and-release.yaml new file mode 100644 index 0000000..1f7bb23 --- /dev/null +++ b/.github/workflows/build-and-release.yaml @@ -0,0 +1,89 @@ +name: Build and release +on: + workflow_dispatch: + push: + branches: + - main + - dev + paths: + - 'main.go' + - 'go.mod' + - '.github/workflows/build-and-release.yml' + tags: + - v* + pull_request: + branches: + - main + +permissions: + contents: write + checks: write + pull-requests: write + packages: write + +concurrency: + group: build-and-release + cancel-in-progress: true + +jobs: + build-and-release: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5 + with: + go-version: 1.22.1 + + # Install dependencies + - name: Install dependencies + run: go mod download + + # Build + - name: Build macOS ARM64 + run: | + GOOS=darwin GOARCH=arm64 go build -o main.go -o gollama-macos-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + echo "macOS ARM64 build completed" >> "$GITHUB_STEP_SUMMARY" + + - name: Build Linux + run: | + GOOS=linux GOARCH=amd64 go build -o main.go -o gollama-linux-amd64${{ github.ref == 'refs/heads/dev' && '-dev' }} + GOOS=linux GOARCH=arm64 go build -o main.go -o gollama-linux-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + echo "Linux build completed" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload artefacts + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: gollama + path: | + gollama-macos-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + gollama-linux-amd64${{ github.ref == 'refs/heads/dev' && '-dev' }} + gollama-linux-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + + # Bump version + - name: Bump version and push tag + id: tag_version + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') && !contains(github.event.head_commit.message, '[skip ci]') + uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + release_branches: main + pre_release_branches: dev + + # Publish + - name: Create a GitHub release + uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1 + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') && !contains(github.event.head_commit.message, '[skip ci]') + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: Release ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} + generateReleaseNotes: true + allowUpdates: true + prerelease: ${{ startsWith(github.ref, 'refs/heads/dev') }} + artifacts: | + gollama-macos-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + gollama-linux-amd64${{ github.ref == 'refs/heads/dev' && '-dev' }} + gollama-linux-arm64${{ github.ref == 'refs/heads/dev' && '-dev' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69723ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +gollama +**/.swp +**/.tmp +**/.trash +**/*.log +dist/ +.selected* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..178cac7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sam McLeod + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f776ab --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +#### Dynamically Generated Interactive Menu #### + +# Error Handling +SHELL := /bin/bash +.SHELLFLAGS := -o pipefail -c + +# Name of this Makefile +MAKEFILE_NAME := $(lastword $(MAKEFILE_LIST)) + +# Special targets that should not be listed +EXCLUDE_LIST := menu all .PHONY + +# Function to extract targets from the Makefile +define extract_targets + $(shell awk -F: '/^[a-zA-Z0-9_-]+:/ {print $$1}' $(MAKEFILE_NAME) | grep -v -E '^($(EXCLUDE_LIST))$$') +endef + +TARGETS := $(call extract_targets) + +.PHONY: $(TARGETS) menu all + +menu: ## Makefile Interactive Menu + @# Check if fzf is installed + @if command -v fzf >/dev/null 2>&1; then \ + echo "Using fzf for selection..."; \ + echo "$(TARGETS)" | tr ' ' '\n' | fzf > .selected_target; \ + target_choice=$$(cat .selected_target); \ + else \ + echo "fzf not found, using numbered menu:"; \ + echo "$(TARGETS)" | tr ' ' '\n' > .targets; \ + awk '{print NR " - " $$0}' .targets; \ + read -p "Enter choice: " choice; \ + target_choice=$$(awk 'NR == '$$choice' {print}' .targets); \ + fi; \ + if [ -n "$$target_choice" ]; then \ + $(MAKE) $$target_choice; \ + else \ + echo "Invalid choice"; \ + fi + +# Default target +all: menu + +help: ## This help function + @egrep '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# Targets (example targets listed below) +lint: ## Run lint + gofmt -w . + +test: ## Run test + go test -v ./... + +build: ## Run build + go build . + +run: ## Run + go run *.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..12406af --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Gollama + +Gollama is a Go-based client for Ollama for managing models. +It provides a TUI for listing, sorting, selecting and deleting models and can link Ollama models to LM-Studio. + +## Table of Contents + +- [Gollama](#gollama) + - [Table of Contents](#table-of-contents) + - [Features](#features) + - [Installation](#installation) + - [Usage](#usage) + - [Configuration](#configuration) + - [Logging](#logging) + - [Contributing](#contributing) + - [License](#license) + - [Architecture](#architecture) + - [Component Diagram](#component-diagram) + - [Class Diagram](#class-diagram) + +## Features + +- Interactive TUI with sorting and filtering capabilities. +- List available models and display basic metadata such as size, quantization level, model family, and modified date. +- Run models. +- Select and delete models. +- Link models to LM-Studio. + +![](screenshots/gollama-v1.0.0.jpg) + +## Installation + +1. Clone the repository: + + ```shell + git clone https://github.com/sammcj/gollama.git + cd gollama + ``` + +2. Build the project: + + ```shell + make build + ``` + +## Usage + +1. Run the application: + + ```shell + ./gollama + ``` + +2. Use the interactive TUI to list, select, delete, and link models. + +## Configuration + +Gollama uses a JSON configuration file located at `~/.config/gollama/config.json`. The configuration file includes options for sorting, columns, API keys, log levels etc... + +Example configuration: + +```json +{ + "default_sort": "Size", + "columns": ["Name", "Size", "Quant", "Family", "Modified", "ID"], + "ollama_api_key": "your-api-key", + "lm_studio_file_paths": "/path/to/lm-studio/models", + "log_level": "debug", + "log_file_path": "gollama.log", + "sort_order": "modified", + "strip_string": "my-private-registry.internal/" +} +``` + +The strip string option can be used to remove a prefix from model names as they are displayed in the TUI. +This can be useful if you have a common prefix such as a private registry that you want to remove for display purposes. + +## Logging + +Logs can be found in the `gollama.log` which is stored in `$HOME/.config/gollama/gollama.log` by default. +The log level can be set in the configuration file. + +## Contributing + +Contributions are welcome! +Please fork the repository and create a pull request with your changes. + +## License + +Copyright © 2024 Sam McLeod + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Architecture + +### Component Diagram + +```mermaid +graph TD + A[Main Application] --> B[API Client] + A --> C[Configuration] + A --> D[Logging] + A --> E[User Interface] + E --> F[Model List] + E --> G[Key Bindings] + E --> H[Item Delegate] +``` + +### Class Diagram + +```mermaid +classDiagram + class AppModel { + +client : *api.Client + +list : list.Model + +keys : *KeyMap + +models : []Model + +width : int + +height : int + +confirmDeletion : bool + +selectedForDeletion : []Model + +ollamaModelsDir : string + +lmStudioModelsDir : string + +noCleanup : bool + +cfg : *config.Config + +message : string + +Init() tea.Cmd + +Update(msg tea.Msg) (tea.Model, tea.Cmd) + +View() string + +refreshList() + +clearScreen() tea.Model + } + + class Model { + +Name : string + +ID : string + +Size : float64 + +QuantizationLevel : string + +Modified : time.Time + +Selected : bool + +Family : string + +IDStr() string + +SizeStr() string + +FamilyStr() string + +ModifiedStr() string + +QuantStr() string + +SelectedStr() string + +NameStr() string + +Title() string + +Description() string + +FilterValue() string + } + + class Config { + +DefaultSort : string + +Columns : []string + +OllamaAPIKey : string + +LMStudioFilePaths : string + +LogLevel : string + +LogFilePath : string + +SortOrder : string + +LastSortSelection : string + +StripString : string + +LoadConfig() (Config, error) + +SaveConfig(config Config) error + +getConfigPath() string + } + + class KeyMap { + +Space : key.Binding + +Delete : key.Binding + +SortByName : key.Binding + +SortBySize : key.Binding + +SortByModified : key.Binding + +SortByQuant : key.Binding + +SortByFamily : key.Binding + +RunModel : key.Binding + +ConfirmYes : key.Binding + +ConfirmNo : key.Binding + +LinkModel : key.Binding + +LinkAllModels : key.Binding + +ClearScreen : key.Binding + +GetSortOrder() string + } + + class Logging { + +DebugLogger : *log.Logger + +InfoLogger : *log.Logger + +ErrorLogger : *log.Logger + +Init(logLevel, logFilePath string) error + } + + AppModel --> Model + AppModel --> KeyMap + AppModel --> Config + AppModel --> Logging +``` diff --git a/app_model.go b/app_model.go new file mode 100644 index 0000000..bcbd72b --- /dev/null +++ b/app_model.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "gollama/logging" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m *AppModel) Init() tea.Cmd { + return nil +} + +func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + logging.DebugLogger.Printf("AppModel received key: %s\n", msg.String()) + 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) + item.Selected = !item.Selected + m.models[m.list.Index()] = item + m.list.SetItem(m.list.Index(), item) + logging.DebugLogger.Printf("Toggled selection for model: %s (after: %v)\n", item.Name, item.Selected) + } + case key.Matches(msg, m.keys.Delete): + logging.InfoLogger.Println("Delete key pressed") + m.selectedForDeletion = getSelectedModels(m.models) + logging.InfoLogger.Printf("Selected models for deletion: %+v\n", m.selectedForDeletion) + if len(m.selectedForDeletion) == 0 { + logging.InfoLogger.Println("No models selected for deletion") + break + } + m.confirmDeletion = true + case key.Matches(msg, m.keys.ConfirmYes): + if m.confirmDeletion { + for _, selectedModel := range m.selectedForDeletion { + logging.InfoLogger.Printf("Attempting to delete model: %s\n", selectedModel.Name) + err := deleteModel(m.client, selectedModel.Name) + if err != nil { + logging.ErrorLogger.Println("Error deleting model:", err) + } + } + + // Remove the selected models from the slice + m.models = removeModels(m.models, m.selectedForDeletion) + m.refreshList() + m.confirmDeletion = false + m.selectedForDeletion = nil + } + case key.Matches(msg, m.keys.ConfirmNo): + if m.confirmDeletion { + logging.InfoLogger.Println("Deletion cancelled by user") + m.confirmDeletion = false + m.selectedForDeletion = nil + } + case key.Matches(msg, m.keys.SortByName): + sort.Slice(m.models, func(i, j int) bool { + return m.models[i].Name < m.models[j].Name + }) + m.refreshList() + m.keys.SortOrder = "name" + case key.Matches(msg, m.keys.SortBySize): + sort.Slice(m.models, func(i, j int) bool { + return m.models[i].Size > m.models[j].Size + }) + m.refreshList() + m.keys.SortOrder = "size" + case key.Matches(msg, m.keys.SortByModified): + sort.Slice(m.models, func(i, j int) bool { + return m.models[i].Modified.After(m.models[j].Modified) + }) + m.refreshList() + m.keys.SortOrder = "modified" + case key.Matches(msg, m.keys.SortByQuant): + sort.Slice(m.models, func(i, j int) bool { + return m.models[i].QuantizationLevel < m.models[j].QuantizationLevel + }) + m.refreshList() + case key.Matches(msg, m.keys.SortByFamily): + sort.Slice(m.models, func(i, j int) bool { + return m.models[i].Family < m.models[j].Family + }) + m.refreshList() + m.keys.SortOrder = "family" + case key.Matches(msg, m.keys.RunModel): + if item, ok := m.list.SelectedItem().(Model); ok { + runModel(item.Name) + } + case key.Matches(msg, m.keys.LinkModel): + if item, ok := m.list.SelectedItem().(Model); ok { + message, err := linkModel(item.Name, m.lmStudioModelsDir, m.noCleanup) + if err != nil { + m.message = fmt.Sprintf("Error linking model: %v", err) + } else if message != "" { + break + } else { + m.message = fmt.Sprintf("Model %s linked successfully", item.Name) + } + } + return m.clearScreen(), tea.ClearScreen + case key.Matches(msg, m.keys.LinkAllModels): + var messages []string + for _, model := range m.models { + message, err := linkModel(model.Name, m.lmStudioModelsDir, m.noCleanup) + // if the message is empty, don't add it to the list + if err != nil { + messages = append(messages, fmt.Sprintf("Error linking model %s: %v", model.Name, err)) + } else if message != "" { + continue + } else { + messages = append(messages, message) + } + } + // remove any empty messages or duplicates + for i := 0; i < len(messages); i++ { + for j := i + 1; j < len(messages); j++ { + if messages[i] == messages[j] { + messages = append(messages[:j], messages[j+1:]...) + j-- + } + } + } + messages = append(messages, "Linking complete") + m.message = strings.Join(messages, "\n") + return m.clearScreen(), tea.ClearScreen + case key.Matches(msg, m.keys.ClearScreen): + return m.clearScreen(), tea.ClearScreen + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.list.SetSize(m.width, m.height-5) + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *AppModel) View() string { + if m.confirmDeletion { + selectedModelsList := "" + for _, model := range m.selectedForDeletion { + selectedModelsList += fmt.Sprintf("- %s\n", model.Name) + } + return fmt.Sprintf("Are you sure you want to delete the following models?\n%s(y/N): ", selectedModelsList) + } + + nameWidth, sizeWidth, quantWidth, modifiedWidth, idWidth, familyWidth := calculateColumnWidths(m.width) + + header := lipgloss.NewStyle().Bold(true).Render( + fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s", + nameWidth, "Name", + sizeWidth, "Size", + quantWidth, "Quant", + familyWidth, "Family", + modifiedWidth, "Modified", + idWidth, "ID", + ), + ) + message := "" + if m.message != "" { + message = lipgloss.NewStyle().Foreground(lipgloss.Color("green")).Render(m.message) + "\n" + } + return message + header + "\n" + m.list.View() +} + +func (m *AppModel) refreshList() { + items := make([]list.Item, len(m.models)) + for i, model := range m.models { + items[i] = model + } + m.list.SetItems(items) +} + +func (m *AppModel) clearScreen() tea.Model { + m.list.ResetFilter() + m.refreshList() + return m +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..71148a7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "encoding/json" + "fmt" + "gollama/logging" + "os" + "path/filepath" +) + +type Config struct { + DefaultSort string `json:"default_sort"` + Columns []string `json:"columns"` + OllamaAPIKey string `json:"ollama_api_key"` + LMStudioFilePaths string `json:"lm_studio_file_paths"` + LogLevel string `json:"log_level"` + LogFilePath string `json:"log_file_path"` + 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) +} + +var defaultConfig = Config{ + DefaultSort: "Size", + Columns: []string{"Name", "Size", "Quant", "Family", "Modified", "ID"}, + OllamaAPIKey: "", + LMStudioFilePaths: "", + LogLevel: "warning", + LogFilePath: os.Getenv("HOME") + "/.config/gollama/gollama.log", + SortOrder: "modified", // Default sort order + StripString: "", +} + +func LoadConfig() (Config, error) { + configPath := getConfigPath() + fmt.Println("Loading config from:", configPath) + + file, err := os.Open(configPath) + if err != nil { + if os.IsNotExist(err) { + logging.DebugLogger.Println("Config file does not exist, creating with default values") + fmt.Println("Config file does not exist, creating with default values") + + // Create the config directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + logging.ErrorLogger.Printf("Failed to create config directory: %v\n", err) + return Config{}, fmt.Errorf("failed to create config directory: %w", err) + } + + // Save the default config + if err := SaveConfig(defaultConfig); err != nil { + logging.ErrorLogger.Printf("Failed to save default config: %v\n", err) + return Config{}, fmt.Errorf("failed to save default config: %w", err) + } + + return defaultConfig, nil + } + logging.ErrorLogger.Printf("Failed to open config file: %v\n", err) + return Config{}, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + var config Config + if err := json.NewDecoder(file).Decode(&config); err != nil { + logging.ErrorLogger.Printf("Failed to decode config file: %v\n", err) + return Config{}, fmt.Errorf("failed to decode config file: %w", err) + } + + // Set the last sort selection to the current sort order + config.LastSortSelection = config.SortOrder + + return config, nil +} + +func SaveConfig(config Config) error { + configPath := getConfigPath() + logging.DebugLogger.Printf("Saving config to: %s\n", configPath) + + // if the config file doesn't exist, create it + file, err := os.Create(configPath) + if err != nil { + logging.ErrorLogger.Printf("Failed to create config file: %v\n", err) + return fmt.Errorf("failed to create config file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") // Set indentation for better readability + + if err := encoder.Encode(config); err != nil { + logging.ErrorLogger.Printf("Failed to encode config to file: %v\n", err) + return fmt.Errorf("failed to encode config to file: %w", err) + } + return nil +} + +func getConfigPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "gollama", "config.json") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6ce86cc --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module gollama + +go 1.22.3 + +require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.4 + github.com/charmbracelet/lipgloss v0.11.0 + github.com/ollama/ollama v0.1.39 + golang.org/x/term v0.20.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/x/input v0.1.1 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..885ebc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= +github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= +github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= +github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= +github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/ollama/ollama v0.1.39 h1:EYMew3AxWGXSrBycyjHhMEqau0dljg3s334Yfpx9ir8= +github.com/ollama/ollama v0.1.39/go.mod h1:dOOmmCPUbZJGdHLPbhghW7HCa8CUA8SP6edbRLqxNBs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..a91e020 --- /dev/null +++ b/helpers.go @@ -0,0 +1,112 @@ +package main + +import ( + "gollama/logging" + + "github.com/charmbracelet/lipgloss" + "github.com/ollama/ollama/api" +) + +func parseAPIResponse(resp *api.ListResponse) []Model { + models := make([]Model, len(resp.Models)) + for i, modelResp := range resp.Models { + models[i] = Model{ + Name: lipgloss.NewStyle().Foreground(lipgloss.Color("white")).Render(modelResp.Name), + ID: truncate(modelResp.Digest, 7), // Truncate the ID + Size: float64(modelResp.Size) / (1024 * 1024 * 1024), // Convert bytes to GB + QuantizationLevel: modelResp.Details.QuantizationLevel, + Family: modelResp.Details.Family, + Modified: modelResp.ModifiedAt, + Selected: false, + } + } + return models +} + +func normalizeSize(size float64) float64 { + return size // Sizes are already in GB in the API response +} + +func calculateColumnWidths(totalWidth int) (nameWidth, sizeWidth, quantWidth, modifiedWidth, idWidth int, familyWidth int) { + // Calculate column widths + nameWidth = int(0.45 * float64(totalWidth)) + sizeWidth = int(0.05 * float64(totalWidth)) + quantWidth = int(0.05 * float64(totalWidth)) + familyWidth = int(0.05 * float64(totalWidth)) + modifiedWidth = int(0.05 * float64(totalWidth)) + idWidth = int(0.02 * float64(totalWidth)) + + // Set the absolute minimum width for each column + if nameWidth < 20 { + nameWidth = 20 + } + if sizeWidth < 10 { + sizeWidth = 10 + } + if quantWidth < 5 { + quantWidth = 5 + } + if modifiedWidth < 10 { + modifiedWidth = 10 + } + if idWidth < 10 { + idWidth = 10 + } + if familyWidth < 14 { + familyWidth = 14 + } + + // If the total width is less than the sum of the minimum column widths, adjust the name column width and make sure all columns are aligned + if totalWidth < nameWidth+sizeWidth+quantWidth+familyWidth+modifiedWidth+idWidth { + nameWidth = totalWidth - sizeWidth - quantWidth - familyWidth - modifiedWidth - idWidth + } + + return +} + +func getSelectedModels(models []Model) []Model { + selectedModels := make([]Model, 0) + for _, model := range models { + if model.Selected { + logging.DebugLogger.Printf("Model selected for deletion: %s\n", model.Name) + selectedModels = append(selectedModels, model) + } + } + return selectedModels +} + +func removeModels(models []Model, selectedModels []Model) []Model { + result := make([]Model, 0) + for _, model := range models { + found := false + for _, selectedModel := range selectedModels { + if model.Name == selectedModel.Name { + found = true + break + } + } + if !found { + result = append(result, model) + } + } + return result +} + +// truncate ensures the string fits within the specified width +func truncate(text string, width int) string { + if len(text) > width { + return text[:width] + } + return text +} + +// wrapText ensures the text wraps to the next line if it exceeds the column width +func wrapText(text string, width int) string { + var wrapped string + for len(text) > width { + wrapped += text[:width] + text = text[width:] + " " + } + wrapped += text + return wrapped +} diff --git a/item_delegate.go b/item_delegate.go new file mode 100644 index 0000000..c8208f9 --- /dev/null +++ b/item_delegate.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "gollama/logging" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type itemDelegate struct { + appModel *AppModel +} + +func NewItemDelegate(appModel *AppModel) itemDelegate { + return itemDelegate{appModel: appModel} +} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + logging.DebugLogger.Printf("itemDelegate received key: %s\n", msg.String()) // Add this line + switch msg.String() { + case " ": // space key pressed + i, ok := m.SelectedItem().(Model) + if ok { + logging.DebugLogger.Printf("Delegate toggling selection for model: %s (before: %v)\n", i.Name, i.Selected) + i.Selected = !i.Selected + m.SetItem(m.Index(), i) + logging.DebugLogger.Printf("Delegate toggled selection for model: %s (after: %v)\n", i.Name, i.Selected) + + // Update the main model list + d.appModel.models[m.Index()] = i + logging.DebugLogger.Printf("Updated main model list for model: %s (after: %v)\n", i.Name, i.Selected) + } + } + } + return nil +} + +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + model, ok := item.(Model) + if !ok { + return + } + + // Alternate colours for model names + nameColours := []lipgloss.Color{ + lipgloss.Color("#FFFFFF"), + lipgloss.Color("#818BA9"), + } + + // If StripString is set in the config, strip it from the model name + if d.appModel.cfg.StripString != "" { + model.Name = strings.Replace(model.Name, d.appModel.cfg.StripString, "", 1) + } + + nameStyle := lipgloss.NewStyle().Foreground(nameColours[index%len(nameColours)]) + idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("254")).Faint(true) + sizeStyle := lipgloss.NewStyle().Foreground(sizeColour(model.Size)) + familyStyle := lipgloss.NewStyle().Foreground(familyColour(model.Family, index)) + quantStyle := lipgloss.NewStyle().Foreground(quantColour(model.QuantizationLevel)) + modifiedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("254")) + + if index == m.Index() { + // set the name boarder to pink + nameStyle = nameStyle.Bold(true).BorderLeft(true).BorderStyle(lipgloss.InnerHalfBlockBorder()).BorderForeground(lipgloss.Color("125")).PaddingLeft(1) + sizeStyle = sizeStyle.Bold(true).BorderLeft(true).PaddingLeft(-2).PaddingRight(-2) + quantStyle = quantStyle.Bold(true).BorderLeft(true).PaddingLeft(-2).PaddingRight(-2) + familyStyle = familyStyle.Bold(true).BorderLeft(true).PaddingLeft(-2).PaddingRight(-2) + modifiedStyle = modifiedStyle.Foreground(lipgloss.Color("115")).BorderLeft(true).PaddingLeft(-2).PaddingRight(-2) + idStyle = idStyle.Foreground(lipgloss.Color("225")).BorderLeft(true).PaddingLeft(-2).PaddingRight(-2) + } + + if model.Selected { + // de-indent to allow for selection border + selectedStyle := lipgloss.NewStyle().Background(lipgloss.Color("92")).Bold(true).Italic(true) + nameStyle = nameStyle.Inherit(selectedStyle) + idStyle = idStyle.Inherit(selectedStyle) + sizeStyle = sizeStyle.Inherit(selectedStyle) + familyStyle = familyStyle.Inherit(selectedStyle) + quantStyle = quantStyle.Inherit(selectedStyle) + modifiedStyle = modifiedStyle.Inherit(selectedStyle) + } + + nameWidth, sizeWidth, quantWidth, modifiedWidth, idWidth, familyWidth := calculateColumnWidths(m.Width()) + + // Ensure the text fits within the terminal width + name := wrapText(nameStyle.Width(nameWidth).Render(truncate(model.Name, nameWidth)), nameWidth) + size := wrapText(sizeStyle.Width(sizeWidth).Render(fmt.Sprintf("%.2fGB", model.Size)), sizeWidth) + quant := wrapText(quantStyle.Width(quantWidth).Render(truncate(model.QuantizationLevel, quantWidth)), quantWidth) + family := wrapText(familyStyle.Width(familyWidth).Render(model.Family), familyWidth) + modified := wrapText(modifiedStyle.Width(modifiedWidth).Render(model.Modified.Format("2006-01-02")), modifiedWidth) + id := wrapText(idStyle.Width(idWidth).Render(model.ID), idWidth) + + fmt.Fprint(w, lipgloss.JoinHorizontal(lipgloss.Top, name, size, quant, family, modified, id)) +} diff --git a/keymap.go b/keymap.go new file mode 100644 index 0000000..90856d3 --- /dev/null +++ b/keymap.go @@ -0,0 +1,43 @@ +package main + +import "github.com/charmbracelet/bubbles/key" + +type KeyMap struct { + Space key.Binding + Delete key.Binding + SortByName key.Binding + SortBySize key.Binding + SortByModified key.Binding + SortByQuant key.Binding + SortByFamily key.Binding + RunModel key.Binding + ConfirmYes key.Binding + ConfirmNo key.Binding + LinkModel key.Binding + LinkAllModels key.Binding + ClearScreen key.Binding + SortOrder string +} + +func NewKeyMap() *KeyMap { + return &KeyMap{ + Space: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "select")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete selected")), + SortByName: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "sort name")), + SortBySize: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort size")), + SortByModified: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "sort modified")), + SortByQuant: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "sort quant")), + SortByFamily: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "sort family")), + RunModel: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "run")), + ConfirmYes: key.NewBinding(key.WithKeys("y")), + ConfirmNo: key.NewBinding(key.WithKeys("n")), + LinkModel: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "link to LMStudio")), + LinkAllModels: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "link all to LMStudio")), + ClearScreen: key.NewBinding(key.WithKeys("c")), + } +} + +// a function to get the state of the sort order +func (k *KeyMap) GetSortOrder() string { + return k.SortOrder +} diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..6c997bf --- /dev/null +++ b/logging/logging.go @@ -0,0 +1,29 @@ +package logging + +import ( + "io" + "log" + "os" +) + +var ( + DebugLogger *log.Logger + InfoLogger *log.Logger + ErrorLogger *log.Logger +) + +func Init(logLevel, logFilePath string) error { + f, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + + DebugLogger = log.New(io.Discard, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile) + InfoLogger = log.New(f, "INFO: ", log.Ldate|log.Ltime) + ErrorLogger = log.New(f, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) + + if logLevel == "debug" { + DebugLogger.SetOutput(f) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f2fa205 --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/ollama/ollama/api" + "golang.org/x/term" + + "gollama/config" + "gollama/logging" +) + +type AppModel struct { + client *api.Client + list list.Model + keys *KeyMap + models []Model + width int + height int + confirmDeletion bool + selectedForDeletion []Model + ollamaModelsDir string + lmStudioModelsDir string + noCleanup bool + cfg *config.Config + message string +} + +func main() { + var version = "1.0.0" + logging.InfoLogger.Printf("Starting gollama version %s\n", version) + + // Load config + cfg, err := config.LoadConfig() + if err != nil { + fmt.Println("Error loading config:", err) + os.Exit(1) + } + + // Initialize logging + err = logging.Init(cfg.LogLevel, cfg.LogFilePath) + if err != nil { + fmt.Println("Error initializing logging:", err) + os.Exit(1) + } + + // Parse command-line arguments + ollamaDirFlag := flag.String("ollama-dir", cfg.OllamaAPIKey, "Custom Ollama models directory") + lmStudioDirFlag := flag.String("lm-dir", cfg.LMStudioFilePaths, "Custom LM Studio models directory") + noCleanupFlag := flag.Bool("no-cleanup", false, "Don't cleanup broken symlinks") + cleanupFlag := flag.Bool("cleanup", false, "Remove all symlinked models and empty directories and exit") + flag.Parse() + + client, err := api.ClientFromEnvironment() + if err != nil { + logging.ErrorLogger.Println("Error creating API client:", err) + return + } + + ctx := context.Background() + resp, err := client.List(ctx) + if err != nil { + logging.ErrorLogger.Println("Error fetching models:", err) + return + } + + logging.InfoLogger.Println("Fetched models from API") + models := parseAPIResponse(resp) + + // Group models by ID and normalize sizes to GB + modelMap := make(map[string][]Model) + for _, model := range models { + model.Size = normalizeSize(model.Size) + modelMap[model.ID] = append(modelMap[model.ID], model) + } + + // Flatten the map into a slice + groupedModels := make([]Model, 0) + for _, group := range modelMap { + groupedModels = append(groupedModels, group...) + } + + // Apply sorting order from config + switch cfg.SortOrder { + case "name": + sort.Slice(groupedModels, func(i, j int) bool { + return groupedModels[i].Name < groupedModels[j].Name + }) + case "size": + sort.Slice(groupedModels, func(i, j int) bool { + return groupedModels[i].Size > groupedModels[j].Size + }) + case "modified": + sort.Slice(groupedModels, func(i, j int) bool { + return groupedModels[i].Modified.After(groupedModels[j].Modified) + }) + case "family": + sort.Slice(groupedModels, func(i, j int) bool { + return groupedModels[i].Family < groupedModels[j].Family + }) + } + + items := make([]list.Item, len(groupedModels)) + for i, model := range groupedModels { + items[i] = model + } + + keys := NewKeyMap() + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + width, height = 80, 24 // default size if terminal size can't be determined + } + app := AppModel{ + client: client, + keys: keys, + models: groupedModels, + width: width, + height: height, + ollamaModelsDir: *ollamaDirFlag, + lmStudioModelsDir: *lmStudioDirFlag, + noCleanup: *noCleanupFlag, + cfg: &cfg, + } + + if *ollamaDirFlag == "" { + app.ollamaModelsDir = filepath.Join(os.Getenv("HOME"), ".ollama", "models") + } + if *lmStudioDirFlag == "" { + app.lmStudioModelsDir = filepath.Join(os.Getenv("HOME"), ".cache", "lm-studio", "models") + } + + if *cleanupFlag { + cleanupSymlinkedModels(app.lmStudioModelsDir) + os.Exit(0) + } + + l := list.New(items, NewItemDelegate(&app), width, height-5) + + l.Title = "Ollama Models" + l.InfiniteScrolling = true + l.SetShowTitle(false) + l.SetShowStatusBar(false) + + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + keys.Space, + keys.Delete, + keys.SortByName, + keys.SortBySize, + keys.SortByModified, + keys.RunModel, + keys.ConfirmYes, + keys.ConfirmNo, + keys.LinkModel, + keys.LinkAllModels, + } + } + + app.list = l + + p := tea.NewProgram(&app, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + logging.ErrorLogger.Printf("Error: %v", err) + } else { + // Clear the terminal screen again to refresh the application view + fmt.Print("\033[H\033[2J") + } + + // Save the updated configuration + cfg.SortOrder = keys.GetSortOrder() + if err := config.SaveConfig(cfg); err != nil { + panic(err) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..fd459a1 --- /dev/null +++ b/model.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "time" +) + +type Model struct { + Name string + ID string + Size float64 + QuantizationLevel string + Modified time.Time + Selected bool + Family string +} + +func (m Model) SelectedStr() string { + if m.Selected { + return "X" + } + return "" +} + +func (m Model) Description() string { + return fmt.Sprintf("ID: %s, Size: %.2f GB, Quant: %s, Modified: %s", m.ID, m.Size, m.QuantizationLevel, m.Modified.Format("2006-01-02")) +} + +func (m Model) FilterValue() string { + return m.Name +} diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..2b7c241 --- /dev/null +++ b/operations.go @@ -0,0 +1,278 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gollama/logging" + + "github.com/ollama/ollama/api" + "golang.org/x/term" +) + +func runModel(modelName string) { + // Save the current terminal state + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + logging.ErrorLogger.Printf("Error saving terminal state: %v\n", err) + return + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + + // Clear the terminal screen + fmt.Print("\033[H\033[2J") + + // Run the Ollama model + cmd := exec.Command("ollama", "run", modelName) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + logging.ErrorLogger.Printf("Error running model: %v\n", err) + } else { + logging.InfoLogger.Printf("Successfully ran model: %s\n", modelName) + } + + // Restore the terminal state + if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil { + logging.ErrorLogger.Printf("Error restoring terminal state: %v\n", err) + } + + // Clear the terminal screen again to refresh the application view + fmt.Print("\033[H\033[2J") +} + +func deleteModel(client *api.Client, name string) error { + ctx := context.Background() + req := &api.DeleteRequest{Name: name} + logging.DebugLogger.Printf("Attempting to delete model: %s\n", name) + + // Log the request details + logging.DebugLogger.Printf("Delete request: %+v\n", req) + + err := client.Delete(ctx, req) + if err != nil { + // Print a detailed error message to the console + logging.ErrorLogger.Printf("Error deleting model %s: %v\n", name, err) + // Return an error so that it can be handled by the calling function + return fmt.Errorf("error deleting model %s: %v", name, err) + } + + // If we reach this point, the model was deleted successfully + logging.InfoLogger.Printf("Successfully deleted model: %s\n", name) + return nil +} + +func linkModel(modelName, lmStudioModelsDir string, noCleanup bool) (string, error) { + modelPath, err := getModelPath(modelName) + if err != nil { + return "", fmt.Errorf("error getting model path for %s: %v", modelName, err) + } + + parts := strings.Split(modelName, ":") + author := "unknown" + if len(parts) > 1 { + author = strings.ReplaceAll(parts[0], "/", "-") + } + + lmStudioModelName := strings.ReplaceAll(strings.ReplaceAll(modelName, ":", "-"), "_", "-") + lmStudioModelDir := filepath.Join(lmStudioModelsDir, author, lmStudioModelName+"-GGUF") + + // Check if the model path is a valid file + fileInfo, err := os.Stat(modelPath) + if err != nil || fileInfo.IsDir() { + return "", fmt.Errorf("invalid model path for %s: %s", modelName, modelPath) + } + + // Check if the symlink already exists and is valid + lmStudioModelPath := filepath.Join(lmStudioModelDir, filepath.Base(lmStudioModelName)+".gguf") + if _, err := os.Lstat(lmStudioModelPath); err == nil { + if isValidSymlink(lmStudioModelPath, modelPath) { + message := "Model %s is already symlinked to %s" + logging.InfoLogger.Printf(message+"\n", modelName, lmStudioModelPath) + return "", nil + } + // Remove the invalid symlink + err = os.Remove(lmStudioModelPath) + if err != nil { + message := "failed to remove invalid symlink %s: %v" + logging.ErrorLogger.Printf(message+"\n", lmStudioModelPath, err) + return "", fmt.Errorf(message, lmStudioModelPath, err) + } + } + + // Check if the model is already symlinked in another location + var existingSymlinkPath string + err = filepath.Walk(lmStudioModelsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + linkPath, err := os.Readlink(path) + if err != nil { + return err + } + if linkPath == modelPath { + existingSymlinkPath = path + return nil + } + } + return nil + }) + if err != nil { + message := "error walking LM Studio models directory: %v" + logging.ErrorLogger.Printf(message+"\n", err) + return "", fmt.Errorf(message, err) + } + + if existingSymlinkPath != "" { + // Remove the duplicated model directory + err = os.RemoveAll(lmStudioModelDir) + if err != nil { + message := "failed to remove duplicated model directory %s: %v" + logging.ErrorLogger.Printf(message+"\n", lmStudioModelDir, err) + return "", fmt.Errorf(message, lmStudioModelDir, err) + } + return fmt.Sprintf("Removed duplicated model directory %s", lmStudioModelDir), nil + } + + // Create the symlink + err = os.MkdirAll(lmStudioModelDir, os.ModePerm) + if err != nil { + message := "failed to create directory %s: %v" + logging.ErrorLogger.Printf(message+"\n", lmStudioModelDir, err) + return "", fmt.Errorf(message, lmStudioModelDir, err) + } + err = os.Symlink(modelPath, lmStudioModelPath) + if err != nil { + message := "failed to symlink %s: %v" + logging.ErrorLogger.Printf(message+"\n", modelName, err) + return "", fmt.Errorf(message, modelName, err) + } + if !noCleanup { + cleanBrokenSymlinks(lmStudioModelsDir) + } + message := "Symlinked %s to %s" + logging.InfoLogger.Printf(message+"\n", modelName, lmStudioModelPath) + return "", nil +} + +func getModelPath(modelName string) (string, error) { + cmd := exec.Command("ollama", "show", "--modelfile", modelName) + output, err := cmd.Output() + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "FROM ") { + return strings.TrimSpace(line[5:]), nil + } + } + message := "failed to get model path for %s: no 'FROM' line in output" + logging.ErrorLogger.Printf(message+"\n", modelName) + return "", fmt.Errorf(message, modelName) +} + +func cleanBrokenSymlinks(lmStudioModelsDir string) { + err := filepath.Walk(lmStudioModelsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + files, err := os.ReadDir(path) + if err != nil { + return err + } + if len(files) == 0 { + logging.InfoLogger.Printf("Removing empty directory: %s\n", path) + err = os.Remove(path) + if err != nil { + return err + } + } + } else if info.Mode()&os.ModeSymlink != 0 { + linkPath, err := os.Readlink(path) + if err != nil { + return err + } + if !isValidSymlink(path, linkPath) { + logging.InfoLogger.Printf("Removing invalid symlink: %s\n", path) + err = os.Remove(path) + if err != nil { + return err + } + } + } + return nil + }) + if err != nil { + logging.ErrorLogger.Printf("Error walking LM Studio models directory: %v\n", err) + return + } +} + +func isValidSymlink(symlinkPath, targetPath string) bool { + // Check if the symlink matches the expected naming convention + expectedSuffix := ".gguf" + if !strings.HasSuffix(filepath.Base(symlinkPath), expectedSuffix) { + return false + } + + // Check if the target file exists + if _, err := os.Stat(targetPath); os.IsNotExist(err) { + return false + } + + // 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() { + return false + } + + return true +} + +func cleanupSymlinkedModels(lmStudioModelsDir string) { + for { + hasEmptyDir := false + err := filepath.Walk(lmStudioModelsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + files, err := os.ReadDir(path) + if err != nil { + return err + } + if len(files) == 0 { + logging.InfoLogger.Printf("Removing empty directory: %s\n", path) + err = os.Remove(path) + if err != nil { + return err + } + hasEmptyDir = true + } + } else if info.Mode()&os.ModeSymlink != 0 { + logging.InfoLogger.Printf("Removing symlinked model: %s\n", path) + err = os.Remove(path) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + logging.ErrorLogger.Printf("Error walking LM Studio models directory: %v\n", err) + return + } + if !hasEmptyDir { + break + } + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..b2ea9ba --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>sammcj/renovate-config" + ] +} diff --git a/screenshots/gollama-v1.0.0.jpg b/screenshots/gollama-v1.0.0.jpg new file mode 100644 index 0000000..39d778f Binary files /dev/null and b/screenshots/gollama-v1.0.0.jpg differ diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..721496a --- /dev/null +++ b/styles.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "math" + + "github.com/charmbracelet/lipgloss" +) + +var ( + // Define neon colours for different model families + familyColours = map[string]lipgloss.Color{ + "alpaca": lipgloss.Color("#FF00FF"), + "bert": lipgloss.Color("#FF40CB"), + "command-r": lipgloss.Color("#FF69B4"), + "gemma": lipgloss.Color("#FFB6C1"), + "llama": lipgloss.Color("#FF1493"), + "nomic-bert": lipgloss.Color("#FF8C00"), + "phi2": lipgloss.Color("#554AAF"), + "phi3": lipgloss.Color("#554FFF"), + "qwen": lipgloss.Color("#7FFF00"), + "qwen2": lipgloss.Color("#AAE"), + "starcoder": lipgloss.Color("#DDA0DD"), + "starcoder2": lipgloss.Color("#EE82EE"), + "vicuna": lipgloss.Color("#00CED1"), + "granite": lipgloss.Color("#00BFFF"), + } + + // Define colour gradients + synthGradient = []string{ + "#DDA0DD", "#DA70D6", "#BA55D3", "#9932CC", "#9400D3", "#8A2BE2", + "#9400D3", "#9932CC", "#BA55D3", "#DA70D6", "#DDA0DD", "#EE82EE", + "#FF00FF", "#FF0000", + } +) + +func quantColour(quant string) lipgloss.Color { + quantMap := map[string]int{ + "IQ1_XXS": 0, "IQ1_XS": 0, "IQ1_S": 0, "IQ1_NL": 0, + "Q2_K": 0, "Q2_K_S": 0, "Q2_K_M": 0, "Q2_K_L": 0, + "Q3_0": 1, "IQ2_XXS": 2, "Q3_K_S": 2, + "IQ2_XS": 3, "IQ2_S": 3, "IQ2_NL": 3, + "Q3_K_M": 4, "Q3_K_L": 4, + "Q4_0": 5, "IQ3_XXS": 5, "IQ3_XS": 5, "IQ3_NL": 5, "IQ3_S": 6, + "Q4_K_S": 6, "Q4_1": 6, "IQ4_XXS": 6, "Q4_K_M": 7, + "IQ4_XS": 7, "IQ4_S": 8, "IQ4_NL": 7, "Q4_K_L": 8, + "Q5_K_S": 8, "Q5_K_M": 9, "Q5_1": 9, "Q5_K_L": 10, + "Q6_0": 11, "Q6_1": 11, "Q6_K": 11, + "Q8": 12, "Q8_0": 12, "Q8_K": 12, + "FP16": 13, "F16": 13, + } + + index, exists := quantMap[quant] + if !exists { + index = 0 // Default to lightest if unknown quant + } + return lipgloss.Color(synthGradient[index]) +} + +func sizeColour(size float64) lipgloss.Color { + index := int(math.Log10(size+1) * 2.5) + if index >= len(synthGradient) { + index = len(synthGradient) - 1 + } + return lipgloss.Color(synthGradient[index]) +} + +func familyColour(family string, index int) lipgloss.Color { + colour, exists := familyColours[family] + if !exists { + colour = lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", 10+index%190, 10+index%190, 10+index%190)) + } + return colour +}