From 2f760f6103e5976d4603c45e921530456b44f7a0 Mon Sep 17 00:00:00 2001 From: weinguyen Date: Sun, 9 Aug 2026 02:13:03 +0700 Subject: [PATCH] Add 9Router control plugin (#291) * Add 9Router panel plugin This commit introduces the initial version of the 9Router panel plugin. It provides a Noctalia panel to manage 9Router model combos directly from the bar, without requiring the web dashboard. Key features: - List, create, rename, and delete model combos. - Reorder models within a combo via drag-and-drop. - Change combo routing kind (fallback, round-robin, fusion). - Search and filter combos. - Supports 9Router dashboard authentication via CLI token or password. Includes a bar widget showing connection status, a full UI panel, and a background service to interact with the 9Router REST API. Translations for English and Vietnamese are included. * Rename 9Router Panel to 9Router Control Update plugin IDs, IPC commands, and internal references. * Quote shell args in service commands Add shell_quote() for URLs, bodies, and file paths passed to noctalia.runAsync. Declare required external commands in plugin.toml and document them in the README. --- 9router-control/LICENSE | 21 + 9router-control/README.md | 84 +++ 9router-control/panel.luau | 901 +++++++++++++++++++++++++++ 9router-control/plugin.toml | 68 ++ 9router-control/service.luau | 553 ++++++++++++++++ 9router-control/thumbnail.webp | Bin 0 -> 86942 bytes 9router-control/translations/en.json | 120 ++++ 9router-control/translations/vi.json | 120 ++++ 9router-control/widget.luau | 165 +++++ 9 files changed, 2032 insertions(+) create mode 100644 9router-control/LICENSE create mode 100644 9router-control/README.md create mode 100644 9router-control/panel.luau create mode 100644 9router-control/plugin.toml create mode 100644 9router-control/service.luau create mode 100644 9router-control/thumbnail.webp create mode 100644 9router-control/translations/en.json create mode 100644 9router-control/translations/vi.json create mode 100644 9router-control/widget.luau diff --git a/9router-control/LICENSE b/9router-control/LICENSE new file mode 100644 index 0000000..5a0c344 --- /dev/null +++ b/9router-control/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 weinguyen + +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/9router-control/README.md b/9router-control/README.md new file mode 100644 index 0000000..d6a757b --- /dev/null +++ b/9router-control/README.md @@ -0,0 +1,84 @@ +# 9Router Control + +Manage your [9Router](https://github.com/decolua/9router) combos right from the +Noctalia bar — no web dashboard needed. + +- **List combos** — see every combo with its model chain and routing kind. +- **Reorder models** — open a combo and move models up/down to change fallback + order. +- **Create / rename / delete combos** — full CRUD from the panel. +- **Change routing kind** — fallback, round-robin, or fusion. +- **Search** — filter combos by name or model chain. + +## Plugin + +| Field | Value | +| ------- | --------------------------- | +| ID | `weinguyen/9router-control` | +| Widget | `widget` (bar) | +| Panel | `panel` | +| Service | `service` | + +Toggle the panel from the bar widget, or with: + +```sh +noctalia msg panel-toggle weinguyen/9router-control:panel +``` + +## Requirements + +- Noctalia v5.0.0 or higher. +- A running 9Router instance (dashboard) whose REST API the plugin talks to via + `server_host:server_port`. +- The following external commands, used by the service backend: + - `curl` — password login posts to `/api/auth/login` and reads the `Set-Cookie` + header (Noctalia's HTTP binding does not expose response headers). + - `sha256sum`, `cat`, `cut`, `printf` — compute the CLI authentication token + from the `machine-id` and `auth/cli-secret` files when the dashboard has + login enabled. + - `sleep` — back-off polling while a CLI token computation is in flight. + + Ordinary (login-disabled) usage needs only `curl`; the remaining commands are + required when dashboard login is enabled. + +## Usage + +Click the 9Router widget on the bar to open the panel. + +- The **combo list** shows every combo; the search box filters by name or model + chain. +- Select a combo to **reorder** its models (move up/down) and change its routing + kind. +- Use **create / rename / delete** to manage combos without touching the web + dashboard. + +If the dashboard has login enabled and no CLI token is available, the panel +shows a login screen where you enter the dashboard password; the plugin then +reuses the session cookie for subsequent requests. + +## Settings + +| Setting | Default | Description | +| -------------- | ------------ | ----------------------------------------------------------------------- | +| Server Host | `127.0.0.1` | Hostname of the 9Router dashboard. | +| Server Port | `20128` | Port of the 9Router dashboard. | +| Data Directory | `~/.9router` | 9Router data dir, used to compute the CLI token when login is required. | +| Language | `auto` | UI language (auto / English / Tiếng Việt). | +| Debug Logging | off | Print debug messages to the Noctalia log. | + +## Notes + +- The plugin talks to the local 9Router REST API (`/api/combos`). By default the + dashboard login is disabled, so plain requests work. +- When login is enabled, the plugin first tries the 9Router CLI token (computed + from the `machine-id` and `auth/cli-secret` files, sent as an `x-9r-cli-token` + header). If that isn't available, it falls back to a password login and reuses + the session cookie. +- Reordering writes the new `models` array via `PUT /api/combos/:id`. + +## Development + +- `widget.luau` — bar widget entry. +- `panel.luau` — chat-style panel surface. +- `service.luau` — headless API / state backend. +- `translations/` — user-facing strings. diff --git a/9router-control/panel.luau b/9router-control/panel.luau new file mode 100644 index 0000000..5df9ce6 --- /dev/null +++ b/9router-control/panel.luau @@ -0,0 +1,901 @@ +-- 9Router Control — the combo management surface. +-- +-- Three views: +-- • List — all combos with search + "new combo" button +-- • Detail — a single combo's model chain with reorder/edit/delete +-- • Create — a form to build a new combo +-- +-- Pure subscriber of 9router.* state. User actions are dispatched as IPC +-- events to the service. + +local STATE = { + connection = "9router.connection", + combos = "9router.combos", + models = "9router.models", + selected = "9router.selected_combo", + last_error = "9router.last_error", +} + +local PAD = 12 +local WRAP = 480 + +-- Confirmation / form state +local pending_delete = nil +local rename_id = nil +local rename_value = "" +local creating = false +local create_name = "" +local create_models = "" +local create_kind = "none" +local search_query = "" +local login_password = "" +local adding_model = false +local model_search = "" +local create_error = "" +local rename_error = "" + +local KIND_OPTIONS = { + { value = "none", label_key = "kind.none" }, + { value = "fallback", label_key = "kind.fallback" }, + { value = "round-robin", label_key = "kind.round_robin" }, + { value = "fusion", label_key = "kind.fusion" }, +} + +-- ── i18n ───────────────────────────────────────────────────────────────────── + +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local node = load_lang_table(lang) + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if type(node) ~= "string" then + return noctalia.tr(key, args) + end + if type(args) == "table" then + node = string.gsub(node, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return node +end + +local function kind_label(value) + for _, o in ipairs(KIND_OPTIONS) do + if o.value == value then return tr(o.label_key) end + end + return value or tr("kind.none") +end + +-- ── helpers ───────────────────────────────────────────────────────────────── + +local function dispatch_ipc(event, payload) + local cmd = "noctalia msg plugin 'weinguyen/9router-control:service' all " .. event + if payload ~= nil then + -- Shell-escape the payload (single-quote wrap + escape embedded quotes). + payload = tostring(payload):gsub("'", "'\\''") + cmd = cmd .. " '" .. payload .. "'" + end + noctalia.runAsync(cmd) +end + +local function find_combo(combos, id) + if type(combos) ~= "table" then return nil end + for _, c in ipairs(combos) do + if type(c) == "table" and c.id == id then return c end + end + return nil +end + +local function model_chain_text(models) + if type(models) ~= "table" then return "" end + local parts = {} + for _, m in ipairs(models) do + if type(m) == "string" and m ~= "" then parts[#parts + 1] = m end + end + return table.concat(parts, " → ") +end + +-- 9Router combo name rule: only a-z, A-Z, 0-9, -, _ and . +local NAME_RE = "^[%w%.%-]+$" +local function valid_name(name) + return type(name) == "string" and name ~= "" and string.match(name, NAME_RE) ~= nil +end + +-- Compact fingerprint of the combo list (names + model chains + kinds) so a +-- reorder/edit that keeps the count the same still triggers a re-render. +local function combos_fingerprint(combos) + if type(combos) ~= "table" then return "0" end + local parts = {} + for _, c in ipairs(combos) do + if type(c) == "table" then + parts[#parts + 1] = tostring(c.id or "") .. "|" .. tostring(c.name or "") .. "|" .. tostring(c.kind or "") .. "|" .. model_chain_text(c.models) + end + end + return table.concat(parts, "\1") +end + +-- ── list view ─────────────────────────────────────────────────────────────── + +local function render_list(combos, conn) + local rows = {} + + -- Header + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "route", color = "primary", size = 24 }), + ui.label({ text = tr("list.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ gap = 8 }, { + ui.button({ glyph = "refresh", onClick = function() + dispatch_ipc("refresh") + end, tooltip = tr("list.refresh") }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + -- Error banner (if any) + local err = noctalia.state.get(STATE.last_error) + if type(err) == "table" and err.message then + rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, { + ui.glyph({ name = "alert-circle", color = "error", size = 16 }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }), + ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }), + }), + ui.button({ + glyph = "x", + variant = "ghost", + tooltip = tr("error.dismiss"), + onClick = function() + dispatch_ipc("clear_error") + end, + }), + }) + end + + -- New combo button + rows[#rows + 1] = ui.button({ + text = tr("list.new_combo"), + glyph = "plus", + onClick = function() + creating = true + create_name = "" + create_models = "" + create_kind = "none" + render() + end, + }) + + -- Search box + rows[#rows + 1] = ui.input({ + key = "combo_search", + value = search_query, + placeholder = tr("list.search"), + onChange = function(text) + search_query = text or "" + render() + end, + }) + + -- Filter combos by search query + local visible = combos + if type(combos) == "table" and search_query ~= "" then + local needle = string.lower(search_query) + visible = {} + for _, c in ipairs(combos) do + if type(c) == "table" then + local hay = string.lower(tostring(c.name or "") .. " " .. model_chain_text(c.models)) + if string.find(hay, needle, 1, true) then + visible[#visible + 1] = c + end + end + end + end + + -- Combo list + local list_items = {} + if type(visible) ~= "table" or #visible == 0 then + list_items[#list_items + 1] = ui.label({ + text = search_query ~= "" and tr("list.no_match") or tr("list.empty"), + maxWidth = WRAP, + opacity = 0.7, + }) + else + for _, c in ipairs(visible) do + if type(c) == "table" and c.id then + local combo_id = c.id + local name = c.name or c.id + local chain = model_chain_text(c.models) + local kind = kind_label(c.kind) + + local function open_this() + dispatch_ipc("select_combo", combo_id) + end + + local actions + if pending_delete == combo_id then + actions = ui.row({ gap = 6 }, { + ui.button({ + glyph = "check", + variant = "primary", + tooltip = tr("list.delete_confirm"), + onClick = function() + dispatch_ipc("delete_combo", combo_id) + pending_delete = nil + render() + end, + }), + ui.button({ + glyph = "x", + variant = "secondary", + tooltip = tr("list.delete_cancel"), + onClick = function() + pending_delete = nil + render() + end, + }), + }) + else + actions = ui.row({ gap = 6 }, { + ui.button({ + glyph = "trash", + variant = "secondary", + tooltip = tr("list.delete"), + onClick = function() + pending_delete = combo_id + render() + end, + }), + ui.button({ + glyph = "arrow-right", + variant = "primary", + tooltip = tr("list.open"), + onClick = open_this, + }), + }) + end + + list_items[#list_items + 1] = ui.row({ + key = combo_id, + justify = "space_between", + align = "center", + padding = 10, + radius = 10, + fill = "surface", + border = "surface", + borderWidth = 1, + }, { + ui.column({ + gap = 2, flexGrow = 1, + onClick = open_this, + }, { + ui.label({ + text = name, + maxWidth = WRAP - 140, + fontWeight = "bold", + color = "on_surface", + }), + ui.label({ + text = chain, + maxWidth = WRAP - 140, + opacity = 0.6, + fontSize = 12, + }), + ui.label({ + text = tr("list.kind", { kind = kind }), + maxWidth = WRAP - 140, + opacity = 0.5, + fontSize = 11, + }), + }), + actions, + }) + end + end + end + + rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, list_items) + + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── create view ────────────────────────────────────────────────────────────── + +local function render_create() + local rows = {} + + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + tooltip = tr("create.back"), + onClick = function() + creating = false + render() + end, + }), + ui.glyph({ name = "plus", color = "primary", size = 20 }), + ui.label({ text = tr("create.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + rows[#rows + 1] = ui.label({ text = tr("create.name_label"), fontWeight = "bold", fontSize = 13 }) + rows[#rows + 1] = ui.input({ + key = "create_name", + value = create_name, + placeholder = tr("create.name_placeholder"), + onChange = function(text) + create_name = text or "" + create_error = "" + end, + }) + + if create_error ~= "" then + rows[#rows + 1] = ui.label({ text = create_error, color = "error", fontSize = 12, maxWidth = WRAP }) + end + + rows[#rows + 1] = ui.label({ text = tr("create.models_label"), fontWeight = "bold", fontSize = 13 }) + rows[#rows + 1] = ui.label({ text = tr("create.models_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP }) + rows[#rows + 1] = ui.input({ + key = "create_models", + value = create_models, + placeholder = tr("create.models_placeholder"), + multiline = true, + flexGrow = 1, + onChange = function(text) + create_models = text or "" + end, + }) + + rows[#rows + 1] = ui.label({ text = tr("create.kind_label"), fontWeight = "bold", fontSize = 13 }) + local kind_labels = {} + local kind_values = {} + local kind_index = nil + for i, o in ipairs(KIND_OPTIONS) do + kind_labels[i] = tr(o.label_key) + kind_values[i] = o.value + if o.value == create_kind then kind_index = i - 1 end + end + local kind_props = { + key = "create_kind", + options = kind_labels, + placeholder = tr("create.kind_label"), + onChange = function(idx) + local n = tonumber(idx) + if n ~= nil then + create_kind = kind_values[math.floor(n) + 1] or "none" + end + end, + } + if kind_index ~= nil then kind_props.selectedIndex = kind_index end + rows[#rows + 1] = ui.select(kind_props) + + rows[#rows + 1] = ui.separator({}) + + rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, { + ui.button({ + text = tr("create.cancel"), + variant = "secondary", + onClick = function() + creating = false + render() + end, + }), + ui.button({ + text = tr("create.submit"), + glyph = "check", + variant = "primary", + onClick = function() + if not valid_name(create_name) then + create_error = tr("error.invalid_name") + render() + return + end + local models = {} + for line in string.gmatch(create_models, "[^\n]+") do + local m = line:gsub("^%s+", ""):gsub("%s+$", "") + if m ~= "" then models[#models + 1] = m end + end + if #models == 0 then + create_error = tr("error.need_name_models") + render() + return + end + local payload = noctalia.json.encode({ + name = create_name, + models = models, + kind = (create_kind == "none" or create_kind == "") and nil or create_kind, + }) + dispatch_ipc("create_combo", payload) + creating = false + create_error = "" + render() + end, + }), + }) + + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── detail view ───────────────────────────────────────────────────────────── + +local function render_detail(combo) + local rows = {} + + -- Header with back button + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + tooltip = tr("detail.back"), + onClick = function() + dispatch_ipc("back_to_list") + end, + }), + ui.glyph({ name = "route", color = "primary", size = 20 }), + ui.column({ gap = 0, flexGrow = 1 }, { + ui.label({ text = combo.name or combo.id, fontWeight = "bold", maxWidth = WRAP - 200, fontSize = 14 }), + ui.label({ text = tr("detail.models_count", { count = #(combo.models or {}) }), opacity = 0.6, fontSize = 11 }), + }), + }), + ui.row({ gap = 6 }, { + ui.button({ + glyph = "refresh", + tooltip = tr("detail.refresh"), + onClick = function() + dispatch_ipc("refresh") + end, + }), + ui.button({ + glyph = "trash", + variant = "secondary", + tooltip = tr("detail.delete"), + onClick = function() + pending_delete = combo.id + render() + end, + }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + -- Inline delete confirmation + if pending_delete == combo.id then + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, { + ui.label({ text = tr("detail.delete_confirm"), color = "error", fontWeight = "bold", maxWidth = WRAP - 120 }), + ui.row({ gap = 6 }, { + ui.button({ + glyph = "check", + variant = "primary", + tooltip = tr("detail.delete_yes"), + onClick = function() + dispatch_ipc("delete_combo", combo.id) + pending_delete = nil + render() + end, + }), + ui.button({ + glyph = "x", + variant = "secondary", + tooltip = tr("detail.delete_no"), + onClick = function() + pending_delete = nil + render() + end, + }), + }), + }) + end + + -- Kind selector + local kind_labels = {} + local kind_values = {} + local kind_index = nil + for i, o in ipairs(KIND_OPTIONS) do + kind_labels[i] = tr(o.label_key) + kind_values[i] = o.value + if o.value == (combo.kind or "none") then kind_index = i - 1 end + end + local kind_props = { + key = "detail_kind", + options = kind_labels, + placeholder = tr("detail.kind_label"), + flexGrow = 1, + onChange = function(idx) + local n = tonumber(idx) + if n ~= nil then + local chosen = kind_values[math.floor(n) + 1] or "none" + dispatch_ipc("set_kind", combo.id .. ":" .. chosen) + end + end, + } + if kind_index ~= nil then kind_props.selectedIndex = kind_index end + rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("detail.kind_label"), fontWeight = "bold", fontSize = 13 }), + ui.select(kind_props), + }) + + -- Rename + if rename_id == combo.id then + rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, { + ui.input({ + key = "rename_input", + value = rename_value, + placeholder = tr("detail.rename_placeholder"), + flexGrow = 1, + onChange = function(text) + rename_value = text or "" + rename_error = "" + end, + }), + ui.button({ + glyph = "check", + variant = "primary", + tooltip = tr("detail.rename_save"), + onClick = function() + if not valid_name(rename_value) then + rename_error = tr("error.invalid_name") + render() + return + end + dispatch_ipc("rename_combo", combo.id .. ":" .. rename_value) + rename_id = nil + rename_value = "" + rename_error = "" + render() + end, + }), + ui.button({ + glyph = "x", + variant = "secondary", + tooltip = tr("detail.rename_cancel"), + onClick = function() + rename_id = nil + rename_value = "" + rename_error = "" + render() + end, + }), + }) + if rename_error ~= "" then + rows[#rows + 1] = ui.label({ text = rename_error, color = "error", fontSize = 12, maxWidth = WRAP }) + end + else + rows[#rows + 1] = ui.button({ + text = tr("detail.rename"), + glyph = "pencil", + variant = "outline", + onClick = function() + rename_id = combo.id + rename_value = combo.name or "" + render() + end, + }) + end + + rows[#rows + 1] = ui.separator({}) + + -- Model chain (reorderable via drag-and-drop) + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = tr("detail.models_title"), fontWeight = "bold", fontSize = 13 }), + ui.button({ + glyph = "plus", + variant = "secondary", + tooltip = tr("detail.add_model"), + onClick = function() + adding_model = true + model_search = "" + render() + end, + }), + }) + local models = combo.models or {} + local model_items = {} + if #models == 0 then + model_items[#model_items + 1] = ui.label({ text = tr("detail.no_models"), opacity = 0.7, maxWidth = WRAP }) + else + model_items[#model_items + 1] = ui.label({ text = tr("detail.drag_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP }) + for i, m in ipairs(models) do + local idx = i + model_items[#model_items + 1] = ui.dropZone({ + key = "drop_" .. tostring(idx), + value = tostring(idx), + accepts = { "model" }, + onDrop = function(payload, target) + dispatch_ipc("move_model", combo.id .. ":" .. tostring(payload) .. ":" .. tostring(target)) + end, + direction = "column", + gap = 6, + padding = 2, + radius = 8, + }, { + ui.dragSource({ + key = "drag_" .. tostring(idx), + dragType = "model", + payload = tostring(idx), + }, { + ui.row({ + justify = "space_between", + align = "center", + padding = 8, + radius = 8, + fill = "surface", + border = "surface", + borderWidth = 1, + }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "grip-vertical", color = "on_surface", size = 16, opacity = 0.5 }), + ui.label({ text = tostring(idx), color = "primary", fontWeight = "bold", fontSize = 12 }), + ui.label({ text = tostring(m), maxWidth = WRAP - 140, fontSize = 13 }), + }), + ui.button({ + glyph = "x", + variant = "ghost", + tooltip = tr("detail.remove_model"), + onClick = function() + dispatch_ipc("remove_model", combo.id .. ":" .. tostring(idx)) + end, + }), + }), + }), + }) + end + end + rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, model_items) + + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── add model view ─────────────────────────────────────────────────────────────── + +local function render_add_model(combo) + local rows = {} + + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + tooltip = tr("add_model.back"), + onClick = function() + adding_model = false + model_search = "" + render() + end, + }), + ui.glyph({ name = "plus", color = "primary", size = 20 }), + ui.label({ text = tr("add_model.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + rows[#rows + 1] = ui.input({ + key = "model_search", + value = model_search, + placeholder = tr("add_model.search"), + onChange = function(text) + model_search = text or "" + render() + end, + }) + + -- Available models from the system, minus ones already in this combo. + local all_models = noctalia.state.get(STATE.models) or {} + local in_combo = {} + for _, m in ipairs(combo.models or {}) do in_combo[m] = true end + local needle = string.lower(model_search) + local items = {} + local count = 0 + for _, m in ipairs(all_models) do + if type(m) == "table" and type(m.fullModel) == "string" then + local full = m.fullModel + if not in_combo[full] then + if needle == "" or string.find(string.lower(full), needle, 1, true) then + count = count + 1 + items[#items + 1] = ui.button({ + key = "add_" .. full, + text = full, + variant = "outline", + contentAlign = "start", + onClick = function() + dispatch_ipc("add_model", combo.id .. ":" .. full) + adding_model = false + model_search = "" + render() + end, + }) + end + end + end + end + + if count == 0 then + items[#items + 1] = ui.label({ + text = needle ~= "" and tr("add_model.no_match") or tr("add_model.empty"), + opacity = 0.7, + maxWidth = WRAP, + }) + end + + rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, items) + + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── login view ──────────────────────────────────────────────────────────────── + +local function render_login() + local rows = {} + + rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "lock", color = "error", size = 24 }), + ui.label({ text = tr("login.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }), + }), + }) + + rows[#rows + 1] = ui.separator({}) + + rows[#rows + 1] = ui.label({ text = tr("login.hint"), opacity = 0.7, fontSize = 12, maxWidth = WRAP }) + + rows[#rows + 1] = ui.input({ + key = "login_password", + value = login_password, + placeholder = tr("login.password_placeholder"), + password = true, + onChange = function(text) + login_password = text or "" + end, + }) + + -- Error banner (if any) + local err = noctalia.state.get(STATE.last_error) + if type(err) == "table" and err.message then + rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, { + ui.glyph({ name = "alert-circle", color = "error", size = 16 }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }), + ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }), + }), + }) + end + + rows[#rows + 1] = ui.separator({}) + + rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, { + ui.button({ + text = tr("login.submit"), + glyph = "login", + variant = "primary", + onClick = function() + dispatch_ipc("login", login_password) + login_password = "" + render() + end, + }), + }) + + panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows)) +end + +-- ── main render ────────────────────────────────────────────────────────────── + +render = function() + local conn = noctalia.state.get(STATE.connection) + local combos = noctalia.state.get(STATE.combos) or {} + local selected_id = noctalia.state.get(STATE.selected) + + -- Login view takes priority when the dashboard requires authentication. + if type(conn) == "table" and conn.status == "auth_required" then + render_login() + return + end + + -- Create view takes priority + if creating then + render_create() + return + end + + -- Detail view when a combo is selected and still exists + local selected = find_combo(combos, selected_id) + if selected then + if adding_model then + render_add_model(selected) + return + end + render_detail(selected) + return + end + + -- Otherwise the list view + render_list(combos, conn) +end + +-- ── lifecycle ─────────────────────────────────────────────────────────────── + +local function fingerprint_state() + local conn = noctalia.state.get(STATE.connection) + local combos = noctalia.state.get(STATE.combos) + local selected_id = noctalia.state.get(STATE.selected) + local err = noctalia.state.get(STATE.last_error) + return table.concat({ + (type(conn) == "table" and conn.status or "offline"), + tostring(selected_id or "none"), + (type(err) == "table" and tostring(err.message) or "none"), + combos_fingerprint(combos), + }, "\1") +end + +local snap_cache = { fingerprint = nil } + +function onOpen(_context) + panel.setWantsSecondTicks(true) + for _, key in pairs(STATE) do + noctalia.state.watch(key, function() + local fp = fingerprint_state() + if fp ~= snap_cache.fingerprint then + snap_cache.fingerprint = fp + render() + end + end) + end + render() +end + +function onClose() +end + +function update() + local fp = fingerprint_state() + if fp ~= snap_cache.fingerprint then + snap_cache.fingerprint = fp + render() + end +end diff --git a/9router-control/plugin.toml b/9router-control/plugin.toml new file mode 100644 index 0000000..252cfb1 --- /dev/null +++ b/9router-control/plugin.toml @@ -0,0 +1,68 @@ +id = "weinguyen/9router-control" +name = "9Router Control" +version = "0.1.0" +plugin_api = 3 +author = "weinguyen" +license = "MIT" +icon = "route" +description = "Manage 9Router model combos from the Noctalia bar — list, reorder chains, edit routing without the web dashboard." +tags = ["ai", "productivity", "development", "network", "bar", "panel"] +dependencies = ["cat", "curl", "cut", "sha256sum", "sleep"] + +# ── User settings ───────────────────────────────────────────────────────────── +[[setting]] +key = "server_host" +type = "string" +default = "127.0.0.1" +label_key = "settings.server_host.label" +description_key = "settings.server_host.description" + +[[setting]] +key = "server_port" +type = "double" +default = 20128 +label_key = "settings.server_port.label" +description_key = "settings.server_port.description" + +[[setting]] +key = "data_dir" +type = "string" +default = "~/.9router" +label_key = "settings.data_dir.label" +description_key = "settings.data_dir.description" + +[[setting]] +key = "language" +type = "select" +default = "auto" +label_key = "settings.language.label" +description_key = "settings.language.description" +options = [ + { value = "auto", label_key = "settings.language.options.auto" }, + { value = "en", label_key = "settings.language.options.en" }, + { value = "vi", label_key = "settings.language.options.vi" }, +] + +[[setting]] +key = "debug_logging" +type = "bool" +default = false +label_key = "settings.debug_logging.label" +description_key = "settings.debug_logging.description" + +# ── Entries ─────────────────────────────────────────────────────────────────── +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +open_near_click = true +placement = "floating" +width = 520 +height = 680 + +[[service]] +id = "service" +entry = "service.luau" diff --git a/9router-control/service.luau b/9router-control/service.luau new file mode 100644 index 0000000..6e31198 --- /dev/null +++ b/9router-control/service.luau @@ -0,0 +1,553 @@ +-- 9Router Control service — the headless backend and single source of truth. +-- +-- Responsibilities: +-- • Talk to the 9Router REST API (GET/POST/PUT/DELETE /api/combos) +-- • Authenticate: plain requests work when the dashboard has login disabled +-- (the default local setup); on a 401 we compute the CLI token from the +-- machine-id + cli-secret files and retry with the x-9r-cli-token header. +-- • Track connection state, the combo list, and the selected combo +-- • Publish shared state for the widget and panel subscribers +-- +-- The widget and panel are pure subscribers of `9router.*` state keys. + +-- ── helpers ───────────────────────────────────────────────────────────────── + +-- Translation with an optional per-plugin language override (mirrors panel.luau). +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local node = load_lang_table(lang) + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if type(node) ~= "string" then + return noctalia.tr(key, args) + end + if type(args) == "table" then + node = string.gsub(node, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return node +end + +local function debug_log(msg) + if noctalia.getConfig and noctalia.getConfig("debug_logging") then + noctalia.log("9router-control: " .. tostring(msg)) + end +end + +-- Single-quote a value so it is treated as a literal argument by the shell, +-- neutralising any metacharacters in user-provided strings (data_dir, host). +local function shell_quote(value) + return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'" +end + +local function get_server_host() + local v = noctalia.getConfig and noctalia.getConfig("server_host") + if type(v) == "string" and v ~= "" then return v end + return "127.0.0.1" +end + +local function get_server_port() + local v = noctalia.getConfig and noctalia.getConfig("server_port") + if type(v) == "number" and v > 0 then return v end + return 20128 +end + +local function get_data_dir() + local v = noctalia.getConfig and noctalia.getConfig("data_dir") + if type(v) == "string" and v ~= "" then return v end + return "~/.9router" +end + +-- ── state ──────────────────────────────────────────────────────────────────── + +local connection = { status = "offline", error = "" } +local combos = {} +local models = {} +local selected_combo_id = nil +local last_error = nil +local auth_token = nil -- JWT cookie captured from a successful login + +local function publish() + noctalia.state.set("9router.connection", connection) + noctalia.state.set("9router.combos", combos) + noctalia.state.set("9router.models", models) + noctalia.state.set("9router.selected_combo", selected_combo_id) + noctalia.state.set("9router.last_error", last_error) +end + +local function set_connection(status, err) + connection = { status = status, error = err or "" } + publish() +end + +local function set_last_error(message, detail) + last_error = { message = message, detail = detail } + publish() +end + +local function clear_last_error() + last_error = nil + publish() +end + +-- ── HTTP ───────────────────────────────────────────────────────────────────── + +local base_url = nil +local cli_token = nil +local token_loading = false + +local function build_base_url() + return "http://" .. get_server_host() .. ":" .. tostring(get_server_port()) +end + +-- Compute the 9Router CLI token: sha256(rawMachineId + "9r-cli-auth" + +-- cliSecret).substring(0,16). Noctalia has no sha256 binding, so we shell out. +-- The command reads the machine-id and cli-secret files from the data dir. +local function compute_cli_token(callback) + if cli_token then + callback(cli_token) + return + end + if token_loading then + -- Wait for the in-flight computation to finish. + local tries = 0 + local function poll() + tries = tries + 1 + if cli_token then + callback(cli_token) + elseif tries > 50 then + callback(nil) + else + noctalia.runAsync("sleep 0.1 && echo done", function() + poll() + end) + end + end + poll() + return + end + token_loading = true + local dir = get_data_dir() + -- Expand a leading "~/" to the user's home directory here (not in the + -- shell) so the path is an absolute path with no shell variable expansion + -- and can be safely single-quoted. + if dir:sub(1, 2) == "~/" then + dir = (noctalia.getenv and noctalia.getenv("HOME") or "") .. dir:sub(2) + end + local cmd = "printf '%s' \"$(cat " .. shell_quote(dir .. "/machine-id") .. " 2>/dev/null)9r-cli-auth$(cat " .. shell_quote(dir .. "/auth/cli-secret") .. " 2>/dev/null)\" | sha256sum | cut -c1-16" + noctalia.runAsync(cmd, function(result) + token_loading = false + local token = "" + if result and result.exitCode == 0 and type(result.stdout) == "string" then + token = noctalia.string and noctalia.string.trim and noctalia.string.trim(result.stdout) or result.stdout + end + if token ~= "" then + cli_token = token + debug_log("computed CLI token") + end + callback(token ~= "" and token or nil) + end) +end + +-- Perform a request with auth fallback. `with_token` is true on the retry pass. +-- Once a CLI token or login cookie is known it is attached to every request, so +-- the retry only fires when a 401 arrives before we have any auth material. +local function request(method, path, body, callback, with_token) + local url = (base_url or build_base_url()) .. path + local headers = { "Accept: application/json" } + local body_str = nil + if body then + body_str = noctalia.json.encode(body) + table.insert(headers, "Content-Type: application/json") + end + -- Attach the CLI token on every request once it is known. + if cli_token then + table.insert(headers, "x-9r-cli-token: " .. cli_token) + end + -- Attach the login cookie on every request once it is known. + if auth_token then + table.insert(headers, "Cookie: auth_token=" .. auth_token) + end + noctalia.http({ + url = url, + method = method, + headers = headers, + body = body_str, + follow_redirects = false, + }, function(response) + if not response then + callback({ ok = false, status = 0, body = "" }) + return + end + local status = response.status or 0 + -- On 401: if we have not tried with auth yet, compute the CLI token (if + -- needed) and retry once. If we already have auth material but still got + -- a 401, the token/cookie is stale — surface it so the UI can re-login. + if status == 401 and not with_token then + if cli_token or auth_token then + request(method, path, body, callback, true) + else + compute_cli_token(function(token) + if token then + request(method, path, body, callback, true) + else + callback({ ok = false, status = status, body = response.body }) + end + end) + end + return + end + callback({ ok = status >= 200 and status < 300, status = status, body = response.body }) + end) +end + +local function decode_body(body) + if type(body) ~= "string" or body == "" then return nil end + local ok, data = pcall(noctalia.json.decode, body) + if not ok or type(data) ~= "table" then return nil end + return data +end + +-- ── combo operations ───────────────────────────────────────────────────────── + +local function load_combos() + request("GET", "/api/combos", nil, function(resp) + if not resp.ok then + if resp.status == 0 then + set_connection("offline", tr("error.connection_failed")) + elseif resp.status == 401 then + set_connection("auth_required", tr("error.auth_required")) + else + set_connection("offline", tr("error.http_status", { status = tostring(resp.status) })) + end + return + end + local data = decode_body(resp.body) + if not data or type(data.combos) ~= "table" then + set_connection("offline", tr("error.bad_response")) + return + end + combos = data.combos + set_connection("online", "") + clear_last_error() + publish() + end) +end + +local function refresh_combos() + load_combos() +end + +local function load_models() + request("GET", "/api/models", nil, function(resp) + if not resp.ok then return end + local data = decode_body(resp.body) + if not data or type(data.models) ~= "table" then return end + models = data.models + publish() + end) +end + +local function select_combo(id) + selected_combo_id = id + publish() +end + +local function back_to_list() + selected_combo_id = nil + publish() +end + +local function find_combo(id) + for _, c in ipairs(combos) do + if type(c) == "table" and c.id == id then return c end + end + return nil +end + +local function reorder_model(payload) + -- payload: "combo_id:index:direction" + local id, idx_str, direction = payload:match("^([^:]+):(%d+):(%w+)$") + if not id or not idx_str or not direction then return end + local combo = find_combo(id) + if not combo or type(combo.models) ~= "table" then return end + local idx = tonumber(idx_str) + local models = {} + for _, m in ipairs(combo.models) do models[#models + 1] = m end + local n = #models + if idx < 1 or idx > n then return end + local target = direction == "up" and idx - 1 or idx + 1 + if target < 1 or target > n then return end + models[idx], models[target] = models[target], models[idx] + request("PUT", "/api/combos/" .. id, { models = models }, function(resp) + if resp.ok then + refresh_combos() + else + set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +local function move_model(payload) + -- payload: "combo_id:from_index:to_index" (1-based) + local id, from_str, to_str = payload:match("^([^:]+):(%d+):(%d+)$") + if not id or not from_str or not to_str then return end + local combo = find_combo(id) + if not combo or type(combo.models) ~= "table" then return end + local from = tonumber(from_str) + local to = tonumber(to_str) + local models = {} + for _, m in ipairs(combo.models) do models[#models + 1] = m end + local n = #models + if from < 1 or from > n or to < 1 or to > n or from == to then return end + local moved = table.remove(models, from) + table.insert(models, to, moved) + request("PUT", "/api/combos/" .. id, { models = models }, function(resp) + if resp.ok then + refresh_combos() + else + set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +local function add_model(payload) + -- payload: "combo_id:model_fullModel" (provider/model) + local id, model = payload:match("^([^:]+):(.+)$") + if not id or not model or model == "" then return end + local combo = find_combo(id) + if not combo or type(combo.models) ~= "table" then return end + local models = {} + for _, m in ipairs(combo.models) do models[#models + 1] = m end + for _, m in ipairs(models) do + if m == model then return end -- already present + end + models[#models + 1] = model + request("PUT", "/api/combos/" .. id, { models = models }, function(resp) + if resp.ok then + refresh_combos() + else + set_last_error(tr("error.add_model_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +local function remove_model(payload) + -- payload: "combo_id:index" (1-based) + local id, idx_str = payload:match("^([^:]+):(%d+)$") + if not id or not idx_str then return end + local combo = find_combo(id) + if not combo or type(combo.models) ~= "table" then return end + local idx = tonumber(idx_str) + local models = {} + for _, m in ipairs(combo.models) do models[#models + 1] = m end + if idx < 1 or idx > #models then return end + table.remove(models, idx) + request("PUT", "/api/combos/" .. id, { models = models }, function(resp) + if resp.ok then + refresh_combos() + else + set_last_error(tr("error.remove_model_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +local function create_combo(payload) + -- payload: JSON string { name, models, kind } + local ok, data = pcall(noctalia.json.decode, payload) + if not ok or type(data) ~= "table" then return end + local name = type(data.name) == "string" and data.name or "" + local models = type(data.models) == "table" and data.models or {} + local kind = type(data.kind) == "string" and data.kind or nil + if name == "" or #models == 0 then + set_last_error(tr("error.create_failed"), tr("error.need_name_models")) + return + end + request("POST", "/api/combos", { name = name, models = models, kind = kind }, function(resp) + if resp.ok then + refresh_combos() + else + local data2 = decode_body(resp.body) + local detail = (type(data2) == "table" and type(data2.error) == "string" and data2.error) + or tr("error.http_status", { status = tostring(resp.status) }) + set_last_error(tr("error.create_failed"), detail) + end + end) +end + +local function delete_combo(id) + request("DELETE", "/api/combos/" .. id, nil, function(resp) + if resp.ok then + if selected_combo_id == id then selected_combo_id = nil end + refresh_combos() + else + set_last_error(tr("error.delete_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +local function rename_combo(payload) + -- payload: "combo_id:new_name" + local id, name = payload:match("^([^:]+):(.+)$") + if not id or not name or name == "" then return end + request("PUT", "/api/combos/" .. id, { name = name }, function(resp) + if resp.ok then + refresh_combos() + else + local data = decode_body(resp.body) + local detail = (type(data) == "table" and type(data.error) == "string" and data.error) + or tr("error.http_status", { status = tostring(resp.status) }) + set_last_error(tr("error.rename_failed"), detail) + end + end) +end + +local function set_kind(payload) + -- payload: "combo_id:kind" + local id, kind = payload:match("^([^:]+):(.+)$") + if not id or not kind then return end + local value = (kind == "none" or kind == "") and nil or kind + request("PUT", "/api/combos/" .. id, { kind = value }, function(resp) + if resp.ok then + refresh_combos() + else + set_last_error(tr("error.kind_failed"), tr("error.http_status", { status = tostring(resp.status) })) + end + end) +end + +-- ── auth (login / logout) ──────────────────────────────────────────────────── + +-- Log in against the dashboard and capture the auth_token cookie. Noctalia's +-- http binding does not expose response headers, so we shell out to curl with +-- `-i` to read the Set-Cookie header, then reuse the cookie on later requests. +local function login(payload) + if type(payload) ~= "string" or payload == "" then + set_last_error(tr("error.login_failed"), tr("error.need_password")) + return + end + local url = (base_url or build_base_url()) .. "/api/auth/login" + -- Escape the password for the JSON body, then for the shell single-quote. + local json_password = payload:gsub("\\", "\\\\"):gsub('"', '\\"') + local body = '{"password":"' .. json_password .. '"}' + local cmd = "curl -s -i -X POST " .. shell_quote(url) .. " -H 'Content-Type: application/json' -d " .. shell_quote(body) + debug_log("login: posting to /api/auth/login") + noctalia.runAsync(cmd, function(result) + if not result or result.exitCode ~= 0 then + set_last_error(tr("error.login_failed"), tr("error.connection_failed")) + return + end + local stdout = result.stdout or "" + local cookie = stdout:match("auth_token=([^;%s]+)") + if not cookie then + -- No cookie set — likely a bad password. Surface the server error. + local body_part = stdout:match("\r?\n\r?\n(.*)$") or stdout + local ok, data = pcall(noctalia.json.decode, body_part) + local detail = (ok and type(data) == "table" and type(data.error) == "string" and data.error) + or tr("error.login_failed") + set_last_error(tr("error.login_failed"), detail) + return + end + auth_token = cookie + debug_log("login: authenticated") + clear_last_error() + set_connection("online", "") + load_combos() + end) +end + +local function logout() + auth_token = nil + cli_token = nil + set_connection("auth_required", tr("error.auth_required")) +end + +-- ── ipc handling ───────────────────────────────────────────────────────────── + +function onIpc(event, payload) + debug_log("IPC: " .. tostring(event)) + if event == "refresh" then + refresh_combos() + elseif event == "select_combo" then + if type(payload) == "string" then select_combo(payload) end + elseif event == "back_to_list" then + back_to_list() + elseif event == "reorder_model" then + if type(payload) == "string" then reorder_model(payload) end + elseif event == "move_model" then + if type(payload) == "string" then move_model(payload) end + elseif event == "add_model" then + if type(payload) == "string" then add_model(payload) end + elseif event == "remove_model" then + if type(payload) == "string" then remove_model(payload) end + elseif event == "create_combo" then + if type(payload) == "string" then create_combo(payload) end + elseif event == "delete_combo" then + if type(payload) == "string" then delete_combo(payload) end + elseif event == "rename_combo" then + if type(payload) == "string" then rename_combo(payload) end + elseif event == "set_kind" then + if type(payload) == "string" then set_kind(payload) end + elseif event == "login" then + if type(payload) == "string" then login(payload) end + elseif event == "logout" then + logout() + elseif event == "clear_error" then + clear_last_error() + end +end + +-- ── init ───────────────────────────────────────────────────────────────────── + +function onOpen() + base_url = build_base_url() + publish() + load_combos() + load_models() +end + +function onExit() +end + +-- Start on load +onOpen() diff --git a/9router-control/thumbnail.webp b/9router-control/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..2a6de9237b81ccb12e19329e866474c7fce77c29 GIT binary patch literal 86942 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 z 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 literal 0 HcmV?d00001 diff --git a/9router-control/translations/en.json b/9router-control/translations/en.json new file mode 100644 index 0000000..fba49fc --- /dev/null +++ b/9router-control/translations/en.json @@ -0,0 +1,120 @@ +{ + "state": { + "tip": { + "online": "9Router: connected", + "offline": "9Router: offline", + "auth_required": "9Router: login required" + }, + "combos": "{count} combo(s)" + }, + "login": { + "title": "9Router Login", + "hint": "The dashboard requires authentication. Enter your 9Router password to continue.", + "password_placeholder": "Password…", + "submit": "Log in" + }, + "list": { + "title": "9Router — Combos", + "refresh": "Refresh", + "new_combo": "New Combo", + "search": "Search combos…", + "empty": "No combos yet. Create one to get started.", + "no_match": "No combos match your search.", + "open": "Open combo", + "delete": "Delete combo", + "delete_confirm": "Confirm delete", + "delete_cancel": "Cancel", + "kind": "Kind: {kind}" + }, + "detail": { + "back": "Back to combos", + "models_count": "{count} model(s)", + "refresh": "Refresh", + "delete": "Delete combo", + "delete_confirm": "Delete this combo?", + "delete_yes": "Yes, delete", + "delete_no": "Cancel", + "kind_label": "Kind", + "rename": "Rename", + "rename_placeholder": "New name…", + "rename_save": "Save", + "rename_cancel": "Cancel", + "models_title": "Model Chain", + "no_models": "This combo has no models yet.", + "drag_hint": "Drag a model to reorder", + "add_model": "Add model", + "remove_model": "Remove model", + "move_up": "Move up", + "move_down": "Move down" + }, + "add_model": { + "title": "Add Model", + "back": "Back to combo", + "search": "Search models…", + "empty": "No models available.", + "no_match": "No models match your search." + }, + "create": { + "title": "New Combo", + "back": "Back", + "name_label": "Name", + "name_placeholder": "e.g. my-fallback", + "models_label": "Models", + "models_hint": "One model per line, in fallback order (e.g. cc/claude-opus-4-7).", + "models_placeholder": "cc/claude-opus-4-7\nglm/glm-5.1", + "kind_label": "Kind", + "cancel": "Cancel", + "submit": "Create" + }, + "kind": { + "none": "None", + "fallback": "Fallback", + "round_robin": "Round-robin", + "fusion": "Fusion" + }, + "error": { + "dismiss": "Dismiss", + "connection_failed": "Could not connect to 9Router", + "http_status": "HTTP {status}", + "bad_response": "Unexpected response from 9Router", + "auth_required": "Authentication required", + "login_failed": "Login failed", + "need_password": "Please enter your password.", + "reorder_failed": "Failed to reorder models", + "add_model_failed": "Failed to add model", + "remove_model_failed": "Failed to remove model", + "invalid_name": "Name can only contain letters, numbers, -, _ and .", + "create_failed": "Failed to create combo", + "need_name_models": "A name and at least one model are required.", + "delete_failed": "Failed to delete combo", + "rename_failed": "Failed to rename combo", + "kind_failed": "Failed to update kind" + }, + "settings": { + "server_host": { + "label": "Server Host", + "description": "Hostname of the 9Router dashboard. Default: 127.0.0.1." + }, + "server_port": { + "label": "Server Port", + "description": "Port of the 9Router dashboard. Default: 20128." + }, + "data_dir": { + "label": "Data Directory", + "description": "9Router data directory (used to compute the CLI token when the dashboard requires login). Default: ~/.9router." + }, + "language": { + "label": "Language", + "description": "Language for this plugin's UI. 'Auto' follows the Noctalia shell language.", + "options": { + "auto": "Auto (follow shell)", + "en": "English", + "vi": "Tiếng Việt" + } + }, + "debug_logging": { + "label": "Debug Logging", + "description": "Print debug messages to the Noctalia log." + } + } +} diff --git a/9router-control/translations/vi.json b/9router-control/translations/vi.json new file mode 100644 index 0000000..501844e --- /dev/null +++ b/9router-control/translations/vi.json @@ -0,0 +1,120 @@ +{ + "state": { + "tip": { + "online": "9Router: đã kết nối", + "offline": "9Router: ngoại tuyến", + "auth_required": "9Router: cần đăng nhập" + }, + "combos": "{count} combo" + }, + "login": { + "title": "Đăng nhập 9Router", + "hint": "Dashboard yêu cầu xác thực. Nhập mật khẩu 9Router của bạn để tiếp tục.", + "password_placeholder": "Mật khẩu…", + "submit": "Đăng nhập" + }, + "list": { + "title": "9Router — Combos", + "refresh": "Làm mới", + "new_combo": "Combo mới", + "search": "Tìm combo…", + "empty": "Chưa có combo nào. Tạo một combo để bắt đầu.", + "no_match": "Không có combo nào khớp tìm kiếm.", + "open": "Mở combo", + "delete": "Xóa combo", + "delete_confirm": "Xác nhận xóa", + "delete_cancel": "Hủy", + "kind": "Loại: {kind}" + }, + "detail": { + "back": "Quay lại danh sách", + "models_count": "{count} model", + "refresh": "Làm mới", + "delete": "Xóa combo", + "delete_confirm": "Xóa combo này?", + "delete_yes": "Có, xóa", + "delete_no": "Hủy", + "kind_label": "Loại", + "rename": "Đổi tên", + "rename_placeholder": "Tên mới…", + "rename_save": "Lưu", + "rename_cancel": "Hủy", + "models_title": "Chuỗi Model", + "no_models": "Combo này chưa có model nào.", + "drag_hint": "Kéo model để sắp xếp lại", + "add_model": "Thêm model", + "remove_model": "Xóa model", + "move_up": "Di chuyển lên", + "move_down": "Di chuyển xuống" + }, + "add_model": { + "title": "Thêm Model", + "back": "Quay lại combo", + "search": "Tìm model…", + "empty": "Không có model nào.", + "no_match": "Không có model khớp tìm kiếm." + }, + "create": { + "title": "Combo mới", + "back": "Quay lại", + "name_label": "Tên", + "name_placeholder": "vd: my-fallback", + "models_label": "Models", + "models_hint": "Mỗi model một dòng, theo thứ tự fallback (vd: cc/claude-opus-4-7).", + "models_placeholder": "cc/claude-opus-4-7\nglm/glm-5.1", + "kind_label": "Loại", + "cancel": "Hủy", + "submit": "Tạo" + }, + "kind": { + "none": "Không", + "fallback": "Fallback", + "round_robin": "Round-robin", + "fusion": "Fusion" + }, + "error": { + "dismiss": "Đóng", + "connection_failed": "Không kết nối được 9Router", + "http_status": "HTTP {status}", + "bad_response": "Phản hồi không mong đợi từ 9Router", + "auth_required": "Cần xác thực", + "login_failed": "Đăng nhập thất bại", + "need_password": "Vui lòng nhập mật khẩu.", + "reorder_failed": "Không thể sắp xếp lại model", + "add_model_failed": "Không thể thêm model", + "remove_model_failed": "Không thể xóa model", + "invalid_name": "Tên chỉ được chứa chữ, số, -, _ và .", + "create_failed": "Không thể tạo combo", + "need_name_models": "Cần tên và ít nhất một model.", + "delete_failed": "Không xóa được combo", + "rename_failed": "Không đổi tên được combo", + "kind_failed": "Không cập nhật được loại" + }, + "settings": { + "server_host": { + "label": "Máy chủ", + "description": "Hostname của dashboard 9Router. Mặc định: 127.0.0.1." + }, + "server_port": { + "label": "Cổng", + "description": "Cổng của dashboard 9Router. Mặc định: 20128." + }, + "data_dir": { + "label": "Thư mục dữ liệu", + "description": "Thư mục dữ liệu 9Router (dùng để tính CLI token khi dashboard yêu cầu đăng nhập). Mặc định: ~/.9router." + }, + "language": { + "label": "Ngôn ngữ", + "description": "Ngôn ngữ giao diện của plugin. 'Tự động' theo ngôn ngữ shell Noctalia.", + "options": { + "auto": "Tự động (theo shell)", + "en": "English", + "vi": "Tiếng Việt" + } + }, + "debug_logging": { + "label": "Ghi log gỡ lỗi", + "description": "In thông điệp gỡ lỗi vào log Noctalia." + } + } +} diff --git a/9router-control/widget.luau b/9router-control/widget.luau new file mode 100644 index 0000000..e3b54b9 --- /dev/null +++ b/9router-control/widget.luau @@ -0,0 +1,165 @@ +-- 9Router Control bar widget — a pure subscriber of 9router.connection state. +-- Shows connection status as an accent-colored glyph with a breathing glow, +-- plus the number of combos when connected. Click opens the panel. + +local STATE_KEY = "9router.connection" +local COMBOS_KEY = "9router.combos" + +local GLYPH = { + online = "route", + offline = "route-off", + auth_required = "lock", +} +local COLOR = { + online = "primary", + offline = "error", + auth_required = "error", +} + +local BREATH_PERIOD = 6.0 +local BREATH_FLOOR = 0.45 +local BREATH_CEIL = 1.0 +local TICK_MS = 100 + +local snap = { status = "offline" } +local combo_count = 0 +local phase = BREATH_PERIOD / 2 + +-- Translation with an optional per-plugin language override (mirrors panel.luau). +local i18n_cache = { lang = nil, table = nil } + +local function load_lang_table(lang) + if i18n_cache.lang == lang and i18n_cache.table then + return i18n_cache.table + end + local merged = {} + local function merge_file(path) + local ok, content = pcall(noctalia.readFile, path) + if not ok or type(content) ~= "string" or content == "" then return end + local ok2, data = pcall(noctalia.json.decode, content) + if not ok2 or type(data) ~= "table" then return end + local function deep(dst, src) + for k, v in pairs(src) do + if type(v) == "table" and type(dst[k]) == "table" then + deep(dst[k], v) + else + dst[k] = v + end + end + end + deep(merged, data) + end + merge_file("translations/en.json") + if lang ~= "en" then + merge_file("translations/" .. lang .. ".json") + end + i18n_cache.lang = lang + i18n_cache.table = merged + return merged +end + +local function tr(key, args) + local lang = noctalia.getConfig and noctalia.getConfig("language") + if type(lang) ~= "string" or lang == "" or lang == "auto" then + return noctalia.tr(key, args) + end + local node = load_lang_table(lang) + for part in string.gmatch(key, "[^%.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if type(node) ~= "string" then + return noctalia.tr(key, args) + end + if type(args) == "table" then + node = string.gsub(node, "{(%w+)}", function(name) + local v = args[name] + if v == nil then return "{" .. name .. "}" end + return tostring(v) + end) + end + return node +end + +-- Scale an RRGGBB hex toward black by factor b, returning "#RRGGBB" +local function dim(hex, b) + if type(hex) ~= "string" or #hex ~= 6 then return "#808080" end + local function ch(i) + local x = math.floor((tonumber(hex:sub(i, i + 1), 16) or 0) * b + 0.5) + if x < 0 then x = 0 elseif x > 255 then x = 255 end + return x + end + return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5)) +end + +local ACCENT_RGB = { + primary = "B4FF00", + secondary = "EAFF00", + error = "FF4D1F", +} + +local function level_for(period) + local t = phase % period + local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi) + return BREATH_FLOOR + (BREATH_CEIL - BREATH_FLOOR) * s +end + +local function paint() + local g = GLYPH[snap.status] or GLYPH.offline + local c = COLOR[snap.status] or COLOR.offline + barWidget.setGlyph(g) + barWidget.setGlyphColor(dim(ACCENT_RGB[c] or ACCENT_RGB.secondary, level_for(BREATH_PERIOD))) +end + +local function render() + paint() + local tip = tr("state.tip." .. tostring(snap.status)) or tr("state.tip.offline") + if snap.error and snap.error ~= "" then + tip = tip .. "\n" .. snap.error + end + if snap.status == "online" then + tip = tip .. "\n" .. tr("state.combos", { count = combo_count }) + end + barWidget.setTooltip(tip) +end + +local function apply(s) + if type(s) ~= "table" then return end + local status = type(s.status) == "string" and s.status or "offline" + local changed = (status ~= snap.status) + snap = { status = status, error = s.error } + if changed then + phase = BREATH_PERIOD / 2 + end + render() +end + +local function apply_combos(list) + if type(list) == "table" then + combo_count = #list + end + render() +end + +function onClick() + noctalia.togglePanel("weinguyen/9router-control:panel") +end + +function onRightClick() + noctalia.runAsync("noctalia msg plugin 'weinguyen/9router-control:service' all refresh") +end + +function update() + noctalia.setUpdateInterval(TICK_MS) + phase = phase + TICK_MS / 1000 + if phase > 1e6 then phase = 0 end + paint() +end + +noctalia.state.watch(STATE_KEY, apply) +noctalia.state.watch(COMBOS_KEY, apply_combos) +noctalia.setUpdateInterval(TICK_MS) + +apply(noctalia.state.get(STATE_KEY)) +apply_combos(noctalia.state.get(COMBOS_KEY)) +render()