From 7614b74acfd454bf68f0b973945564c126aba511 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Fri, 21 Aug 2026 19:13:39 +0200 Subject: [PATCH] feat: plasmoide KDE Plasma 6 per il credito residuo DeepSeek e OpenRouter --- .gitignore | 1 + README.md | 43 +++++ assets/repository-avatar.png | Bin 0 -> 8451 bytes contents/config/main.xml | 45 +++++ contents/scripts/balance.sh | 88 ++++++++++ contents/ui/CreditCard.qml | 120 +++++++++++++ contents/ui/configGeneral.qml | 66 ++++++++ contents/ui/main.qml | 308 ++++++++++++++++++++++++++++++++++ metadata.json | 18 ++ 9 files changed, 689 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/repository-avatar.png create mode 100644 contents/config/main.xml create mode 100755 contents/scripts/balance.sh create mode 100644 contents/ui/CreditCard.qml create mode 100644 contents/ui/configGeneral.qml create mode 100644 contents/ui/main.qml create mode 100644 metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7773828 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b72aaf9 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# AI Credits Balance + +Plasmoide (widget) per KDE Plasma 6 che mostra il **credito residuo** di **DeepSeek** e **OpenRouter**, leggendo le chiavi API da `~/.pi/agent/auth.json` (i provider `deepseek` e `openrouter` del pi agent). + +![dashboard](assets/repository-avatar.png) + +## Dati mostrati + +- **DeepSeek** — saldo totale (`total_balance`), scomposto in `granted` + `top-up`, con badge di disponibilità (`is_available`). Barra opzionale calcolata su un budget di riferimento configurabile. +- **OpenRouter** — credito residuo (`total_credits - total_usage`), usato / totale e percentuale residua con barra. + +## Sorgenti API + +| Servizio | Endpoint | Autenticazione | +|-------------|-------------------------------------------|--------------------------| +| DeepSeek | `GET https://api.deepseek.com/user/balance` | `Authorization: Bearer ` | +| OpenRouter | `GET https://openrouter.ai/api/v1/credits` | `Authorization: Bearer ` (management key) | + +> **Nota OpenRouter:** l'endpoint `/api/v1/credits` richiede una *management key*. Una routing key normale può restituire un errore di autorizzazione. + +## Installazione + +```bash +# copia in ~/.local/share/plasma/plasmoids/ (riavvio plasmashell) +cp -r org.enne2.ai-credits.balance ~/.local/share/plasmoids/ +systemctl --user restart plasma-plasmashell.service +# poi "Aggiungi widget" → "Credito AI Residuo" +``` + +Oppure via `kpackagetool6`: + +```bash +kpackagetool6 -t Plasma/Applet -i org.enne2.ai-credits.balance.tar.gz +``` + +## Configurazione + +Dal plasmoide → **Impostazioni**: + +- Intervallo di aggiornamento (30–3600 s, default 300) +- Soglia gialla/rossa residuo (%) per le barre +- Budget di riferimento DeepSeek ($) per la barra (0 = nessuna barra) +- Mostra/nascondi DeepSeek, OpenRouter \ No newline at end of file diff --git a/assets/repository-avatar.png b/assets/repository-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..eee5fa474c1205f756be3ef53f809decfa0172a4 GIT binary patch literal 8451 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 + + + + + 300 + 30 + 3600 + + Quanto spesso il plasmoide aggiorna i crediti. Minimo 30s. Default 300s (5 minuti). + + + 30 + 0 + 100 + + Sotto questa percentuale di residuo la barra diventa gialla. Si applica alla barra di OpenRouter e (se dsBudget>0) a DeepSeek. + + + 15 + 0 + 100 + + Sotto questa percentuale di residuo la barra diventa rossa. + + + 10.0 + 0 + 100000 + + Il budget iniziale DeepSeek usato per calcolare la percentuale residua e la barra. Imposta a quanto hai caricato inizialmente. 0 = nasconde la barra (mostra solo il valore). + + + true + + + + true + + + + \ No newline at end of file diff --git a/contents/scripts/balance.sh b/contents/scripts/balance.sh new file mode 100755 index 0000000..93c5df4 --- /dev/null +++ b/contents/scripts/balance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Backend del plasmoide AI Credits Balance. +# Chiama le API di DeepSeek (GET /user/balance) e OpenRouter +# (GET /api/v1/credits) e restituisce un JSON unificato. Gli errori sono +# gestiti per-provider: anche se uno fallisce, l'altro viene comunque riportato. +set -u + +AUTH_JSON="${AI_AUTH_JSON:-$HOME/.pi/agent/auth.json}" +NOW="$(date +%s)" + +DS_KEY="" +OR_KEY="" +if [ -f "$AUTH_JSON" ]; then + DS_KEY="$(jq -r '.["deepseek"].key // empty' "$AUTH_JSON" 2>/dev/null)" + OR_KEY="$(jq -r '.["openrouter"].key // empty' "$AUTH_JSON" 2>/dev/null)" +fi +[ -n "$DS_KEY" ] || DS_KEY="${DEEPSEEK_API_KEY:-}" +[ -n "$OR_KEY" ] || OR_KEY="${OPENROUTER_API_KEY:-}" + +# --- DeepSeek ------------------------------------------------------------- +ds_block() { + if [ -z "$DS_KEY" ]; then + printf '{"ok":false,"error":"no_api_key"}' + return + fi + local resp rc + resp="$(curl -sS --max-time 15 https://api.deepseek.com/user/balance \ + -H "Authorization: Bearer $DS_KEY" 2>/dev/null)" + rc=$? + if [ "$rc" -ne 0 ] || [ -z "$resp" ]; then + printf '{"ok":false,"error":"fetch_failed","detail":"curl exit %s"}' "$rc" + return + fi + if ! printf '%s' "$resp" | jq -e '.balance_infos | type == "array"' >/dev/null 2>&1; then + printf '{"ok":false,"error":"bad_json","detail":"risposta non valida da DeepSeek"}' + return + fi + printf '%s' "$resp" | jq -c '{ + ok:true, + available:(.is_available // false), + currency:((.balance_infos[0].currency) // "USD"), + total:((.balance_infos[0].total_balance) | tonumber), + granted:((.balance_infos[0].granted_balance) | tonumber), + topped_up:((.balance_infos[0].topped_up_balance) | tonumber) + }' 2>/dev/null || printf '{"ok":false,"error":"bad_json","detail":"impossibile elaborare DeepSeek"}' +} + +# --- OpenRouter ----------------------------------------------------------- +or_block() { + if [ -z "$OR_KEY" ]; then + printf '{"ok":false,"error":"no_api_key"}' + return + fi + local resp rc + resp="$(curl -sS --max-time 15 https://openrouter.ai/api/v1/credits \ + -H "Authorization: Bearer $OR_KEY" 2>/dev/null)" + rc=$? + if [ "$rc" -ne 0 ] || [ -z "$resp" ]; then + printf '{"ok":false,"error":"fetch_failed","detail":"curl exit %s"}' "$rc" + return + fi + if ! printf '%s' "$resp" | jq -e '.data.total_credits | type == "number"' >/dev/null 2>&1; then + # 401/403 => la routing key non ha permessi sul /credits (serve management key). + printf '{"ok":false,"error":"bad_json","detail":"risposta non valida (la chiave potrebbe essere una routing key: serve una management key)"}' + return + fi + printf '%s' "$resp" | jq -c '{ + ok:true, + total_credits:(.data.total_credits), + total_usage:(.data.total_usage), + remaining:((.data.total_credits) - (.data.total_usage)) + }' 2>/dev/null || printf '{"ok":false,"error":"bad_json","detail":"impossibile elaborare OpenRouter"}' +} + +DS_JSON="$(ds_block)" +OR_JSON="$(or_block)" + +# ok globale = almeno un provider sano. +GLOBAL_OK="$(jq -n --argjson d "$DS_JSON" --argjson o "$OR_JSON" '($d.ok or $o.ok)')" + +jq -nc \ + --argjson ok "$GLOBAL_OK" \ + --argjson deepseek "$DS_JSON" \ + --argjson openrouter "$OR_JSON" \ + --argjson fetched "$NOW" \ + '{ok:$ok, deepseek:$deepseek, openrouter:$openrouter, fetched_at:$fetched}' + +exit 0 \ No newline at end of file diff --git a/contents/ui/CreditCard.qml b/contents/ui/CreditCard.qml new file mode 100644 index 0000000..9021be7 --- /dev/null +++ b/contents/ui/CreditCard.qml @@ -0,0 +1,120 @@ +/* Card per un servizio AI: saldo residuo grande + barra + sottocampi. */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Rectangle { + id: root + + property string title: "" + property string badge: "" // es. "USD" + property color accent: "#00bcd4" + property string remainingText: "—" // testo grande del residuo (es. "$1.56") + property string subText: "" // riga secondaria + property string footText: "" // riga footer (es. "16% · usati $8.44") + property real progress: 1.0 // 0..1 per la barra; NaN per nasconderla + property bool available: true + property bool hasError: false + property string errorText: "" + + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.preferredHeight: 54 + radius: 6 + color: Qt.rgba(0, 0, 0, 0.26) + border.width: 1 + border.color: root.accent + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Rectangle { + Layout.preferredWidth: 34 + Layout.preferredHeight: 14 + radius: 8 + color: root.accent + PC3.Label { + anchors.centerIn: parent + text: root.badge + color: "#101012" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + PC3.Label { + text: root.title + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + Layout.minimumWidth: 0 + } + Rectangle { + visible: !root.hasError + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: root.available ? root.accent : Kirigami.Theme.negativeTextColor + } + PC3.Label { + text: root.remainingText + color: root.accent + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 3 + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 6 + visible: !root.hasError && !isNaN(root.progress) + Rectangle { + id: track + anchors.fill: parent + radius: 4 + color: Qt.rgba(0, 0, 0, 0.55) + } + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: track.width * Math.max(0, Math.min(1, root.progress)) + height: parent.height + radius: 4 + color: root.accent + } + } + + PC3.Label { + visible: root.hasError + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Kirigami.Theme.negativeTextColor + text: root.errorText + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + + RowLayout { + Layout.fillWidth: true + visible: !root.hasError + PC3.Label { + text: root.subText + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideRight + } + PC3.Label { + text: root.footText + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } +} \ No newline at end of file diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..6f773f0 --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,66 @@ +/* + * configGeneral.qml — pagina di configurazione del plasmoide AI Credits Balance. + */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Item { + id: page + + property alias cfg_refreshIntervalSec: refreshSpin.value + property alias cfg_warnThreshold: warnSpin.value + property alias cfg_critThreshold: critSpin.value + property alias cfg_dsBudget: budgetSpin.value + property alias cfg_showDeepSeek: dsCheck.checked + property alias cfg_showOpenRouter: orCheck.checked + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Intervallo di aggiornamento (secondi)") + font.bold: true + } + PC3.SpinBox { + id: refreshSpin + from: 30 + to: 3600 + stepSize: 30 + Layout.fillWidth: true + } + + PC3.Label { text: i18n("Soglia residuo giallo (%)"); font.bold: true } + PC3.SpinBox { id: warnSpin; from: 0; to: 100; Layout.fillWidth: true } + + PC3.Label { text: i18n("Soglia residuo rosso (%)"); font.bold: true } + PC3.SpinBox { id: critSpin; from: 0; to: 100; Layout.fillWidth: true } + + PC3.Label { + text: i18n("Budget di riferimento DeepSeek ($)") + font.bold: true + } + PC3.SpinBox { + id: budgetSpin + from: 0 + to: 100000 + stepSize: 1 + Layout.fillWidth: true + } + PC3.Label { + text: i18n("Usato per calcolare % e barra di DeepSeek. 0 = nessuna barra (solo valore).") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + PC3.CheckBox { id: dsCheck; text: i18n("Mostra DeepSeek") } + PC3.CheckBox { id: orCheck; text: i18n("Mostra OpenRouter") } + + Item { Layout.fillHeight: true } + } +} \ No newline at end of file diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..d414b71 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,308 @@ +/* AI Credits Balance — dashboard desktop per KDE Plasma 6. */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.plasma5support as Plasma5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + property var data: null + property string lastUpdate: "" + property bool loading: false + + property int refreshSecs: Plasmoid.configuration.refreshIntervalSec + property int warnThreshold: Plasmoid.configuration.warnThreshold + property int critThreshold: Plasmoid.configuration.critThreshold + property real dsBudget: Plasmoid.configuration.dsBudget + property bool showDeepSeek: Plasmoid.configuration.showDeepSeek + property bool showOpenRouter: Plasmoid.configuration.showOpenRouter + + readonly property color dsAccent: "#4f93ff" + readonly property color orAccent: "#00bcd4" + + Plasmoid.title: i18n("AI Credits") + Plasmoid.icon: "view-statistics" + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + toolTipMainText: i18n("Credito AI residuo") + toolTipSubText: root.toolTipSub() + + // Desktop-first; la vista compatta è solo fallback per pannelli. + preferredRepresentation: fullRepresentation + switchWidth: 150 + switchHeight: 150 + + Plasma5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function(sourceName, data) { + root.loading = false + executable.disconnectSource(sourceName) + if (data["exit code"] !== 0) { + // lo script non fallisce quasi mai (gestione interna), ma… + return + } + try { + const result = JSON.parse((data["stdout"] || "").trim()) + if (result) { + root.data = result + root.lastUpdate = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") + } + } catch (e) { + // ignore + } + } + } + + function refresh() { + if (root.loading) return + root.loading = true + const path = Qt.resolvedUrl("../scripts/balance.sh").toString().replace(/^file:\/\//, "") + executable.connectSource("/bin/bash " + shellQuote(path)) + } + function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\"'\"'") + "'" } + + // Colore accent in base alla percentuale residua. + function accentFor(pct, normalAccent) { + if (isNaN(pct)) return normalAccent + if (pct <= root.critThreshold) return Kirigami.Theme.negativeTextColor + if (pct <= root.warnThreshold) return Kirigami.Theme.neutralTextColor + return normalAccent + } + function money(v) { + if (typeof v !== "number" || isNaN(v)) return "—" + return "$" + v.toFixed(2) + } + function pctStr(pct) { + return isNaN(pct) ? "" : Math.round(pct) + "%" + } + + // DeepSeek + function dsData() { return root.data && root.data.deepseek ? root.data.deepseek : null } + function dsRemainingPct() { + const d = dsData() + if (!d || !d.ok || root.dsBudget <= 0) return NaN + return Math.max(0, (d.total / root.dsBudget) * 100) + } + function dsProgress() { + const d = dsData() + if (!d || !d.ok || root.dsBudget <= 0) return NaN + return Math.max(0, Math.min(1, d.total / root.dsBudget)) + } + function dsRemainingText() { + const d = dsData() + if (!d) return "…" + if (!d.ok) return "ERR" + return money(d.total) + } + function dsAccentColor() { + const d = dsData() + if (!d || !d.ok) return Kirigami.Theme.negativeTextColor + const p = dsRemainingPct() + return isNaN(p) ? root.dsAccent : root.accentFor(p, root.dsAccent) + } + function dsSub() { + const d = dsData() + if (!d || !d.ok) return "" + return i18n("Granted %1 · Top-up %2", money(d.granted), money(d.topped_up)) + } + function dsFoot() { + const d = dsData() + if (!d || !d.ok) return "" + const p = dsRemainingPct() + if (isNaN(p)) return d.available ? i18n("disponibile") : i18n("NON disponibile") + return (d.available ? i18n("disponibile · %1", pctStr(p)) : i18n("NON disponibile · %1", pctStr(p))) + } + function dsError() { + const d = dsData() + if (!d || d.ok) return "" + const map = { "no_api_key": i18n("nessuna chiave DeepSeek"), "fetch_failed": i18n("connessione fallita"), "bad_json": i18n("risposta non valida") } + return map[d.error] ? map[d.error] : i18n("errore") + } + + // OpenRouter + function orData() { return root.data && root.data.openrouter ? root.data.openrouter : null } + function orRemaining() { + const d = orData() + return d && d.ok ? d.remaining : NaN + } + function orRemainingPct() { + const d = orData() + if (!d || !d.ok || !d.total_credits) return NaN + return Math.max(0, (d.remaining / d.total_credits) * 100) + } + function orProgress() { + const d = orData() + if (!d || !d.ok || !d.total_credits) return NaN + return Math.max(0, Math.min(1, d.remaining / d.total_credits)) + } + function orRemainingText() { + const d = orData() + if (!d) return "…" + if (!d.ok) return "ERR" + return money(d.remaining) + } + function orAccentColor() { + const d = orData() + if (!d || !d.ok) return Kirigami.Theme.negativeTextColor + return root.accentFor(orRemainingPct(), root.orAccent) + } + function orSub() { + const d = orData() + if (!d || !d.ok) return "" + return i18n("Usati %1 / %2", money(d.total_usage), money(d.total_credits)) + } + function orFoot() { + const d = orData() + if (!d || !d.ok) return "" + return pctStr(orRemainingPct()) + } + function orError() { + const d = orData() + if (!d || d.ok) return "" + const map = { "no_api_key": i18n("nessuna chiave OpenRouter"), "fetch_failed": i18n("connessione fallita"), "bad_json": i18n("risposta non valida (forse serve una management key)") } + return map[d.error] ? map[d.error] : i18n("errore") + } + + function toolTipSub() { + if (!root.data) return i18n("Aggiornamento…") + const d = dsData(), o = orData() + const ds = d && d.ok ? money(d.total) : "—" + const ors = o && o.ok ? money(o.remaining) : "—" + return i18n("DeepSeek %1 · OpenRouter %2", ds, ors) + } + + Component.onCompleted: root.refresh() + Timer { + interval: Math.max(30, root.refreshSecs) * 1000 + repeat: true + running: true + onTriggered: root.refresh() + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: Plasmoid.icon + color: root.data && root.data.ok ? root.dsAccent : Kirigami.Theme.disabledTextColor + } + MouseArea { anchors.fill: parent; onClicked: Plasmoid.expanded = !Plasmoid.expanded } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 224 + implicitHeight: 232 + Layout.preferredWidth: 224 + Layout.preferredHeight: 232 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 5 + + // Header + RowLayout { + Layout.fillWidth: true + Kirigami.Icon { + source: Plasmoid.icon + color: root.dsAccent + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + ColumnLayout { + spacing: 0 + PC3.Label { + text: i18n("CREDITO AI") + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 1 + } + PC3.Label { + text: root.data && root.data.ok ? i18n("RESIDUO") : i18n("OFFLINE") + color: root.data && root.data.ok ? root.dsAccent : Kirigami.Theme.negativeTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: 9 + Layout.preferredHeight: 9 + radius: 5 + color: root.data && root.data.ok ? "#7cb342" : Kirigami.Theme.negativeTextColor + } + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !root.loading + onClicked: root.refresh() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(0, 188 / 255, 212 / 255, 0.45) + } + + CreditCard { + Layout.fillWidth: true + visible: root.showDeepSeek + title: i18n("DeepSeek") + badge: dsData() && dsData().ok ? dsData().currency : "DS" + accent: root.dsAccentColor() + remainingText: root.dsRemainingText() + subText: root.dsSub() + footText: root.dsFoot() + progress: root.dsProgress() + available: dsData() ? dsData().available : false + hasError: dsData() ? !dsData().ok : false + errorText: root.dsError() + } + + CreditCard { + Layout.fillWidth: true + visible: root.showOpenRouter + title: i18n("OpenRouter") + badge: "USD" + accent: root.orAccentColor() + remainingText: root.orRemainingText() + subText: root.orSub() + footText: root.orFoot() + progress: root.orProgress() + available: orData() ? orData().ok : false + hasError: orData() ? !orData().ok : false + errorText: root.orError() + } + + Item { Layout.fillHeight: true } + + // Footer sync + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.lastUpdate ? i18n("SYNC %1", root.lastUpdate) : i18n("SYNC…") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.loading ? i18n("aggiornamento…") : "" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } +} \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..828937c --- /dev/null +++ b/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.ai-credits.balance", + "Name": "AI Credits Balance", + "Name[it]": "Credito AI Residuo", + "Description": "Shows the remaining credit for DeepSeek and OpenRouter, reading the API keys from ~/.pi/agent/auth.json (the 'deepseek' and 'openrouter' providers of the pi agent)", + "Description[it]": "Mostra il credito residuo di DeepSeek e OpenRouter, leggendo le chiavi API da ~/.pi/agent/auth.json (provider 'deepseek' e 'openrouter' di pi agent)", + "Icon": "view-statistics", + "Category": "System Information", + "License": "GPL-3.0", + "Version": "1.0.0", + "Authors": [ + { "Name": "enne2" } + ] + }, + "X-Plasma-API-Minimum-Version": "6.0" +} \ No newline at end of file