From fac28131ddc63c1fab4ef015fc3ff26c64968daf Mon Sep 17 00:00:00 2001 From: David Perov Date: Fri, 17 Jul 2026 15:56:46 +0300 Subject: [PATCH] feat(cloud): show wallet limits in desktop --- src-tauri/src/live_dictation.rs | 30 ++-- src-tauri/src/live_translation.rs | 24 +-- src/components/UserPanel.tsx | 34 ++++- src/lib/cloudAuth.ts | 26 +++- src/lib/fileTranscription.ts | 7 +- src/lib/i18n/dict/components.ts | 21 +-- src/lib/i18n/dict/libMessages.ts | 8 +- src/lib/i18n/dict/settingsModels.ts | 27 ++-- src/lib/i18n/dict/widget.ts | 4 +- src/windows/settings/tabs/SettingsTabs.tsx | 140 ++++++++++++++++-- .../widget/services/transcriptionPipeline.ts | 7 + 11 files changed, 252 insertions(+), 76 deletions(-) diff --git a/src-tauri/src/live_dictation.rs b/src-tauri/src/live_dictation.rs index 214c6d6..82c880e 100644 --- a/src-tauri/src/live_dictation.rs +++ b/src-tauri/src/live_dictation.rs @@ -438,7 +438,7 @@ async fn resolve_cloud_live_dictation_connection( ) -> Result { 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!( diff --git a/src-tauri/src/live_translation.rs b/src-tauri/src/live_translation.rs index fd63613..8172f3a 100644 --- a/src-tauri/src/live_translation.rs +++ b/src-tauri/src/live_translation.rs @@ -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!( diff --git a/src/components/UserPanel.tsx b/src/components/UserPanel.tsx index 8cf6bbd..1343324 100644 --- a/src/components/UserPanel.tsx +++ b/src/components/UserPanel.tsx @@ -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(() => 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
; } - // ── 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 (
- {t("userPanel.subscriptionActive")} + {balance + ? t("userPanel.balance", { tokens: balance }) + : t("userPanel.subscriptionActive")}
+ {profile.wallet?.low && ( + + )}
); } - // ── Authenticated but no active subscription ──────────────── + // ── Authenticated but empty cloud balance ─────────────────── if (profile && !profile.subscription.active) { return (
@@ -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 ( diff --git a/src/lib/cloudAuth.ts b/src/lib/cloudAuth.ts index 76f6a50..53d65a4 100644 --- a/src/lib/cloudAuth.ts +++ b/src/lib/cloudAuth.ts @@ -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. */ diff --git a/src/lib/fileTranscription.ts b/src/lib/fileTranscription.ts index ac4b201..50e3ff2 100644 --- a/src/lib/fileTranscription.ts +++ b/src/lib/fileTranscription.ts @@ -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"); } diff --git a/src/lib/i18n/dict/components.ts b/src/lib/i18n/dict/components.ts index 29de78f..2105a61 100644 --- a/src/lib/i18n/dict/components.ts +++ b/src/lib/i18n/dict/components.ts @@ -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; diff --git a/src/lib/i18n/dict/libMessages.ts b/src/lib/i18n/dict/libMessages.ts index 07cfd1e..a06cf05 100644 --- a/src/lib/i18n/dict/libMessages.ts +++ b/src/lib/i18n/dict/libMessages.ts @@ -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: "Не удалось подготовить аудио для разметки говорящих. Попробуйте другой аудио- или видеофайл.", diff --git a/src/lib/i18n/dict/settingsModels.ts b/src/lib/i18n/dict/settingsModels.ts index 47bb256..2437d35 100644 --- a/src/lib/i18n/dict/settingsModels.ts +++ b/src/lib/i18n/dict/settingsModels.ts @@ -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" }, diff --git a/src/lib/i18n/dict/widget.ts b/src/lib/i18n/dict/widget.ts index 7d0bb70..787b0ef 100644 --- a/src/lib/i18n/dict/widget.ts +++ b/src/lib/i18n/dict/widget.ts @@ -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.", diff --git a/src/windows/settings/tabs/SettingsTabs.tsx b/src/windows/settings/tabs/SettingsTabs.tsx index c3b2d97..5de09f6 100644 --- a/src/windows/settings/tabs/SettingsTabs.tsx +++ b/src/windows/settings/tabs/SettingsTabs.tsx @@ -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 ( -
-
-
- -
-
-
{t("models.subscription.active")}
-
- {t("models.subscription.unlimitedUntil", { date: profile.subscription.expiresAt ? new Date(profile.subscription.expiresAt).toLocaleDateString(lang === "ru" ? "ru-RU" : "en-US", { day: "numeric", month: "long" }) : "—" })} +
+
+
+
+ +
+
+
{t("models.subscription.active")}
+
+ {wallet + ? t("models.subscription.balance", { + tokens: formatCloudTokens(balance, locale), + }) + : t("models.cloud.proReady")} +
+
-
+ + {wallet && ( + <> +
+
+
+ +
+
+ {low + ? t("models.subscription.low") + : reserved > 0n + ? t("models.subscription.reserved", { + tokens: formatCloudTokens(reserved, locale), + }) + : `${Math.round(progress)}%`} +
+ {low && ( + + )} +
+ + )}
); } @@ -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) { { void handleCloudLogout(); }} diff --git a/src/windows/widget/services/transcriptionPipeline.ts b/src/windows/widget/services/transcriptionPipeline.ts index 828a5ca..b104529 100644 --- a/src/windows/widget/services/transcriptionPipeline.ts +++ b/src/windows/widget/services/transcriptionPipeline.ts @@ -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");