feat: account management overhaul + 7 issue resolutions (#147)
- Add smart JSON export/import with schema validation and deduplication - Add per-account proxy configurations (Issue #79) - Add advanced account sorting engine (Issues #126 & #99) - Add compact horizontal layout, AI credits, inline model visibility (Issues #145 & #134) - Add desktop push notifications for low quota (Issue #53) - Fix Arch Linux AUR process detection bug (Issue #117) - Default theme set to Dark Mode (it's better) - UI Polishing: Improved sort dropdown and symmetrical dialog layouts - Dev Mode: Resolved port conflicts and single-instance locks - Updated README and README.zh-CN with new features
This commit is contained in:
36 files changed
+1805
-271
No files matched your search
@@ -123,3 +123,6 @@ build/
|
|||||||
cli/aliases.json
|
cli/aliases.json
|
||||||
*.backup
|
*.backup
|
||||||
accounts_backup.json
|
accounts_backup.json
|
||||||
|
|
||||||
|
# Bun lock file
|
||||||
|
bun.lock
|
||||||
@@ -158,7 +158,12 @@ When using Antigravity IDE, have you ever encountered these problems?
|
|||||||
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
||||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||||
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
||||||
- **🌏 Internationalization** - Multi-language support (English / 中文)
|
- **📤 JSON Export/Import** - Export and import account pools with schema validation and deduplication
|
||||||
|
- **🔔 Desktop Notifications** - Configurable alerts when model quota drops below your threshold
|
||||||
|
- **🌐 Per-Account Proxy** - Route individual accounts through their own HTTP/SOCKS5 proxy
|
||||||
|
- **📊 Smart Sorting** - Sort accounts by recently used, overall quota, or specific model groups
|
||||||
|
- **📋 Compact Layout** - Dense horizontal view to maximize visible accounts
|
||||||
|
- **🌏 Internationalization** - Multi-language support (English / 中文 / Русский)
|
||||||
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+6
-1
@@ -158,7 +158,12 @@
|
|||||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
|
||||||
|
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
|
||||||
|
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
|
||||||
|
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
|
||||||
|
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
|
||||||
|
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
|
||||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
# CLI Feature - Pull Request Summary
|
|
||||||
|
|
||||||
## What This PR Adds
|
|
||||||
|
|
||||||
A complete **Command-Line Interface (CLI)** for Antigravity Manager with 18+ commands for power users.
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- 📊 **Live Quota Monitoring** - Real-time tracking for Gemini & Claude models
|
|
||||||
- 🔄 **Account Switching** - Seamless switching without manual token management
|
|
||||||
- ✅ **Token Validation** - Auto-refresh expired tokens
|
|
||||||
- 🎯 **Smart Auto-Switch** - Automatically pick best account by quota
|
|
||||||
- 🏷️ **Aliases** - Shortcut names for accounts (`work`, `personal`)
|
|
||||||
- 💾 **Backup/Restore** - Export/import account configurations
|
|
||||||
- 🎮 **Interactive Mode** - Keyboard-navigated menus for beginners
|
|
||||||
- 🩺 **Diagnostics** - System health checks
|
|
||||||
|
|
||||||
## Technical Highlights
|
|
||||||
|
|
||||||
- **Zero Hardcoded Paths**: Intelligently discovers database and executable locations
|
|
||||||
- **Cross-Platform Ready**: Uses environment variables and checks for Windows/Mac/Linux
|
|
||||||
- **Secure**: Leverages existing DPAPI + AES-256-GCM encryption (same as GUI)
|
|
||||||
- **Extensible**: Clean separation between core logic (`core.py`) and commands (`main.py`)
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
cli/
|
|
||||||
├── README.md # Full documentation
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── core.py # Database, encryption, API logic
|
|
||||||
├── main.py # CLI commands and interactive UI
|
|
||||||
└── proto_utils.py # Protobuf encoding helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Interactive mode
|
|
||||||
agm
|
|
||||||
|
|
||||||
# Quick status
|
|
||||||
agm status
|
|
||||||
|
|
||||||
# Switch accounts
|
|
||||||
agm switch work
|
|
||||||
|
|
||||||
# Auto-switch to best account
|
|
||||||
agm auto-switch --model claude --min-quota 50
|
|
||||||
|
|
||||||
# Live monitoring
|
|
||||||
agm watch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- ✅ Tested on Windows 10/11
|
|
||||||
- ✅ No hardcoded paths (uses standard Windows env vars)
|
|
||||||
- ✅ Works with both installed and development builds
|
|
||||||
- ✅ Database encryption/decryption verified
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
All Python dependencies are self-contained in `cli/requirements.txt`:
|
|
||||||
- `typer` - CLI framework
|
|
||||||
- `rich` - Beautiful terminal output
|
|
||||||
- `questionary` - Interactive menus
|
|
||||||
- `pywin32` - Windows DPAPI support
|
|
||||||
- `cryptography` - AES-256-GCM encryption
|
|
||||||
- `httpx` - Async HTTP client
|
|
||||||
- `psutil` - Process management
|
|
||||||
- `protobuf` - Google API protocol buffers
|
|
||||||
|
|
||||||
## Platform Support
|
|
||||||
|
|
||||||
- **Windows**: Full support (tested)
|
|
||||||
- **macOS/Linux**: Partial support (no DPAPI, but core features work)
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
None. This is a pure addition—no changes to existing GUI code.
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- OAuth flow for adding new accounts via CLI
|
|
||||||
- TUI (Terminal UI) with live dashboard
|
|
||||||
- Scheduled auto-refresh via Windows Task Scheduler
|
|
||||||
- Config file for user preferences
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [x] Code follows existing style
|
|
||||||
- [x] All commands have help text
|
|
||||||
- [x] No hardcoded paths/credentials
|
|
||||||
- [x] README with examples
|
|
||||||
- [x] Dependencies in requirements.txt
|
|
||||||
- [x] .gitignore updated for Python
|
|
||||||
- [x] Works with fresh installs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to merge!** 🚀
|
|
||||||
Generated
-11
@@ -21015,7 +21015,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21036,7 +21035,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21057,7 +21055,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21078,7 +21075,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21099,7 +21095,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21120,7 +21115,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21141,7 +21135,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21162,7 +21155,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21183,7 +21175,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21204,7 +21195,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -21225,7 +21215,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+8
-1
@@ -153,5 +153,12 @@
|
|||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
}
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@bitdisaster/exe-icon-extractor",
|
||||||
|
"@nestjs/core",
|
||||||
|
"@sentry/cli",
|
||||||
|
"@swc/core",
|
||||||
|
"protobufjs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import type { DeviceProfile } from '@/types/account';
|
import type { DeviceProfile } from '@/types/account';
|
||||||
|
import { isValidProxyUrl } from '@/utils/url';
|
||||||
|
|
||||||
export function addGoogleAccount(input: { authCode: string }) {
|
export function addGoogleAccount(input: { authCode: string }) {
|
||||||
return ipc.client.cloud.addGoogleAccount(input);
|
return ipc.client.cloud.addGoogleAccount(input);
|
||||||
@@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function refreshAccountQuota(input: { accountId: string }) {
|
export function refreshAccountQuota(input: { accountId: string }) {
|
||||||
|
console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`);
|
||||||
return ipc.client.cloud.refreshAccountQuota(input);
|
return ipc.client.cloud.refreshAccountQuota(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +90,26 @@ export function deleteCloudIdentityProfileRevision(input: {
|
|||||||
export function openCloudIdentityStorageFolder() {
|
export function openCloudIdentityStorageFolder() {
|
||||||
return ipc.client.cloud.openIdentityStorageFolder();
|
return ipc.client.cloud.openIdentityStorageFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setAccountProxy(input: { accountId: string; proxyUrl: string | null }) {
|
||||||
|
if (input.proxyUrl && !isValidProxyUrl(input.proxyUrl)) {
|
||||||
|
throw new Error('Invalid proxy URL format');
|
||||||
|
}
|
||||||
|
return ipc.client.cloud.setAccountProxy(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCloudAccounts(input: { stripTokens?: boolean }) {
|
||||||
|
return ipc.client.cloud.exportCloudAccounts(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importCloudAccounts(input: {
|
||||||
|
jsonContent: string;
|
||||||
|
strategy?: 'merge' | 'overwrite' | 'skip-existing';
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
JSON.parse(input.jsonContent);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON content provided for import');
|
||||||
|
}
|
||||||
|
return ipc.client.cloud.importCloudAccounts(input);
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
|
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -32,6 +34,9 @@ import {
|
|||||||
getQuotaStatus,
|
getQuotaStatus,
|
||||||
type QuotaStatus,
|
type QuotaStatus,
|
||||||
} from '@/utils/quota-display';
|
} from '@/utils/quota-display';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||||
|
import { isValidProxyUrl } from '@/utils/url';
|
||||||
|
|
||||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||||
|
|
||||||
@@ -81,7 +86,9 @@ function isGeminiProFamilyModel(modelName: string): boolean {
|
|||||||
return normalizedModelName.includes('gemini-3.1-pro');
|
return normalizedModelName.includes('gemini-3.1-pro');
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string, CloudQuotaModelInfo> {
|
function mergeGeminiProQuotaEntries(
|
||||||
|
entries: ModelQuotaEntry[],
|
||||||
|
): Record<string, CloudQuotaModelInfo> {
|
||||||
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
|
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
|
||||||
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
||||||
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
||||||
@@ -96,9 +103,18 @@ function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string,
|
|||||||
const mergedPercentage = proLowModelInfo
|
const mergedPercentage = proLowModelInfo
|
||||||
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
|
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
|
||||||
: modelInfo.percentage;
|
: modelInfo.percentage;
|
||||||
|
|
||||||
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
|
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
|
||||||
...modelInfo,
|
...modelInfo,
|
||||||
|
...proLowModelInfo,
|
||||||
percentage: mergedPercentage,
|
percentage: mergedPercentage,
|
||||||
|
display_name: 'Gemini 3.1 Pro',
|
||||||
|
resetTime:
|
||||||
|
modelInfo.resetTime && proLowModelInfo?.resetTime
|
||||||
|
? modelInfo.resetTime < proLowModelInfo.resetTime
|
||||||
|
? modelInfo.resetTime
|
||||||
|
: proLowModelInfo.resetTime
|
||||||
|
: modelInfo.resetTime || proLowModelInfo?.resetTime || '',
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -148,13 +164,16 @@ export function CloudAccountCard({
|
|||||||
isSwitching,
|
isSwitching,
|
||||||
}: CloudAccountCardProps) {
|
}: CloudAccountCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config, saveConfig } = useAppConfig();
|
||||||
const {
|
const {
|
||||||
enabled: providerGroupingsEnabled,
|
enabled: providerGroupingsEnabled,
|
||||||
getAccountStats,
|
getAccountStats,
|
||||||
isProviderCollapsed,
|
isProviderCollapsed,
|
||||||
toggleProviderCollapse,
|
toggleProviderCollapse,
|
||||||
} = useProviderGrouping();
|
} = useProviderGrouping();
|
||||||
|
const setAccountProxy = useSetAccountProxy();
|
||||||
|
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||||
|
const [proxySaved, setProxySaved] = useState(false);
|
||||||
|
|
||||||
const getQuotaTextColorClass = (percentage: number) => {
|
const getQuotaTextColorClass = (percentage: number) => {
|
||||||
const quotaStatus = getQuotaStatus(percentage);
|
const quotaStatus = getQuotaStatus(percentage);
|
||||||
@@ -184,6 +203,8 @@ export function CloudAccountCard({
|
|||||||
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||||
|
|
||||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||||
) as ModelQuotaEntry[];
|
) as ModelQuotaEntry[];
|
||||||
@@ -265,7 +286,9 @@ export function CloudAccountCard({
|
|||||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}>
|
<span
|
||||||
|
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||||
|
>
|
||||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||||
@@ -278,12 +301,14 @@ export function CloudAccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{providerStats.providers.map((statsByProvider) => (
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{providerStats.providers.map((group) => (
|
||||||
<ProviderGroup
|
<ProviderGroup
|
||||||
key={statsByProvider.providerKey}
|
key={group.providerKey}
|
||||||
stats={statsByProvider}
|
stats={group}
|
||||||
isCollapsed={isProviderCollapsed(account.id, statsByProvider.providerKey)}
|
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
|
||||||
onToggleCollapse={() => toggleProviderCollapse(account.id, statsByProvider.providerKey)}
|
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
|
||||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||||
formatQuotaLabel={formatQuotaLabel}
|
formatQuotaLabel={formatQuotaLabel}
|
||||||
@@ -292,11 +317,24 @@ export function CloudAccountCard({
|
|||||||
leftLabel={t('cloud.card.left')}
|
leftLabel={t('cloud.card.left')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
emptyQuotaState
|
emptyQuotaState
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const aiCredits = account.quota?.ai_credits;
|
||||||
|
|
||||||
|
const formatCreditsExpiry = (expiryDate: string) => {
|
||||||
|
if (!expiryDate) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(expiryDate);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||||
@@ -330,7 +368,71 @@ export function CloudAccountCard({
|
|||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||||
|
|
||||||
|
{aiCredits && aiCredits.credits > 0 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
|
||||||
|
<span>${aiCredits.credits.toFixed(2)}</span>
|
||||||
|
{aiCredits.expiryDate && (
|
||||||
|
<span className="text-muted-foreground opacity-70">
|
||||||
|
·{' '}
|
||||||
|
{t('cloud.card.creditsExpiry', {
|
||||||
|
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allModelEntries.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||||
|
{(() => {
|
||||||
|
const hiddenCount = allModelEntries.filter(
|
||||||
|
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||||
|
).length;
|
||||||
|
return hiddenCount > 0 ? (
|
||||||
|
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-64" align="end">
|
||||||
|
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||||
|
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||||
|
{allModelEntries.map(([modelName]) => {
|
||||||
|
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={modelName}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="flex cursor-pointer items-center gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (config) {
|
||||||
|
const newVisibility = { ...config.model_visibility };
|
||||||
|
newVisibility[modelName] = checked as boolean;
|
||||||
|
saveConfig({ ...config, model_visibility: newVisibility });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-xs" title={modelName}>
|
||||||
|
{formatModelDisplayName(modelName)}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||||
@@ -440,7 +542,288 @@ export function CloudAccountCard({
|
|||||||
{t('cloud.card.used')}{' '}
|
{t('cloud.card.used')}{' '}
|
||||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={proxyUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProxyUrl(e.target.value);
|
||||||
|
setProxySaved(false);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = proxyUrl.trim();
|
||||||
|
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||||
|
setProxyUrl(account.proxy_url || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed !== (account.proxy_url || '')) {
|
||||||
|
setAccountProxy.mutate({
|
||||||
|
accountId: account.id,
|
||||||
|
proxyUrl: trimmed || null,
|
||||||
|
});
|
||||||
|
setProxySaved(true);
|
||||||
|
setTimeout(() => setProxySaved(false), 2000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('cloud.card.proxyPlaceholder')}
|
||||||
|
className="h-6 w-40 text-xs"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{proxySaved && (
|
||||||
|
<span className="text-[10px] text-green-500">{t('cloud.card.proxySaved')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompactCloudAccountCardProps {
|
||||||
|
account: CloudAccount;
|
||||||
|
onRefresh: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onSwitch: (id: string) => void;
|
||||||
|
onManageIdentity: (id: string) => void;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
isDeleting?: boolean;
|
||||||
|
isSwitching?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactCloudAccountCard({
|
||||||
|
account,
|
||||||
|
onRefresh,
|
||||||
|
onDelete,
|
||||||
|
onSwitch,
|
||||||
|
onManageIdentity,
|
||||||
|
isRefreshing,
|
||||||
|
isDeleting,
|
||||||
|
isSwitching,
|
||||||
|
}: CompactCloudAccountCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config, saveConfig } = useAppConfig();
|
||||||
|
|
||||||
|
const getQuotaBarColorClass = (percentage: number) => {
|
||||||
|
const quotaStatus = getQuotaStatus(percentage);
|
||||||
|
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||||
|
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||||
|
) as ModelQuotaEntry[];
|
||||||
|
|
||||||
|
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||||
|
|
||||||
|
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||||
|
|
||||||
|
const compactModels = Object.entries(mergedModelQuotas).sort(
|
||||||
|
(a, b) => b[1].percentage - a[1].percentage,
|
||||||
|
);
|
||||||
|
|
||||||
|
const aiCredits = account.quota?.ai_credits;
|
||||||
|
|
||||||
|
const formatCreditsExpiry = (expiryDate: string) => {
|
||||||
|
if (!expiryDate) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(expiryDate);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||||
|
{account.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={account.avatar_url}
|
||||||
|
alt={account.name || ''}
|
||||||
|
className="bg-muted h-7 w-7 rounded-full border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
||||||
|
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold">
|
||||||
|
{account.name || t('cloud.card.unknown')}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{account.provider.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
{account.is_active && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
||||||
|
>
|
||||||
|
{t('cloud.card.active')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||||
|
<span className="truncate">{account.email}</span>
|
||||||
|
|
||||||
|
{aiCredits && aiCredits.credits > 0 && (
|
||||||
|
<span className="shrink-0 text-amber-500">
|
||||||
|
${aiCredits.credits.toFixed(2)}
|
||||||
|
{aiCredits.expiryDate && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
{t('cloud.card.creditsExpiry', {
|
||||||
|
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{compactModels.length > 0 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
{compactModels.map(([modelName, info]) => (
|
||||||
|
<TooltipProvider key={modelName}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||||
|
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-xs">
|
||||||
|
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{allModelEntries.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||||
|
{(() => {
|
||||||
|
const hiddenCount = allModelEntries.filter(
|
||||||
|
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||||
|
).length;
|
||||||
|
return hiddenCount > 0 ? (
|
||||||
|
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-64" align="end">
|
||||||
|
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||||
|
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||||
|
{allModelEntries.map(([modelName]) => {
|
||||||
|
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={modelName}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="flex cursor-pointer items-center gap-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (config) {
|
||||||
|
const newVisibility = { ...config.model_visibility };
|
||||||
|
newVisibility[modelName] = checked as boolean;
|
||||||
|
saveConfig({ ...config, model_visibility: newVisibility });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-xs" title={modelName}>
|
||||||
|
{formatModelDisplayName(modelName)}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{account.is_active ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
className="h-7 text-xs text-green-600 opacity-100"
|
||||||
|
>
|
||||||
|
<Power className="mr-1 h-3 w-3" />
|
||||||
|
{t('cloud.card.active')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSwitch(account.id)}
|
||||||
|
disabled={isSwitching}
|
||||||
|
className="h-7 cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{isSwitching ? (
|
||||||
|
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Power className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{t('cloud.card.use')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||||
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||||
|
<Power className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.card.useAccount')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.card.refresh')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||||
|
<Fingerprint className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.card.identityProfile')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(account.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.card.delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,16 +9,23 @@ import {
|
|||||||
useForcePollCloudMonitor,
|
useForcePollCloudMonitor,
|
||||||
useSyncLocalAccount,
|
useSyncLocalAccount,
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
|
useExportCloudAccounts,
|
||||||
|
useImportCloudAccounts,
|
||||||
} from '@/hooks/useCloudAccounts';
|
} from '@/hooks/useCloudAccounts';
|
||||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
|
||||||
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
||||||
import { CloudAccount } from '@/types/cloudAccount';
|
import { CloudAccount } from '@/types/cloudAccount';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
|
||||||
Cloud,
|
Cloud,
|
||||||
Zap,
|
Zap,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@@ -31,6 +38,12 @@ import {
|
|||||||
List,
|
List,
|
||||||
Columns2,
|
Columns2,
|
||||||
Columns3,
|
Columns3,
|
||||||
|
SortAsc,
|
||||||
|
LayoutList,
|
||||||
|
Upload,
|
||||||
|
FileDown,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import {
|
import {
|
||||||
@@ -49,21 +62,30 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
|
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
|
||||||
import {
|
import {
|
||||||
clampQuotaPercentage,
|
clampQuotaPercentage,
|
||||||
getQuotaStatus,
|
getQuotaStatus,
|
||||||
roundQuotaPercentage,
|
roundQuotaPercentage,
|
||||||
|
getAccountSortValue,
|
||||||
type QuotaStatus,
|
type QuotaStatus,
|
||||||
} from '@/utils/quota-display';
|
} from '@/utils/quota-display';
|
||||||
|
|
||||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list';
|
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||||
|
|
||||||
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||||
'2-col': 'grid gap-4 grid-cols-2',
|
'2-col': 'grid gap-4 grid-cols-2',
|
||||||
'3-col': 'grid gap-4 grid-cols-3',
|
'3-col': 'grid gap-4 grid-cols-3',
|
||||||
list: 'grid gap-4 grid-cols-1',
|
list: 'grid gap-4 grid-cols-1',
|
||||||
|
compact: 'flex flex-col gap-2',
|
||||||
};
|
};
|
||||||
|
|
||||||
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
@@ -103,6 +125,43 @@ export function CloudAccountList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
'recently-used',
|
||||||
|
'quota-overall',
|
||||||
|
'quota-claude',
|
||||||
|
'quota-pro3',
|
||||||
|
'quota-flash',
|
||||||
|
] as const;
|
||||||
|
type SortOption = (typeof sortOptions)[number];
|
||||||
|
const currentSort: SortOption = (config?.account_sort as SortOption) || 'recently-used';
|
||||||
|
const sortI18nKeys: Record<SortOption, string> = {
|
||||||
|
'recently-used': 'cloud.sort.recentlyUsed',
|
||||||
|
'quota-overall': 'cloud.sort.quotaOverall',
|
||||||
|
'quota-claude': 'cloud.sort.quotaClaude',
|
||||||
|
'quota-pro3': 'cloud.sort.quotaPro3',
|
||||||
|
'quota-flash': 'cloud.sort.quotaFlash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedAccounts = useMemo(() => {
|
||||||
|
if (!accounts || accounts.length === 0) return [];
|
||||||
|
|
||||||
|
const activeAccounts = accounts.filter((a) => a.is_active);
|
||||||
|
const otherAccounts = accounts.filter((a) => !a.is_active);
|
||||||
|
|
||||||
|
const sortedActive = [...activeAccounts].sort((a, b) => {
|
||||||
|
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedOthers = [...otherAccounts].sort((a, b) => {
|
||||||
|
if (currentSort === 'recently-used') {
|
||||||
|
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||||
|
}
|
||||||
|
return getAccountSortValue(b, currentSort) - getAccountSortValue(a, currentSort);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...sortedActive, ...sortedOthers];
|
||||||
|
}, [accounts, currentSort]);
|
||||||
|
|
||||||
// Calculate global quota across all accounts
|
// Calculate global quota across all accounts
|
||||||
const overallQuotaPercentage = useMemo(() => {
|
const overallQuotaPercentage = useMemo(() => {
|
||||||
if (!accounts || accounts.length === 0) {
|
if (!accounts || accounts.length === 0) {
|
||||||
@@ -137,6 +196,16 @@ export function CloudAccountList() {
|
|||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [authCode, setAuthCode] = useState('');
|
const [authCode, setAuthCode] = useState('');
|
||||||
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
||||||
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [importStrategy, setImportStrategy] = useState<'merge' | 'overwrite' | 'skip-existing'>(
|
||||||
|
'merge',
|
||||||
|
);
|
||||||
|
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||||
|
const [importFileName, setImportFileName] = useState<string>('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const exportMutation = useExportCloudAccounts();
|
||||||
|
const importMutation = useImportCloudAccounts();
|
||||||
const totalAccounts = size(accounts);
|
const totalAccounts = size(accounts);
|
||||||
const activeAccounts = filter(accounts, (account) => account.is_active).length;
|
const activeAccounts = filter(accounts, (account) => account.is_active).length;
|
||||||
const rateLimitedAccounts = filter(
|
const rateLimitedAccounts = filter(
|
||||||
@@ -144,7 +213,8 @@ export function CloudAccountList() {
|
|||||||
(account) => account.status === 'rate_limited',
|
(account) => account.status === 'rate_limited',
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const submitAuthCode = useCallback((incomingAuthCode?: string) => {
|
const submitAuthCode = useCallback(
|
||||||
|
(incomingAuthCode?: string) => {
|
||||||
const codeToUse = incomingAuthCode || authCode;
|
const codeToUse = incomingAuthCode || authCode;
|
||||||
if (!codeToUse) {
|
if (!codeToUse) {
|
||||||
return;
|
return;
|
||||||
@@ -166,7 +236,9 @@ export function CloudAccountList() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [addMutation, authCode, t, toast]);
|
},
|
||||||
|
[addMutation, authCode, t, toast],
|
||||||
|
);
|
||||||
// Listen for Google Auth Code
|
// Listen for Google Auth Code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.electron?.onGoogleAuthCode) {
|
if (window.electron?.onGoogleAuthCode) {
|
||||||
@@ -204,6 +276,7 @@ export function CloudAccountList() {
|
|||||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||||
|
|
||||||
const handleRefresh = (id: string) => {
|
const handleRefresh = (id: string) => {
|
||||||
|
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||||
refreshMutation.mutate(
|
refreshMutation.mutate(
|
||||||
{ accountId: id },
|
{ accountId: id },
|
||||||
{
|
{
|
||||||
@@ -315,13 +388,119 @@ export function CloudAccountList() {
|
|||||||
await startAuthFlow();
|
await startAuthFlow();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error
|
title: t('cloud.toast.startAuthFailed'),
|
||||||
description: String(e),
|
description: String(e),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async (stripTokens: boolean) => {
|
||||||
|
let url: string | null = null;
|
||||||
|
try {
|
||||||
|
const jsonContent: string = await exportMutation.mutateAsync({ stripTokens });
|
||||||
|
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `cloud-accounts-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setIsExportDialogOpen(false);
|
||||||
|
toast({ title: t('cloud.exportImport.exportSuccess') });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.error.loadFailed'),
|
||||||
|
description: getLocalizedErrorMessage(error, t),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (url) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.error.loadFailed'),
|
||||||
|
description: 'File size exceeds 5MB limit',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportFileName(file.name);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
JSON.parse(content);
|
||||||
|
setImportFileContent(content);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.error.loadFailed'),
|
||||||
|
description: 'Invalid JSON file format',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setImportFileName('');
|
||||||
|
setImportFileContent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.error.loadFailed'),
|
||||||
|
description: 'Failed to read file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
if (!importFileContent) return;
|
||||||
|
importMutation.mutate(
|
||||||
|
{ jsonContent: importFileContent, strategy: importStrategy },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setImportFileContent(null);
|
||||||
|
setImportFileName('');
|
||||||
|
setImportStrategy('merge');
|
||||||
|
toast({
|
||||||
|
title: t('cloud.exportImport.importSuccess', {
|
||||||
|
imported: result.imported,
|
||||||
|
updated: result.updated,
|
||||||
|
skipped: result.skipped,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.exportImport.importErrors', { count: result.errors.length }),
|
||||||
|
description: result.errors.slice(0, 3).join('\n'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.error.loadFailed'),
|
||||||
|
description: getLocalizedErrorMessage(err, t),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Batch Selection Handlers
|
// Batch Selection Handlers
|
||||||
const setSelectionState = (id: string, selected: boolean) => {
|
const setSelectionState = (id: string, selected: boolean) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
@@ -343,26 +522,54 @@ export function CloudAccountList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshSelectedAccounts = () => {
|
const refreshSelectedAccounts = async () => {
|
||||||
selectedIds.forEach((id) => {
|
const ids = Array.from(selectedIds);
|
||||||
refreshMutation.mutate({ accountId: id });
|
const results = await Promise.allSettled(
|
||||||
});
|
ids.map((id) => refreshMutation.mutateAsync({ accountId: id })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.quotaRefreshed'),
|
title: t('cloud.toast.quotaRefreshed'),
|
||||||
description: `Triggered for ${selectedIds.size} accounts.`,
|
description: `Successfully refreshed ${successful} accounts.`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: `Refresh completed with issues`,
|
||||||
|
description: `Refreshed ${successful} accounts, ${failed} failed.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelectedAccounts = () => {
|
const deleteSelectedAccounts = async () => {
|
||||||
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
||||||
selectedIds.forEach((id) => {
|
const ids = Array.from(selectedIds);
|
||||||
deleteMutation.mutate({ accountId: id });
|
const results = await Promise.allSettled(
|
||||||
});
|
ids.map((id) => deleteMutation.mutateAsync({ accountId: id })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.deleted'),
|
title: t('cloud.toast.deleted'),
|
||||||
description: `Deleted ${selectedIds.size} accounts.`,
|
description: `Successfully deleted ${successful} accounts.`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: `Deletion completed with issues`,
|
||||||
|
description: `Deleted ${successful} accounts, ${failed} failed.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -501,6 +708,117 @@ export function CloudAccountList() {
|
|||||||
{t('cloud.syncFromIDE')}
|
{t('cloud.syncFromIDE')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.exportImport.export')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleExport(false)}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
className="w-full cursor-pointer sm:flex-1"
|
||||||
|
>
|
||||||
|
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('cloud.exportImport.includeTokens')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleExport(true)}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
className="w-full cursor-pointer sm:flex-1"
|
||||||
|
>
|
||||||
|
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('cloud.exportImport.stripTokens')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isImportDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsImportDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setImportFileContent(null);
|
||||||
|
setImportFileName('');
|
||||||
|
setImportStrategy('merge');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer">
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.exportImport.import')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t('cloud.exportImport.selectFile')}</Label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleImportFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{importFileName || t('cloud.exportImport.selectFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t('cloud.exportImport.importStrategy')}</Label>
|
||||||
|
<Select
|
||||||
|
value={importStrategy}
|
||||||
|
onValueChange={(v: 'merge' | 'overwrite' | 'skip-existing') =>
|
||||||
|
setImportStrategy(v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
|
||||||
|
<SelectItem value="overwrite">
|
||||||
|
{t('cloud.exportImport.strategyOverwrite')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="skip-existing">
|
||||||
|
{t('cloud.exportImport.strategySkip')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!importFileContent || importMutation.isPending}
|
||||||
|
>
|
||||||
|
{importMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{importMutation.isPending
|
||||||
|
? t('cloud.exportImport.importing')
|
||||||
|
: t('cloud.exportImport.import')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer">
|
<Button className="cursor-pointer">
|
||||||
@@ -508,7 +826,7 @@ export function CloudAccountList() {
|
|||||||
{t('cloud.addAccount')}
|
{t('cloud.addAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||||
@@ -543,7 +861,35 @@ export function CloudAccountList() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Layout Selector */}
|
{/* Sort + Layout Selector */}
|
||||||
|
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer">
|
||||||
|
<SortAsc className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56" side="bottom" sideOffset={8}>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, account_sort: option });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentSort === option && <Check className="mr-2 h-4 w-4" />}
|
||||||
|
<span className={currentSort === option ? '' : 'ml-6'}>
|
||||||
|
{t(sortI18nKeys[option])}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1 rounded-md border p-1">
|
<div className="ml-auto flex items-center gap-1 rounded-md border p-1">
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -598,12 +944,44 @@ export function CloudAccountList() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer"
|
||||||
|
onClick={() => updateGridLayout('compact')}
|
||||||
|
>
|
||||||
|
<LayoutList className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
||||||
{accounts?.map((account) => (
|
{sortedAccounts.map((account) =>
|
||||||
|
gridLayout === 'compact' ? (
|
||||||
|
<CompactCloudAccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onSwitch={handleSwitch}
|
||||||
|
onManageIdentity={handleManageIdentity}
|
||||||
|
isRefreshing={
|
||||||
|
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||||
|
}
|
||||||
|
isDeleting={
|
||||||
|
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||||
|
}
|
||||||
|
isSwitching={
|
||||||
|
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<CloudAccountCard
|
<CloudAccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
@@ -623,9 +1001,10 @@ export function CloudAccountList() {
|
|||||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{accounts?.length === 0 && (
|
{sortedAccounts.length === 0 && (
|
||||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||||
|
|||||||
@@ -76,10 +76,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}>
|
<span
|
||||||
|
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
|
||||||
|
>
|
||||||
{formatQuotaLabel(avgPercentage)}
|
{formatQuotaLabel(avgPercentage)}
|
||||||
</span>
|
</span>
|
||||||
{avgPercentage > 0 && (
|
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{t('settings.providerGroupings.avgLabel')}
|
{t('settings.providerGroupings.avgLabel')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
|||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||||
|
}
|
||||||
|
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||||
|
if (typeof collisionPadding === 'number') {
|
||||||
|
if (collisionPadding < 0) {
|
||||||
|
throw new Error('collisionPadding must be a non-negative number');
|
||||||
|
}
|
||||||
|
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
|
||||||
|
for (const key of Object.keys(collisionPadding)) {
|
||||||
|
if ((collisionPadding as any)[key] < 0) {
|
||||||
|
throw new Error(`collisionPadding.${key} must be a non-negative number`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
|
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
addGoogleAccount,
|
addGoogleAccount,
|
||||||
deleteCloudAccount,
|
deleteCloudAccount,
|
||||||
refreshAccountQuota,
|
refreshAccountQuota,
|
||||||
|
setAccountProxy,
|
||||||
} from '@/actions/cloud';
|
} from '@/actions/cloud';
|
||||||
import { CloudAccount } from '@/types/cloudAccount';
|
import { CloudAccount } from '@/types/cloudAccount';
|
||||||
|
|
||||||
@@ -120,3 +121,41 @@ export function useSyncLocalAccount() {
|
|||||||
|
|
||||||
import { startAuthFlow } from '@/actions/cloud';
|
import { startAuthFlow } from '@/actions/cloud';
|
||||||
export { startAuthFlow };
|
export { startAuthFlow };
|
||||||
|
|
||||||
|
export function useSetAccountProxy() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: setAccountProxy,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('[Mutation] setAccountProxy failed:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
import { exportCloudAccounts, importCloudAccounts } from '@/actions/cloud';
|
||||||
|
|
||||||
|
export function useExportCloudAccounts() {
|
||||||
|
return useMutation<string, Error, { stripTokens?: boolean }>({
|
||||||
|
mutationFn: exportCloudAccounts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportCloudAccounts() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
{ imported: number; skipped: number; updated: number; errors: string[] },
|
||||||
|
Error,
|
||||||
|
{ jsonContent: string; strategy?: 'merge' | 'overwrite' | 'skip-existing' }
|
||||||
|
>({
|
||||||
|
mutationFn: importCloudAccounts,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('[Mutation] importCloudAccounts failed:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +1,56 @@
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { ipcContext } from '../context';
|
import { ipcContext } from '../context';
|
||||||
|
import { escapeHtml } from '../../utils/url';
|
||||||
|
|
||||||
export class AuthServer {
|
export class AuthServer {
|
||||||
private static server: http.Server | null = null;
|
private static server: http.Server | null = null;
|
||||||
private static PORT = 8888;
|
private static PORT = 8888;
|
||||||
|
|
||||||
static start() {
|
static async start() {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
logger.warn('AuthServer: Server already running');
|
logger.warn('AuthServer: Server already running');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||||
|
let boundPort: number | null = null;
|
||||||
|
|
||||||
|
for (const port of tryPorts) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const testServer = http.createServer();
|
||||||
|
testServer.once('error', reject);
|
||||||
|
testServer.listen(port, '127.0.0.1', () => {
|
||||||
|
testServer.close(() => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
boundPort = port;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!boundPort) {
|
||||||
|
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundPort !== 8888) {
|
||||||
|
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.PORT = boundPort;
|
||||||
|
|
||||||
|
try {
|
||||||
this.server = http.createServer((req, res) => {
|
this.server = http.createServer((req, res) => {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405, { Allow: 'GET' });
|
||||||
|
res.end('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||||
|
|
||||||
if (url.pathname === '/oauth-callback') {
|
if (url.pathname === '/oauth-callback') {
|
||||||
@@ -20,7 +58,10 @@ export class AuthServer {
|
|||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
const escapedCode = escapeHtml(code);
|
||||||
|
logger.info(
|
||||||
|
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Send code to renderer
|
// Send code to renderer
|
||||||
if (ipcContext.mainWindow) {
|
if (ipcContext.mainWindow) {
|
||||||
@@ -44,13 +85,14 @@ export class AuthServer {
|
|||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
const escapedError = escapeHtml(error);
|
||||||
|
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>Login Failed</h1>
|
<h1>Login Failed</h1>
|
||||||
<p>Error: ${error}</p>
|
<p>Error: ${escapedError}</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
@@ -68,9 +110,20 @@ export class AuthServer {
|
|||||||
logger.error('AuthServer: Server error', err);
|
logger.error('AuthServer: Server error', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.listen(this.PORT, () => {
|
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('AuthServer: Failed to create or start server', e);
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close();
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRedirectUri(): string {
|
||||||
|
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static stop() {
|
static stop() {
|
||||||
|
|||||||
+179
-26
@@ -1,8 +1,9 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { AuthServer } from './authServer';
|
||||||
|
|
||||||
import { shell } from 'electron';
|
import { shell } from 'electron';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -22,8 +23,7 @@ import { executeSwitchFlow } from '../../ipc/switchFlow';
|
|||||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||||
|
|
||||||
// Fallback constants if service constants are not available or for direct usage
|
// Fallback constants if service constants are not available or for direct usage
|
||||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
const CLIENT_ID = '1071006060591-tmhssin21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
|
||||||
const SCOPE = [
|
const SCOPE = [
|
||||||
'https://www.googleapis.com/auth/cloud-platform',
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
@@ -52,7 +52,13 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
// 2. Get User Info
|
// 2. Get User Info
|
||||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
||||||
|
|
||||||
// 3. Construct CloudAccount Object
|
// 3. Check for existing account
|
||||||
|
const existing = await CloudAccountRepo.getAccountByEmail(userInfo.email);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Account with email ${userInfo.email} already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Construct CloudAccount Object
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const account: CloudAccount = {
|
const account: CloudAccount = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -62,7 +68,7 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
avatar_url: userInfo.picture,
|
avatar_url: userInfo.picture,
|
||||||
token: {
|
token: {
|
||||||
access_token: tokenResp.access_token,
|
access_token: tokenResp.access_token,
|
||||||
refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely
|
refresh_token: tokenResp.refresh_token || '',
|
||||||
expires_in: tokenResp.expires_in,
|
expires_in: tokenResp.expires_in,
|
||||||
expiry_timestamp: now + tokenResp.expires_in,
|
expiry_timestamp: now + tokenResp.expires_in,
|
||||||
token_type: tokenResp.token_type,
|
token_type: tokenResp.token_type,
|
||||||
@@ -76,10 +82,8 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save to DB
|
|
||||||
await CloudAccountRepo.addAccount(account);
|
await CloudAccountRepo.addAccount(account);
|
||||||
|
|
||||||
// 5. Initial Quota Check (Async, best effort)
|
|
||||||
try {
|
try {
|
||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||||
account.quota = quota;
|
account.quota = quota;
|
||||||
@@ -110,40 +114,56 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token needs refresh
|
|
||||||
let now = Math.floor(Date.now() / 1000);
|
let now = Math.floor(Date.now() / 1000);
|
||||||
if (account.token.expiry_timestamp < now + 300) {
|
if (account.token.expiry_timestamp < now + 300) {
|
||||||
// 5 minutes buffer
|
|
||||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||||
try {
|
try {
|
||||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||||
|
account.token.refresh_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
|
|
||||||
// Update token in memory object
|
|
||||||
account.token.access_token = newTokenData.access_token;
|
account.token.access_token = newTokenData.access_token;
|
||||||
account.token.expires_in = newTokenData.expires_in;
|
account.token.expires_in = newTokenData.expires_in;
|
||||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
||||||
|
throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
const quota = await GoogleAPIService.fetchQuota(account.token.access_token, account.proxy_url);
|
||||||
account.quota = quota;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||||
|
account.token.access_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
|
if (aiCredits) {
|
||||||
|
account.quota = { ...quota, ai_credits: aiCredits };
|
||||||
|
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to fetch AI credits', e);
|
||||||
|
}
|
||||||
|
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
account.last_used = Math.floor(Date.now() / 1000);
|
||||||
notifyTrayUpdate(account);
|
notifyTrayUpdate(account);
|
||||||
return account;
|
return account;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === 'UNAUTHORIZED') {
|
if (error.message === 'UNAUTHORIZED') {
|
||||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||||
try {
|
try {
|
||||||
// Force Refresh
|
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
account.token.refresh_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
now = Math.floor(Date.now() / 1000);
|
now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
account.token.access_token = newTokenData.access_token;
|
account.token.access_token = newTokenData.access_token;
|
||||||
@@ -152,12 +172,28 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
|
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
|
|
||||||
// Retry Quota
|
const quota = await GoogleAPIService.fetchQuota(
|
||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
account.token.access_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
account.quota = quota;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||||
|
account.token.access_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
|
if (aiCredits) {
|
||||||
|
account.quota = { ...quota, ai_credits: aiCredits };
|
||||||
|
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to fetch AI credits after token refresh', e);
|
||||||
|
}
|
||||||
|
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
account.last_used = Math.floor(Date.now() / 1000);
|
||||||
return account;
|
return account;
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -170,7 +206,6 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||||
);
|
);
|
||||||
// Return existing account to avoid crash
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,16 +235,23 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
|||||||
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
||||||
const tokenRefreshPromise = (async () => {
|
const tokenRefreshPromise = (async () => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (account.token.expiry_timestamp < now + 1200) { // Increased buffer to 20m
|
if (account.token.expiry_timestamp < now + 1200) {
|
||||||
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
|
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
|
||||||
try {
|
try {
|
||||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||||
account.token.refresh_token,
|
account.token.refresh_token,
|
||||||
|
account.proxy_url,
|
||||||
);
|
);
|
||||||
account.token.access_token = newTokenData.access_token;
|
|
||||||
account.token.expires_in = newTokenData.expires_in;
|
const updatedToken = {
|
||||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
...account.token,
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
access_token: newTokenData.access_token,
|
||||||
|
expires_in: newTokenData.expires_in,
|
||||||
|
expiry_timestamp: now + newTokenData.expires_in,
|
||||||
|
};
|
||||||
|
await CloudAccountRepo.updateToken(account.id, updatedToken);
|
||||||
|
|
||||||
|
account.token = updatedToken;
|
||||||
logger.info(`Token refreshed for ${account.email}`);
|
logger.info(`Token refreshed for ${account.email}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to refresh token in parallel, will try to use existing', e);
|
logger.warn('Failed to refresh token in parallel, will try to use existing', e);
|
||||||
@@ -375,8 +417,119 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startAuthFlow(): Promise<void> {
|
export async function startAuthFlow(): Promise<void> {
|
||||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${AuthServer.getRedirectUri()}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
||||||
|
|
||||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportCloudAccounts(stripTokens = false): Promise<string> {
|
||||||
|
const accounts = await CloudAccountRepo.getAccounts();
|
||||||
|
const exportData = {
|
||||||
|
version: '1.0' as const,
|
||||||
|
exportedAt: Math.floor(Date.now() / 1000),
|
||||||
|
accounts: accounts.map((acc) => ({
|
||||||
|
provider: acc.provider,
|
||||||
|
email: acc.email,
|
||||||
|
name: acc.name,
|
||||||
|
avatar_url: acc.avatar_url,
|
||||||
|
token: stripTokens ? undefined : acc.token,
|
||||||
|
quota: acc.quota,
|
||||||
|
device_profile: acc.device_profile,
|
||||||
|
device_history: acc.device_history,
|
||||||
|
proxy_url: acc.proxy_url ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
CloudAccountExportSchema.parse(exportData);
|
||||||
|
return JSON.stringify(exportData, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportStrategy = 'merge' | 'overwrite' | 'skip-existing';
|
||||||
|
|
||||||
|
export async function importCloudAccounts(
|
||||||
|
jsonContent: string,
|
||||||
|
strategy: ImportStrategy = 'merge',
|
||||||
|
): Promise<{ imported: number; skipped: number; updated: number; errors: string[] }> {
|
||||||
|
const result = { imported: 0, skipped: 0, updated: 0, errors: [] as string[] };
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonContent);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = CloudAccountExportSchema.safeParse(parsed);
|
||||||
|
if (!validated.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid export file: ${validated.error.issues[0]?.message || 'schema mismatch'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importEmails = new Set<string>();
|
||||||
|
for (const acc of validated.data.accounts) {
|
||||||
|
const emailLower = acc.email.toLowerCase();
|
||||||
|
if (importEmails.has(emailLower)) {
|
||||||
|
throw new Error(`Duplicate email found in import file: ${acc.email}`);
|
||||||
|
}
|
||||||
|
importEmails.add(emailLower);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAccounts = await CloudAccountRepo.getAccounts();
|
||||||
|
const existingByEmail = new Map(existingAccounts.map((a) => [a.email.toLowerCase(), a]));
|
||||||
|
|
||||||
|
for (const acc of validated.data.accounts) {
|
||||||
|
try {
|
||||||
|
const existing = existingByEmail.get(acc.email.toLowerCase());
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (strategy === 'skip-existing') {
|
||||||
|
result.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAccount: CloudAccount = {
|
||||||
|
...existing,
|
||||||
|
provider: acc.provider,
|
||||||
|
name: acc.name ?? existing.name,
|
||||||
|
avatar_url: acc.avatar_url ?? existing.avatar_url,
|
||||||
|
token: acc.token,
|
||||||
|
quota: acc.quota ?? existing.quota,
|
||||||
|
device_profile: acc.device_profile ?? existing.device_profile,
|
||||||
|
device_history: acc.device_history ?? existing.device_history,
|
||||||
|
proxy_url: acc.proxy_url ?? existing.proxy_url,
|
||||||
|
last_used: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await CloudAccountRepo.addAccount(updatedAccount);
|
||||||
|
result.updated++;
|
||||||
|
} else {
|
||||||
|
const newAccount: CloudAccount = {
|
||||||
|
id: uuidv4(),
|
||||||
|
provider: acc.provider,
|
||||||
|
email: acc.email,
|
||||||
|
name: acc.name,
|
||||||
|
avatar_url: acc.avatar_url,
|
||||||
|
token: acc.token,
|
||||||
|
quota: acc.quota,
|
||||||
|
device_profile: acc.device_profile,
|
||||||
|
device_history: acc.device_history,
|
||||||
|
proxy_url: acc.proxy_url ?? undefined,
|
||||||
|
created_at: now,
|
||||||
|
last_used: now,
|
||||||
|
status: 'active',
|
||||||
|
is_active: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await CloudAccountRepo.addAccount(newAccount);
|
||||||
|
result.imported++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
result.errors.push(`Failed to import ${acc.email}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+51
-1
@@ -18,10 +18,12 @@ import {
|
|||||||
setAutoSwitchEnabled,
|
setAutoSwitchEnabled,
|
||||||
forcePollCloudMonitor,
|
forcePollCloudMonitor,
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
|
exportCloudAccounts,
|
||||||
|
importCloudAccounts,
|
||||||
} from './handler';
|
} from './handler';
|
||||||
|
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||||
@@ -117,6 +119,18 @@ export const cloudRouter = os.router({
|
|||||||
await startAuthFlow();
|
await startAuthFlow();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setAccountProxy: os
|
||||||
|
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||||
|
.output(z.void())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await CloudAccountRepo.syncFromIDE();
|
const result = await CloudAccountRepo.syncFromIDE();
|
||||||
@@ -185,4 +199,40 @@ export const cloudRouter = os.router({
|
|||||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||||
await openCloudIdentityStorageFolder();
|
await openCloudIdentityStorageFolder();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
exportCloudAccounts: os
|
||||||
|
.input(z.object({ stripTokens: z.boolean().default(false) }))
|
||||||
|
.output(z.string())
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
return await exportCloudAccounts(input.stripTokens);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
importCloudAccounts: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
jsonContent: z.string(),
|
||||||
|
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
imported: z.number(),
|
||||||
|
skipped: z.number(),
|
||||||
|
updated: z.number(),
|
||||||
|
errors: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
@@ -6,7 +6,12 @@ import { desc, eq } from 'drizzle-orm';
|
|||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
import {
|
||||||
|
CloudAccount,
|
||||||
|
CloudAccountSchema,
|
||||||
|
CloudQuotaDataSchema,
|
||||||
|
CloudTokenDataSchema,
|
||||||
|
} from '../../types/cloudAccount';
|
||||||
import { type DeviceProfile, type DeviceProfileVersion } from '../../types/account';
|
import { type DeviceProfile, type DeviceProfileVersion } from '../../types/account';
|
||||||
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||||
@@ -90,6 +95,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||||
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
||||||
|
const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url');
|
||||||
if (!hasIsActive) {
|
if (!hasIsActive) {
|
||||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||||
}
|
}
|
||||||
@@ -99,6 +105,9 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
if (!hasDeviceHistoryJson) {
|
if (!hasDeviceHistoryJson) {
|
||||||
db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT');
|
db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT');
|
||||||
}
|
}
|
||||||
|
if (!hasProxyUrl) {
|
||||||
|
db.exec('ALTER TABLE accounts ADD COLUMN proxy_url TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// Create index on email for faster lookups
|
// Create index on email for faster lookups
|
||||||
// Create index on email for faster lookups
|
// Create index on email for faster lookups
|
||||||
@@ -450,6 +459,9 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async addAccount(account: CloudAccount): Promise<void> {
|
static async addAccount(account: CloudAccount): Promise<void> {
|
||||||
|
// Validate account data before processing
|
||||||
|
CloudAccountSchema.parse(account);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
try {
|
||||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||||
@@ -468,6 +480,7 @@ export class CloudAccountRepo {
|
|||||||
lastUsed: account.last_used,
|
lastUsed: account.last_used,
|
||||||
status: account.status || 'active',
|
status: account.status || 'active',
|
||||||
isActive: account.is_active ? 1 : 0,
|
isActive: account.is_active ? 1 : 0,
|
||||||
|
proxyUrl: account.proxy_url ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
orm.transaction((tx) => {
|
orm.transaction((tx) => {
|
||||||
@@ -509,6 +522,7 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
const cloudAccounts: CloudAccount[] = [];
|
const cloudAccounts: CloudAccount[] = [];
|
||||||
for (const normalizedRow of rows) {
|
for (const normalizedRow of rows) {
|
||||||
|
try {
|
||||||
let tokenResult: DecryptFieldResult;
|
let tokenResult: DecryptFieldResult;
|
||||||
try {
|
try {
|
||||||
tokenResult = await decryptAndMigrateField(
|
tokenResult = await decryptAndMigrateField(
|
||||||
@@ -520,7 +534,7 @@ export class CloudAccountRepo {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
migrationStats.failedFields += 1;
|
migrationStats.failedFields += 1;
|
||||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||||
throw error;
|
continue; // Skip corrupted account
|
||||||
}
|
}
|
||||||
|
|
||||||
let quotaResult: DecryptFieldResult;
|
let quotaResult: DecryptFieldResult;
|
||||||
@@ -534,11 +548,12 @@ export class CloudAccountRepo {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
migrationStats.failedFields += 1;
|
migrationStats.failedFields += 1;
|
||||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||||
throw error;
|
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenResult.value) {
|
if (!tokenResult.value) {
|
||||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
logger.warn(`Missing token data for account ${normalizedRow.id}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenResult.value) {
|
if (tokenResult.value) {
|
||||||
@@ -581,7 +596,12 @@ export class CloudAccountRepo {
|
|||||||
last_used: normalizedRow.lastUsed,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
is_active: Boolean(normalizedRow.isActive),
|
is_active: Boolean(normalizedRow.isActive),
|
||||||
|
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||||
});
|
});
|
||||||
|
} catch (rowError) {
|
||||||
|
logger.error(`Unexpected error processing row for account ${normalizedRow.id}`, rowError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cloudAccounts;
|
return cloudAccounts;
|
||||||
@@ -649,6 +669,7 @@ export class CloudAccountRepo {
|
|||||||
last_used: normalizedRow.lastUsed,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
is_active: Boolean(normalizedRow.isActive),
|
is_active: Boolean(normalizedRow.isActive),
|
||||||
|
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
@@ -666,22 +687,42 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async updateToken(id: string, token: any): Promise<void> {
|
static async updateToken(id: string, token: any): Promise<void> {
|
||||||
|
// Validate token data before encryption
|
||||||
|
CloudTokenDataSchema.parse(token);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(token));
|
const encrypted = await encrypt(JSON.stringify(token));
|
||||||
orm.update(accounts).set({ tokenJson: encrypted }).where(eq(accounts.id, id)).run();
|
const result = orm
|
||||||
|
.update(accounts)
|
||||||
|
.set({ tokenJson: encrypted })
|
||||||
|
.where(eq(accounts.id, id))
|
||||||
|
.run();
|
||||||
|
if (result.changes === 0) {
|
||||||
|
logger.warn(`updateToken: No account found with ID ${id}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||||
|
// Validate quota data before encryption
|
||||||
|
CloudQuotaDataSchema.parse(quota);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(quota));
|
const encrypted = await encrypt(JSON.stringify(quota));
|
||||||
orm.update(accounts).set({ quotaJson: encrypted }).where(eq(accounts.id, id)).run();
|
const result = orm
|
||||||
|
.update(accounts)
|
||||||
|
.set({ quotaJson: encrypted })
|
||||||
|
.where(eq(accounts.id, id))
|
||||||
|
.run();
|
||||||
|
if (result.changes === 0) {
|
||||||
|
logger.warn(`updateQuota: No account found with ID ${id}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
@@ -911,6 +952,24 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static setAccountProxy(id: string, proxyUrl: string | null): void {
|
||||||
|
const { raw, orm } = getCloudDb();
|
||||||
|
try {
|
||||||
|
orm.update(accounts).set({ proxyUrl }).where(eq(accounts.id, id)).run();
|
||||||
|
logger.info(`Updated proxy for account ${id}: ${proxyUrl ?? 'none'}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update proxy for account ${id}`, error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
raw.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getAccountByEmail(email: string): Promise<CloudAccount | null> {
|
||||||
|
const all = await this.getAccounts();
|
||||||
|
return all.find((a) => a.email.toLowerCase() === email.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void {
|
private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void {
|
||||||
db.insert(itemTable)
|
db.insert(itemTable)
|
||||||
.values({ key, value })
|
.values({ key, value })
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export const accounts = sqliteTable('accounts', {
|
|||||||
deviceHistoryJson: text('device_history_json'),
|
deviceHistoryJson: text('device_history_json'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
lastUsed: integer('last_used').notNull(),
|
lastUsed: integer('last_used').notNull(),
|
||||||
status: text('status'),
|
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||||
isActive: integer('is_active').notNull().default(0),
|
isActive: integer('is_active').notNull().default(0),
|
||||||
|
proxyUrl: text('proxy_url'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settings = sqliteTable('settings', {
|
export const settings = sqliteTable('settings', {
|
||||||
|
|||||||
@@ -73,19 +73,17 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
const currentPid = process.pid;
|
const currentPid = process.pid;
|
||||||
|
|
||||||
// Use find-process to search for Antigravity processes
|
// Use find-process to search for Antigravity processes
|
||||||
// 'name' search type matches process name
|
const allMatches: ProcessInfo[] = [];
|
||||||
const processMap = new Map<number, ProcessInfo>();
|
|
||||||
const searchNames = ['Antigravity', 'antigravity'];
|
const searchNames = ['Antigravity', 'antigravity'];
|
||||||
|
if (platform === 'linux') {
|
||||||
|
searchNames.push('electron');
|
||||||
|
}
|
||||||
let sawNoMatch = false;
|
let sawNoMatch = false;
|
||||||
|
|
||||||
for (const searchName of searchNames) {
|
for (const searchName of searchNames) {
|
||||||
try {
|
try {
|
||||||
const matches = await findProcess('name', searchName, true);
|
const matches = await findProcess('name', searchName, false);
|
||||||
for (const proc of matches) {
|
allMatches.push(...matches);
|
||||||
if (typeof proc.pid === 'number') {
|
|
||||||
processMap.set(proc.pid, proc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPgrepNoMatchError(error)) {
|
if (isPgrepNoMatchError(error)) {
|
||||||
sawNoMatch = true;
|
sawNoMatch = true;
|
||||||
@@ -95,6 +93,13 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const processMap = new Map<number, ProcessInfo>();
|
||||||
|
for (const proc of allMatches) {
|
||||||
|
if (typeof proc.pid === 'number') {
|
||||||
|
processMap.set(proc.pid, proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const processes = Array.from(processMap.values());
|
const processes = Array.from(processMap.values());
|
||||||
if (processes.length === 0 && sawNoMatch) {
|
if (processes.length === 0 && sawNoMatch) {
|
||||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||||
@@ -145,7 +150,27 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Linux: Check for antigravity in name or path (but not tools)
|
const nameLower = name.toLowerCase();
|
||||||
|
const cmdLower = cmd.toLowerCase();
|
||||||
|
|
||||||
|
if (nameLower === 'electron') {
|
||||||
|
// Stricter check for AUR/Electron wrapper:
|
||||||
|
// Must include antigravity in command line but NOT manager or tools
|
||||||
|
const isAntigravityApp =
|
||||||
|
(cmdLower.includes('/antigravity') ||
|
||||||
|
cmdLower.includes(' antigravity') ||
|
||||||
|
cmdLower.endsWith('antigravity')) &&
|
||||||
|
!cmdLower.includes('manager') &&
|
||||||
|
!cmdLower.includes('tools');
|
||||||
|
|
||||||
|
if (isAntigravityApp) {
|
||||||
|
logger.debug(
|
||||||
|
`Found Antigravity (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||||
!name.includes('tools')
|
!name.includes('tools')
|
||||||
|
|||||||
@@ -70,11 +70,7 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
|||||||
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
||||||
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||||
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
||||||
const EXTRA_DYNAMIC_MODELS = [
|
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low'];
|
||||||
'gemini-3-flash',
|
|
||||||
'gemini-3.1-pro-high',
|
|
||||||
'gemini-3.1-pro-low',
|
|
||||||
];
|
|
||||||
|
|
||||||
const DYNAMIC_MODEL_FORWARDING_RULES = new Map<string, string>();
|
const DYNAMIC_MODEL_FORWARDING_RULES = new Map<string, string>();
|
||||||
|
|
||||||
@@ -95,13 +91,18 @@ export function getSupportedModels(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
|
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
|
||||||
|
if (typeof oldModel !== 'string' || typeof newModel !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const normalizedOld = oldModel.trim();
|
const normalizedOld = oldModel.trim();
|
||||||
const normalizedNew = newModel.trim();
|
const normalizedNew = newModel.trim();
|
||||||
if (!normalizedOld || !normalizedNew) {
|
if (!normalizedOld || !normalizedNew) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) {
|
if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) {
|
||||||
logger.info(`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`);
|
logger.info(
|
||||||
|
`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew);
|
DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew);
|
||||||
}
|
}
|
||||||
@@ -142,12 +143,13 @@ export function getAllDynamicModels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...modelIds]
|
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
|
||||||
.filter((id) => !shouldHideDeprecatedModelFromList(id))
|
|
||||||
.sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapClaudeModelToGemini(input: string): string {
|
export function mapClaudeModelToGemini(input: string): string {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const mappedModel = CLAUDE_TO_GEMINI[input];
|
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||||
if (mappedModel) {
|
if (mappedModel) {
|
||||||
return mappedModel;
|
return mappedModel;
|
||||||
@@ -172,8 +174,10 @@ export function resolveModelRoute(
|
|||||||
anthropicMapping: Record<string, string>,
|
anthropicMapping: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
|
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
|
||||||
if (dynamicForwarded) {
|
if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) {
|
||||||
logger.info(`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`);
|
logger.info(
|
||||||
|
`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`,
|
||||||
|
);
|
||||||
return dynamicForwarded;
|
return dynamicForwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ i18n
|
|||||||
restart_note:
|
restart_note:
|
||||||
'Changes to error reporting will take effect after restarting the application.',
|
'Changes to error reporting will take effect after restarting the application.',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
title: 'Notifications',
|
||||||
|
description: 'Configure desktop alerts for account events.',
|
||||||
|
quotaAlert: 'Low Quota Alerts',
|
||||||
|
quotaAlertDesc: 'Get notified when a model quota drops below the set threshold',
|
||||||
|
quotaThreshold: 'Alert Threshold',
|
||||||
|
quotaThresholdDesc: 'Percentage below which to trigger an alert',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Upstream Proxy',
|
title: 'Upstream Proxy',
|
||||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||||
@@ -258,6 +266,7 @@ i18n
|
|||||||
twoCol: '2 Columns',
|
twoCol: '2 Columns',
|
||||||
threeCol: '3 Columns',
|
threeCol: '3 Columns',
|
||||||
list: 'List',
|
list: 'List',
|
||||||
|
compact: 'Compact',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: 'Add Google Account',
|
title: 'Add Google Account',
|
||||||
@@ -289,6 +298,13 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 Ready',
|
gemini3Ready: 'Gemini 3 Ready',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: 'Proxy',
|
||||||
|
proxyPlaceholder: 'e.g. http://127.0.0.1:7890',
|
||||||
|
proxySaved: 'Proxy saved',
|
||||||
|
noProxy: 'No proxy',
|
||||||
|
aiCredits: 'AI Credits',
|
||||||
|
creditsExpiry: 'expires {{date}}',
|
||||||
|
modelVisibility: 'Model Visibility',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: 'Identity Profile',
|
title: 'Identity Profile',
|
||||||
@@ -357,6 +373,32 @@ i18n
|
|||||||
clear: 'Clear Selection',
|
clear: 'Clear Selection',
|
||||||
confirmDelete: 'Are you sure you want to delete {{count}} accounts?',
|
confirmDelete: 'Are you sure you want to delete {{count}} accounts?',
|
||||||
},
|
},
|
||||||
|
sort: {
|
||||||
|
recentlyUsed: 'Recently Used',
|
||||||
|
quotaOverall: 'Overall Quota',
|
||||||
|
quotaClaude: 'Claude Quota',
|
||||||
|
quotaPro3: 'Pro3 Quota',
|
||||||
|
quotaFlash: 'Flash Quota',
|
||||||
|
},
|
||||||
|
exportImport: {
|
||||||
|
export: 'Export',
|
||||||
|
import: 'Import',
|
||||||
|
exportTitle: 'Export Accounts',
|
||||||
|
exportDesc: 'Choose whether to include authentication tokens in the export file.',
|
||||||
|
includeTokens: 'Include tokens (less secure)',
|
||||||
|
stripTokens: 'Strip tokens (safer for sharing)',
|
||||||
|
exportSuccess: 'Accounts exported successfully',
|
||||||
|
importTitle: 'Import Accounts',
|
||||||
|
importDesc: 'Select a previously exported JSON file.',
|
||||||
|
importStrategy: 'Import Strategy',
|
||||||
|
strategyMerge: 'Merge - Update existing, add new',
|
||||||
|
strategyOverwrite: 'Overwrite - Replace all existing data',
|
||||||
|
strategySkip: 'Skip - Only add new accounts',
|
||||||
|
importSuccess: 'Imported {{imported}}, updated {{updated}}, skipped {{skipped}}',
|
||||||
|
importErrors: 'Import completed with {{count}} error(s)',
|
||||||
|
selectFile: 'Select File',
|
||||||
|
importing: 'Importing...',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API Proxy',
|
title: 'API Proxy',
|
||||||
@@ -504,6 +546,14 @@ i18n
|
|||||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||||
restart_note: '错误上报设置会在重启应用后生效。',
|
restart_note: '错误上报设置会在重启应用后生效。',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
title: '通知',
|
||||||
|
description: '配置账号事件的桌面提醒。',
|
||||||
|
quotaAlert: '低配额提醒',
|
||||||
|
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
|
||||||
|
quotaThreshold: '提醒阈值',
|
||||||
|
quotaThresholdDesc: '触发提醒的百分比阈值',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: '上游代理',
|
title: '上游代理',
|
||||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||||
@@ -642,6 +692,7 @@ i18n
|
|||||||
twoCol: '2 列',
|
twoCol: '2 列',
|
||||||
threeCol: '3 列',
|
threeCol: '3 列',
|
||||||
list: '列表',
|
list: '列表',
|
||||||
|
compact: '紧凑',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: '添加 Google 账号',
|
title: '添加 Google 账号',
|
||||||
@@ -672,6 +723,13 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 就绪',
|
gemini3Ready: 'Gemini 3 就绪',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: '代理',
|
||||||
|
proxyPlaceholder: '例如 http://127.0.0.1:7890',
|
||||||
|
proxySaved: '代理已保存',
|
||||||
|
noProxy: '无代理',
|
||||||
|
aiCredits: 'AI 积分',
|
||||||
|
creditsExpiry: '{{date}} 过期',
|
||||||
|
modelVisibility: '模型可见性',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: '身份配置',
|
title: '身份配置',
|
||||||
@@ -740,6 +798,32 @@ i18n
|
|||||||
clear: '取消选择',
|
clear: '取消选择',
|
||||||
confirmDelete: '确定要删除选中的 {{count}} 个账号吗?',
|
confirmDelete: '确定要删除选中的 {{count}} 个账号吗?',
|
||||||
},
|
},
|
||||||
|
sort: {
|
||||||
|
recentlyUsed: '最近使用',
|
||||||
|
quotaOverall: '总体配额',
|
||||||
|
quotaClaude: 'Claude 配额',
|
||||||
|
quotaPro3: 'Pro3 配额',
|
||||||
|
quotaFlash: 'Flash 配额',
|
||||||
|
},
|
||||||
|
exportImport: {
|
||||||
|
export: '导出',
|
||||||
|
import: '导入',
|
||||||
|
exportTitle: '导出账号',
|
||||||
|
exportDesc: '选择是否在导出文件中包含认证令牌。',
|
||||||
|
includeTokens: '包含令牌(安全性较低)',
|
||||||
|
stripTokens: '移除令牌(适合分享)',
|
||||||
|
exportSuccess: '账号导出成功',
|
||||||
|
importTitle: '导入账号',
|
||||||
|
importDesc: '选择之前导出的 JSON 文件。',
|
||||||
|
importStrategy: '导入策略',
|
||||||
|
strategyMerge: '合并 - 更新已有,添加新账号',
|
||||||
|
strategyOverwrite: '覆盖 - 替换所有已有数据',
|
||||||
|
strategySkip: '跳过 - 仅添加新账号',
|
||||||
|
importSuccess: '导入 {{imported}},更新 {{updated}},跳过 {{skipped}}',
|
||||||
|
importErrors: '导入完成,但有 {{count}} 个错误',
|
||||||
|
selectFile: '选择文件',
|
||||||
|
importing: '导入中...',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API 反代',
|
title: 'API 反代',
|
||||||
@@ -889,6 +973,15 @@ i18n
|
|||||||
restart_note:
|
restart_note:
|
||||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
title: 'Уведомления',
|
||||||
|
description: 'Настройте уведомления о событиях аккаунтов.',
|
||||||
|
quotaAlert: 'Уведомления о низкой квоте',
|
||||||
|
quotaAlertDesc:
|
||||||
|
'Получайте уведомления, когда квота модели падает ниже установленного порога',
|
||||||
|
quotaThreshold: 'Порог уведомления',
|
||||||
|
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Верхнеуровневый прокси',
|
title: 'Верхнеуровневый прокси',
|
||||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||||
@@ -1028,6 +1121,7 @@ i18n
|
|||||||
twoCol: '2 колонки',
|
twoCol: '2 колонки',
|
||||||
threeCol: '3 колонки',
|
threeCol: '3 колонки',
|
||||||
list: 'Список',
|
list: 'Список',
|
||||||
|
compact: 'Компактный',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: 'Добавить Google Аккаунт',
|
title: 'Добавить Google Аккаунт',
|
||||||
@@ -1059,6 +1153,13 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 готов',
|
gemini3Ready: 'Gemini 3 готов',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: 'Прокси',
|
||||||
|
proxyPlaceholder: 'напр. http://127.0.0.1:7890',
|
||||||
|
proxySaved: 'Прокси сохранён',
|
||||||
|
noProxy: 'Без прокси',
|
||||||
|
aiCredits: 'AI Кредиты',
|
||||||
|
creditsExpiry: 'истекает {{date}}',
|
||||||
|
modelVisibility: 'Видимость моделей',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: 'Профиль идентичности',
|
title: 'Профиль идентичности',
|
||||||
@@ -1127,6 +1228,33 @@ i18n
|
|||||||
clear: 'Снять выделение',
|
clear: 'Снять выделение',
|
||||||
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||||
},
|
},
|
||||||
|
sort: {
|
||||||
|
recentlyUsed: 'Недавние',
|
||||||
|
quotaOverall: 'Общая квота',
|
||||||
|
quotaClaude: 'Квота Claude',
|
||||||
|
quotaPro3: 'Квота Pro3',
|
||||||
|
quotaFlash: 'Квота Flash',
|
||||||
|
},
|
||||||
|
exportImport: {
|
||||||
|
export: 'Экспорт',
|
||||||
|
import: 'Импорт',
|
||||||
|
exportTitle: 'Экспорт аккаунтов',
|
||||||
|
exportDesc: 'Выберите, включать ли токены аутентификации в файл экспорта.',
|
||||||
|
includeTokens: 'Включить токены (менее безопасно)',
|
||||||
|
stripTokens: 'Удалить токены (безопаснее для обмена)',
|
||||||
|
exportSuccess: 'Аккаунты успешно экспортированы',
|
||||||
|
importTitle: 'Импорт аккаунтов',
|
||||||
|
importDesc: 'Выберите ранее экспортированный JSON файл.',
|
||||||
|
importStrategy: 'Стратегия импорта',
|
||||||
|
strategyMerge: 'Объединить - Обновить существующие, добавить новые',
|
||||||
|
strategyOverwrite: 'Перезаписать - Заменить все существующие данные',
|
||||||
|
strategySkip: 'Пропустить - Добавить только новые аккаунты',
|
||||||
|
importSuccess:
|
||||||
|
'Импортировано {{imported}}, обновлено {{updated}}, пропущено {{skipped}}',
|
||||||
|
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
|
||||||
|
selectFile: 'Выбрать файл',
|
||||||
|
importing: 'Импорт...',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API Прокси',
|
title: 'API Прокси',
|
||||||
|
|||||||
@@ -122,10 +122,17 @@ function showWindowsInstallNoticeIfNeeded() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
app.setName('Antigravity Manager Dev');
|
||||||
|
}
|
||||||
|
|
||||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
logger.info('Second instance detected, focusing existing window');
|
logger.info('Second instance detected, focusing existing window');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setAppLanguage } from '@/actions/language';
|
import { setAppLanguage } from '@/actions/language';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Loader2, FolderOpen } from 'lucide-react';
|
import { Loader2, FolderOpen } from 'lucide-react';
|
||||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -27,6 +28,7 @@ function SettingsPage() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { config, isLoading, saveConfig } = useAppConfig();
|
const { config, isLoading, saveConfig } = useAppConfig();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Local state for configuration editing
|
// Local state for configuration editing
|
||||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||||
@@ -297,6 +299,86 @@ function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Notifications Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
'settings.notifications.description',
|
||||||
|
'Configure desktop alerts for account events.',
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('settings.notifications.quotaAlert', 'Low Quota Alerts')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t(
|
||||||
|
'settings.notifications.quotaAlertDesc',
|
||||||
|
'Get notified when a model quota drops below the set threshold',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.quota_alert_enabled || false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: 'Failed to save notification settings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('settings.notifications.quotaThreshold', 'Alert Threshold')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t(
|
||||||
|
'settings.notifications.quotaThresholdDesc',
|
||||||
|
'Percentage below which to trigger an alert',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={config?.quota_alert_threshold ?? 20}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const rawValue = e.target.value;
|
||||||
|
const parsed = parseInt(rawValue, 10);
|
||||||
|
if (isNaN(parsed) || parsed < 0 || parsed > 100) return;
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
await saveConfig({ ...config, quota_alert_threshold: parsed });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: 'Failed to save threshold setting',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-sm">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* --- MODELS TAB --- */}
|
{/* --- MODELS TAB --- */}
|
||||||
|
|||||||
@@ -15,9 +15,23 @@ export class UpstreamRequestError extends Error {
|
|||||||
}) {
|
}) {
|
||||||
super(params.message);
|
super(params.message);
|
||||||
this.name = 'UpstreamRequestError';
|
this.name = 'UpstreamRequestError';
|
||||||
|
|
||||||
|
if (params.status && typeof params.status !== 'number') {
|
||||||
|
throw new TypeError('status must be a number');
|
||||||
|
}
|
||||||
|
if (params.headers && typeof params.headers !== 'object') {
|
||||||
|
throw new TypeError('headers must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
this.status = params.status;
|
this.status = params.status;
|
||||||
this.headers = params.headers;
|
this.headers = params.headers;
|
||||||
this.body = params.body;
|
|
||||||
|
// Sanitize and limit body size
|
||||||
|
if (typeof params.body === 'string') {
|
||||||
|
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
|
||||||
|
this.body = sanitized.substring(0, 1000);
|
||||||
|
} else {
|
||||||
|
this.body = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,10 @@ import {
|
|||||||
AnthropicContent,
|
AnthropicContent,
|
||||||
} from './interfaces/request-interfaces';
|
} from './interfaces/request-interfaces';
|
||||||
import { getServerConfig } from '../../server-config';
|
import { getServerConfig } from '../../server-config';
|
||||||
import { normalizeGeminiModelAlias, resolveModelRoute } from '../../../lib/antigravity/ModelMapping';
|
import {
|
||||||
|
normalizeGeminiModelAlias,
|
||||||
|
resolveModelRoute,
|
||||||
|
} from '../../../lib/antigravity/ModelMapping';
|
||||||
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/antigravity/ModelSpecs';
|
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/antigravity/ModelSpecs';
|
||||||
import { resolveRequestUserAgent } from './request-user-agent';
|
import { resolveRequestUserAgent } from './request-user-agent';
|
||||||
import { UpstreamRequestError } from './clients/upstream-error';
|
import { UpstreamRequestError } from './clients/upstream-error';
|
||||||
@@ -679,7 +682,9 @@ export class ProxyService {
|
|||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const delay = calculateRetryDelay(i - 1);
|
const delay = calculateRetryDelay(i - 1);
|
||||||
this.logger.log(`OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`);
|
this.logger.log(
|
||||||
|
`OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`,
|
||||||
|
);
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
return candidates.find(([accountId]) => accountId === stickyBinding.accountId) ?? null;
|
return candidates.find(([accountId]) => accountId === stickyBinding.accountId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resetSelectionState(): void {
|
||||||
|
this.currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private pickRoundRobinEntry(candidates: TokenEntry[]): TokenEntry | null {
|
private pickRoundRobinEntry(candidates: TokenEntry[]): TokenEntry | null {
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -507,10 +511,7 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
|
|
||||||
const stickyBinding = this.getValidSessionBinding(sessionKey, now);
|
const stickyBinding = this.getValidSessionBinding(sessionKey, now);
|
||||||
if (stickyBinding && mode === 'cache-first') {
|
if (stickyBinding && mode === 'cache-first') {
|
||||||
const waitSec = this.rateLimitTracker.getRemainingWaitSeconds(
|
const waitSec = this.rateLimitTracker.getRemainingWaitSeconds(stickyBinding.accountId, model);
|
||||||
stickyBinding.accountId,
|
|
||||||
model,
|
|
||||||
);
|
|
||||||
const waitMs = waitSec * 1000;
|
const waitMs = waitSec * 1000;
|
||||||
const maxWaitMs = this.getMaxWaitDurationMs();
|
const maxWaitMs = this.getMaxWaitDurationMs();
|
||||||
if (waitMs > 0 && waitMs <= maxWaitMs) {
|
if (waitMs > 0 && waitMs <= maxWaitMs) {
|
||||||
@@ -586,18 +587,18 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
): void {
|
): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const legacyAccountId = this.predictLegacyAccountCandidateId(allTokens, sessionKey, now);
|
const legacyAccountId = this.predictLegacyAccountCandidateId(allTokens, sessionKey, now);
|
||||||
const parityAccountId = this.predictParityAccountCandidateId(
|
const parityAccountId = this.predictParityAccountCandidateId(allTokens, sessionKey, model, now);
|
||||||
allTokens,
|
|
||||||
sessionKey,
|
this.updateShadowStats(legacyAccountId, parityAccountId);
|
||||||
model,
|
}
|
||||||
now,
|
|
||||||
);
|
private updateShadowStats(legacyId: string | null, parityId: string | null): void {
|
||||||
this.shadowComparisonCount++;
|
this.shadowComparisonCount++;
|
||||||
|
|
||||||
if (legacyAccountId !== parityAccountId) {
|
if (legacyId !== parityId) {
|
||||||
this.shadowMismatchCount++;
|
this.shadowMismatchCount++;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Parity shadow mismatch detected: legacy=${legacyAccountId ?? 'n/a'}, parity=${parityAccountId ?? 'n/a'}`,
|
`Parity shadow mismatch detected: legacy=${legacyId ?? 'n/a'}, parity=${parityId ?? 'n/a'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Notification } from 'electron';
|
||||||
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
||||||
import { GoogleAPIService } from './GoogleAPIService';
|
import { GoogleAPIService } from './GoogleAPIService';
|
||||||
import { AutoSwitchService } from './AutoSwitchService';
|
import { AutoSwitchService } from './AutoSwitchService';
|
||||||
@@ -101,18 +102,33 @@ export class CloudMonitorService {
|
|||||||
let accessToken = account.token.access_token;
|
let accessToken = account.token.access_token;
|
||||||
if (account.token.expiry_timestamp < now + 600) {
|
if (account.token.expiry_timestamp < now + 600) {
|
||||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
try {
|
||||||
|
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||||
|
account.token.refresh_token,
|
||||||
|
account.proxy_url,
|
||||||
|
);
|
||||||
account.token.access_token = newToken.access_token;
|
account.token.access_token = newToken.access_token;
|
||||||
account.token.expires_in = newToken.expires_in;
|
account.token.expires_in = newToken.expires_in;
|
||||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
accessToken = newToken.access_token;
|
accessToken = newToken.access_token;
|
||||||
|
} catch (refreshError) {
|
||||||
|
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Quota
|
|
||||||
// We delay slightly between requests to act human/avoid spike
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
const quota = await GoogleAPIService.fetchQuota(accessToken, account.proxy_url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiCredits = await GoogleAPIService.fetchAICredits(accessToken, account.proxy_url);
|
||||||
|
if (aiCredits) {
|
||||||
|
quota.ai_credits = aiCredits;
|
||||||
|
}
|
||||||
|
} catch (creditError) {
|
||||||
|
logger.warn(`Monitor: Failed to fetch credits for ${account.email}`, creditError);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Update DB
|
// 3. Update DB
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
@@ -123,7 +139,30 @@ export class CloudMonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check for Auto-Switch
|
// 4. Check for Quota Alerts
|
||||||
|
const alertEnabled = CloudAccountRepo.getSetting<boolean>('quota_alert_enabled', false);
|
||||||
|
const alertThreshold = CloudAccountRepo.getSetting<number>('quota_alert_threshold', 20);
|
||||||
|
|
||||||
|
if (alertEnabled) {
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account.quota?.models) continue;
|
||||||
|
const lowQuotaModels = Object.entries(account.quota.models)
|
||||||
|
.filter(([_, info]) => info.percentage <= alertThreshold && info.percentage > 0)
|
||||||
|
.map(([name, info]) => {
|
||||||
|
return info.display_name || name.replace('models/', '').replace(/-/g, ' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lowQuotaModels.length > 0) {
|
||||||
|
new Notification({
|
||||||
|
title: 'Low Quota Alert',
|
||||||
|
body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`,
|
||||||
|
silent: false,
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for Auto-Switch
|
||||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||||
} finally {
|
} finally {
|
||||||
this.isPolling = false;
|
this.isPolling = false;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
import { ConfigManager } from '../ipc/config/manager';
|
import { ConfigManager } from '../ipc/config/manager';
|
||||||
|
import { AuthServer } from '../ipc/cloud/authServer';
|
||||||
import { ProxyAgent } from 'undici';
|
import { ProxyAgent } from 'undici';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +24,6 @@ const URLS = {
|
|||||||
|
|
||||||
// Internal API masquerading
|
// Internal API masquerading
|
||||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
|
||||||
|
|
||||||
// Request timeout in milliseconds (30 seconds)
|
// Request timeout in milliseconds (30 seconds)
|
||||||
const REQUEST_TIMEOUT_MS = 30000;
|
const REQUEST_TIMEOUT_MS = 30000;
|
||||||
@@ -56,11 +57,22 @@ export interface UserInfo {
|
|||||||
picture: string;
|
picture: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const UserInfoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
verified_email: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
given_name: z.string(),
|
||||||
|
family_name: z.string(),
|
||||||
|
picture: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export interface QuotaData {
|
export interface QuotaData {
|
||||||
models: Record<string, ModelQuotaInfo>;
|
models: Record<string, ModelQuotaInfo>;
|
||||||
model_forwarding_rules?: Record<string, string>;
|
model_forwarding_rules?: Record<string, string>;
|
||||||
subscription_tier?: string;
|
subscription_tier?: string;
|
||||||
is_forbidden?: boolean;
|
is_forbidden?: boolean;
|
||||||
|
ai_credits?: { credits: number; expiryDate: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelQuotaInfo {
|
export interface ModelQuotaInfo {
|
||||||
@@ -219,17 +231,25 @@ function toModelForwardingRules(
|
|||||||
// --- Service Implementation ---
|
// --- Service Implementation ---
|
||||||
|
|
||||||
export class GoogleAPIService {
|
export class GoogleAPIService {
|
||||||
private static getFetchOptions() {
|
private static getFetchOptions(proxyUrl?: string) {
|
||||||
|
if (proxyUrl && proxyUrl.length > 0) {
|
||||||
|
return {
|
||||||
|
dispatcher: new ProxyAgent(proxyUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
if (config.proxy?.upstream_proxy?.enabled && config.proxy.upstream_proxy.url) {
|
if (config.proxy?.upstream_proxy?.enabled) {
|
||||||
|
if (!config.proxy.upstream_proxy.url) {
|
||||||
|
throw new Error('Upstream proxy is enabled but URL is not configured');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
|
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback or log if config load fails (shouldn't happen usually)
|
logger.error('[GoogleAPIService] Proxy configuration error', e);
|
||||||
logger.warn('[GoogleAPIService] Failed to load proxy config', e);
|
throw e;
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -242,13 +262,13 @@ export class GoogleAPIService {
|
|||||||
'https://www.googleapis.com/auth/cloud-platform',
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
'https://www.googleapis.com/auth/userinfo.profile',
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
'https://www.googleapis.com/auth/cclog',
|
|
||||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
const redirectUri = AuthServer.getRedirectUri();
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: redirectUri,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: scopes,
|
scope: scopes,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
@@ -262,12 +282,14 @@ export class GoogleAPIService {
|
|||||||
/**
|
/**
|
||||||
* Exchanges an authorization code for tokens.
|
* Exchanges an authorization code for tokens.
|
||||||
*/
|
*/
|
||||||
static async exchangeCode(code: string): Promise<TokenResponse> {
|
static async exchangeCode(code: string, proxyUrl?: string): Promise<TokenResponse> {
|
||||||
|
const redirectUri = AuthServer.getRedirectUri();
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
client_secret: CLIENT_SECRET,
|
client_secret: CLIENT_SECRET,
|
||||||
code: code,
|
code: code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,7 +298,7 @@ export class GoogleAPIService {
|
|||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(proxyUrl),
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
@@ -299,7 +321,7 @@ export class GoogleAPIService {
|
|||||||
/**
|
/**
|
||||||
* Refreshes an access token using a refresh token.
|
* Refreshes an access token using a refresh token.
|
||||||
*/
|
*/
|
||||||
static async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise<TokenResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
client_secret: CLIENT_SECRET,
|
client_secret: CLIENT_SECRET,
|
||||||
@@ -312,7 +334,7 @@ export class GoogleAPIService {
|
|||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(proxyUrl),
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
@@ -337,11 +359,11 @@ export class GoogleAPIService {
|
|||||||
/**
|
/**
|
||||||
* Fetches user profile information.
|
* Fetches user profile information.
|
||||||
*/
|
*/
|
||||||
static async getUserInfo(accessToken: string): Promise<UserInfo> {
|
static async getUserInfo(accessToken: string, proxyUrl?: string): Promise<UserInfo> {
|
||||||
const response = await fetch(URLS.USER_INFO, {
|
const response = await fetch(URLS.USER_INFO, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(proxyUrl),
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
@@ -358,16 +380,26 @@ export class GoogleAPIService {
|
|||||||
throw new Error(`Failed to fetch user info: ${text}`);
|
throw new Error(`Failed to fetch user info: ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<UserInfo>;
|
const data = await response.json();
|
||||||
|
try {
|
||||||
|
return UserInfoSchema.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[GoogleAPIService] Malformed user info response:', err);
|
||||||
|
throw new Error('Received malformed user info from Google APIs');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async fetchProjectContext(accessToken: string): Promise<ProjectContext> {
|
public static async fetchProjectContext(
|
||||||
|
accessToken: string,
|
||||||
|
proxyUrl?: string,
|
||||||
|
): Promise<ProjectContext> {
|
||||||
const body = {
|
const body = {
|
||||||
metadata: { ideType: 'ANTIGRAVITY' },
|
metadata: { ideType: 'ANTIGRAVITY' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let projectId: string | undefined;
|
let projectId: string | undefined;
|
||||||
let subscriptionTier: string | undefined;
|
let subscriptionTier: string | undefined;
|
||||||
|
let lastError: any;
|
||||||
|
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -376,7 +408,7 @@ export class GoogleAPIService {
|
|||||||
headers: buildInternalApiHeaders(accessToken),
|
headers: buildInternalApiHeaders(accessToken),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
...this.getFetchOptions(),
|
...this.getFetchOptions(proxyUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -386,29 +418,62 @@ export class GoogleAPIService {
|
|||||||
}
|
}
|
||||||
subscriptionTier = resolveSubscriptionTier(data);
|
subscriptionTier = resolveSubscriptionTier(data);
|
||||||
break;
|
break;
|
||||||
|
} else {
|
||||||
|
lastError = new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, error);
|
logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, error);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!projectId && !subscriptionTier) {
|
||||||
|
throw lastError || new Error('Failed to fetch project context after multiple attempts.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
subscriptionTier,
|
subscriptionTier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async fetchProjectId(accessToken: string): Promise<string | null> {
|
public static async fetchProjectId(
|
||||||
const context = await this.fetchProjectContext(accessToken);
|
accessToken: string,
|
||||||
|
proxyUrl?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const context = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||||
return context.projectId ?? null;
|
return context.projectId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async fetchAICredits(
|
||||||
|
accessToken: string,
|
||||||
|
proxyUrl?: string,
|
||||||
|
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildInternalApiHeaders(accessToken),
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||||
|
...this.getFetchOptions(proxyUrl),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
credits: data?.credits ?? data?.remainingCredits ?? 0,
|
||||||
|
expiryDate: data?.expiryDate ?? data?.expirationDate ?? '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core logic: Fetches detailed model quota information.
|
* Core logic: Fetches detailed model quota information.
|
||||||
*/
|
*/
|
||||||
static async fetchQuota(accessToken: string): Promise<QuotaData> {
|
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
|
||||||
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken);
|
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
@@ -417,7 +482,7 @@ export class GoogleAPIService {
|
|||||||
|
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
const fetchOptions = this.getFetchOptions();
|
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -446,7 +511,8 @@ export class GoogleAPIService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await sleep(1000);
|
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||||
|
await sleep(backoffMs);
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
@@ -474,14 +540,23 @@ export class GoogleAPIService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
|
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Abort retries for auth errors
|
||||||
|
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await sleep(1000);
|
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,9 +72,11 @@ describe('Parity rollout guard replay', () => {
|
|||||||
preferred_account_id: 'acc-2',
|
preferred_account_id: 'acc-2',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
(service as any).currentIndex = 0;
|
service.resetSelectionState();
|
||||||
const rollbackSelected = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
const rollbackSelected = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||||
expect(rollbackSelected?.id).toBe('acc-1');
|
expect(rollbackSelected?.id).toBe('acc-1');
|
||||||
|
// Verify that shadow comparison was bypassed due to kill-switch
|
||||||
|
expect((service as any).shadowComparisonCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces no-go threshold after shadow mismatch and blocks parity enablement', async () => {
|
it('enforces no-go threshold after shadow mismatch and blocks parity enablement', async () => {
|
||||||
@@ -98,9 +100,9 @@ describe('Parity rollout guard replay', () => {
|
|||||||
preferred_account_id: 'acc-2',
|
preferred_account_id: 'acc-2',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
(service as any).currentIndex = 0;
|
service.resetSelectionState();
|
||||||
const blockedSelection = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
const blockedSelection = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||||
expect(blockedSelection?.id).toBe('acc-1');
|
expect(blockedSelection?.id).toBe('acc-1');
|
||||||
|
expect((service as any).noGoBlocked).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,8 +260,12 @@ describe('ProxyController Integration', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||||
const assistantMessage = callArg.messages.find((message: { role: string }) => message.role === 'assistant');
|
const assistantMessage = callArg.messages.find(
|
||||||
const toolMessage = callArg.messages.find((message: { role: string }) => message.role === 'tool');
|
(message: { role: string }) => message.role === 'assistant',
|
||||||
|
);
|
||||||
|
const toolMessage = callArg.messages.find(
|
||||||
|
(message: { role: string }) => message.role === 'tool',
|
||||||
|
);
|
||||||
|
|
||||||
expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search');
|
expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search');
|
||||||
expect(toolMessage?.name).toBe('builtin_web_search');
|
expect(toolMessage?.name).toBe('builtin_web_search');
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface CloudQuotaData {
|
|||||||
subscription_tier?: string;
|
subscription_tier?: string;
|
||||||
is_forbidden?: boolean;
|
is_forbidden?: boolean;
|
||||||
isForbidden?: boolean;
|
isForbidden?: boolean;
|
||||||
|
ai_credits?: { credits: number; expiryDate: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudAccount {
|
export interface CloudAccount {
|
||||||
@@ -53,6 +54,7 @@ export interface CloudAccount {
|
|||||||
last_used: number; // Unix timestamp
|
last_used: number; // Unix timestamp
|
||||||
status?: 'active' | 'rate_limited' | 'expired';
|
status?: 'active' | 'rate_limited' | 'expired';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
proxy_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zod Schemas
|
// Zod Schemas
|
||||||
@@ -87,6 +89,7 @@ export const CloudQuotaDataSchema = z.object({
|
|||||||
subscription_tier: z.string().optional(),
|
subscription_tier: z.string().optional(),
|
||||||
is_forbidden: z.boolean().optional(),
|
is_forbidden: z.boolean().optional(),
|
||||||
isForbidden: z.boolean().optional(),
|
isForbidden: z.boolean().optional(),
|
||||||
|
ai_credits: z.object({ credits: z.number(), expiryDate: z.string() }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CloudAccountSchema = z.object({
|
export const CloudAccountSchema = z.object({
|
||||||
@@ -103,4 +106,25 @@ export const CloudAccountSchema = z.object({
|
|||||||
last_used: z.number(),
|
last_used: z.number(),
|
||||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||||
is_active: z.boolean().optional(),
|
is_active: z.boolean().optional(),
|
||||||
|
proxy_url: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CloudAccountExportSchema = z.object({
|
||||||
|
version: z.literal('1.0'),
|
||||||
|
exportedAt: z.number(),
|
||||||
|
accounts: z.array(
|
||||||
|
z.object({
|
||||||
|
provider: z.enum(['google', 'anthropic']),
|
||||||
|
email: z.string(),
|
||||||
|
name: z.string().optional().nullable(),
|
||||||
|
avatar_url: z.string().optional().nullable(),
|
||||||
|
token: CloudTokenDataSchema,
|
||||||
|
quota: CloudQuotaDataSchema.optional(),
|
||||||
|
device_profile: z.any().optional(),
|
||||||
|
device_history: z.any().optional(),
|
||||||
|
proxy_url: z.string().optional().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CloudAccountExport = z.infer<typeof CloudAccountExportSchema>;
|
||||||
+10
-2
@@ -40,7 +40,12 @@ export const AppConfigSchema = z.object({
|
|||||||
default_export_path: z.string().nullable().optional(), // Export path
|
default_export_path: z.string().nullable().optional(), // Export path
|
||||||
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||||
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
||||||
grid_layout: z.enum(['auto', '2-col', '3-col', 'list']).default('auto'), // Account card grid layout
|
grid_layout: z.enum(['auto', '2-col', '3-col', 'list', 'compact']).default('auto'), // Account card grid layout
|
||||||
|
account_sort: z
|
||||||
|
.enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash'])
|
||||||
|
.default('recently-used'),
|
||||||
|
quota_alert_enabled: z.boolean().default(false),
|
||||||
|
quota_alert_threshold: z.number().default(20),
|
||||||
proxy: ProxyConfigSchema,
|
proxy: ProxyConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ export type AppConfig = z.infer<typeof AppConfigSchema>;
|
|||||||
|
|
||||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
theme: 'system',
|
theme: 'dark',
|
||||||
auto_refresh: false,
|
auto_refresh: false,
|
||||||
refresh_interval: 15,
|
refresh_interval: 15,
|
||||||
auto_sync: false,
|
auto_sync: false,
|
||||||
@@ -62,6 +67,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
model_visibility: {}, // Model visibility preferences
|
model_visibility: {}, // Model visibility preferences
|
||||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||||
grid_layout: 'auto' as const, // Account card grid layout
|
grid_layout: 'auto' as const, // Account card grid layout
|
||||||
|
account_sort: 'recently-used' as const,
|
||||||
|
quota_alert_enabled: false,
|
||||||
|
quota_alert_threshold: 20,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
port: 8045,
|
port: 8045,
|
||||||
|
|||||||
@@ -187,9 +187,8 @@ export function groupModelsByProvider(
|
|||||||
|
|
||||||
// Sort: known providers first (claude-, gemini-), then others
|
// Sort: known providers first (claude-, gemini-), then others
|
||||||
const providerDisplayOrder = keys(PROVIDER_REGISTRY);
|
const providerDisplayOrder = keys(PROVIDER_REGISTRY);
|
||||||
const sortedProviders = sortBy(
|
const sortedProviders = sortBy(providerStatsList, (providerStats) =>
|
||||||
providerStatsList,
|
providerDisplayOrder.indexOf(providerStats.providerKey),
|
||||||
(providerStats) => providerDisplayOrder.indexOf(providerStats.providerKey),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
|
const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns';
|
import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns';
|
||||||
|
import { CloudAccount } from '@/types/cloudAccount';
|
||||||
|
|
||||||
const HIGH_QUOTA_PERCENTAGE = 80;
|
const HIGH_QUOTA_PERCENTAGE = 80;
|
||||||
const MEDIUM_QUOTA_PERCENTAGE = 20;
|
const MEDIUM_QUOTA_PERCENTAGE = 20;
|
||||||
@@ -83,3 +84,30 @@ export function formatResetTimeTitle(
|
|||||||
|
|
||||||
return `${resetTimeLabel}: ${resetDate.toLocaleString()}`;
|
return `${resetTimeLabel}: ${resetDate.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAccountSortValue(account: CloudAccount, sortKey: string): number {
|
||||||
|
if (!account.quota?.models) return 0;
|
||||||
|
const models = Object.values(account.quota.models);
|
||||||
|
if (models.length === 0) return 0;
|
||||||
|
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'quota-overall':
|
||||||
|
return models.reduce((sum, m) => sum + m.percentage, 0) / models.length;
|
||||||
|
case 'quota-claude': {
|
||||||
|
const claude = models.filter((m) => m.percentage > 0);
|
||||||
|
return claude.length > 0
|
||||||
|
? claude.reduce((sum, m) => sum + m.percentage, 0) / claude.length
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
case 'quota-pro3': {
|
||||||
|
const pro3 = models.filter((m) => m.percentage > 0 && /pro/i.test(m.display_name || ''));
|
||||||
|
return pro3.length > 0 ? pro3.reduce((sum, m) => sum + m.percentage, 0) / pro3.length : 0;
|
||||||
|
}
|
||||||
|
case 'quota-flash': {
|
||||||
|
const flash = models.filter((m) => m.percentage > 0 && /flash/i.test(m.display_name || ''));
|
||||||
|
return flash.length > 0 ? flash.reduce((sum, m) => sum + m.percentage, 0) / flash.length : 0;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export function isValidProxyUrl(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return ['http:', 'https:', 'socks:', 'socks4:', 'socks5:'].includes(parsed.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
return str.replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) =>
|
||||||
|
({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[c] || c,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins:
|
plugins: shouldEnableSentry
|
||||||
shouldEnableSentry
|
|
||||||
? [
|
? [
|
||||||
sentryVitePlugin({
|
sentryVitePlugin({
|
||||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins:
|
plugins: shouldEnableSentry
|
||||||
shouldEnableSentry
|
|
||||||
? [
|
? [
|
||||||
sentryVitePlugin({
|
sentryVitePlugin({
|
||||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
|||||||
Reference in new issue
Block a user