Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ArrowRight, CreditCard } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
import { formatNumber, formatQuota } from '@/lib/format'
Expand Down Expand Up @@ -87,11 +88,53 @@ function buildSummarySparklines(
}
}

/** localStorage flag so we only celebrate the first-call moment once
* per browser. The user may have triggered their first call from
* Cherry Studio / Playground / Cursor / curl — wherever — and we'd
* like to acknowledge it the next time they open the dashboard.
* Backend doesn't track `first_call_at` so we rely on request_count
* becoming non-zero as the signal. */
const FIRST_CALL_CELEBRATED_KEY = 'dr_first_call_celebrated'

function hasCelebrated(): boolean {
if (typeof window === 'undefined') return true
try {
return window.localStorage.getItem(FIRST_CALL_CELEBRATED_KEY) === '1'
} catch {
return true
}
}

function markCelebrated(): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(FIRST_CALL_CELEBRATED_KEY, '1')
} catch {
/* private mode / storage disabled — silent */
}
}

export function SummaryCards() {
const { t } = useTranslation()
const user = useAuthStore((state) => state.auth.user)
const { status, loading } = useStatus()

// First-call celebration: pure frontend, no backend `first_call_at`
// field needed. When request_count flips from 0 → 1+, fire one
// sonner toast and set a localStorage flag so it never repeats.
useEffect(() => {
const count = Number(user?.request_count ?? 0)
if (count > 0 && !hasCelebrated()) {
toast.success(t('🎉 Your first API call worked!'), {
description: t(
'You are officially live on DeepRouter. Welcome aboard.'
),
duration: 6000,
})
markCelebrated()
}
}, [user?.request_count, t])

const summaryTimeRange = useMemo(() => computeTimeRange(1), [])

const usageTrendQuery = useQuery({
Expand Down Expand Up @@ -179,10 +222,12 @@ export function SummaryCards() {
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex flex-col gap-1'>
<h3 className='text-base font-semibold'>
{t('Usage at a glance')}
{t('Your AI usage')}
</h3>
<p className='text-muted-foreground text-sm'>
{t('Monitor balance, usage, and request volume')}
{t(
'What you have, what you used, how many calls you made.'
)}
</p>
</div>
</div>
Expand Down Expand Up @@ -220,11 +265,32 @@ export function SummaryCards() {
aria-hidden='true'
/>
</div>
<p className='text-muted-foreground text-sm leading-relaxed'>
{currencyEnabled
? `${t('Displayed in')} ${currencyLabel}`
: t('Balance is shown in quota units')}
</p>
{/* Friendly "how many chats" estimate using a mid-tier model
* average ($0.005/chat). Quota units are 500_000 = $1 so
* chats ≈ quota / 2_500. Marketing-grade approximation; the
* actual cost depends on which model the user invokes. */}
{(() => {
const remainQuota = Number(user?.quota ?? 0)
if (remainQuota <= 0) {
return (
<p className='text-muted-foreground text-sm leading-relaxed'>
{t('Top up to start using AI models.')}
</p>
)
}
const chats = Math.max(0, Math.floor(remainQuota / 2500))
const chatsLabel =
chats >= 10_000
? `${Math.floor(chats / 1000)}k`
: chats >= 1000
? `${(chats / 1000).toFixed(1).replace(/\.0$/, '')}k`
: String(chats)
return (
<p className='text-muted-foreground text-sm leading-relaxed'>
{t('≈ {{count}} chats remaining', { count: chatsLabel })}
</p>
)
})()}
</div>
<Button className='justify-between' render={<Link to='/wallet' />}>
<span>{t('Recharge')}</span>
Expand Down
26 changes: 19 additions & 7 deletions web/default/src/features/pricing/components/model-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -599,6 +600,11 @@ function GroupPricingSection(props: {
showRechargePrice?: boolean
}) {
const { t } = useTranslation()
// Group multiplier ("5x") is an operator concept — non-admin users
// see the final per-token price computed from the ratio, but never
// the multiplier itself. Same pattern as the Create Key form and
// Usage Logs detail dialog gates.
const isAdmin = useIsAdmin()
const showRechargePrice = props.showRechargePrice ?? false

const availableGroups = useMemo(
Expand Down Expand Up @@ -701,9 +707,11 @@ function GroupPricingSection(props: {
<div key={group} className='overflow-hidden rounded-lg border'>
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
<GroupBadge group={group} size='sm' />
<span className='text-muted-foreground font-mono text-xs'>
{ratio}x
</span>
{isAdmin && (
<span className='text-muted-foreground font-mono text-xs'>
{ratio}x
</span>
)}
</div>
<div className='overflow-x-auto'>
<Table className='text-sm'>
Expand Down Expand Up @@ -775,7 +783,9 @@ function GroupPricingSection(props: {
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Group')}</TableHead>
<TableHead className={thClass}>{t('Ratio')}</TableHead>
{isAdmin && (
<TableHead className={thClass}>{t('Ratio')}</TableHead>
)}
{isTokenBased ? (
<>
<TableHead className={`${thClass} text-right`}>
Expand Down Expand Up @@ -808,9 +818,11 @@ function GroupPricingSection(props: {
<TableCell className='py-2.5'>
<GroupBadge group={group} size='sm' />
</TableCell>
<TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
{ratio}x
</TableCell>
{isAdmin && (
<TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
{ratio}x
</TableCell>
)}
{isTokenBased ? (
<>
<TableCell className='py-2.5 text-right font-mono'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ReactNode } from 'react'
import { ChevronDown, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
Expand Down Expand Up @@ -156,6 +157,9 @@ function FilterSection(props: FilterSectionProps) {

export function PricingSidebar(props: PricingSidebarProps) {
const { t } = useTranslation()
// Group ratio suffix ("x1.2") is an operator concept; non-admin
// users don't need to see markup multipliers next to group filters.
const isAdmin = useIsAdmin()
const quotaTypeLabels = getQuotaTypeLabels(t)
const endpointTypeLabels = getEndpointTypeLabels(t)

Expand Down Expand Up @@ -186,7 +190,9 @@ export function PricingSidebar(props: PricingSidebarProps) {
...props.groups.map((group) => ({
value: group,
label: group,
suffix: formatGroupRatio(props.groupRatios?.[group]),
suffix: isAdmin
? formatGroupRatio(props.groupRatios?.[group])
: undefined,
})),
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ export function BillingHistoryDialog({
) : records.length === 0 ? (
<div className='text-muted-foreground flex h-[320px] flex-col items-center justify-center text-center sm:h-[400px]'>
<p className='text-sm font-medium'>
{t('No billing records found')}
{keyword
? t('No billing records found')
: t("You haven't recharged yet")}
</p>
<p className='mt-1 text-xs'>
{keyword
? t('Try adjusting your search')
: t('Your transaction history will appear here')}
: t('Once you top up, your records appear here.')}
</p>
</div>
) : (
Expand Down
9 changes: 8 additions & 1 deletion web/default/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,7 @@
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.",
"Ollama": "Ollama",
"Ollama Models": "Ollama Models",
"Once you top up, your records appear here.": "Once you top up, your records appear here.",
"One API": "One API",
"One IP or CIDR range per line": "One IP or CIDR range per line",
"One IP per line (empty for no restriction)": "One IP per line (empty for no restriction)",
Expand Down Expand Up @@ -3855,6 +3856,7 @@
"Top up balance and view billing history.": "Top up balance and view billing history.",
"Top up to call AI models. Your trial credit covers your first few requests so you can try things out.": "Top up to call AI models. Your trial credit covers your first few requests so you can try things out.",
"Top up to start using AI models": "Top up to start using AI models",
"Top up to start using AI models.": "Top up to start using AI models.",
"Top vendors": "Top vendors",
"Top {{count}}": "Top {{count}}",
"Top-Up Link": "Top-Up Link",
Expand Down Expand Up @@ -4226,6 +4228,7 @@
"Well-Known URL must start with http:// or https://": "Well-Known URL must start with http:// or https://",
"What can I help you with?": "What can I help you with?",
"What would you like to know?": "What would you like to know?",
"What you have, what you used, how many calls you made.": "What you have, what you used, how many calls you made.",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "When enabled, Midjourney callbacks are accepted (reveals server IP).",
Expand Down Expand Up @@ -4261,16 +4264,19 @@
"Year": "Year",
"You Pay": "You Pay",
"You are about to delete {{count}} API key(s).": "You are about to delete {{count}} API key(s).",
"You are officially live on DeepRouter. Welcome aboard.": "You are officially live on DeepRouter. Welcome aboard.",
"You are running the latest version ({{version}}).": "You are running the latest version ({{version}}).",
"You can close this tab once the binding completes or a success message appears in the original window.": "You can close this tab once the binding completes or a success message appears in the original window.",
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.",
"You can only check in once per day": "You can only check in once per day",
"You don't have necessary permission": "You don't have necessary permission",
"You have unsaved changes": "You have unsaved changes",
"You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?",
"You haven't recharged yet": "You haven't recharged yet",
"You save": "You save",
"You will be redirected to Telegram to complete the binding process.": "You will be redirected to Telegram to complete the binding process.",
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.",
"Your AI usage": "Your AI usage",
"Your Azure OpenAI endpoint URL": "Your Azure OpenAI endpoint URL",
"Your Bot Name": "Your Bot Name",
"Your Cloudflare Account ID": "Your Cloudflare Account ID",
Expand Down Expand Up @@ -4529,6 +4535,7 @@
"| Based on": "| Based on",
"© 2025 Your Company. All rights reserved.": "© 2025 Your Company. All rights reserved.",
"≈ {{count}} chats": "≈ {{count}} chats",
"≈ {{count}} chats remaining": "≈ {{count}} chats remaining"
"≈ {{count}} chats remaining": "≈ {{count}} chats remaining",
"🎉 Your first API call worked!": "🎉 Your first API call worked!"
}
}
9 changes: 8 additions & 1 deletion web/default/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,7 @@
"Old format: Direct override. New format: Supports conditional judgment and custom JSON operations.": "旧格式:直接覆盖。新格式:支持条件判断和自定义 JSON 操作。",
"Ollama": "Ollama",
"Ollama Models": "Ollama 模型",
"Once you top up, your records appear here.": "充值后记录会出现在这里。",
"One API": "One API",
"One IP or CIDR range per line": "每行一个 IP 或 CIDR 范围",
"One IP per line (empty for no restriction)": "每行一个 IP (留空表示无限制)",
Expand Down Expand Up @@ -3855,6 +3856,7 @@
"Top up balance and view billing history.": "充值余额并查看账单历史。",
"Top up to call AI models. Your trial credit covers your first few requests so you can try things out.": "充值后调用 AI 模型。注册赠送的额度够你试用前几次请求。",
"Top up to start using AI models": "充值后开始使用 AI 模型",
"Top up to start using AI models.": "充值后开始使用 AI 模型。",
"Top vendors": "热门厂商",
"Top {{count}}": "前 {{count}}",
"Top-Up Link": "充值链接",
Expand Down Expand Up @@ -4226,6 +4228,7 @@
"Well-Known URL must start with http:// or https://": "知名 URL 必须以 http:// 或 https:// 开头",
"What can I help you with?": "今天可以帮你做点什么?",
"What would you like to know?": "您想了解什么?",
"What you have, what you used, how many calls you made.": "余额、消费、调用次数一览。",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件满足时,最终价格乘以 X;多条命中的倍率会相乘;小于 1 的值为折扣。",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "启用时,接受 Midjourney 回调 (会泄露服务器 IP)。",
Expand Down Expand Up @@ -4261,16 +4264,19 @@
"Year": "今年",
"You Pay": "您支付",
"You are about to delete {{count}} API key(s).": "您即将删除 {{count}} 个 API 密钥。",
"You are officially live on DeepRouter. Welcome aboard.": "你正式开始用 DeepRouter 啦,欢迎!",
"You are running the latest version ({{version}}).": "您正在运行最新版本 ({{version}})。",
"You can close this tab once the binding completes or a success message appears in the original window.": "绑定完成后或原窗口出现成功消息后,您可以关闭此标签页。",
"You can manually add them in \"Custom Model Names\", click \"Fill\" and then submit, or use the operations below to handle automatically.": "你可以在\"自定义模型名称\"处手动添加它们,然后点击\"填入\"后再提交,或者直接使用下方操作自动处理。",
"You can only check in once per day": "每日仅可签到一次,请勿重复签到",
"You don't have necessary permission": "您没有必要的权限",
"You have unsaved changes": "您有未保存的更改",
"You have unsaved changes. Are you sure you want to leave?": "您有未保存的更改。确定要离开吗?",
"You haven't recharged yet": "你还没有充值过",
"You save": "您节省",
"You will be redirected to Telegram to complete the binding process.": "您将被重定向到 Telegram 以完成绑定过程。",
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds.": "您将被自动重定向。如果几秒钟后无反应,您可以返回上一页。",
"Your AI usage": "你的 AI 用量",
"Your Azure OpenAI endpoint URL": "您的 Azure OpenAI 端点 URL",
"Your Bot Name": "您的机器人名称",
"Your Cloudflare Account ID": "您的 Cloudflare 账户 ID",
Expand Down Expand Up @@ -4529,6 +4535,7 @@
"| Based on": "| 基于",
"© 2025 Your Company. All rights reserved.": "© 2025 您的公司。保留所有权利。",
"≈ {{count}} chats": "≈ 聊 {{count}} 次",
"≈ {{count}} chats remaining": "≈ 还能聊 {{count}} 次"
"≈ {{count}} chats remaining": "≈ 还能聊 {{count}} 次",
"🎉 Your first API call worked!": "🎉 第一次 API 调用成功了!"
}
}