feat: plasmoide KDE Plasma 6 per il credito residuo DeepSeek e OpenRouter
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 <key>` |
|
||||||
|
| OpenRouter | `GET https://openrouter.ai/api/v1/credits` | `Authorization: Bearer <key>` (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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
|
||||||
|
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
|
||||||
|
<kcfgfile name=""/>
|
||||||
|
<group name="General">
|
||||||
|
<entry name="refreshIntervalSec" type="Int">
|
||||||
|
<default>300</default>
|
||||||
|
<min>30</min>
|
||||||
|
<max>3600</max>
|
||||||
|
<label>Intervallo di aggiornamento (secondi)</label>
|
||||||
|
<whatsthis>Quanto spesso il plasmoide aggiorna i crediti. Minimo 30s. Default 300s (5 minuti).</whatsthis>
|
||||||
|
</entry>
|
||||||
|
<entry name="warnThreshold" type="Int">
|
||||||
|
<default>30</default>
|
||||||
|
<min>0</min>
|
||||||
|
<max>100</max>
|
||||||
|
<label>Soglia gialla per il residuo (%)</label>
|
||||||
|
<whatsthis>Sotto questa percentuale di residuo la barra diventa gialla. Si applica alla barra di OpenRouter e (se dsBudget>0) a DeepSeek.</whatsthis>
|
||||||
|
</entry>
|
||||||
|
<entry name="critThreshold" type="Int">
|
||||||
|
<default>15</default>
|
||||||
|
<min>0</min>
|
||||||
|
<max>100</max>
|
||||||
|
<label>Soglia rossa per il residuo (%)</label>
|
||||||
|
<whatsthis>Sotto questa percentuale di residuo la barra diventa rossa.</whatsthis>
|
||||||
|
</entry>
|
||||||
|
<entry name="dsBudget" type="Double">
|
||||||
|
<default>10.0</default>
|
||||||
|
<min>0</min>
|
||||||
|
<max>100000</max>
|
||||||
|
<label>Budget di riferimento DeepSeek ($)</label>
|
||||||
|
<whatsthis>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).</whatsthis>
|
||||||
|
</entry>
|
||||||
|
<entry name="showDeepSeek" type="Bool">
|
||||||
|
<default>true</default>
|
||||||
|
<label>Mostra DeepSeek</label>
|
||||||
|
</entry>
|
||||||
|
<entry name="showOpenRouter" type="Bool">
|
||||||
|
<default>true</default>
|
||||||
|
<label>Mostra OpenRouter</label>
|
||||||
|
</entry>
|
||||||
|
</group>
|
||||||
|
</kcfg>
|
||||||
Executable
+88
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user