feat: add endpoint for retrieving user space credits and implement caching mechanism in UserService

This commit is contained in:
Junyan Qin
2025-12-29 22:23:11 +08:00
parent f11e01b549
commit 9c82eeddeb
8 changed files with 125 additions and 31 deletions

View File

@@ -158,6 +158,12 @@ class UserRouterGroup(group.RouterGroup):
}
)
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get Space credits balance for current user"""
credits = await self.ap.user_service.get_space_credits(user_email)
return self.success(data={'credits': credits})
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""

View File

@@ -16,10 +16,12 @@ from ....utils import constants
class UserService:
ap: app.Application
_create_user_lock: asyncio.Lock
_space_credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)}
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._create_user_lock = asyncio.Lock()
self._space_credits_cache = {}
def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file"""
@@ -178,6 +180,30 @@ class UserService:
raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {})
async def get_space_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
"""Get Space credits for user with caching (60s TTL)"""
import time
cache_ttl = 60
if not force_refresh and user_email in self._space_credits_cache:
credits, ts = self._space_credits_cache[user_email]
if time.time() - ts < cache_ttl:
return credits
user_obj = await self.get_user_by_email(user_email)
if not user_obj or user_obj.account_type != 'space' or not user_obj.space_access_token:
return None
try:
info = await self.get_space_user_info(user_obj.space_access_token)
credits = info.get('credits')
if credits is not None:
self._space_credits_cache[user_email] = (credits, time.time())
return credits
except Exception:
return self._space_credits_cache.get(user_email, (None, 0))[0]
async def refresh_space_token(self, refresh_token: str) -> typing.Dict:
"""Refresh Space access token"""
space_config = self._get_space_config()

View File

