308 lines
11 KiB
QML
308 lines
11 KiB
QML
/* 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |