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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user