diff --git a/llamanager/LICENSE b/llamanager/LICENSE new file mode 100644 index 00000000..d8ec153d --- /dev/null +++ b/llamanager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 marccvictoria + +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/llamanager/README.md b/llamanager/README.md new file mode 100644 index 00000000..41b235fd --- /dev/null +++ b/llamanager/README.md @@ -0,0 +1,93 @@ +# Llamanager + +> **A Noctalia v5 plugin for managing local Ollama models, Modelfiles, and runtime state.** + +Llamanager provides a graphical frontend for [Ollama](https://ollama.com/) inside Noctalia. It wraps the Ollama CLI and HTTP API into a single panel, and allows model management, runtime inspection, model downloads, Modelfile editing, and launcher integration without requiring direct terminal interaction. + +## Demo + +![Launcher](assets/launcher.gif) +![Panel](assets/panel.gif) + +## Plugin + +| Field | Value | +| --------------- | ---------------------------------------------------------------------------------------- | +| ID | `marccvictoria/llamanager` | +| Entries | bar widget: `widget`; service: `registry`; launcher_provider: `launcher`; panel: `panel` | +| Launcher Prefix | `/ll` | + +## Usage + +Before using Llamanager, complete the following setup steps: + +1. Install Ollama and ensure it is available on your `PATH`. See [Requirements](#requirements). +2. Open the plugin settings and configure: + - the Modelfile directory + - the preferred AI model used by the launcher +3. Add the Llamanager widget to your Noctalia bar. + +## Requirements + +- **Noctalia v5** +- `ollama` available on your `PATH`: required for model management, downloads, launching models, and Modelfile operations. + +Verify the installation: + +```bash +ollama --version +``` + +## Settings + +| Setting | Type | Description | +| ---------------- | -------- | ------------------------------------- | +| `modelfile_path` | `folder` | Directory containing user Modelfiles. | +| `launcher` | `string` | Model used by the launcher. | + +## Implementation + +### Panel + +`panel.luau` renders whichever view is currently active. User interactions dispatch commands through `llamanager.nextCommand`; the service performs the requested operation, updates state, and the panel re-renders. + +`registry.luau` is the headless service responsible for interacting with Ollama. Filesystem operation, CLI invocation, and HTTP request originates here. It discovers installed models through `ollama list`, queries runtime information from the Ollama HTTP API, manages Modelfiles on disk, downloads models through `/api/pull`, and executes `ollama create` and `ollama rm` when building or deleting models. + +**Model List**. The list displays every model currently installed in Ollama. To launch a model, select it from the model selector and click the launch button. This executes `ollama run ` and opens an interactive session in a new terminal window. + +**Model Downloader**. Downloads models directly through Ollama's HTTP API. Downloads execute using the streaming `/api/pull` endpoint and automatically refresh the model library after completion. + +**Modelfile Editor**. Modelfiles are ordinary text files stored inside the configured Modelfile directory, with the suffix `.modelfile`. + +- **Load into Ollama**. Builds the modelfile into an ollama model by executing ollama create. +- **Delete model/modelfile**. Deleting a modelfile also deletes the model in ollama using `ollama rm`. In case the modelfile has not been loaded yet to ollama, it will only delete the modelfile. When deleting a model, it is recommended to include their tag, e.g. `qwen3:latest`. +- **Create Modelfile**. Creating a Modelfile requires the Name field to be populated. This only creates the Modelfile on the directory and does not register it with Ollama. To make the model available in Ollama, use _Load into Ollama_ after creating or editing the Modelfile. +- **Edit Modelfile**. Editing a Modelfile also requires the Name field to be populated. Saving changes only updates the Modelfile on the directory; it does not update the existing Ollama model. After modifying the Modelfile, use _Load into Ollama_ to rebuild and apply the changes. + +**Runtime Dashboard** + +Queries `http://localhost:11434/api/ps` and displays: + +- Running models +- Parameter size +- Quantization level +- Context length +- Expiration time +- Ollama version +- API connectivity + +### Launcher + +The launcher entry `llamanger.luau` is independent from the panel. Queries submitted through `/ll` execute the configured model directly with `ollama run`. + +The launcher provides quick model execution through `/ll //`. Append `//` to send, the model response can be seen through notification and can be copied in your clipboard by pressing Enter key. + +## IPC + +``` +noctalia msg panel-toggle marccvictoria/llamanager:panel +``` + +## License + +[MIT](LICENSE) diff --git a/llamanager/assets/launcher.gif b/llamanager/assets/launcher.gif new file mode 100644 index 00000000..0cb16111 Binary files /dev/null and b/llamanager/assets/launcher.gif differ diff --git a/llamanager/assets/panel.gif b/llamanager/assets/panel.gif new file mode 100644 index 00000000..a257fa90 Binary files /dev/null and b/llamanager/assets/panel.gif differ diff --git a/llamanager/llamanager.luau b/llamanager/llamanager.luau new file mode 100644 index 00000000..e5a6523f --- /dev/null +++ b/llamanager/llamanager.luau @@ -0,0 +1,79 @@ +function onQuery(text) + if text == "" then + launcher.setResults(text, { + { + id = "hint", + title = "Ask Ollama anything", + subtitle = "Finish with // to send", + glyph = "brain", + } + }) + return + end + + if not text:match("%s//%s*$") then + launcher.setResults(text, { + { + id = "waiting", + title = text, + subtitle = "Append // to send", + glyph = "keyboard", + } + }) + return + end + + local prompt = text:gsub("%s//%s*$", "") + + launcher.setResults(text, { + { + id = "loading", + title = "Thinking...", + subtitle = prompt, + glyph = "loader", + } + }) + + local request = { + url = "http://localhost:11434/api/generate", + method = "POST", + headers = { + "Content-Type: application/json", + }, + body = noctalia.json.encode({ + model = noctalia.getConfig("launcher"), + prompt = prompt, + stream = false, + think = false, + }), + } + + noctalia.http(request, function(response) + if not (response.ok and response.status == 200) then + launcher.setResults(text, { + { + id = "error", + title = "Ollama request failed", + subtitle = response.body or ("HTTP " .. tostring(response.status)), + glyph = "alert-circle", + } + }) + return + end + + local data = noctalia.json.decode(response.body) + noctalia.notify("LLamanager", data.response) + launcher.setResults(text, { + { + id = data.response, + title = data.response, + subtitle = "Press Enter to copy", + glyph = "brain", + } + }) + end) +end + +function onActivate(id) + noctalia.copyToClipboard(id, "text/plain") +end \ No newline at end of file diff --git a/llamanager/panel.luau b/llamanager/panel.luau new file mode 100644 index 00000000..f41904fb --- /dev/null +++ b/llamanager/panel.luau @@ -0,0 +1,342 @@ +local modelfilePath = "" +local pathSet + +local stageIcon = "topology-star-3" + +-- models and runtime view +function onRefreshButtonClick() + noctalia.state.set("llamanager.nextCommand", "refresh") +end + +function onLaunchButtonClick() + noctalia.state.set("llamanager.nextCommand", "launch") +end + +function onRuntimeButtonClick() + noctalia.state.set("llamanager.nextCommand", "runtime") +end + +function onBackButtonClick() + noctalia.state.set("llamanager.nextCommand", "refresh") + noctalia.state.set("llamanager.view", "modelsView") +end + +function onEditorButtonClick() + if not pathSet then + noctalia.notify("Llamanager", "Please configure a valid Modelfile directory first.") + return + end + + noctalia.state.set("llamanager.nextCommand", "editor") +end + +function onDownloadButtonClick() + noctalia.state.set("llamanager.nextCommand", "download") +end + +function changeSelectedIndexModel(index) + noctalia.state.set("llamanager.selectedModel", index) +end + +function changeDownloadModelField(downloadRequest) + noctalia.state.set("llamanager.downloadModelField", {modelName=downloadRequest, data=nil, result=nil}) +end + +-- editor view +function onLoadOllamaButtonClick() + noctalia.state.set("llamanager.nextCommand", "loadToOllama") +end + +function onCreateButtonClick() + noctalia.state.set("llamanager.nextCommand", "create") +end + +function onEditButtonClick() + noctalia.state.set("llamanager.nextCommand", "edit") +end + +function onDeleteButtonClick() + noctalia.state.set("llamanager.nextCommand", "delete") +end + +function changeModelfileName(fileName) + noctalia.state.set("llamanager.modelfileNameField", fileName) +end + +function changeModelfileContent(content) + noctalia.state.set("llamanager.modelfileContent", content) + local path = modelfilePath .. noctalia.state.get("llamanager.modelfileNameField") .. ".modelfile" + noctalia.writeFile(path, content) +end + +local function renderModelsView(models) + local modelRows = {} + + local function getModelOptions() + local options = {"Default (Ollama)"} + + for _, model in ipairs(models) do + table.insert(options, model.name) + end + + return options + end + + local function populateModelRows(models, modelRows) + if #models ~= 0 then + for _, model in ipairs(models) do + table.insert(modelRows, + ui.row({ justify = "space_between" }, { + ui.label({ + text = "└── " .. model.name, + }), + }) + ) + end + else + table.insert(modelRows, + ui.label({text = "No models installed... :("}) + ) + end + end + + populateModelRows(models, modelRows) + + panel.render( + ui.column({ flexGrow = 1 }, { + -- header + ui.row({ justify = "space_between" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = stageIcon, color = "primary"}), + ui.label({ text = "Llamanager", fontSize = 18, fontWeight = "bold", color = "on_surface",}), + }), + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "activity", onClick = "onRuntimeButtonClick", tooltip = "Runtime Dashboard",}), + ui.button({ glyph = "refresh", onClick = "onRefreshButtonClick" }), + }), + }), + + ui.scroll({ flexGrow = 1 }, { + ui.label({ text = "Model List", fontWeight = "bold", color = "primary" }), + ui.spacer({ height = 8 }), + ui.column({ gap = 6 }, modelRows), + ui.spacer({ height = 8 }), + + ui.label({ text = "Modelfile Editor", fontWeight = "bold", color = "primary",}), + ui.spacer({ height = 8 }), + ui.button({ text = "Open Editor", onClick = "onEditorButtonClick" }), + ui.spacer({ height = 8 }), + ui.label({ text = "Model Downloader ", color = "primary", fontWeight = "bold"}), + ui.spacer({ height = 8 }), + + ui.row({ gap=8 }, { + ui.input({ flexGrow = 1, placeholder="llama3.1:8b, deepseek-r1:8b, ...", onChange="changeDownloadModelField"}), + ui.button({ glyph="download", onClick = "onDownloadButtonClick" }), + }), + }), + + -- footer + ui.column({ gap = 8 }, { + ui.row({ gap = 8, justify = "end"}, { + ui.select({ placeholder = "Model", flexGrow=1, options = getModelOptions(), onChange = "changeSelectedIndexModel" }), + ui.button({ glyph = "rocket", onClick = "onLaunchButtonClick" }), + }), + }), + }) + ) +end + +local function renderRuntimeView(ollama) + local ollamaRows = {} + local runtimeRows = {} + + local function populateRuntimeRows(runtime, runtimeRows) + for i, model in ipairs(runtime) do + if next(model) ~= nil then + local date, time = "-", "-" + if model.expires then + date, time = model.expires:match("(%d%d%d%d%-%d%d%-%d%d)T(%d%d:%d%d:%d%d)") + end + + table.insert(runtimeRows, + ui.column({gap = 6 }, { + ui.label({ text = "[" .. i .. "] " .. model.name , color = "primary" }), + ui.label({ text = "Parameters\t" .. (model.parameterSize or "-") }), + ui.label({ text = "Quantization\t" .. (model.quantization or "-") }), + ui.label({ text = "Context\t\t" .. tostring(model.context or "-") }), + ui.label({ text = "Expires\t\t" .. date .. " | ".. time}), + }) + ) + end + end + + if next(runtimeRows) == nil then + table.insert(runtimeRows, + ui.column({}, { + ui.label({text = "No models are currently running :)"}) + })) + end + end + + local function populateOllamaRows(server, ollamaRows) + table.insert(ollamaRows, + ui.column({ gap = 6 }, { + ui.label({ text = "Installed:\t" .. tostring(server.installed)}), + ui.label({ text = "Version:\t" .. tostring(server.version)}), + ui.label({ text = "Executable:\t" .. server.executable}), + ui.label({ text = "Endpoint:\t" .. server.endpoint}), + ui.label({ text = "API Connected:\t" .. tostring(server.api_connected)}), + }) + ) + end + + populateRuntimeRows(ollama.runtime, runtimeRows) + populateOllamaRows(ollama.server, ollamaRows) + + -- runtime dashboard + panel.render( + ui.column({flexGrow=1, justify="space_between"}, { + -- header + ui.row({ justify = "space_between" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = stageIcon, color = "primary"}), + ui.label({ text = "Llamanager", fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "arrow-left", onClick = "onBackButtonClick" }), + ui.button({ glyph = "refresh", onClick = "onRefreshButtonClick" }), + }), + }), + + ui.scroll({ flexGrow = 1}, { + ui.label({ text = "Runtime Dashboard", fontWeight = "bold", color = "primary" }), + ui.spacer({ height = 8 }), + + ui.label({ text = "Ollama", color = "primary" }), + ui.column({ gap = 6 }, ollamaRows), + + ui.separator({spacing = 8,}), + + ui.label({ text = "Active Models", color = "primary" }), + ui.label({text = "Installed: " .. #ollama.models,}), + ui.spacer({height = 8,}), + + ui.column({ gap = 6 }, runtimeRows), + }), + }) + ) +end + +local function renderEditorView() + local modelDirRows = {} + local function populateModelDirRows(modelDirRows) + local dirs = noctalia.state.get("llamanager.modelfilePaths") + for i, dir in ipairs(dirs) do + table.insert(modelDirRows, + ui.row({ justify = "space_between" }, { + ui.label({ + text = "└── " .. dir, + }), + }) + ) + end + end + + populateModelDirRows(modelDirRows) + + panel.render( + ui.column({flexGrow=1, justify="space_between"}, { + -- header + ui.row({ justify = "space_between" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = stageIcon, color = "primary"}), + ui.label({ text = "Llamanager", fontSize = 18, fontWeight = "bold", color = "on_surface", }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "arrow-left", onClick = "onBackButtonClick" }), + ui.button({ glyph = "refresh", onClick = "onRefreshButtonClick" }), + }), + }), + + -- body + ui.scroll({ flexGrow = 1 }, { + ui.label({ text = "Modelfiles", fontWeight = "bold", color = "primary",}), + ui.label({text = "Modelfile Path: " .. modelfilePath,}), + ui.spacer({height = 8,}), + ui.column({}, modelDirRows) + }), + + -- foot + ui.row({ gap = 8 }, { + ui.button({ glyph = "file-code", onClick = "onLoadOllamaButtonClick", tooltip="Load to Ollama" }), + ui.input({ flexGrow = 1, placeholder = "Modelfile Name", onChange = "changeModelfileName" }), + ui.button({ glyph = "plus", onClick = "onCreateButtonClick", tooltip="Create" }), + ui.button({ glyph = "edit", onClick = "onEditButtonClick", tooltip="Edit" }), + ui.button({ glyph = "trash", onClick = "onDeleteButtonClick", tooltip="Delete"}), + }), + }) + ) +end + +local function renderModelfileEditorView() + local path = modelfilePath .. noctalia.state.get("llamanager.modelfileNameField") .. ".modelfile" + content = noctalia.readFile(path) + + panel.render( + ui.column({}, { + -- header + ui.row({ justify = "space_between" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = stageIcon, color = "primary"}), + ui.label({ text = "Llamanager", fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = "arrow-left", onClick = "onBackButtonClick" }), + ui.button({ glyph = "refresh", onClick = "onRefreshButtonClick" }), + }), + }), + ui.label({ text = "Editing " .. path }), + ui.spacer({ height = 2, flexGrow = 0}), + -- body + ui.input({ value = content, flexGrow = 1, placeholder = "Modelfile", onChange = "changeModelfileContent", multiline = true}), + }) + ) +end + +-- main + +function onOpen(_context) + modelfilePath = noctalia.expandPath(noctalia.getConfig("modelfile_path")) + + if not modelfilePath or modelfilePath == "" or not noctalia.fileExists(modelfilePath) then + pathSet = false + else + if modelfilePath:sub(-1) ~= "/" then + modelfilePath = modelfilePath .. "/" + end + pathSet = true + end + + -- trigger initial load + noctalia.state.set("llamanager.view", "modelsView") + noctalia.state.set("llamanager.nextCommand", "refresh") +end + +local function render() + local ollama = noctalia.state.get("llamanager.ollama") or {} + local view = noctalia.state.get("llamanager.view") + + if view == "modelsView" then + renderModelsView(ollama.models or {}) + elseif view == "runtimeView" then + renderRuntimeView(ollama or {}) + elseif view == "editorView" and pathSet then + renderEditorView() + elseif view == "modelfileEditorView" then + renderModelfileEditorView() + end +end + +-- UI updates +noctalia.state.watch("llamanager.view", render) +noctalia.state.watch("llamanager.ollama", render) \ No newline at end of file diff --git a/llamanager/plugin.toml b/llamanager/plugin.toml new file mode 100644 index 00000000..9070c078 --- /dev/null +++ b/llamanager/plugin.toml @@ -0,0 +1,47 @@ +id = "marccvictoria/llamanager" +name = "Llamanager" +version = "1.0.0" +plugin_api = 4 +author = "marccvictoria" +license = "MIT" +deprecated = false +icon = "topology-star-3" +description = "Launcher and Manager for Ollama." +tags = ["ai", "productivity", "bar", "launcher", "panel"] +dependencies = ["ollama"] + +[[setting]] +key = "modelfile_path" +type = "folder" +default = "" +label_key = "settings.modelfile_path.label" +description_key = "settings.modelfile_path.description" + +[[setting]] +key = "launcher" +type = "string" +default = "" +label_key = "settings.launcher.label" +description_key = "settings.launcher.description" + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +open_near_click = true +placement = "floating" +width = 400 +height = 350 + +[[service]] +id = "registry" +entry = "registry.luau" + +[[launcher_provider]] +id = "launcher" +entry = "llamanager.luau" +prefix = "ll" +glyph = "robot" diff --git a/llamanager/registry.luau b/llamanager/registry.luau new file mode 100644 index 00000000..d7f3d258 --- /dev/null +++ b/llamanager/registry.luau @@ -0,0 +1,352 @@ +local modelfilePath = noctalia.expandPath(noctalia.getConfig("modelfile_path")) +if modelfilePath:sub(-1) ~= "/" then + modelfilePath = modelfilePath .. "/" +end + +local function joinModelNameToPath(modelName) + return modelfilePath .. modelName .. ".modelfile" +end + +local function isNotEmpty(value) + if not value or value:match("^%s*$") then + noctalia.notify("LLamanager", "Field cannot be empty.") + return false + end + + return true +end + +local function shellEscape(str) + return "'" .. tostring(str):gsub("'", "'\\''") .. "'" +end + +-- return ollama table +local function loadOllama() + local isInstalled = function() return noctalia.commandExists("ollama") end + + local ollama = { + server = { + executable = "ollama", + installed = isInstalled(), + endpoint = "localhost:11434", + api_connected = false, + version = nil, + }, + models = {}, -- models installed + runtime = {}, -- runtime models and other info + } + + return ollama +end + +-- models dashboard +local function getModels(onDone) + local models = {} + + noctalia.runAsync("ollama list | awk 'NR>1 {print $1}'", function(result) + if result.exitCode ~= 0 then + onDone({}) + return + end + + for line in string.gmatch(result.stdout, "[^\r\n]+") do + local model_name = line:gsub("%s+$", "") + + if model_name ~= "" then + table.insert(models, + { + name = model_name, + executable = "ollama run " .. model_name .. " --think=false", + }) + end + end + onDone(models) + end) +end + +-- runtime dashboard +local function getOllamaVersion(onVersion) + noctalia.runAsync("ollama --version | awk '{print $4}'", function(result) + if result.exitCode ~= 0 then + noctalia.notify("Failed to get Ollama version") + return + end + local version = result.stdout + onVersion(version) + end) +end + +local function launch(ollama) + local selectedModel = tonumber(noctalia.state.get("llamanager.selectedModel")) + local models = ollama.models + + if selectedModel ~= 0 then + local model = models[selectedModel] + + if not model then + noctalia.notify("Selected model not found") + return + end + + noctalia.notify(model.name) + noctalia.runInTerminal(model.executable) + else + noctalia.notify("Ollama") + noctalia.runInTerminal(ollama.server.executable) + end +end + +local function getRuntimeInfo(onLoad) + local request = { + url = "http://localhost:11434/api/ps", + method = "GET", + headers = { "Accept: application/json" }, + follow_redirects = false, + } + + noctalia.http(request, function(response) + if not (response.ok and response.status == 200) then + noctalia.notify("Request failed") + return + end + local api_status = true + + -- parse + local data = noctalia.json.decode(response.body) + + if not data.models or #data.models == 0 then + onLoad({}, api_status) + return + end + + -- update models + local info = {} + for _, model in ipairs(data.models) do + table.insert(info, { + name = model.name, + parameterSize = model.details.parameter_size, + quantization = model.details.quantization_level, + context = model.context_length, + expires = model.expires_at, + }) + end + + onLoad(info, api_status) + end) +end + +local function downloadModel(modelName, onProgress, onFinish) + local request = { + url = "http://localhost:11434/api/pull", + method = "POST", + headers = { + "Content-Type: application/json", + "Accept: application/json", + }, + body = noctalia.json.encode({ + model = modelName, + stream = true, + }), + } + + noctalia.httpStream( request, + -- progress + function(line) + local ok, data = pcall(noctalia.json.decode, line) + if not ok then + return + end + + if onProgress then + onProgress(data) + end + end, + + -- finished + function(result) + if onFinish then + onFinish(result) + end + end + ) +end + +local function edit() + local listDir, err = noctalia.listDir(modelfilePath) + + if not listDir then + noctalia.notify("Llamanager", err) + return + end + + modelfileDirs = {} + for _, dir in ipairs(listDir) do + table.insert(modelfileDirs, dir) + end + noctalia.state.set("llamanager.modelfilePaths", modelfileDirs) +end + +local function loadToOllama(modelName, modelfilePath) + local cmd = string.format( + 'ollama create %s -f %s', + shellEscape(modelName), + shellEscape(modelfilePath) + ) + + noctalia.runAsync(cmd, function(result) + if result.exitCode == 0 then + noctalia.notify("Model created!") + else + local err = result.stderr ~= "" and result.stderr or result.stdout + local message = err:match("Error:.-[\r\n]") or err:match("Error:.*") or err + message = message:gsub("[\r\n]", "") + noctalia.notify("Llamanager", message) + end + end) +end + +local function deleteModelOllama(modelName) + local cmd = string.format('ollama rm %s', shellEscape(modelName)) + + noctalia.runAsync(cmd, function(result) + if result.exitCode == 0 then + noctalia.notify("Ollama: Removed ".. modelName) + -- refresh model list + noctalia.state.set("llamanager.nextCommand", "refresh") + else + noctalia.notify("LLamanager", "Modelfile successfully deleted, but no matching ollama model was found.") + noctalia.state.set("llamanager.nextCommand", "refresh") + end + end) +end + +noctalia.state.watch("llamanager.nextCommand", function(command) + if command == "refresh" then -- load ollama and its models + local ollama = loadOllama() + getModels(function(models) + ollama.models = models + noctalia.state.set("llamanager.ollama", ollama) + noctalia.state.set("llamanager.view", "modelsView") + end) + + -- reset field value + noctalia.state.set("llamanager.modelfileNameField", "") + + elseif command == "launch" then + local ollama = noctalia.state.get("llamanager.ollama") + launch(ollama) + + elseif command == "runtime" then + local ollama = loadOllama() + + getModels(function(models) + ollama.models = models + getRuntimeInfo(function(runtime, status) + ollama.runtime = runtime + ollama.server.api_connected = status + getOllamaVersion(function(version) + ollama.server.version = version + noctalia.state.set( + "llamanager.ollama", + ollama + ) + noctalia.state.set( + "llamanager.view", + "runtimeView" + ) + end) + end) + end) + + elseif command == "download" then + local downloadData = noctalia.state.get("llamanager.downloadModelField") + noctalia.notify("Download Started...") + local lastNotified = -30 + local hadError = false + + downloadModel( + downloadData.modelName, + + function(progress) + if progress.error then + hadError = true + noctalia.notify(progress.error) + return + end + + if progress.completed and progress.total then + local percent = math.floor(progress.completed / progress.total * 100) + local milestone = math.floor(percent / 30) * 30 + + if milestone >= 30 and milestone > lastNotified then + lastNotified = milestone + noctalia.notify("Download Progress: " .. tostring(milestone) .. "%") + end + end + end, + + function(result) + if hadError then + return + end + + if result.ok then + noctalia.notify("Download complete!") + noctalia.state.set("llamanager.nextCommand", "refresh") + else + noctalia.notify( + "Download failed (" .. + tostring(result.status) .. + ")" + ) + end + end + ) + + -- editor + elseif command == "editor" then + edit() + noctalia.state.set("llamanager.view", "editorView") + + elseif command == "create" then + local modelName = noctalia.state.get("llamanager.modelfileNameField") + if isNotEmpty(modelName) then + local path = joinModelNameToPath(modelName) + noctalia.writeFile(path, "") + noctalia.state.set("llamanager.view", "modelfileEditorView") + end + + elseif command == "edit" then + local modelName = noctalia.state.get("llamanager.modelfileNameField") + if isNotEmpty(modelName) then + local path = joinModelNameToPath(modelName) + local content = noctalia.readFile(path) + noctalia.state.set("llamanager.modelfileContent", content) + noctalia.state.set("llamanager.view", "modelfileEditorView") + end + + elseif command == "delete" then + -- note: Include the model tag (e.g. :latest, :v2) on deleting a model. + local modelName = noctalia.state.get("llamanager.modelfileNameField") + + if isNotEmpty(modelName) then + local base = modelName:gsub(":.*$", "") + local path = modelfilePath .. base .. ".modelfile" + local ok, err = noctalia.removeFile(path) + if not ok then + noctalia.notify("LLamanager", tostring(err)) + else + -- delete in ollama + deleteModelOllama(modelName) + end + end + + elseif command == "loadToOllama" then + local modelName = noctalia.state.get("llamanager.modelfileNameField") + if isNotEmpty(modelName) then + local path = joinModelNameToPath(modelName) + loadToOllama(modelName, path) + end + end + +end) diff --git a/llamanager/thumbnail.webp b/llamanager/thumbnail.webp new file mode 100644 index 00000000..004b9d57 Binary files /dev/null and b/llamanager/thumbnail.webp differ diff --git a/llamanager/translations/en.json b/llamanager/translations/en.json new file mode 100644 index 00000000..78bf6fc1 --- /dev/null +++ b/llamanager/translations/en.json @@ -0,0 +1,12 @@ +{ + "settings": { + "modelfile_path": { + "label": "Modelfile Path", + "description": "The directory where all your modelfiles are stored." + }, + "launcher": { + "label": "Launcher", + "description": "Preferred AI model when using /ll in the launcher." + } + } +} \ No newline at end of file diff --git a/llamanager/widget.luau b/llamanager/widget.luau new file mode 100644 index 00000000..a43c7d37 --- /dev/null +++ b/llamanager/widget.luau @@ -0,0 +1,6 @@ +function onClick() + noctalia.togglePanel("marccvictoria/llamanager:panel") +end + +-- main +barWidget.setGlyph("topology-star-3") \ No newline at end of file