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.
This commit is contained in:
weinguyen
2026-08-08 15:13:03 -04:00
committed by GitHub
parent 220bdb0773
commit 2f760f6103
9 changed files with 2032 additions and 0 deletions
+21
View File
@@ -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.
+84
View File
@@ -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.
+901
View File
@@ -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
+68
View File
@@ -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"
+553
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+120
View File
@@ -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."
}
}
}
+120
View File
@@ -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."
}
}
}
+165
View File
@@ -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()