feat(cloud): show wallet limits in desktop
Some checks failed
CI / cargo check (linux) (push) Has been cancelled
CI / cargo check (macos) (push) Has been cancelled
CI / cargo check (windows) (push) Has been cancelled
CI / tsc + hotkey smoke (push) Has been cancelled

This commit is contained in:
David Perov 2026-07-17 15:56:46 +03:00
parent d0ea444d66
commit fac28131dd
11 changed files with 252 additions and 76 deletions

View file

@ -438,7 +438,7 @@ async fn resolve_cloud_live_dictation_connection(
) -> Result<RealtimeConnectionRequest, String> {
let device_token = req.api_key.as_deref().unwrap_or_default().trim();
if device_token.is_empty() {
return Err("Войдите в Talkis и выберите активную облачную подписку.".to_string());
return Err("Войдите в Talkis и проверьте облачный баланс.".to_string());
}
let url = format!(
"{}{}",
@ -474,19 +474,21 @@ async fn resolve_cloud_live_dictation_connection(
.and_then(|error| error.as_str())
.map(str::to_string)
});
let message = match status.as_u16() {
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
403 => "Для realtime-транскрибации нужна активная подписка Talkis.".to_string(),
429 => {
"Слишком много запусков realtime-транскрибации. Повторите через минуту.".to_string()
}
_ => upstream_message.unwrap_or_else(|| {
format!(
"Облако Talkis не запустило realtime-транскрибацию ({})",
status
)
}),
};
let message =
match status.as_u16() {
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
402 => "Облачный баланс закончился. Пополните его в личном кабинете Talkis."
.to_string(),
403 => "Облачный доступ Talkis сейчас недоступен.".to_string(),
429 => "Слишком много запусков realtime-транскрибации. Повторите через минуту."
.to_string(),
_ => upstream_message.unwrap_or_else(|| {
format!(
"Облако Talkis не запустило realtime-транскрибацию ({})",
status
)
}),
};
logger::log_error(
"LIVE_DICTATION",
&format!(

View file

@ -513,7 +513,7 @@ async fn resolve_translation_connection(
}
if req.api_key.trim().is_empty() {
return Err("Войдите в Talkis и выберите активную облачную подписку.".to_string());
return Err("Войдите в Talkis и проверьте облачный баланс.".to_string());
}
let url = format!(
"{}{}",
@ -554,16 +554,18 @@ async fn resolve_translation_connection(
.and_then(|error| error.as_str())
.map(str::to_string)
});
let message = match status.as_u16() {
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
403 => "Для синхронного перевода нужна активная подписка Talkis.".to_string(),
429 => {
"Слишком много запусков синхронного перевода. Повторите через минуту.".to_string()
}
_ => upstream_message.unwrap_or_else(|| {
format!("Облако Talkis не запустило синхронный перевод ({})", status)
}),
};
let message =
match status.as_u16() {
401 => "Сессия Talkis истекла. Войдите в облако повторно.".to_string(),
402 => "Облачный баланс закончился. Пополните его в личном кабинете Talkis."
.to_string(),
403 => "Облачный доступ Talkis сейчас недоступен.".to_string(),
429 => "Слишком много запусков синхронного перевода. Повторите через минуту."
.to_string(),
_ => upstream_message.unwrap_or_else(|| {
format!("Облако Talkis не запустило синхронный перевод ({})", status)
}),
};
logger::log_error(
"LIVE_TRANSLATION",
&format!(

View file

@ -10,7 +10,7 @@ import {
CloudProfile,
fetchCloudProfile,
cloudLogout,
getAuthLoginUrl,
getCloudTopUpUrl,
handleAuthToken,
generateExchangeCode,
getAuthLoginUrlWithCode,
@ -35,7 +35,7 @@ function extractTokenFromUrl(url: string): string | null {
}
export function UserPanel() {
const { t } = useI18n();
const { t, lang } = useI18n();
const [profile, setProfile] = useState<CloudProfile | null | undefined>(() => getCachedCloudProfile());
const [loading, setLoading] = useState(() => getCachedCloudProfile() === undefined);
const [waitingForAuth, setWaitingForAuth] = useState(false);
@ -200,7 +200,7 @@ export function UserPanel() {
const handleActivate = async () => {
try {
if (profile) {
await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard"));
await openUrl(profile.topUpUrl || getCloudTopUpUrl());
return;
}
@ -228,20 +228,34 @@ export function UserPanel() {
return <div style={styles.container} />;
}
// ── Authenticated + active subscription ─────────────────────
// ── Authenticated + available cloud balance ──────────────────
if (profile && profile.subscription.active) {
const balance = profile.wallet
? formatCloudBalance(
profile.wallet.balanceMilliTokens,
lang === "ru" ? "ru-RU" : "en-US",
)
: null;
return (
<div style={styles.container}>
<ProfileRow profile={profile} onLogout={handleLogout} />
<div style={styles.badgeActive}>
<div style={styles.badgeDot} />
{t("userPanel.subscriptionActive")}
{balance
? t("userPanel.balance", { tokens: balance })
: t("userPanel.subscriptionActive")}
</div>
{profile.wallet?.low && (
<button onClick={handleActivate} style={styles.compactCta}>
<IconCrown size={13} stroke={2} color="var(--accent-contrast)" />
<span style={styles.compactCtaLabel}>{t("userPanel.upgradeToPro")}</span>
</button>
)}
</div>
);
}
// ── Authenticated but no active subscription ────────────────
// ── Authenticated but empty cloud balance ───────────────────
if (profile && !profile.subscription.active) {
return (
<div style={styles.container}>
@ -262,6 +276,14 @@ export function UserPanel() {
);
}
function formatCloudBalance(value: string, locale: string): string {
try {
return (BigInt(value) / 1_000n).toLocaleString(locale);
} catch {
return "0";
}
}
function ProfileRow({ profile, onLogout }: { profile: CloudProfile; onLogout: () => void }) {
const { t } = useI18n();
return (

View file

@ -2,7 +2,7 @@
* Talkis Cloud authentication client.
*
* Handles communication with talkis.ru API for:
* - Fetching user profile and subscription status
* - Fetching user profile and cloud balance
* - Deep link token handling
* - Logout
*/
@ -53,9 +53,29 @@ export interface CloudSubscription {
expiresAt: string | null;
}
export interface CloudWallet {
balanceMilliTokens: string;
balanceTokens: string;
reservedMilliTokens: string;
debtMilliTokens: string;
lifetimeGrantedMilliTokens: string;
lifetimeSpentMilliTokens: string;
low: boolean;
empty: boolean;
cloudBlocked: boolean;
}
export interface CloudThresholds {
lowBalanceMilliTokens: string;
progressTargetMilliTokens: string;
}
export interface CloudProfile {
user: CloudUser;
subscription: CloudSubscription;
wallet?: CloudWallet;
thresholds?: CloudThresholds;
topUpUrl?: string;
}
export function getCachedCloudProfile(): CloudProfile | null | undefined {
@ -197,6 +217,10 @@ export function getAuthLoginUrl(): string {
return `${CLOUD_API_BASE}/auth/login?device=true`;
}
export function getCloudTopUpUrl(): string {
return `${CLOUD_API_BASE}/dashboard/billing`;
}
/**
* Generate a random exchange code for device auth.
*/

View file

@ -458,7 +458,12 @@ export function toFileTranscriptionErrorMessage(
return tn("fileTranscription.errLocalRuntimeRejected");
}
if (normalized.includes("subscription inactive") || normalized.includes("403")) {
if (
normalized.includes("subscription inactive") ||
normalized.includes("insufficient_cloud_tokens") ||
normalized.includes("402") ||
normalized.includes("403")
) {
return tn("fileTranscription.errSubscriptionInactive");
}

View file

@ -167,26 +167,27 @@ export const components = {
// ── UserPanel ─────────────────────────────────────────────
"userPanel.subscriptionActive": {
ru: "Подписка активна",
en: "Subscription active",
ru: "Облако доступно",
en: "Cloud is available",
},
"userPanel.upgradeToPro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
"userPanel.balance": { ru: "Баланс: {tokens} токенов", en: "Balance: {tokens} tokens" },
"userPanel.upgradeToPro": { ru: "Пополнить баланс", en: "Top up balance" },
"userPanel.logout": { ru: "Выйти", en: "Log out" },
"userPanel.cta.title": { ru: "Активируйте Talkis", en: "Activate Talkis" },
"userPanel.cta.title": { ru: "Облако Talkis", en: "Talkis Cloud" },
"userPanel.cta.feature.unlimited": {
ru: "Безлимитное использование",
en: "Unlimited usage",
ru: "Без подписки и автоплатежей",
en: "No subscription or recurring charges",
},
"userPanel.cta.feature.noVpn": {
ru: "Без VPN и Прокси",
en: "No VPN or proxy",
},
"userPanel.cta.feature.deviceSync": {
ru: "Синхронизация устройств",
en: "Device sync",
ru: "Токены не сгорают",
en: "Tokens do not expire",
},
"userPanel.cta.feature.freeTrial": {
ru: "7 дней бесплатно",
en: "7 days free",
ru: "500 токенов после регистрации",
en: "500 tokens after registration",
},
} as const;

View file

@ -69,12 +69,12 @@ export const libMessages = {
en: "Cloud speaker labeling is not available yet. Use local preparation in the file transcription section.",
},
"fileTranscription.errCloudDiarizationUnavailable": {
ru: "Облачное разделение по говорящим сейчас недоступно. Проверьте активную подписку PRO или переключитесь на локальный режим.",
en: "Cloud speaker separation is currently unavailable. Check your active PRO subscription or switch to local mode.",
ru: "Облачное разделение по говорящим сейчас недоступно. Проверьте баланс или переключитесь на локальный режим.",
en: "Cloud speaker separation is currently unavailable. Check your balance or switch to local mode.",
},
"fileTranscription.errSubscriptionInactive": {
ru: "Для облачной транскрибации нужна активная подписка Talkis.",
en: "Cloud transcription requires an active Talkis subscription.",
ru: "Облачный баланс закончился. Пополните его или переключитесь на локальный режим.",
en: "Your cloud balance is empty. Top it up or switch to local mode.",
},
"fileTranscription.errCannotPrepareDiarization": {
ru: "Не удалось подготовить аудио для разметки говорящих. Попробуйте другой аудио- или видеофайл.",

View file

@ -2,14 +2,17 @@
export const settingsModels = {
// ── Account / subscription cards ──
"models.account.logout": { ru: "Выйти", en: "Log out" },
"models.cta.upgradePro": { ru: "Перейти на PRO", en: "Upgrade to PRO" },
"models.cta.freeTrial": { ru: "7 дней бесплатно", en: "7 days free" },
"models.guest.title": { ru: "Подписка Talkis", en: "Talkis subscription" },
"models.guest.benefit1": { ru: "• Безлимитное использование без ограничений", en: "• Unlimited use, no limits" },
"models.guest.benefit2": { ru: "• Без VPN и Прокси", en: "• No VPN or proxy required" },
"models.guest.benefit3": { ru: "• Синхронизация со всеми устройствами", en: "• Sync across all your devices" },
"models.subscription.active": { ru: "Подписка активна", en: "Subscription active" },
"models.subscription.unlimitedUntil": { ru: "Безлимитный доступ до {date}", en: "Unlimited access until {date}" },
"models.cta.upgradePro": { ru: "Пополнить баланс", en: "Top up balance" },
"models.cta.freeTrial": { ru: "500 токенов новым пользователям", en: "500 tokens for new users" },
"models.guest.title": { ru: "Облачный баланс", en: "Cloud balance" },
"models.guest.benefit1": { ru: "• Без подписки и автоплатежей", en: "• No subscription or recurring charges" },
"models.guest.benefit2": { ru: "• Токены не сгорают", en: "• Tokens do not expire" },
"models.guest.benefit3": { ru: "• Локальные модели остаются бесплатными", en: "• Local models remain free" },
"models.subscription.active": { ru: "Облако доступно", en: "Cloud is available" },
"models.subscription.balance": { ru: "Доступно {tokens} токенов", en: "{tokens} tokens available" },
"models.subscription.reserved": { ru: "Зарезервировано: {tokens}", en: "Reserved: {tokens}" },
"models.subscription.low": { ru: "Осталось меньше 20%", en: "Less than 20% remaining" },
"models.subscription.topUp": { ru: "Пополнить", en: "Top up" },
// ── Prompt library ──
"models.prompt.nameLabel": { ru: "Название", en: "Name" },
@ -77,11 +80,11 @@ export const settingsModels = {
en: "Recognition, text processing, and live translation run through Talkis Cloud. Data is encrypted in transit, and the primary API key remains on the server.",
},
"models.cloud.descGuest": {
ru: "Для облачного режима нужна авторизация и активная подписка. После входа плашка и статус подписки обновятся автоматически.",
en: "Cloud mode requires sign-in and an active subscription. After you log in, the banner and subscription status update automatically.",
ru: "Для облачного режима нужна авторизация и положительный баланс. После входа данные обновятся автоматически.",
en: "Cloud mode requires sign-in and a positive balance. The data refreshes automatically after you log in.",
},
"models.cloud.proReady": { ru: "PRO активен, облако готово к выбору", en: "PRO is active, the cloud is ready to select" },
"models.cloud.needPro": { ru: "7 дней бесплатно — перейдите на PRO", en: "7 days free — upgrade to PRO" },
"models.cloud.proReady": { ru: "Облачный режим готов к выбору", en: "Cloud mode is ready to select" },
"models.cloud.needPro": { ru: "Пополните баланс для облачного режима", en: "Top up your balance for cloud mode" },
// ── API adapters section ──
"models.apiSection.title": { ru: "Доступные API-адаптеры", en: "Available API adapters" },

View file

@ -136,8 +136,8 @@ export const widget = {
en: "The recognition service is currently unavailable in your region. Try a different endpoint or a VPN.",
},
"widget.error.subscriptionRequired": {
ru: "Для облачного режима нужна активная подписка Talkis.",
en: "Cloud mode requires an active Talkis subscription.",
ru: "Облачный баланс закончился. Пополните его в личном кабинете Talkis.",
en: "Your cloud balance is empty. Top it up in your Talkis account.",
},
"widget.error.requestRejected": {
ru: "Сервис отклонил запрос. Проверьте API-ключ, регион доступа или настройки endpoint.",

View file

@ -44,7 +44,7 @@ import {
cancelCloudAuthFlow,
CloudProfile,
fetchCloudProfile,
getAuthLoginUrl,
getCloudTopUpUrl,
cloudLogout,
handleAuthToken,
generateExchangeCode,
@ -880,37 +880,146 @@ function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
);
}
const DEFAULT_CLOUD_PROGRESS_TARGET = 3_900_000n;
const DEFAULT_CLOUD_LOW_THRESHOLD = 780_000n;
function cloudMilliTokens(value: string | undefined, fallback = 0n): bigint {
if (!value) return fallback;
try {
return BigInt(value);
} catch {
return fallback;
}
}
function formatCloudTokens(milliTokens: bigint, locale: string): string {
const whole = milliTokens / 1_000n;
const fraction = milliTokens % 1_000n;
if (fraction === 0n) return whole.toLocaleString(locale);
return `${whole.toLocaleString(locale)},${fraction
.toString()
.padStart(3, "0")
.replace(/0+$/, "")}`;
}
/**
* Three-state subscription block, shared by the Models IconCloud section and the
* dedicated "Подписка Talkis" tab: active-subscription banner, signed-in account
* card (activate / log out), or guest promo (sign in + start the free trial).
* Three-state cloud block shared by the recognition mode surfaces: available
* wallet, signed-in empty wallet, or guest sign-in card.
*/
function SubscriptionCards({
profile,
onActivate,
onTopUp,
onLogout,
}: {
profile: CloudProfile | null | undefined;
onActivate: () => void;
onTopUp: () => void;
onLogout: () => void;
}) {
const { t, lang } = useI18n();
if (profile?.subscription.active === true) {
const locale = lang === "ru" ? "ru-RU" : "en-US";
const wallet = profile.wallet;
const balance = cloudMilliTokens(wallet?.balanceMilliTokens);
const reserved = cloudMilliTokens(wallet?.reservedMilliTokens);
const target = cloudMilliTokens(
profile.thresholds?.progressTargetMilliTokens,
DEFAULT_CLOUD_PROGRESS_TARGET,
);
const lowThreshold = cloudMilliTokens(
profile.thresholds?.lowBalanceMilliTokens,
DEFAULT_CLOUD_LOW_THRESHOLD,
);
const boundedBalance = balance > target ? target : balance;
const progress =
target > 0n ? Number((boundedBalance * 10_000n) / target) / 100 : 0;
const low = wallet?.low ?? balance < lowThreshold;
return (
<div className="card" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{ width: 42, height: 42, borderRadius: 999, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<IconCrown size={20} stroke={2.2} color="var(--accent-contrast)" />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>{t("models.subscription.active")}</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
{t("models.subscription.unlimitedUntil", { date: profile.subscription.expiresAt ? new Date(profile.subscription.expiresAt).toLocaleDateString(lang === "ru" ? "ru-RU" : "en-US", { day: "numeric", month: "long" }) : "—" })}
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 14, minWidth: 0 }}>
<div style={{ width: 42, height: 42, borderRadius: 999, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<IconCrown size={20} stroke={2.2} color="var(--accent-contrast)" />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}>{t("models.subscription.active")}</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
{wallet
? t("models.subscription.balance", {
tokens: formatCloudTokens(balance, locale),
})
: t("models.cloud.proReady")}
</div>
</div>
</div>
<div style={{ width: 10, height: 10, borderRadius: 999, background: low ? "var(--text-low)" : "var(--accent)", flexShrink: 0 }} />
</div>
<div style={{ width: 10, height: 10, borderRadius: 999, background: "var(--accent)", flexShrink: 0 }} />
{wallet && (
<>
<div
role="progressbar"
aria-label={t("models.subscription.balance", {
tokens: formatCloudTokens(balance, locale),
})}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
style={{
height: 8,
borderRadius: 999,
overflow: "hidden",
background: "var(--control-track)",
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
borderRadius: 999,
background: "var(--accent)",
transition: "width 0.2s ease",
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ fontSize: 12, color: low ? "var(--text-hi)" : "var(--text-low)", lineHeight: 1.5 }}>
{low
? t("models.subscription.low")
: reserved > 0n
? t("models.subscription.reserved", {
tokens: formatCloudTokens(reserved, locale),
})
: `${Math.round(progress)}%`}
</div>
{low && (
<button
type="button"
onClick={onTopUp}
style={{
padding: "9px 14px",
borderRadius: 10,
border: "none",
background: "var(--accent)",
color: "var(--accent-contrast)",
fontSize: 11,
fontWeight: 700,
fontFamily: "var(--font-main)",
cursor: "pointer",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{t("models.subscription.topUp")}
</button>
)}
</div>
</>
)}
</div>
);
}
@ -1908,7 +2017,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const authed = cloudProfile !== null && cloudProfile !== undefined;
if (authed) {
setWaitingForSubscriptionRefresh(true);
await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard"));
await openUrl(cloudProfile.topUpUrl || getCloudTopUpUrl());
return;
}
@ -3018,6 +3127,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
<SubscriptionCards
profile={cloudProfile}
onActivate={handleActivateSubscription}
onTopUp={handleActivateSubscription}
onLogout={() => {
void handleCloudLogout();
}}

View file

@ -292,6 +292,13 @@ function toUserFacingErrorMessage(error: unknown, settings: AppSettings): string
return tn("widget.error.regionUnsupported");
}
if (
normalized.includes("402") ||
normalized.includes("insufficient_cloud_tokens")
) {
return tn("widget.error.subscriptionRequired");
}
if (normalized.includes("403") || normalized.includes("forbidden")) {
if (isLocalStt) {
return tn("widget.error.localRuntimeRejected");