@@ -9,12 +9,11 @@ import {
ChevronRight,
Trash2,
Settings,
Sparkles,
LogIn,
Eye,
Wrench,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import {
LLMModel,
EmbeddingModel,
@@ -46,6 +45,7 @@ import { Badge } from '@/components/ui/badge';
import LLMForm from './component/llm-form/LLMForm';
import EmbeddingForm from './component/embedding-form/EmbeddingForm';
import ProviderForm from './component/provider-form/ProviderForm';
import langbotIcon from '@/app/assets/langbot-logo.webp';
interface ModelsDialogProps {
open: boolean;
@@ -62,7 +62,7 @@ export default function ModelsDialog({
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceBalance] = useState<number | null>(null);
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
@@ -103,6 +103,10 @@ export default function ModelsDialog({
try {
const userInfo = await httpClient.getUserInfo();
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
} catch {
setAccountType('local');
}
@@ -279,8 +283,12 @@ export default function ModelsDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
{isLangBotModels ? (
<div className="p-2 bg-gradient-to-br from-purple-500 to-blue-500 rounded-lg">
<Sparkles className="h-5 w-5 text-white" />
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
</div>
) : (
<img
@@ -332,11 +340,29 @@ export default function ModelsDialog({
{t('models.loginWithSpace')}
</Button>
)}
{isLangBotModels && accountType === 'space' && (
<Badge variant="secondary">
{t('models.balance')}: {spaceBalance ?? '--'}
</Badge>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{!isLangBotModels && (
<>
<Button
@@ -480,8 +506,12 @@ export default function ModelsDialog({
<CardHeader className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-gradient-to-br from-purple-500 to-blue-500 rounded-lg">
<Sparkles className="h-5 w-5 text-white" />
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
</div>
<div>
<CardTitle className="text-base">
@@ -492,11 +522,32 @@ export default function ModelsDialog({
</p>
</div>
</div>
{accountType !== 'space' && (
{accountType !== 'space' ? (
<Button variant="outline" size="sm" onClick={handleSpaceLogin}>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
</Button>
) : (
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() =>
window.open(
`${systemInfo.cloud_service_url}/billing`,
'_blank',
)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)
)}
</div>
</CardHeader>
@@ -531,12 +582,15 @@ export default function ModelsDialog({
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<div className="flex-1 flex flex-col overflow-hidden px-6 pb-6 mt-4">
<div className="flex-1 flex flex-col overflow-hidden px-6 pb-6 mt-0">
{/* Fixed LangBot Models Card */}
<div className="flex-shrink-0">{renderLangBotModelsCard()}</div>
{/* Add Model Button */}
<div className="flex-shrink-0 mb-3 flex justify-end">
<div className="flex-shrink-0 mb-3 flex justify-between items-center">
<span className="text-sm text-muted-foreground">
{t('models.providerCount', { count: otherProviders.length })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">

View File

@@ -731,6 +731,10 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/user/info');
}
public getSpaceCredits(): Promise<{ credits: number | null }> {
return this.get('/api/v1/user/space-credits');
}
public getAccountInfo(): Promise<{
initialized: boolean;
account_type?: 'local' | 'space';

View File

@@ -189,6 +189,7 @@ const enUS = {
spaceModelReadOnly: 'Space models are read-only',
noSpaceModels: 'No Space models. Click Sync to fetch models from Space.',
noLocalModels: 'No local models. Click Create to add a model.',
providerCount: '{{count}} providers',
// New keys for provider-based structure
addModel: 'Add Model',
addLLMModel: 'Add LLM Model',
@@ -197,17 +198,17 @@ const enUS = {
existingProvider: 'Existing Provider',
newProvider: 'New Provider',
selectProvider: 'Select Provider',
requester: 'Requester',
selectRequester: 'Select Requester',
requester: 'Provider Type',
selectRequester: 'Select Provider Type',
langbotModelsDescription: 'Cloud models powered by LangBot Space',
balance: 'Balance',
credits: 'Credits',
loginWithSpace: 'Login with Space',
loginToUseModels: 'Login with Space to use cloud models',
noModels: 'No models configured',
editProvider: 'Edit Provider',
providerName: 'Provider Name',
providerNameRequired: 'Provider name is required',
requesterRequired: 'Requester is required',
requesterRequired: 'Provider type is required',
providerSaved: 'Provider saved',
providerCreated: 'Provider created',
providerSaveError: 'Failed to save provider: ',

View File

@@ -195,6 +195,7 @@ const jaJP = {
'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',
noLocalModels:
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
providerCount: '{{count}} 件のプロバイダー',
addModel: 'モデルを追加',
addLLMModel: 'LLMモデルを追加',
addEmbeddingModel: '埋め込みモデルを追加',
@@ -202,17 +203,17 @@ const jaJP = {
existingProvider: '既存のプロバイダー',
newProvider: '新規プロバイダー',
selectProvider: 'プロバイダーを選択',
requester: 'リクエスター',
selectRequester: 'リクエスターを選択',
requester: 'プロバイダータイプ',
selectRequester: 'プロバイダータイプを選択',
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
balance: '残高',
credits: 'クレジット',
loginWithSpace: 'Space でログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
noModels: 'モデルがありません',
editProvider: 'プロバイダーを編集',
providerName: 'プロバイダー名',
providerNameRequired: 'プロバイダー名は必須です',
requesterRequired: 'リクエスターは必須です',
requesterRequired: 'プロバイダータイプは必須です',
providerSaved: 'プロバイダーを保存しました',
providerCreated: 'プロバイダーを作成しました',
providerSaveError: 'プロバイダーの保存に失敗しました:',

View File

@@ -182,6 +182,7 @@ const zhHans = {
spaceModelReadOnly: 'Space 模型为只读',
noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',
noLocalModels: '暂无本地模型。点击创建按钮添加模型。',
providerCount: '共 {{count}} 个供应商',
// 供应商结构新增键
addModel: '添加模型',
addLLMModel: '添加对话模型',
@@ -190,17 +191,17 @@ const zhHans = {
existingProvider: '已有供应商',
newProvider: '新建供应商',
selectProvider: '选择供应商',
requester: '请求器',
selectRequester: '选择请求器',
requester: '供应商类型',
selectRequester: '选择供应商类型',
langbotModelsDescription: 'LangBot Space 提供的云端模型',
balance: '余额',
credits: '积分',
loginWithSpace: '通过 Space 登录',
loginToUseModels: '通过 Space 登录以使用云端模型',
noModels: '暂无模型',
editProvider: '编辑供应商',
providerName: '供应商名称',
providerNameRequired: '供应商名称不能为空',
requesterRequired: '请求器不能为空',
requesterRequired: '供应商类型不能为空',
providerSaved: '供应商已保存',
providerCreated: '供应商已创建',
providerSaveError: '保存供应商失败:',

View File

@@ -182,6 +182,7 @@ const zhHant = {
spaceModelReadOnly: 'Space 模型為唯讀',
noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
providerCount: '共 {{count}} 個供應商',
addModel: '新增模型',
addLLMModel: '新增對話模型',
addEmbeddingModel: '新增嵌入模型',
@@ -189,17 +190,17 @@ const zhHant = {
existingProvider: '現有供應商',
newProvider: '新供應商',
selectProvider: '選擇供應商',
requester: '請求器',
selectRequester: '選擇請求器',
requester: '供應商類型',
selectRequester: '選擇供應商類型',
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
balance: '餘額',
credits: '積分',
loginWithSpace: '使用 Space 登入',
loginToUseModels: '使用 Space 登入以使用雲端模型',
noModels: '暫無模型',
editProvider: '編輯供應商',
providerName: '供應商名稱',
providerNameRequired: '供應商名稱不能為空',
requesterRequired: '請求器不能為空',
requesterRequired: '供應商類型不能為空',
providerSaved: '供應商已儲存',
providerCreated: '供應商已建立',
providerSaveError: '儲存供應商失敗:',