add(deepseek_usage): DeepSeek API credit & balance monitor (#220)
* add(deepseek_usage): add DeepSeek usage balance monitor plugin * add(deepseek_usage): add thumbnail.webp * chore(deepseek_usage): update plugin id to coder/deepseek_usage * fix(deepseek_usage): update 960x540 thumbnail, README IPC docs, and plugin.toml schema * fix(deepseek_usage): fix panel ID, normalize ui.graph values 0-1, declare xdg-open dependency * fix(deepseek_usage): replace thumbnail with official Noctalia generator asset
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# DeepSeek Usage Plugin for Noctalia
|
||||
|
||||
A balance and API credit monitor for [Noctalia shell](https://noctalia.dev).
|
||||
|
||||
## Plugin
|
||||
|
||||
Plugin ID: `coder/deepseek_usage`
|
||||
|
||||
Widget(s):
|
||||
- `bar` — bar widget entry.
|
||||
|
||||
Panel(s):
|
||||
- `panel` — panel entry.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `xdg-open` — used by the top-up button to open platform.deepseek.com/top_up in the default browser.
|
||||
|
||||
## External dependencies
|
||||
|
||||
- `xdg-open`
|
||||
|
||||
## Usage
|
||||
|
||||
Widget
|
||||
- Add the `bar` widget to your bar to display usage.
|
||||
|
||||
Panel
|
||||
- Toggle the plugin panel with the following IPC command:
|
||||
`noctalia msg panel-toggle coder/deepseek_usage:panel`
|
||||
|
||||
## Features
|
||||
- **Bar Widget**: Shows live balance directly on your Noctalia bar.
|
||||
- **Low Balance Warning**: Visual indicator and desktop notifications when credits drop below your set threshold.
|
||||
- **Interactive Panel**: Displays account wallet summary, local trend graph, and quick top-up action.
|
||||
- **One-Click Top-Up**: "Add Credits" button opens `platform.deepseek.com/top_up` directly in your browser.
|
||||
|
||||
## Settings
|
||||
Configure the update interval and API key in Noctalia plugin settings.
|
||||
|
||||
- **DeepSeek API Key**: Create an API key at [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) and enter it in plugin settings.
|
||||
- **Refresh Interval**: Default is 15 minutes.
|
||||
- **Low Balance Warning**: Default threshold is 2.00.
|
||||
@@ -0,0 +1,213 @@
|
||||
--!nonstrict
|
||||
-- DeepSeek Usage Bar Widget & Background Service Entry
|
||||
|
||||
local DEFAULT_REFRESH_MINUTES = 15
|
||||
local HISTORY_FILE = "balance_history.json"
|
||||
local MAX_HISTORY_POINTS = 48
|
||||
|
||||
local CURRENCY_SYMBOLS = {
|
||||
USD = "$",
|
||||
CNY = "¥",
|
||||
EUR = "€",
|
||||
GBP = "£",
|
||||
JPY = "¥",
|
||||
}
|
||||
|
||||
local function formatBalance(balance: number?, currency: string?): string
|
||||
if balance == nil then
|
||||
return "--.--"
|
||||
end
|
||||
local curr = currency or "USD"
|
||||
local symbol = CURRENCY_SYMBOLS[curr]
|
||||
if symbol then
|
||||
return string.format("%s%.2f", symbol, balance)
|
||||
else
|
||||
return string.format("%.2f %s", balance, curr)
|
||||
end
|
||||
end
|
||||
|
||||
local state = {
|
||||
balance = nil :: number?,
|
||||
currency = "USD",
|
||||
lastUpdated = nil :: string?,
|
||||
errorMsg = nil :: string?,
|
||||
isFetching = false,
|
||||
}
|
||||
|
||||
-- Load accumulated balance history from disk
|
||||
local function loadHistory(): { { time: number, balance: number } }
|
||||
local dir = noctalia.pluginDataDir()
|
||||
if not dir then return {} end
|
||||
local path = dir .. "/" .. HISTORY_FILE
|
||||
local content = noctalia.readFile(path)
|
||||
if not content then return {} end
|
||||
local decoded = noctalia.json.decode(content)
|
||||
if type(decoded) == "table" then
|
||||
return decoded
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Save balance sample to persistent history
|
||||
local function recordBalanceSample(val: number)
|
||||
local dir = noctalia.pluginDataDir()
|
||||
if not dir then return end
|
||||
|
||||
local history = loadHistory()
|
||||
local now = os.time()
|
||||
|
||||
table.insert(history, { time = now, balance = val })
|
||||
|
||||
-- Keep only the latest N samples
|
||||
while #history > MAX_HISTORY_POINTS do
|
||||
table.remove(history, 1)
|
||||
end
|
||||
|
||||
local encoded = noctalia.json.encode(history, false)
|
||||
if encoded then
|
||||
noctalia.writeFile(dir .. "/" .. HISTORY_FILE, encoded)
|
||||
end
|
||||
|
||||
-- Share history state with panel
|
||||
noctalia.state.set("deepseek.history", history)
|
||||
end
|
||||
|
||||
local function renderWidget()
|
||||
local apiKey = noctalia.getConfig("api_key") or ""
|
||||
|
||||
if apiKey == "" then
|
||||
barWidget.setGlyph("wallet")
|
||||
barWidget.setText(noctalia.tr("widget.no_key"))
|
||||
barWidget.setTooltip({
|
||||
{ key = noctalia.tr("tooltip.status"), value = noctalia.tr("status.unconfigured") },
|
||||
{ key = noctalia.tr("tooltip.action"), value = noctalia.tr("tooltip.click_to_configure") },
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if state.isFetching and state.balance == nil then
|
||||
barWidget.setGlyph("refresh")
|
||||
barWidget.setText(noctalia.tr("widget.loading"))
|
||||
return
|
||||
end
|
||||
|
||||
if state.errorMsg and state.balance == nil then
|
||||
barWidget.setGlyph("alert-circle")
|
||||
barWidget.setText(noctalia.tr("widget.error"))
|
||||
barWidget.setTooltip({
|
||||
{ key = noctalia.tr("tooltip.status"), value = state.errorMsg },
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local threshold = tonumber(noctalia.getConfig("low_balance_threshold")) or 2.0
|
||||
local balanceStr = formatBalance(state.balance, state.currency)
|
||||
|
||||
barWidget.setGlyph("wallet")
|
||||
barWidget.setText(balanceStr)
|
||||
|
||||
if state.balance ~= nil and state.balance < threshold then
|
||||
barWidget.setColor("error")
|
||||
else
|
||||
barWidget.setColor("primary")
|
||||
end
|
||||
|
||||
barWidget.setTooltip({
|
||||
{ key = noctalia.tr("tooltip.balance"), value = balanceStr .. " " .. state.currency },
|
||||
{ key = noctalia.tr("tooltip.last_updated"), value = state.lastUpdated or noctalia.tr("status.never") },
|
||||
{ key = noctalia.tr("tooltip.status"), value = state.errorMsg or noctalia.tr("status.ok") },
|
||||
})
|
||||
end
|
||||
|
||||
local function fetchSummary()
|
||||
local apiKey = noctalia.getConfig("api_key") or ""
|
||||
if apiKey == "" then
|
||||
state.errorMsg = noctalia.tr("status.unconfigured")
|
||||
noctalia.state.set("deepseek.state", state)
|
||||
renderWidget()
|
||||
return
|
||||
end
|
||||
|
||||
state.isFetching = true
|
||||
renderWidget()
|
||||
|
||||
noctalia.http({
|
||||
url = "https://api.deepseek.com/user/balance",
|
||||
method = "GET",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. apiKey,
|
||||
"Accept: application/json",
|
||||
},
|
||||
}, function(res)
|
||||
state.isFetching = false
|
||||
|
||||
if not res.ok then
|
||||
state.errorMsg = string.format("HTTP Transport Error (%d)", res.status)
|
||||
noctalia.notifyError("DeepSeek Usage", state.errorMsg)
|
||||
noctalia.state.set("deepseek.state", state)
|
||||
renderWidget()
|
||||
return
|
||||
end
|
||||
|
||||
if res.status ~= 200 then
|
||||
state.errorMsg = string.format("API Error (%d)", res.status)
|
||||
if res.status == 401 then
|
||||
state.errorMsg = noctalia.tr("error.invalid_key")
|
||||
end
|
||||
noctalia.notifyError("DeepSeek Usage", state.errorMsg)
|
||||
noctalia.state.set("deepseek.state", state)
|
||||
renderWidget()
|
||||
return
|
||||
end
|
||||
|
||||
local parsed, err = noctalia.json.decode(res.body)
|
||||
if not parsed or type(parsed) ~= "table" then
|
||||
state.errorMsg = noctalia.tr("error.parse_failed")
|
||||
noctalia.state.set("deepseek.state", state)
|
||||
renderWidget()
|
||||
return
|
||||
end
|
||||
|
||||
local balanceInfos = parsed.balance_infos
|
||||
if balanceInfos and #balanceInfos > 0 then
|
||||
local primaryWallet = balanceInfos[1]
|
||||
state.currency = primaryWallet.currency or "USD"
|
||||
state.balance = tonumber(primaryWallet.total_balance) or 0.0
|
||||
state.errorMsg = nil
|
||||
state.lastUpdated = noctalia.formatTime("%H:%M:%S", os.time())
|
||||
|
||||
recordBalanceSample(state.balance)
|
||||
|
||||
-- Check low balance notification
|
||||
local threshold = tonumber(noctalia.getConfig("low_balance_threshold")) or 2.0
|
||||
if state.balance < threshold then
|
||||
noctalia.notify("DeepSeek Balance Warning", string.format("Your DeepSeek balance is low: %s", formatBalance(state.balance, state.currency)))
|
||||
end
|
||||
else
|
||||
state.errorMsg = noctalia.tr("error.no_wallet")
|
||||
end
|
||||
|
||||
noctalia.state.set("deepseek.state", state)
|
||||
renderWidget()
|
||||
end)
|
||||
end
|
||||
|
||||
function update()
|
||||
local refreshMinutes = tonumber(noctalia.getConfig("refresh_minutes")) or DEFAULT_REFRESH_MINUTES
|
||||
noctalia.setUpdateInterval(refreshMinutes * 60 * 1000)
|
||||
fetchSummary()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("coder/deepseek_usage:panel")
|
||||
end
|
||||
|
||||
noctalia.state.watch("deepseek.refresh_requested", function(req)
|
||||
if req then
|
||||
noctalia.state.set("deepseek.refresh_requested", false)
|
||||
fetchSummary()
|
||||
end
|
||||
end)
|
||||
|
||||
-- Initial setup
|
||||
renderWidget()
|
||||
@@ -0,0 +1,235 @@
|
||||
--!nonstrict
|
||||
-- DeepSeek Usage Panel Entry — Graphical Balance & Top-Up Dashboard
|
||||
|
||||
local CURRENCY_SYMBOLS = {
|
||||
USD = "$",
|
||||
CNY = "¥",
|
||||
EUR = "€",
|
||||
GBP = "£",
|
||||
JPY = "¥",
|
||||
}
|
||||
|
||||
local function formatBalance(balance: number?, currency: string?): string
|
||||
if balance == nil then
|
||||
return "--.--"
|
||||
end
|
||||
local curr = currency or "USD"
|
||||
local symbol = CURRENCY_SYMBOLS[curr]
|
||||
if symbol then
|
||||
return string.format("%s%.2f", symbol, balance)
|
||||
else
|
||||
return string.format("%.2f %s", balance, curr)
|
||||
end
|
||||
end
|
||||
|
||||
local state = {
|
||||
balance = nil :: number?,
|
||||
currency = "USD",
|
||||
lastUpdated = nil :: string?,
|
||||
errorMsg = nil :: string?,
|
||||
isFetching = false,
|
||||
}
|
||||
|
||||
local history = {}
|
||||
|
||||
function openTopUpPage()
|
||||
noctalia.runAsync("xdg-open https://platform.deepseek.com/top_up")
|
||||
end
|
||||
|
||||
function triggerRefresh()
|
||||
noctalia.state.set("deepseek.refresh_requested", true)
|
||||
end
|
||||
|
||||
function onClosePanel()
|
||||
panel.close()
|
||||
end
|
||||
|
||||
function onOpenSettings()
|
||||
noctalia.openSettings()
|
||||
end
|
||||
|
||||
local function buildGraphValues(): ({ number }, number, number)
|
||||
if #history == 0 then
|
||||
return { 0 }, 0, 0
|
||||
end
|
||||
|
||||
local minVal = history[1].balance
|
||||
local maxVal = history[1].balance
|
||||
|
||||
for _, pt in ipairs(history) do
|
||||
if pt.balance < minVal then minVal = pt.balance end
|
||||
if pt.balance > maxVal then maxVal = pt.balance end
|
||||
end
|
||||
|
||||
local range = maxVal - minVal
|
||||
local values = {}
|
||||
for _, pt in ipairs(history) do
|
||||
table.insert(values, range > 0 and (pt.balance - minVal) / range or 0.5)
|
||||
end
|
||||
|
||||
return values, minVal, maxVal
|
||||
end
|
||||
|
||||
local function renderGraphSection(values: { number }, minVal: number, maxVal: number)
|
||||
-- graph props unverified — adjust after hot-reload test
|
||||
local graphOk, graphNode = pcall(function()
|
||||
return ui.graph({
|
||||
values = values,
|
||||
height = 64,
|
||||
color = "primary",
|
||||
})
|
||||
end)
|
||||
|
||||
local graphElement
|
||||
if graphOk and type(graphNode) == "table" then
|
||||
graphElement = graphNode
|
||||
else
|
||||
graphElement = ui.label({
|
||||
text = string.format("History (%d data points)", #history),
|
||||
fontSize = 11,
|
||||
color = "on_surface_variant",
|
||||
})
|
||||
end
|
||||
|
||||
return ui.column({ gap = 6 }, {
|
||||
ui.row({ justify = "space_between", align = "center" }, {
|
||||
ui.label({ text = noctalia.tr("panel.history_title"), fontSize = 12, fontWeight = "medium", color = "on_surface_variant" }),
|
||||
ui.label({ text = string.format("%d samples", #history), fontSize = 10, color = "on_surface_variant" }),
|
||||
}),
|
||||
ui.column({
|
||||
fill = "surface_variant/0.2",
|
||||
radius = 8,
|
||||
padding = 8,
|
||||
gap = 4,
|
||||
}, {
|
||||
graphElement,
|
||||
ui.row({ justify = "space_between" }, {
|
||||
ui.label({ text = string.format("Min: %s", formatBalance(minVal, state.currency)), fontSize = 10, color = "on_surface_variant" }),
|
||||
ui.label({ text = string.format("Max: %s", formatBalance(maxVal, state.currency)), fontSize = 10, color = "on_surface_variant" }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
local function render()
|
||||
local apiKey = noctalia.getConfig("api_key") or ""
|
||||
local isUnconfigured = apiKey == ""
|
||||
|
||||
local values, minVal, maxVal = buildGraphValues()
|
||||
local balanceDisplay = formatBalance(state.balance, state.currency)
|
||||
|
||||
-- Header Row
|
||||
local headerRow = ui.row({ align = "center", justify = "space_between", gap = 8 }, {
|
||||
ui.row({ gap = 8, align = "center" }, {
|
||||
ui.glyph({ name = "wallet", size = 18, color = "primary" }),
|
||||
ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold", color = "on_surface" }),
|
||||
}),
|
||||
ui.row({ gap = 4, align = "center" }, {
|
||||
ui.button({ glyph = "refresh", variant = "ghost", onClick = "triggerRefresh", tooltip = noctalia.tr("panel.refresh_tooltip") }),
|
||||
ui.button({ glyph = "close", variant = "ghost", onClick = "onClosePanel" }),
|
||||
}),
|
||||
})
|
||||
|
||||
-- Scrollable Body Items
|
||||
local bodyItems = {}
|
||||
|
||||
-- Unconfigured State Banner
|
||||
if isUnconfigured then
|
||||
table.insert(bodyItems, ui.column({
|
||||
fill = "surface_variant/0.5",
|
||||
radius = 8,
|
||||
padding = 12,
|
||||
gap = 8,
|
||||
align = "stretch",
|
||||
}, {
|
||||
ui.label({ text = noctalia.tr("panel.unconfigured_title"), fontWeight = "bold", color = "error" }),
|
||||
ui.label({ text = noctalia.tr("panel.unconfigured_desc"), fontSize = 12, color = "on_surface_variant" }),
|
||||
ui.button({
|
||||
text = noctalia.tr("panel.open_settings"),
|
||||
variant = "primary",
|
||||
onClick = "onOpenSettings",
|
||||
}),
|
||||
}))
|
||||
end
|
||||
|
||||
-- Error Banner
|
||||
if not isUnconfigured and state.errorMsg ~= nil then
|
||||
table.insert(bodyItems, ui.row({
|
||||
fill = "error/0.15",
|
||||
radius = 8,
|
||||
padding = 10,
|
||||
gap = 8,
|
||||
align = "center",
|
||||
}, {
|
||||
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
|
||||
ui.label({ text = state.errorMsg, color = "error", fontSize = 12, flexGrow = 1 }),
|
||||
}))
|
||||
end
|
||||
|
||||
-- Hero Balance Card
|
||||
if not isUnconfigured then
|
||||
table.insert(bodyItems, ui.column({
|
||||
fill = "surface_variant/0.35",
|
||||
radius = 12,
|
||||
padding = 16,
|
||||
align = "center",
|
||||
gap = 6,
|
||||
}, {
|
||||
ui.label({ text = noctalia.tr("panel.current_balance"), fontSize = 12, color = "on_surface_variant" }),
|
||||
ui.label({ text = balanceDisplay .. " " .. state.currency, fontSize = 28, fontWeight = "bold", color = "primary" }),
|
||||
ui.label({ text = noctalia.tr("panel.wallet_info"), fontSize = 11, color = "on_surface_variant" }),
|
||||
ui.spacer({ height = 4 }),
|
||||
ui.button({
|
||||
text = " " .. noctalia.tr("panel.add_credits") .. " ",
|
||||
glyph = "external-link",
|
||||
variant = "primary",
|
||||
onClick = "openTopUpPage",
|
||||
tooltip = noctalia.tr("panel.add_credits_tooltip"),
|
||||
}),
|
||||
}))
|
||||
|
||||
-- Graph / Trend Section
|
||||
table.insert(bodyItems, renderGraphSection(values, minVal, maxVal))
|
||||
end
|
||||
|
||||
-- Footer Status Info
|
||||
local footerRow = ui.row({ justify = "space_between", align = "center" }, {
|
||||
ui.label({
|
||||
text = state.lastUpdated and (noctalia.tr("panel.updated_at") .. ": " .. state.lastUpdated) or "",
|
||||
fontSize = 10,
|
||||
color = "on_surface_variant",
|
||||
}),
|
||||
ui.button({
|
||||
text = noctalia.tr("panel.settings_btn"),
|
||||
variant = "ghost",
|
||||
onClick = "onOpenSettings",
|
||||
}),
|
||||
})
|
||||
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 10, padding = 12 }, {
|
||||
headerRow,
|
||||
ui.separator({}),
|
||||
ui.scroll({ flexGrow = 1, gap = 10 }, bodyItems),
|
||||
footerRow,
|
||||
}))
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
state = noctalia.state.get("deepseek.state") or state
|
||||
history = noctalia.state.get("deepseek.history") or {}
|
||||
render()
|
||||
end
|
||||
|
||||
noctalia.state.watch("deepseek.state", function(newState)
|
||||
if type(newState) == "table" then
|
||||
state = newState
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("deepseek.history", function(newHistory)
|
||||
if type(newHistory) == "table" then
|
||||
history = newHistory
|
||||
render()
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,47 @@
|
||||
id = "coder/deepseek_usage"
|
||||
name = "DeepSeek Usage"
|
||||
version = "1.0.0"
|
||||
plugin_api = 19
|
||||
author = "coder"
|
||||
license = "MIT"
|
||||
dependencies = ["xdg-open"]
|
||||
tags = ["utility", "productivity", "bar", "panel"]
|
||||
icon = "wallet"
|
||||
description = "DeepSeek API credit and balance monitor for Noctalia shell"
|
||||
|
||||
[[widget]]
|
||||
id = "bar"
|
||||
entry = "deepseek_usage.luau"
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
width = 360
|
||||
height = 480
|
||||
placement = "attached"
|
||||
position = "auto"
|
||||
|
||||
[[setting]]
|
||||
key = "api_key"
|
||||
type = "string"
|
||||
label_key = "settings.api_key.label"
|
||||
description_key = "settings.api_key.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "refresh_minutes"
|
||||
type = "int"
|
||||
label_key = "settings.refresh_minutes.label"
|
||||
description_key = "settings.refresh_minutes.description"
|
||||
default = 15
|
||||
min = 1
|
||||
max = 1440
|
||||
|
||||
[[setting]]
|
||||
key = "low_balance_threshold"
|
||||
type = "double"
|
||||
label_key = "settings.low_balance_threshold.label"
|
||||
description_key = "settings.low_balance_threshold.description"
|
||||
default = 2.0
|
||||
min = 0.0
|
||||
max = 100.0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"title": "DeepSeek Usage",
|
||||
"widget": {
|
||||
"no_key": "Set Key",
|
||||
"loading": "Checking...",
|
||||
"error": "Error"
|
||||
},
|
||||
"tooltip": {
|
||||
"balance": "Current Balance",
|
||||
"last_updated": "Last Checked",
|
||||
"status": "API Status",
|
||||
"action": "Action",
|
||||
"click_to_configure": "Click to open settings and set API Key"
|
||||
},
|
||||
"status": {
|
||||
"ok": "Active",
|
||||
"unconfigured": "API Key Not Set",
|
||||
"never": "Never"
|
||||
},
|
||||
"error": {
|
||||
"invalid_key": "Invalid or expired API Key (401)",
|
||||
"parse_failed": "Failed to parse API response",
|
||||
"no_wallet": "No active wallet found in user summary"
|
||||
},
|
||||
"panel": {
|
||||
"title": "DeepSeek Balance",
|
||||
"refresh_tooltip": "Refresh balance now",
|
||||
"unconfigured_title": "API Key Required",
|
||||
"unconfigured_desc": "Please configure your DeepSeek API Key in the plugin settings.",
|
||||
"open_settings": "Open Settings",
|
||||
"current_balance": "Available Balance",
|
||||
"wallet_info": "Normal Wallet",
|
||||
"add_credits": "Add Credits",
|
||||
"add_credits_tooltip": "Open DeepSeek platform top-up page in browser",
|
||||
"history_title": "Balance History (24h Trend)",
|
||||
"updated_at": "Updated",
|
||||
"settings_btn": "Settings"
|
||||
},
|
||||
"settings": {
|
||||
"api_key": {
|
||||
"label": "DeepSeek API Key",
|
||||
"description": "API Key from platform.deepseek.com/api_keys"
|
||||
},
|
||||
"refresh_minutes": {
|
||||
"label": "Refresh Interval (Minutes)",
|
||||
"description": "How often to poll DeepSeek API for balance updates"
|
||||
},
|
||||
"low_balance_threshold": {
|
||||
"label": "Low Balance Warning Threshold",
|
||||
"description": "Send a desktop notification when balance drops below this amount"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user