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:
37 files changed
+1989
-455
No files matched your search
@@ -123,3 +123,6 @@ build/
|
||||
cli/aliases.json
|
||||
*.backup
|
||||
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
|
||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **📦 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
|
||||
|
||||
---
|
||||
|
||||
+6
-1
@@ -158,7 +158,12 @@
|
||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
||||
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
|
||||
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
|
||||
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
|
||||
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
|
||||
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
|
||||
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
|
||||
- **🎨 现代 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": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21036,7 +21035,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21057,7 +21055,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21078,7 +21075,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21099,7 +21095,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21120,7 +21115,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21141,7 +21135,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21162,7 +21155,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21183,7 +21175,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21204,7 +21195,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -21225,7 +21215,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+8
-1
@@ -153,5 +153,12 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"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 type { DeviceProfile } from '@/types/account';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
|
||||
export function addGoogleAccount(input: { authCode: string }) {
|
||||
return ipc.client.cloud.addGoogleAccount(input);
|
||||
@@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) {
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(input: { accountId: string }) {
|
||||
console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`);
|
||||
return ipc.client.cloud.refreshAccountQuota(input);
|
||||
}
|
||||
|
||||
@@ -88,3 +90,26 @@ export function deleteCloudIdentityProfileRevision(input: {
|
||||
export function openCloudIdentityStorageFolder() {
|
||||
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,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -32,6 +34,9 @@ import {
|
||||
getQuotaStatus,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { useState } from 'react';
|
||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
|
||||
@@ -81,7 +86,9 @@ function isGeminiProFamilyModel(modelName: string): boolean {
|
||||
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 hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
||||
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
||||
@@ -96,9 +103,18 @@ function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string,
|
||||
const mergedPercentage = proLowModelInfo
|
||||
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
|
||||
: modelInfo.percentage;
|
||||
|
||||
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
|
||||
...modelInfo,
|
||||
...proLowModelInfo,
|
||||
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;
|
||||
}
|
||||
@@ -148,13 +164,16 @@ export function CloudAccountCard({
|
||||
isSwitching,
|
||||
}: CloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const {
|
||||
enabled: providerGroupingsEnabled,
|
||||
getAccountStats,
|
||||
isProviderCollapsed,
|
||||
toggleProviderCollapse,
|
||||
} = useProviderGrouping();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
const getQuotaTextColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
@@ -184,6 +203,8 @@ export function CloudAccountCard({
|
||||
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
||||
};
|
||||
|
||||
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||
|
||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
) 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">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<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)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
@@ -278,25 +301,40 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{providerStats.providers.map((statsByProvider) => (
|
||||
<ProviderGroup
|
||||
key={statsByProvider.providerKey}
|
||||
stats={statsByProvider}
|
||||
isCollapsed={isProviderCollapsed(account.id, statsByProvider.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, statsByProvider.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{providerStats.providers.map((group) => (
|
||||
<ProviderGroup
|
||||
key={group.providerKey}
|
||||
stats={group}
|
||||
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
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 (
|
||||
<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' : ''}`}
|
||||
@@ -330,7 +368,71 @@ export function CloudAccountCard({
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</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>
|
||||
</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,
|
||||
useSyncLocalAccount,
|
||||
startAuthFlow,
|
||||
useExportCloudAccounts,
|
||||
useImportCloudAccounts,
|
||||
} from '@/hooks/useCloudAccounts';
|
||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
Cloud,
|
||||
Zap,
|
||||
RefreshCcw,
|
||||
@@ -31,6 +38,12 @@ import {
|
||||
List,
|
||||
Columns2,
|
||||
Columns3,
|
||||
SortAsc,
|
||||
LayoutList,
|
||||
Upload,
|
||||
FileDown,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
@@ -49,21 +62,30 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
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 {
|
||||
clampQuotaPercentage,
|
||||
getQuotaStatus,
|
||||
roundQuotaPercentage,
|
||||
getAccountSortValue,
|
||||
type QuotaStatus,
|
||||
} 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> = {
|
||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||
'2-col': 'grid gap-4 grid-cols-2',
|
||||
'3-col': 'grid gap-4 grid-cols-3',
|
||||
list: 'grid gap-4 grid-cols-1',
|
||||
compact: 'flex flex-col gap-2',
|
||||
};
|
||||
|
||||
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
|
||||
const overallQuotaPercentage = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) {
|
||||
@@ -137,6 +196,16 @@ export function CloudAccountList() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
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 activeAccounts = filter(accounts, (account) => account.is_active).length;
|
||||
const rateLimitedAccounts = filter(
|
||||
@@ -144,29 +213,32 @@ export function CloudAccountList() {
|
||||
(account) => account.status === 'rate_limited',
|
||||
).length;
|
||||
|
||||
const submitAuthCode = useCallback((incomingAuthCode?: string) => {
|
||||
const codeToUse = incomingAuthCode || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
const submitAuthCode = useCallback(
|
||||
(incomingAuthCode?: string) => {
|
||||
const codeToUse = incomingAuthCode || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [addMutation, authCode, t, toast]);
|
||||
);
|
||||
},
|
||||
[addMutation, authCode, t, toast],
|
||||
);
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
@@ -204,6 +276,7 @@ export function CloudAccountList() {
|
||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
@@ -315,13 +388,119 @@ export function CloudAccountList() {
|
||||
await startAuthFlow();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error
|
||||
title: t('cloud.toast.startAuthFailed'),
|
||||
description: String(e),
|
||||
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
|
||||
const setSelectionState = (id: string, selected: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -343,26 +522,54 @@ export function CloudAccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelectedAccounts = () => {
|
||||
selectedIds.forEach((id) => {
|
||||
refreshMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Triggered for ${selectedIds.size} accounts.`,
|
||||
});
|
||||
const refreshSelectedAccounts = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
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({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Successfully refreshed ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Refresh completed with issues`,
|
||||
description: `Refreshed ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const deleteSelectedAccounts = () => {
|
||||
const deleteSelectedAccounts = async () => {
|
||||
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
||||
selectedIds.forEach((id) => {
|
||||
deleteMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Deleted ${selectedIds.size} accounts.`,
|
||||
});
|
||||
const ids = Array.from(selectedIds);
|
||||
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({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Successfully deleted ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Deletion completed with issues`,
|
||||
description: `Deleted ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
@@ -501,6 +708,117 @@ export function CloudAccountList() {
|
||||
{t('cloud.syncFromIDE')}
|
||||
</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}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
@@ -508,7 +826,7 @@ export function CloudAccountList() {
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
@@ -543,7 +861,35 @@ export function CloudAccountList() {
|
||||
</DialogContent>
|
||||
</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">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
@@ -598,34 +944,67 @@ export function CloudAccountList() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={setSelectionState}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{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
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={setSelectionState}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
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">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
|
||||
@@ -76,10 +76,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</span>
|
||||
)}
|
||||
<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)}
|
||||
</span>
|
||||
{avgPercentage > 0 && (
|
||||
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('settings.providerGroupings.avgLabel')}
|
||||
</span>
|
||||
|
||||
@@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden 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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
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.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addGoogleAccount,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
setAccountProxy,
|
||||
} from '@/actions/cloud';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
@@ -120,3 +121,41 @@ export function useSyncLocalAccount() {
|
||||
|
||||
import { startAuthFlow } from '@/actions/cloud';
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+89
-36
@@ -1,38 +1,79 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
import { escapeHtml } from '../../utils/url';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static start() {
|
||||
static async start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||
let boundPort: number | null = null;
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
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 (code) {
|
||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
||||
if (!boundPort) {
|
||||
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
if (boundPort !== 8888) {
|
||||
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
this.PORT = boundPort;
|
||||
|
||||
try {
|
||||
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}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
const escapedCode = escapeHtml(code);
|
||||
logger.info(
|
||||
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||
);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
@@ -43,34 +84,46 @@ export class AuthServer {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
} else if (error) {
|
||||
const escapedError = escapeHtml(error);
|
||||
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>Error: ${escapedError}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
static getRedirectUri(): string {
|
||||
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||
}
|
||||
|
||||
static stop() {
|
||||
|
||||
+179
-26
@@ -1,8 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AuthServer } from './authServer';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
@@ -22,8 +23,7 @@ import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||
|
||||
// Fallback constants if service constants are not available or for direct usage
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
const CLIENT_ID = '1071006060591-tmhssin21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
@@ -52,7 +52,13 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
// 2. Get User Info
|
||||
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 account: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
@@ -62,7 +68,7 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
avatar_url: userInfo.picture,
|
||||
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,
|
||||
expiry_timestamp: now + tokenResp.expires_in,
|
||||
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.`);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
await CloudAccountRepo.addAccount(account);
|
||||
|
||||
// 5. Initial Quota Check (Async, best effort)
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
@@ -110,40 +114,56 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
let now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
// 5 minutes buffer
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||
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.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
|
||||
// Save to DB
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (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 {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token, account.proxy_url);
|
||||
account.quota = 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);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
// Force Refresh
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
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);
|
||||
|
||||
// Retry Quota
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const quota = await GoogleAPIService.fetchQuota(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
account.quota = 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);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
@@ -170,7 +206,6 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
// Return existing account to avoid crash
|
||||
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)
|
||||
const tokenRefreshPromise = (async () => {
|
||||
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...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
const updatedToken = {
|
||||
...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}`);
|
||||
} catch (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> {
|
||||
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}`);
|
||||
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,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} from './handler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
@@ -117,6 +119,18 @@ export const cloudRouter = os.router({
|
||||
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 () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
@@ -185,4 +199,40 @@ export const cloudRouter = os.router({
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
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 { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
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 { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||
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 hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
||||
const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
@@ -99,6 +105,9 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
if (!hasDeviceHistoryJson) {
|
||||
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
|
||||
@@ -450,6 +459,9 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async addAccount(account: CloudAccount): Promise<void> {
|
||||
// Validate account data before processing
|
||||
CloudAccountSchema.parse(account);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||
@@ -468,6 +480,7 @@ export class CloudAccountRepo {
|
||||
lastUsed: account.last_used,
|
||||
status: account.status || 'active',
|
||||
isActive: account.is_active ? 1 : 0,
|
||||
proxyUrl: account.proxy_url ?? null,
|
||||
};
|
||||
|
||||
orm.transaction((tx) => {
|
||||
@@ -509,79 +522,86 @@ export class CloudAccountRepo {
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
continue; // Skip corrupted account
|
||||
}
|
||||
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||
}
|
||||
if (!tokenResult.value) {
|
||||
logger.warn(`Missing token data for account ${normalizedRow.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
});
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
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;
|
||||
@@ -649,6 +669,7 @@ export class CloudAccountRepo {
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
};
|
||||
} finally {
|
||||
raw.close();
|
||||
@@ -666,22 +687,42 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async updateToken(id: string, token: any): Promise<void> {
|
||||
// Validate token data before encryption
|
||||
CloudTokenDataSchema.parse(token);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
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 {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||
// Validate quota data before encryption
|
||||
CloudQuotaDataSchema.parse(quota);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
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 {
|
||||
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 {
|
||||
db.insert(itemTable)
|
||||
.values({ key, value })
|
||||
|
||||
@@ -12,8 +12,9 @@ export const accounts = sqliteTable('accounts', {
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').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),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
|
||||
@@ -73,19 +73,17 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Antigravity processes
|
||||
// 'name' search type matches process name
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
let sawNoMatch = false;
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
try {
|
||||
const matches = await findProcess('name', searchName, true);
|
||||
for (const proc of matches) {
|
||||
if (typeof proc.pid === 'number') {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
const matches = await findProcess('name', searchName, false);
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
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());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
@@ -145,7 +150,27 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
} 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 (
|
||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||
!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_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
||||
const EXTRA_DYNAMIC_MODELS = [
|
||||
'gemini-3-flash',
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
];
|
||||
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low'];
|
||||
|
||||
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 {
|
||||
if (typeof oldModel !== 'string' || typeof newModel !== 'string') {
|
||||
return;
|
||||
}
|
||||
const normalizedOld = oldModel.trim();
|
||||
const normalizedNew = newModel.trim();
|
||||
if (!normalizedOld || !normalizedNew) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -142,12 +143,13 @@ export function getAllDynamicModels(
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIds]
|
||||
.filter((id) => !shouldHideDeprecatedModelFromList(id))
|
||||
.sort();
|
||||
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
|
||||
}
|
||||
|
||||
export function mapClaudeModelToGemini(input: string): string {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||
if (mappedModel) {
|
||||
return mappedModel;
|
||||
@@ -172,8 +174,10 @@ export function resolveModelRoute(
|
||||
anthropicMapping: Record<string, string>,
|
||||
): string {
|
||||
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
|
||||
if (dynamicForwarded) {
|
||||
logger.info(`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`);
|
||||
if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) {
|
||||
logger.info(
|
||||
`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`,
|
||||
);
|
||||
return dynamicForwarded;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,14 @@ i18n
|
||||
restart_note:
|
||||
'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: {
|
||||
title: 'Upstream Proxy',
|
||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||
@@ -258,6 +266,7 @@ i18n
|
||||
twoCol: '2 Columns',
|
||||
threeCol: '3 Columns',
|
||||
list: 'List',
|
||||
compact: 'Compact',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Add Google Account',
|
||||
@@ -289,6 +298,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 Ready',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
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: {
|
||||
title: 'Identity Profile',
|
||||
@@ -357,6 +373,32 @@ i18n
|
||||
clear: 'Clear Selection',
|
||||
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: {
|
||||
title: 'API Proxy',
|
||||
@@ -504,6 +546,14 @@ i18n
|
||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||
restart_note: '错误上报设置会在重启应用后生效。',
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
description: '配置账号事件的桌面提醒。',
|
||||
quotaAlert: '低配额提醒',
|
||||
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
|
||||
quotaThreshold: '提醒阈值',
|
||||
quotaThresholdDesc: '触发提醒的百分比阈值',
|
||||
},
|
||||
proxy: {
|
||||
title: '上游代理',
|
||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||
@@ -642,6 +692,7 @@ i18n
|
||||
twoCol: '2 列',
|
||||
threeCol: '3 列',
|
||||
list: '列表',
|
||||
compact: '紧凑',
|
||||
},
|
||||
authDialog: {
|
||||
title: '添加 Google 账号',
|
||||
@@ -672,6 +723,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 就绪',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: '代理',
|
||||
proxyPlaceholder: '例如 http://127.0.0.1:7890',
|
||||
proxySaved: '代理已保存',
|
||||
noProxy: '无代理',
|
||||
aiCredits: 'AI 积分',
|
||||
creditsExpiry: '{{date}} 过期',
|
||||
modelVisibility: '模型可见性',
|
||||
},
|
||||
identity: {
|
||||
title: '身份配置',
|
||||
@@ -740,6 +798,32 @@ i18n
|
||||
clear: '取消选择',
|
||||
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: {
|
||||
title: 'API 反代',
|
||||
@@ -889,6 +973,15 @@ i18n
|
||||
restart_note:
|
||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Уведомления',
|
||||
description: 'Настройте уведомления о событиях аккаунтов.',
|
||||
quotaAlert: 'Уведомления о низкой квоте',
|
||||
quotaAlertDesc:
|
||||
'Получайте уведомления, когда квота модели падает ниже установленного порога',
|
||||
quotaThreshold: 'Порог уведомления',
|
||||
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Верхнеуровневый прокси',
|
||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||
@@ -1028,6 +1121,7 @@ i18n
|
||||
twoCol: '2 колонки',
|
||||
threeCol: '3 колонки',
|
||||
list: 'Список',
|
||||
compact: 'Компактный',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Добавить Google Аккаунт',
|
||||
@@ -1059,6 +1153,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 готов',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: 'Прокси',
|
||||
proxyPlaceholder: 'напр. http://127.0.0.1:7890',
|
||||
proxySaved: 'Прокси сохранён',
|
||||
noProxy: 'Без прокси',
|
||||
aiCredits: 'AI Кредиты',
|
||||
creditsExpiry: 'истекает {{date}}',
|
||||
modelVisibility: 'Видимость моделей',
|
||||
},
|
||||
identity: {
|
||||
title: 'Профиль идентичности',
|
||||
@@ -1127,6 +1228,33 @@ i18n
|
||||
clear: 'Снять выделение',
|
||||
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: {
|
||||
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();
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second instance detected, focusing existing window');
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -27,6 +28,7 @@ function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local state for configuration editing
|
||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||
@@ -297,6 +299,86 @@ function SettingsPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* --- MODELS TAB --- */}
|
||||
|
||||
@@ -15,9 +15,23 @@ export class UpstreamRequestError extends Error {
|
||||
}) {
|
||||
super(params.message);
|
||||
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.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,
|
||||
} from './interfaces/request-interfaces';
|
||||
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 { resolveRequestUserAgent } from './request-user-agent';
|
||||
import { UpstreamRequestError } from './clients/upstream-error';
|
||||
@@ -679,7 +682,9 @@ export class ProxyService {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -435,6 +435,10 @@ export class TokenManagerService implements OnModuleInit {
|
||||
return candidates.find(([accountId]) => accountId === stickyBinding.accountId) ?? null;
|
||||
}
|
||||
|
||||
public resetSelectionState(): void {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
|
||||
private pickRoundRobinEntry(candidates: TokenEntry[]): TokenEntry | null {
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
@@ -507,10 +511,7 @@ export class TokenManagerService implements OnModuleInit {
|
||||
|
||||
const stickyBinding = this.getValidSessionBinding(sessionKey, now);
|
||||
if (stickyBinding && mode === 'cache-first') {
|
||||
const waitSec = this.rateLimitTracker.getRemainingWaitSeconds(
|
||||
stickyBinding.accountId,
|
||||
model,
|
||||
);
|
||||
const waitSec = this.rateLimitTracker.getRemainingWaitSeconds(stickyBinding.accountId, model);
|
||||
const waitMs = waitSec * 1000;
|
||||
const maxWaitMs = this.getMaxWaitDurationMs();
|
||||
if (waitMs > 0 && waitMs <= maxWaitMs) {
|
||||
@@ -586,18 +587,18 @@ export class TokenManagerService implements OnModuleInit {
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const legacyAccountId = this.predictLegacyAccountCandidateId(allTokens, sessionKey, now);
|
||||
const parityAccountId = this.predictParityAccountCandidateId(
|
||||
allTokens,
|
||||
sessionKey,
|
||||
model,
|
||||
now,
|
||||
);
|
||||
const parityAccountId = this.predictParityAccountCandidateId(allTokens, sessionKey, model, now);
|
||||
|
||||
this.updateShadowStats(legacyAccountId, parityAccountId);
|
||||
}
|
||||
|
||||
private updateShadowStats(legacyId: string | null, parityId: string | null): void {
|
||||
this.shadowComparisonCount++;
|
||||
|
||||
if (legacyAccountId !== parityAccountId) {
|
||||
if (legacyId !== parityId) {
|
||||
this.shadowMismatchCount++;
|
||||
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 { GoogleAPIService } from './GoogleAPIService';
|
||||
import { AutoSwitchService } from './AutoSwitchService';
|
||||
@@ -101,18 +102,33 @@ export class CloudMonitorService {
|
||||
let accessToken = account.token.access_token;
|
||||
if (account.token.expiry_timestamp < now + 600) {
|
||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
try {
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.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));
|
||||
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
|
||||
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();
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { ConfigManager } from '../ipc/config/manager';
|
||||
import { AuthServer } from '../ipc/cloud/authServer';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
@@ -22,7 +24,6 @@ const URLS = {
|
||||
|
||||
// Internal API masquerading
|
||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
|
||||
// Request timeout in milliseconds (30 seconds)
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
@@ -56,11 +57,22 @@ export interface UserInfo {
|
||||
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 {
|
||||
models: Record<string, ModelQuotaInfo>;
|
||||
model_forwarding_rules?: Record<string, string>;
|
||||
subscription_tier?: string;
|
||||
is_forbidden?: boolean;
|
||||
ai_credits?: { credits: number; expiryDate: string };
|
||||
}
|
||||
|
||||
export interface ModelQuotaInfo {
|
||||
@@ -219,17 +231,25 @@ function toModelForwardingRules(
|
||||
// --- Service Implementation ---
|
||||
|
||||
export class GoogleAPIService {
|
||||
private static getFetchOptions() {
|
||||
private static getFetchOptions(proxyUrl?: string) {
|
||||
if (proxyUrl && proxyUrl.length > 0) {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(proxyUrl),
|
||||
};
|
||||
}
|
||||
try {
|
||||
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 {
|
||||
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback or log if config load fails (shouldn't happen usually)
|
||||
logger.warn('[GoogleAPIService] Failed to load proxy config', e);
|
||||
logger.error('[GoogleAPIService] Proxy configuration error', e);
|
||||
throw e;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -242,13 +262,13 @@ export class GoogleAPIService {
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
].join(' ');
|
||||
|
||||
const redirectUri = AuthServer.getRedirectUri();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: scopes,
|
||||
access_type: 'offline',
|
||||
@@ -262,12 +282,14 @@ export class GoogleAPIService {
|
||||
/**
|
||||
* 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({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
@@ -276,7 +298,7 @@ export class GoogleAPIService {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
@@ -299,7 +321,7 @@ export class GoogleAPIService {
|
||||
/**
|
||||
* 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({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
@@ -312,7 +334,7 @@ export class GoogleAPIService {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
@@ -337,11 +359,11 @@ export class GoogleAPIService {
|
||||
/**
|
||||
* 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, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
@@ -358,16 +380,26 @@ export class GoogleAPIService {
|
||||
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 = {
|
||||
metadata: { ideType: 'ANTIGRAVITY' },
|
||||
};
|
||||
|
||||
let projectId: string | undefined;
|
||||
let subscriptionTier: string | undefined;
|
||||
let lastError: any;
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
try {
|
||||
@@ -376,7 +408,7 @@ export class GoogleAPIService {
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify(body),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -386,29 +418,62 @@ export class GoogleAPIService {
|
||||
}
|
||||
subscriptionTier = resolveSubscriptionTier(data);
|
||||
break;
|
||||
} else {
|
||||
lastError = new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn(`[GoogleAPIService] Failed to fetch project ID (Attempt ${i + 1}):`, error);
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId && !subscriptionTier) {
|
||||
throw lastError || new Error('Failed to fetch project context after multiple attempts.');
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
subscriptionTier,
|
||||
};
|
||||
}
|
||||
|
||||
public static async fetchProjectId(accessToken: string): Promise<string | null> {
|
||||
const context = await this.fetchProjectContext(accessToken);
|
||||
public static async fetchProjectId(
|
||||
accessToken: string,
|
||||
proxyUrl?: string,
|
||||
): Promise<string | null> {
|
||||
const context = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||
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.
|
||||
*/
|
||||
static async fetchQuota(accessToken: string): Promise<QuotaData> {
|
||||
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken);
|
||||
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
|
||||
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (projectId) {
|
||||
@@ -417,7 +482,7 @@ export class GoogleAPIService {
|
||||
|
||||
const maxRetries = 3;
|
||||
let lastError: Error | null = null;
|
||||
const fetchOptions = this.getFetchOptions();
|
||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
@@ -446,7 +511,8 @@ export class GoogleAPIService {
|
||||
);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await sleep(1000);
|
||||
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||
await sleep(backoffMs);
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(errorMsg);
|
||||
@@ -474,14 +540,23 @@ export class GoogleAPIService {
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof Error) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
}
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Abort retries for auth errors
|
||||
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
);
|
||||
(service as any).currentIndex = 0;
|
||||
service.resetSelectionState();
|
||||
const rollbackSelected = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
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 () => {
|
||||
@@ -98,9 +100,9 @@ describe('Parity rollout guard replay', () => {
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
(service as any).currentIndex = 0;
|
||||
service.resetSelectionState();
|
||||
const blockedSelection = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
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 assistantMessage = callArg.messages.find((message: { role: string }) => message.role === 'assistant');
|
||||
const toolMessage = callArg.messages.find((message: { role: string }) => message.role === 'tool');
|
||||
const assistantMessage = callArg.messages.find(
|
||||
(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(toolMessage?.name).toBe('builtin_web_search');
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface CloudQuotaData {
|
||||
subscription_tier?: string;
|
||||
is_forbidden?: boolean;
|
||||
isForbidden?: boolean;
|
||||
ai_credits?: { credits: number; expiryDate: string };
|
||||
}
|
||||
|
||||
export interface CloudAccount {
|
||||
@@ -53,6 +54,7 @@ export interface CloudAccount {
|
||||
last_used: number; // Unix timestamp
|
||||
status?: 'active' | 'rate_limited' | 'expired';
|
||||
is_active?: boolean;
|
||||
proxy_url?: string;
|
||||
}
|
||||
|
||||
// Zod Schemas
|
||||
@@ -87,6 +89,7 @@ export const CloudQuotaDataSchema = z.object({
|
||||
subscription_tier: z.string().optional(),
|
||||
is_forbidden: z.boolean().optional(),
|
||||
isForbidden: z.boolean().optional(),
|
||||
ai_credits: z.object({ credits: z.number(), expiryDate: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const CloudAccountSchema = z.object({
|
||||
@@ -103,4 +106,25 @@ export const CloudAccountSchema = z.object({
|
||||
last_used: z.number(),
|
||||
status: z.enum(['active', 'rate_limited', 'expired']).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
|
||||
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -50,7 +55,7 @@ export type AppConfig = z.infer<typeof AppConfigSchema>;
|
||||
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
language: 'zh-CN',
|
||||
theme: 'system',
|
||||
theme: 'dark',
|
||||
auto_refresh: false,
|
||||
refresh_interval: 15,
|
||||
auto_sync: false,
|
||||
@@ -62,6 +67,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
model_visibility: {}, // Model visibility preferences
|
||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||
grid_layout: 'auto' as const, // Account card grid layout
|
||||
account_sort: 'recently-used' as const,
|
||||
quota_alert_enabled: false,
|
||||
quota_alert_threshold: 20,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
port: 8045,
|
||||
|
||||
@@ -187,9 +187,8 @@ export function groupModelsByProvider(
|
||||
|
||||
// Sort: known providers first (claude-, gemini-), then others
|
||||
const providerDisplayOrder = keys(PROVIDER_REGISTRY);
|
||||
const sortedProviders = sortBy(
|
||||
providerStatsList,
|
||||
(providerStats) => providerDisplayOrder.indexOf(providerStats.providerKey),
|
||||
const sortedProviders = sortBy(providerStatsList, (providerStats) =>
|
||||
providerDisplayOrder.indexOf(providerStats.providerKey),
|
||||
);
|
||||
|
||||
const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
const HIGH_QUOTA_PERCENTAGE = 80;
|
||||
const MEDIUM_QUOTA_PERCENTAGE = 20;
|
||||
@@ -83,3 +84,30 @@ export function formatResetTimeTitle(
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
+12
-13
@@ -9,19 +9,18 @@ export default defineConfig(({ mode }) => {
|
||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||
|
||||
return {
|
||||
plugins:
|
||||
shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
plugins: shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
define: {
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||
},
|
||||
|
||||
+12
-13
@@ -8,19 +8,18 @@ export default defineConfig(({ mode }) => {
|
||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||
|
||||
return {
|
||||
plugins:
|
||||
shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
plugins: shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
|
||||
@@ -32,7 +32,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
]
|
||||
: []),
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
|
||||
Reference in new issue
Block a user