feat: Add NanoGPT credit stats UI (#5537)

* Add NanoGPT credit stats UI

* fix lint

* fix: type check

* fix: migrate inline styles to css

* feat: add sub active date display

* feat: add link to balance page

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
DeathStalker471
2026-04-26 14:13:06 -07:00
committed by GitHub
parent d327412e29
commit 7201d87f2e
5 changed files with 236 additions and 0 deletions
+2
View File
@@ -3633,6 +3633,8 @@
</div>
<div id="nanogpt_form" data-source="nanogpt">
<h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
<a href="https://nano-gpt.com/balance" target="_blank" rel="noopener noreferrer" class="nanogpt_view_credits" data-i18n="View Remaining Credits">View Remaining Credits</a>
<span class="nanogpt_credits_display marginLeft5"></span>
<div class="flex-container">
<input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_nanogpt"></div>
+98
View File
@@ -1191,5 +1191,103 @@ export async function initSecrets() {
toastr.error(t`Could not fetch OpenRouter credits. Please try again.`);
}
});
const formatNanoGptNumber = (num, decimals = null) => {
const number = Number(num);
if (!Number.isFinite(number)) return decimals === null ? '0' : (0).toFixed(decimals);
if (decimals !== null) return number.toFixed(decimals);
if (number >= 1000000) return (number / 1000000).toFixed(1) + 'M';
if (number >= 1000) return (number / 1000).toFixed(1) + 'K';
return number.toString();
};
const createNanoGptCreditsPopup = (credits) => {
const root = $('<div class="nanogpt-credits-popup"></div>');
root.append($('<h3></h3>').text(t`NanoGPT Credits & Usage`));
const rows = [
[t`USD`, `$${formatNanoGptNumber(credits.usdBalance, 2)}`],
[t`NANO`, formatNanoGptNumber(credits.nanoBalance, 3)],
];
const addUsage = (label, usage, limit) => {
if (usage) {
rows.push([label, t`${formatNanoGptNumber(usage.used)} / ${formatNanoGptNumber(limit)} (${formatNanoGptNumber(usage.remaining)} left)`]);
}
};
if (credits.subscription?.active) {
const sub = credits.subscription;
const subEndDate = sub.period?.currentPeriodEnd ? moment(sub.period.currentPeriodEnd).format('LL') : t`Unknown`;
rows.push([t`Sub`, t`Active (until ${subEndDate})`]);
addUsage(t`Tokens/wk`, sub.weekly_tokens, sub.limits?.weeklyInputTokens);
addUsage(t`Tokens/day`, sub.daily_tokens, sub.limits?.dailyInputTokens);
addUsage(t`Images/day`, sub.daily_images, sub.limits?.dailyImages);
}
for (const [label, value] of rows) {
root.append($('<div></div>').text(label));
root.append($('<div></div>').text(value));
}
return root;
};
$(document).on('click', '.nanogpt_view_credits', async function (event) {
event.preventDefault();
const display = $(this).siblings('.nanogpt_credits_display').first();
display.empty().text(t`Loading…`);
try {
const response = await fetch('/api/nanogpt/credits', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const usdBalance = Number(data.usd_balance);
const nanoBalance = Number(data.nano_balance);
if (!Number.isFinite(usdBalance) || !Number.isFinite(nanoBalance)) {
throw new Error('Invalid response');
}
let balances = [`$${formatNanoGptNumber(usdBalance, 2)}`];
if (nanoBalance > 0) {
balances.push(`${formatNanoGptNumber(nanoBalance, 3)} NANO`);
}
let shortInlineText = balances.join(' | ');
if (data.subscription?.active) {
shortInlineText += ` | ${t`Sub Active`}`;
}
display.empty().text(shortInlineText + ' ');
const infoBtn = $('<i class="fa-solid fa-circle-info cursor-pointer nanogpt_info_btn"></i>');
infoBtn.attr('title', t`View details`);
infoBtn.data('credits', {
usdBalance,
nanoBalance,
subscription: data.subscription,
});
display.append(infoBtn);
} catch (error) {
console.error('Failed to fetch NanoGPT credits:', error);
display.empty().text('');
toastr.error(t`Could not fetch NanoGPT credits. Please try again.`);
}
});
$(document).on('click', '.nanogpt_info_btn', async function () {
const credits = $(this).data('credits');
if (credits) {
await callGenericPopup(createNanoGptCreditsPopup(credits), POPUP_TYPE.TEXT);
}
});
registerSecretSlashCommands();
}
+32
View File
@@ -6400,6 +6400,38 @@ body:not(.movingUI) .drawer-content.maximized {
background-color: rgba(241, 163, 163, 0.2);
}
.nanogpt_info_btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.8em;
min-height: 1.8em;
margin-left: 0.15em;
vertical-align: middle;
}
.nanogpt-credits-popup {
display: grid;
gap: 0.25em 1em;
grid-template-columns: max-content minmax(0, 1fr);
text-align: left;
}
.nanogpt-credits-popup h3 {
grid-column: 1 / -1;
margin: 0 0 0.25em;
}
.nanogpt-credits-popup div:nth-of-type(odd) {
opacity: 0.8;
white-space: nowrap;
}
.nanogpt-credits-popup div:nth-of-type(even) {
font-weight: 600;
overflow-wrap: anywhere;
}
@media (prefers-contrast: more) {
:root {
--interactable-outline-color: CanvasText;