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 0000000..2a6de92 Binary files /dev/null and b/9router-control/thumbnail.webp differ 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()