/* 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 } } } } } }