diff --git a/deepseek_usage/README.md b/deepseek_usage/README.md new file mode 100644 index 0000000..6e2541f --- /dev/null +++ b/deepseek_usage/README.md @@ -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. diff --git a/deepseek_usage/deepseek_usage.luau b/deepseek_usage/deepseek_usage.luau new file mode 100644 index 0000000..14971cd --- /dev/null +++ b/deepseek_usage/deepseek_usage.luau @@ -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() diff --git a/deepseek_usage/panel.luau b/deepseek_usage/panel.luau new file mode 100644 index 0000000..42623ba --- /dev/null +++ b/deepseek_usage/panel.luau @@ -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) diff --git a/deepseek_usage/plugin.toml b/deepseek_usage/plugin.toml new file mode 100644 index 0000000..a520679 --- /dev/null +++ b/deepseek_usage/plugin.toml @@ -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 diff --git a/deepseek_usage/thumbnail.webp b/deepseek_usage/thumbnail.webp new file mode 100644 index 0000000..806ac1e Binary files /dev/null and b/deepseek_usage/thumbnail.webp differ diff --git a/deepseek_usage/translations/en.json b/deepseek_usage/translations/en.json new file mode 100644 index 0000000..babb3ec --- /dev/null +++ b/deepseek_usage/translations/en.json @@ -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" + } + } +}