Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/src/hooks/useCurrency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import {
CURRENCY_CHANGE_EVENT,
formatCost,
getCurrencySettings,
} from "@/utils/currency";

/**
* Provides the instance's display currency and a formatter that converts
* stored USD costs into it. Both come from the server (admin-set system
* setting + server-cached exchange rates). Falls back to USD display when no
* exchange rate is available for the display currency.
*
* NOTE: Currently unused - cost display was intentionally pulled from the UI
* since per-chat/per-run costs are too small to be useful on their own. This
* hook is the intended entry point for upcoming cost features (eg: aggregate
* usage/spend views): call it in any component that renders a stored USD cost
* and pass the value through `formatCost`.
* @returns {{currency: string, formatCost: (usd: number) => string}}
*/
export default function useCurrency() {
const [currency, setCurrency] = useState("USD");
const [rates, setRates] = useState(null);

useEffect(() => {
let mounted = true;

function applySettings(settings) {
if (!mounted || !settings) return;
setCurrency(settings.currency || "USD");
setRates(settings.rates ?? null);
}

getCurrencySettings().then(applySettings);

// When an admin changes the display currency, `CurrencyPreference`
// invalidates the memoized settings and fires this event so every
// mounted consumer refetches without a page reload.
function handleCurrencyChange() {
getCurrencySettings().then(applySettings);
}
window.addEventListener(CURRENCY_CHANGE_EVENT, handleCurrencyChange);
return () => {
mounted = false;
window.removeEventListener(CURRENCY_CHANGE_EVENT, handleCurrencyChange);
};
}, []);

const rate = currency === "USD" ? 1 : rates?.[currency];
const format = useCallback(
(usd) =>
formatCost(usd, rate ? { currency, rate } : { currency: "USD", rate: 1 }),
[currency, rate]
);

return { currency, formatCost: format };
}
5 changes: 5 additions & 0 deletions frontend/src/locales/en/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,11 @@ const TRANSLATIONS = {
description:
"Select the preferred language to render AnythingLLM's UI in - when translations are available.",
},
"display-currency": {
title: "Display Currency",
description:
"Currency used to display LLM usage costs across this instance. Costs are always recorded in USD and converted at current rates for display only.",
},
logo: {
title: "Brand Logo",
description: "Upload your custom logo to showcase on all pages.",
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/models/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,27 @@ const System = {
.then((res) => res?.online || false)
.catch(() => false);
},

/**
* Fetches the instance display currency and the server-cached USD exchange
* rates used to render stored USD costs. `rates` is null when the server
* has no rates available (callers should then display USD).
* @returns {Promise<{base: string, currency: string, rates: Record<string, number>|null}|null>}
*/
exchangeRates: async function () {
return await fetch(`${API_BASE}/system/exchange-rates`, {
method: "GET",
headers: baseHeaders(),
})
.then((res) => {
if (!res.ok) throw new Error("Could not fetch exchange rates.");
return res.json();
})
.catch((e) => {
console.error(e);
return null;
});
},
totalIndexes: async function (slug = null) {
const url = new URL(`${fullApiUrl()}/system/system-vectors`);
if (!!slug) url.searchParams.append("slug", encodeURIComponent(slug));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
// import CurrencyPreference from "../components/CurrencyPreference";
import LanguagePreference from "../components/LanguagePreference";
import ThemePreference from "../components/ThemePreference";

Expand All @@ -27,6 +28,12 @@ export default function InterfaceSettings() {
</div>
<ThemePreference />
<LanguagePreference />
{/* Hidden until cost display features ship. LLM usage costs are
already recorded (in USD) but not yet surfaced anywhere in the
UI, so the display currency setting has nothing to affect.
Re-enable this (and its import above) with the first cost
surface. */}
{/* <CurrencyPreference /> */}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Admin from "@/models/admin";
import showToast from "@/utils/toast";
import {
CURRENCY_CHANGE_EVENT,
SUPPORTED_CURRENCIES,
currencyName,
getCurrencySettings,
invalidateCurrencySettings,
} from "@/utils/currency";

export default function CurrencyPreference() {
const { t, i18n } = useTranslation();
const [currency, setCurrency] = useState("USD");

useEffect(() => {
getCurrencySettings().then((settings) => {
if (settings?.currency) setCurrency(settings.currency);
});
}, []);

async function changeCurrency(e) {
const newCurrency = e.target.value;
const previousCurrency = currency;
setCurrency(newCurrency);

const result = await Admin.updateSystemPreferences({
display_currency: newCurrency,
});
if (!result?.success) {
setCurrency(previousCurrency);
showToast("Failed to update display currency.", "error");
return;
}

// Refetch the memoized settings so every mounted cost display updates.
invalidateCurrencySettings();
window.dispatchEvent(new CustomEvent(CURRENCY_CHANGE_EVENT));
}

return (
<div className="flex flex-col gap-y-0.5 my-4">
<p className="text-sm leading-6 font-semibold text-white">
{t("customization.items.display-currency.title")}
</p>
<p className="text-xs text-white/60">
{t("customization.items.display-currency.description")}
</p>
<div className="flex items-center gap-x-4">
<select
name="displayCurrency"
className="border-none bg-theme-settings-input-bg mt-2 text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-fit py-2 px-4"
value={currency}
onChange={changeCurrency}
>
{SUPPORTED_CURRENCIES.map((code) => {
return (
<option key={code} value={code}>
{code} β€” {currencyName(code, i18n.language)}
</option>
);
})}
</select>
</div>
</div>
);
}
109 changes: 109 additions & 0 deletions frontend/src/utils/currency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import System from "@/models/system";

export const CURRENCY_CHANGE_EVENT = "anythingllm_currency_change";

/**
* Currencies the server's exchange rate source (Frankfurter) can convert USD
* into. Mirrored in `server/utils/helpers/currencyExchange/index.js` - keep
* the two lists in sync.
*/
export const SUPPORTED_CURRENCIES = [
"USD",
"AUD",
"BGN",
"BRL",
"CAD",
"CHF",
"CNY",
"CZK",
"DKK",
"EUR",
"GBP",
"HKD",
"HUF",
"IDR",
"ILS",
"INR",
"ISK",
"JPY",
"KRW",
"MXN",
"MYR",
"NOK",
"NZD",
"PHP",
"PLN",
"RON",
"SEK",
"SGD",
"THB",
"TRY",
"ZAR",
];

/**
* Module-level cache of the exchange-rates fetch so the many components that
* render costs (one per chat message) share a single request per page load.
* @type {Promise<{base: string, currency: string, rates: Record<string, number>|null}|null>|null}
*/
let currencySettingsPromise = null;

/**
* Fetches the instance display currency and USD exchange rates from our own
* server (which caches the upstream source). The result is memoized for the
* lifetime of the page - call `invalidateCurrencySettings` after changing
* the display currency to force a refetch.
* @returns {Promise<{base: string, currency: string, rates: Record<string, number>|null}|null>}
*/
export async function getCurrencySettings() {
currencySettingsPromise ??= System.exchangeRates().then((result) => {
// Never memoize a failed fetch - let the next caller retry.
if (!result) currencySettingsPromise = null;
return result;
});
return currencySettingsPromise;
}

/** Clears the memoized currency settings so the next read refetches. */
export function invalidateCurrencySettings() {
currencySettingsPromise = null;
}

/**
* Returns the display name of a currency code in the user's language,
* falling back to the code itself.
* @param {string} code - ISO 4217 currency code (eg: "EUR")
* @param {string} [locale] - BCP 47 locale tag (eg: "en")
* @returns {string}
*/
export function currencyName(code, locale = undefined) {
try {
return new Intl.DisplayNames(locale, { type: "currency" }).of(code) ?? code;
} catch {
return code;
}
}

/**
* Formats a stored USD cost into the given display currency.
* Precision scales down with the value so tiny per-message costs stay legible:
* >= 1 shows 2 decimals, >= 0.01 shows up to 4, anything smaller up to 6.
* @param {number} usd - the cost in USD
* @param {{currency?: string, rate?: number}} [options] - display currency and its units-per-USD rate
* @returns {string}
*/
export function formatCost(usd, { currency = "USD", rate = 1 } = {}) {
if (typeof usd !== "number" || !isFinite(usd)) return "";
const value = usd * (rate || 1);
const maxDigits = value >= 1 ? 2 : value >= 0.01 ? 4 : value > 0 ? 6 : 2;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: maxDigits,
}).format(value);
} catch {
return `$${value.toFixed(maxDigits)}`;
}
}
7 changes: 0 additions & 7 deletions frontend/src/utils/numbers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@ export function nFormatter(input) {
return Formatter.format(input);
}

export function dollarFormat(input) {
return new Intl.NumberFormat("en-us", {
style: "currency",
currency: "USD",
}).format(input);
}

export function toPercentString(input = null, decimals = 0) {
if (isNaN(input) || input === null) return "";
const percentage = Math.round(input * 100);
Expand Down
1 change: 1 addition & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ storage/plugins/anythingllm_mcp_servers.json
!storage/documents/DOCUMENTS.md
storage/push-notifications/*
storage/direct-uploads
storage/currency
logs/server.log
*.db
*.db-journal
Expand Down
Loading
Loading