chore: commit current working state with config updates, test fixes, and continuity ledgers
This commit is contained in:
1 parent
afe8859f16
commit
04014b8c7d
64 files changed
+5412
-4972
No files matched your search
+7
-9
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { syncWithLocalTheme } from './actions/theme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,12 +29,10 @@ const queryClient = new QueryClient();
|
||||
|
||||
const root = createRoot(document.getElementById('app')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 659 B |
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
@@ -17,7 +17,6 @@ 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, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
@@ -266,9 +266,7 @@ export function CloudAccountList() {
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Registering Google auth code IPC listener');
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
setAuthCode(code);
|
||||
});
|
||||
@@ -286,7 +284,6 @@ export function CloudAccountList() {
|
||||
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
|
||||
})
|
||||
) {
|
||||
console.log('[OAuth] Auto-submitting Google auth code');
|
||||
submitAuthCode(authCode);
|
||||
}
|
||||
}, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]);
|
||||
@@ -308,7 +305,6 @@ export function CloudAccountList() {
|
||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
|
||||
@@ -1,39 +1,61 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Eye, Zap, Code, Mic, Image } from 'lucide-react';
|
||||
import { Loader2, Eye, Zap, Code, Brain, Star } from 'lucide-react';
|
||||
|
||||
interface ModelCapability {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
audio: boolean;
|
||||
imageGeneration: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CAPABILITY_ICONS = {
|
||||
vision: Eye,
|
||||
thinking: Brain,
|
||||
streaming: Zap,
|
||||
jsonMode: Code,
|
||||
audio: Mic,
|
||||
imageGeneration: Image,
|
||||
recommended: Star,
|
||||
} as const;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
vision: 'Vision',
|
||||
thinking: 'Thinking',
|
||||
streaming: 'Streaming',
|
||||
jsonMode: 'JSON Mode',
|
||||
audio: 'Audio',
|
||||
imageGeneration: 'Image Generation',
|
||||
recommended: 'Recommended',
|
||||
} as const;
|
||||
|
||||
function getProvider(modelId: string): string {
|
||||
const id = modelId.toLowerCase();
|
||||
if (id.startsWith('gemini')) return 'Google';
|
||||
if (id.startsWith('claude')) return 'Anthropic';
|
||||
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('text-')) return 'OpenAI';
|
||||
if (id.startsWith('deepseek')) return 'DeepSeek';
|
||||
if (id.startsWith('meta-llama') || id.startsWith('llama')) return 'Meta';
|
||||
if (id.startsWith('mistral') || id.startsWith('mixtral')) return 'Mistral';
|
||||
if (id.startsWith('qwen')) return 'Alibaba';
|
||||
if (id.startsWith('x-ai') || id.startsWith('grok')) return 'xAI';
|
||||
if (id.startsWith('sonar')) return 'Perplexity';
|
||||
if (id.startsWith('command')) return 'Cohere';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
export const CapabilitiesTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -47,12 +69,23 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
? (capabilitiesData.data as ModelCapability[])
|
||||
: [];
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
return groupBy(models, (m) => getProvider(m.id));
|
||||
}, [models]);
|
||||
|
||||
// Sort providers alphabetically, but keep 'Other' at the bottom
|
||||
const sortedProviders = Object.keys(groupedModels).sort((a, b) => {
|
||||
if (a === 'Other') return 1;
|
||||
if (b === 'Other') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{t('proxy.advanced.capabilities.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.capabilities.description')}</p>
|
||||
<h3 className="text-lg font-medium">{t('proxy.advanced.capabilities.title', 'Model Capabilities')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}</p>
|
||||
</div>
|
||||
|
||||
{/* Capabilities Grid */}
|
||||
@@ -61,57 +94,93 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{model.id}</CardTitle>
|
||||
<Badge variant="outline">{model.object}</Badge>
|
||||
</div>
|
||||
<CardDescription>Model capabilities and features</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
const label = CAPABILITY_LABELS[iconKey];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 ${
|
||||
supported
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 ${
|
||||
supported ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
supported
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{supported && (
|
||||
<Badge variant="default" className="ml-auto h-5 bg-green-600 text-[10px]">
|
||||
✓
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-y-8">
|
||||
{sortedProviders.map((provider) => (
|
||||
<div key={provider} className="space-y-4">
|
||||
<h4 className="text-md font-semibold text-foreground border-b pb-2">
|
||||
{provider}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{groupedModels[provider].map((model) => (
|
||||
<Card key={model.id} className="flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base truncate pr-2" title={model.displayName || model.id}>
|
||||
{model.displayName || model.id}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="shrink-0">{model.object}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardDescription className="text-xs font-mono text-muted-foreground truncate" title={model.id}>
|
||||
{model.id}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
const label = CAPABILITY_LABELS[iconKey];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 ${
|
||||
supported
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900/50'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
supported ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[11px] font-medium truncate ${
|
||||
supported
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{Object.values(model.limits).some((v) => v != null) && (
|
||||
<div className="space-y-2 pt-2 border-t mt-auto">
|
||||
<p className="text-xs font-medium text-muted-foreground">Limits</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{model.limits.maxTokens != null && (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
||||
Context: {model.limits.maxTokens.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
{model.limits.maxOutputTokens != null && (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
||||
Output: {model.limits.maxOutputTokens.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
{model.limits.thinkingBudget != null && (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 border-blue-200 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-800 dark:text-blue-300">
|
||||
Think: {model.limits.thinkingBudget.toLocaleString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{models.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground border rounded-lg border-dashed">
|
||||
No models found. Check your provider configurations.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
|
||||
|
||||
@@ -22,6 +22,15 @@ interface TrafficLogTableProps {
|
||||
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
}
|
||||
|
||||
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
||||
@@ -64,15 +73,6 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
||||
});
|
||||
}, [logs, sortKey, sortDir]);
|
||||
|
||||
const SortIcon = ({ column }: { column: SortKey }) => {
|
||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -99,42 +99,42 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
||||
onClick={() => toggleSort('timestamp')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.timestamp')}
|
||||
<SortIcon column="timestamp" />
|
||||
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('direction')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.direction')}
|
||||
<SortIcon column="direction" />
|
||||
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('endpoint')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.endpoint')}
|
||||
<SortIcon column="endpoint" />
|
||||
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('method')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.method')}
|
||||
<SortIcon column="method" />
|
||||
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('status')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.status')}
|
||||
<SortIcon column="status" />
|
||||
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 font-medium select-none"
|
||||
onClick={() => toggleSort('duration')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.duration')}
|
||||
<SortIcon column="duration" />
|
||||
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useSetAccountProxy() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: unknown) => {
|
||||
console.error('[Mutation] setAccountProxy failed:', error);
|
||||
},
|
||||
});
|
||||
@@ -176,7 +176,7 @@ export function useImportCloudAccounts() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: unknown) => {
|
||||
console.error('[Mutation] importCloudAccounts failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -393,8 +393,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await clearAccountStatus(account);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
} catch (error: unknown) {
|
||||
if (extractErrorMessage(error) === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
@@ -462,7 +462,7 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
} else if (extractErrorMessage(error) === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
@@ -574,9 +574,9 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
notifyTrayUpdate(account);
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Failed to switch cloud account', err);
|
||||
throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
|
||||
throw new Error(`Switch failed: ${extractErrorMessage(err) || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
|
||||
// Trigger an immediate check if enabled
|
||||
if (enabled) {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
CloudMonitorService.poll().catch((err: any) =>
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
@@ -834,8 +834,8 @@ export async function importCloudAccounts(
|
||||
await CloudAccountRepo.addAccount(newAccount);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${extractErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -203,8 +203,8 @@ export const cloudRouter = os.router({
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
@@ -273,8 +273,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -297,8 +297,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
+29
-505
@@ -1,70 +1,10 @@
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { getGeminiNexusExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const PROCESS_STARTUP_TIMEOUT_MS = 6000;
|
||||
const PROCESS_STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const LINUX_GPU_SAFE_LAUNCH_ARGS = ['--disable-gpu', '--disable-gpu-compositing'] as const;
|
||||
|
||||
/**
|
||||
* Helper process name patterns to exclude (Electron helper processes)
|
||||
*/
|
||||
const HELPER_PATTERNS = [
|
||||
'helper',
|
||||
'plugin',
|
||||
'renderer',
|
||||
'gpu',
|
||||
'crashpad',
|
||||
'utility',
|
||||
'audio',
|
||||
'sandbox',
|
||||
'language_server',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a process is a helper/auxiliary process that should be excluded.
|
||||
* @param name Process name (lowercase)
|
||||
* @param cmd Process command line (lowercase)
|
||||
* @returns True if the process is a helper process
|
||||
*/
|
||||
function isHelperProcess(name: string, cmd: string): boolean {
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
// Check for --type= argument (Electron helper process indicator)
|
||||
if (cmdLower.includes('--type=')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for helper patterns in process name
|
||||
for (const pattern of HELPER_PATTERNS) {
|
||||
if (nameLower.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for crashpad in path
|
||||
if (cmdLower.includes('crashpad')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPgrepNoMatchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('geminiNexus');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
@@ -72,122 +12,8 @@ function isPgrepNoMatchError(error: unknown): boolean {
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Gemini Nexus processes
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Gemini Nexus', 'geminiNexus'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
let sawNoMatch = false;
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
try {
|
||||
const matches = await findProcess('name', searchName, false);
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
sawNoMatch = true;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
for (const proc of allMatches) {
|
||||
if (isNumber(proc.pid)) {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Gemini Nexus process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'GeminiNexus/geminiNexus'`);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
if (proc.pid === currentPid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = proc.name?.toLowerCase() || '';
|
||||
const cmd = proc.cmd?.toLowerCase() || '';
|
||||
|
||||
// Skip manager process
|
||||
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip helper processes
|
||||
if (isHelperProcess(name, cmd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Check for Gemini Nexus.app in path
|
||||
if (cmd.includes('gemininexus.app')) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Also check if the process name is exactly 'gemininexus' (main process)
|
||||
if (name === 'gemininexus' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Check for gemininexus.exe
|
||||
if (name === 'gemininexus.exe' || name === 'gemininexus') {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
if (nameLower === 'electron') {
|
||||
// Stricter check for AUR/Electron wrapper:
|
||||
// Must include gemininexus in command line but NOT manager or tools
|
||||
const isGeminiNexusApp =
|
||||
(cmdLower.includes('/gemininexus') ||
|
||||
cmdLower.includes(' gemininexus') ||
|
||||
cmdLower.endsWith('gemininexus')) &&
|
||||
!cmdLower.includes('manager') &&
|
||||
!cmdLower.includes('tools');
|
||||
|
||||
if (isGeminiNexusApp) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('gemininexus') || cmd.includes('/gemininexus')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Error checking process status with find-process:', error);
|
||||
return false;
|
||||
}
|
||||
// Check the embedded NestJS proxy server status instead of an external process
|
||||
return isNestServerRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,290 +21,26 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Closing Gemini Nexus...');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
// Stage 1: Graceful Shutdown (Platform specific)
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use AppleScript to quit gracefully
|
||||
try {
|
||||
logger.info('Attempting graceful exit via AppleScript...');
|
||||
execSync('osascript -e \'tell application "Gemini Nexus" to quit\'', {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
// Wait for a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
} catch {
|
||||
logger.warn('AppleScript exit failed, proceeding to next stage');
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use taskkill /IM (without /F) for graceful close
|
||||
try {
|
||||
logger.info('Attempting graceful exit via taskkill...');
|
||||
// /T = Tree (child processes), /IM = Image Name
|
||||
// We do not wait long here.
|
||||
execSync('taskkill /IM "GeminiNexus.exe" /T', {
|
||||
stdio: 'ignore',
|
||||
timeout: 2000,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch {
|
||||
// Ignore failure, we play hard next.
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2 & 3: Find and Kill remaining processes
|
||||
// We use a more aggressive approach here but try to avoid killing ourselves
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Helper to list processes
|
||||
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
|
||||
try {
|
||||
let output = '';
|
||||
if (platform === 'win32') {
|
||||
const psCommand = (cmdlet: string) =>
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'GeminiNexus%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
|
||||
try {
|
||||
output = execSync(psCommand('Get-CimInstance'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch (e) {
|
||||
// CIM failed (likely older OS), try WMI
|
||||
try {
|
||||
output = execSync(psCommand('Get-WmiObject'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
} catch (innerE) {
|
||||
// Both failed, throw original or log? Throwing lets the outer catch handle it (returning empty list)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unix/Linux/macOS
|
||||
output = execSync('ps -A -o pid,comm,args', {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const processList: { pid: number; name: string; cmd: string }[] = [];
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Parse CSV Output
|
||||
const lines = output.trim().split(/\r?\n/);
|
||||
// First line is headers "ProcessId","Name","CommandLine"
|
||||
// We start from index 1
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regex to match CSV fields: "val1","val2","val3"
|
||||
const match = line.match(/^"(\d+)","(.*?)","(.*?)"$/);
|
||||
|
||||
if (match) {
|
||||
const pid = parseInt(match[1]);
|
||||
const name = match[2];
|
||||
const cmdLine = match[3];
|
||||
|
||||
processList.push({ pid, name, cmd: cmdLine || name });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
if (rest.includes('Gemini Nexus') || rest.includes('geminiNexus')) {
|
||||
processList.push({ pid, name: parts[1], cmd: rest });
|
||||
}
|
||||
}
|
||||
}
|
||||
return processList;
|
||||
} catch (e) {
|
||||
logger.error('Failed to list processes', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const targetProcessList = getProcesses().filter((p) => {
|
||||
// Exclude self
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Gemini Nexus or gemini-nexus)
|
||||
if (p.cmd.includes('Gemini Nexus') || p.cmd.includes('gemini-nexus')) {
|
||||
return false;
|
||||
}
|
||||
// Match Gemini Nexus (but not manager)
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('GeminiNexus.exe') ||
|
||||
(p.cmd.includes('geminiNexus') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
// Explicit !manager check for Linux/macOS to be defensive
|
||||
return (
|
||||
(p.cmd.includes('Gemini Nexus') || p.cmd.includes('geminiNexus')) &&
|
||||
!p.cmd.includes('manager')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (targetProcessList.length === 0) {
|
||||
logger.info('No Gemini Nexus processes found running.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${targetProcessList.length} remaining Gemini Nexus processes. Killing...`);
|
||||
|
||||
for (const p of targetProcessList) {
|
||||
try {
|
||||
process.kill(p.pid, 'SIGKILL'); // Force kill as final step
|
||||
} catch {
|
||||
// Ignore if already dead
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Gemini Nexus', error);
|
||||
// Fallback to simple kill if everything fails
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
execSync('taskkill /F /IM "GeminiNexus.exe" /T', { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync('pkill -9 -f Gemini Nexus', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
logger.info('Stopping Gemini Nexus proxy server...');
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* @param timeoutMs {number} The timeout in milliseconds.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
* @param timeoutMs {number} Maximum time to wait in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
export async function _waitForProcessExit(
|
||||
timeoutMs: number,
|
||||
pollInterval = 100, // Make it configurable, but keep fast 100ms default
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (!(await isProcessRunning())) {
|
||||
return;
|
||||
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (isNestServerRunning()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Gemini Nexus process did not exit within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a URI protocol.
|
||||
* @param uri {string} The URI to open.
|
||||
* @returns {Promise<boolean>} True if the URI was opened successfully, false otherwise.
|
||||
*/
|
||||
async function openUri(uri: string): Promise<boolean> {
|
||||
const platform = process.platform;
|
||||
const wsl = isWsl();
|
||||
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
// macOS: use open command
|
||||
await execAsync(`open "${uri}"`);
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: use start command
|
||||
await execAsync(`start "" "${uri}"`);
|
||||
} else if (wsl) {
|
||||
// WSL: use cmd.exe to open URI
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${uri}"`);
|
||||
} else {
|
||||
// Linux: use xdg-open
|
||||
await execAsync(`xdg-open "${uri}"`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to open URI', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGeminiNexusStartup(
|
||||
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
|
||||
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isProcessRunning()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enableLinuxGpuRaw = process.env.ANTIGRAVITY_MANAGER_ENABLE_LINUX_GPU?.trim().toLowerCase();
|
||||
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
|
||||
}
|
||||
|
||||
async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Gemini Nexus`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWsl()) {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
|
||||
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
|
||||
if (launchArgs.length > 0) {
|
||||
logger.info(`Linux launch with GPU-safe args: ${launchArgs.join(' ')}`);
|
||||
}
|
||||
|
||||
const child = spawn(execPath, launchArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,56 +48,18 @@ async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startGeminiNexus(useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus...');
|
||||
export async function startGeminiNexus(_useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus proxy server...');
|
||||
|
||||
if (await isProcessRunning()) {
|
||||
logger.info('Gemini Nexus is already running');
|
||||
if (isNestServerRunning()) {
|
||||
logger.info('Gemini Nexus proxy server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUri) {
|
||||
logger.info('Using URI protocol to start...');
|
||||
const uri = 'geminiNexus://oauth-success';
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Gemini Nexus URI launch command sent');
|
||||
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await waitForGeminiNexusStartup()) {
|
||||
logger.info('Gemini Nexus process detected after URI launch');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'URI launch did not keep Gemini Nexus running on Linux. Falling back to executable launch.',
|
||||
);
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to executable path
|
||||
logger.info('Using executable path to start...');
|
||||
const execPath = getGeminiNexusExecutablePath();
|
||||
|
||||
try {
|
||||
await startGeminiNexusByExecutable(execPath);
|
||||
logger.info('Gemini Nexus launch command sent');
|
||||
|
||||
if (process.platform === 'linux' && !isWsl()) {
|
||||
const started = await waitForGeminiNexusStartup();
|
||||
if (!started) {
|
||||
logger.warn(
|
||||
'Gemini Nexus launch command completed, but process startup could not be confirmed on Linux.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Gemini Nexus via executable', error);
|
||||
throw error;
|
||||
const config = ConfigManager.loadConfig();
|
||||
if (config.proxy) {
|
||||
await bootstrapNestServer(config.proxy);
|
||||
} else {
|
||||
throw new Error('Proxy configuration not found');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { proxyAdvancedRegistry } from './service-registry';
|
||||
import { getServiceOrThrow } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
@@ -23,6 +24,13 @@ export interface TrafficLogEntryParsed {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeServiceError(error: unknown): string {
|
||||
if (error instanceof Error && error.message.includes('is not registered')) {
|
||||
return 'Proxy advanced service not initialized';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function getRecentTrafficLogs(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
@@ -104,24 +112,13 @@ export async function getParityCounters(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = proxyAdvancedRegistry.tokenManager;
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalRequests: 0,
|
||||
matchedRequests: 0,
|
||||
parityViolations: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,16 +128,7 @@ export async function getCircuitBreakerStatus(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = proxyAdvancedRegistry.tokenManager;
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states: {},
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
@@ -160,7 +148,7 @@ export async function getCircuitBreakerStatus(): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,21 +166,7 @@ export async function getProxyMetrics(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = proxyAdvancedRegistry.proxyMetrics;
|
||||
if (!proxyMetrics) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: 0,
|
||||
totalRequests: 0,
|
||||
activeConnections: 0,
|
||||
avgLatency: 0,
|
||||
errorRate: 0,
|
||||
requestsPerMinute: 0,
|
||||
cacheHitRate: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
@@ -209,25 +183,19 @@ export async function getProxyMetrics(): Promise<{
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy metrics', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
|
||||
if (!proxyReplay) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,50 +203,77 @@ export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
|
||||
if (!proxyReplay) {
|
||||
return { success: false, error: 'Replay service not available' };
|
||||
}
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelCapabilityEntry {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; object: string; capabilities: { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean } }[];
|
||||
data?: ModelCapabilityEntry[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const capabilitiesMap: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {
|
||||
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'gemini-3.1-pro-low': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'gemini-3.1-pro-high': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-sonnet-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-opus-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
};
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
|
||||
const models = [
|
||||
{ id: 'gemini-3-flash', object: 'model' },
|
||||
{ id: 'gemini-3.1-pro-low', object: 'model' },
|
||||
{ id: 'gemini-3.1-pro-high', object: 'model' },
|
||||
{ id: 'claude-sonnet-4-6-thinking', object: 'model' },
|
||||
{ id: 'claude-opus-4-6-thinking', object: 'model' },
|
||||
];
|
||||
const modelMap = new Map<string, ModelCapabilityEntry>();
|
||||
|
||||
const data = models.map((m) => {
|
||||
const baseId = Object.keys(capabilitiesMap).find((key) => m.id.includes(key)) ?? 'gemini-3-flash';
|
||||
return {
|
||||
id: m.id,
|
||||
object: m.object,
|
||||
capabilities: capabilitiesMap[baseId] ?? capabilitiesMap['gemini-3-flash'],
|
||||
};
|
||||
});
|
||||
for (const account of accounts) {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
|
||||
if (modelMap.has(modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
modelMap.set(modelId, {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
displayName: modelInfo.display_name,
|
||||
capabilities: {
|
||||
vision: modelInfo.supports_images ?? false,
|
||||
thinking: modelInfo.supports_thinking ?? false,
|
||||
streaming: true,
|
||||
jsonMode: true,
|
||||
recommended: modelInfo.recommended ?? false,
|
||||
},
|
||||
limits: {
|
||||
maxTokens: modelInfo.max_tokens,
|
||||
maxOutputTokens: modelInfo.max_output_tokens,
|
||||
thinkingBudget: modelInfo.thinking_budget,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model capabilities', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -354,10 +349,7 @@ export async function generateIdeConfig(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = proxyAdvancedRegistry.proxyIdeConfig;
|
||||
if (!proxyIdeConfig) {
|
||||
return { success: false, error: 'IDE config service not available' };
|
||||
}
|
||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
@@ -375,6 +367,6 @@ export async function generateIdeConfig(
|
||||
data: mapIdeConfigResult(result),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
@@ -130,12 +130,18 @@ const ModelCapabilitiesResponseSchema = z.object({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
capabilities: z.object({
|
||||
vision: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
streaming: z.boolean(),
|
||||
jsonMode: z.boolean(),
|
||||
audio: z.boolean(),
|
||||
imageGeneration: z.boolean(),
|
||||
recommended: z.boolean(),
|
||||
}),
|
||||
limits: z.object({
|
||||
maxTokens: z.number().optional(),
|
||||
maxOutputTokens: z.number().optional(),
|
||||
thinkingBudget: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -29,3 +29,11 @@ export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegist
|
||||
): void {
|
||||
proxyAdvancedRegistry[key] = instance;
|
||||
}
|
||||
|
||||
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
): NonNullable<ProxyAdvancedRegistry[K]> {
|
||||
const svc = proxyAdvancedRegistry[key];
|
||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
return svc;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import iconPng from '@/assets/icon.png';
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -85,7 +86,7 @@ export const MainLayout: React.FC = () => {
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
||||
<img
|
||||
src="/src/assets/icon.png"
|
||||
src={iconPng}
|
||||
alt="Gemini Nexus Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
|
||||
@@ -61,8 +61,8 @@ export class QuotaService {
|
||||
} else {
|
||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
return [undefined, undefined];
|
||||
@@ -138,7 +138,7 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
return { quotaData, projectId };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
let shouldFallback = true;
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -174,7 +174,7 @@ export class QuotaService {
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = !isNumber(status);
|
||||
} else {
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ export class QuotaService {
|
||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} else {
|
||||
throw lastError ?? new Error(`Quota query failed: ${error.message}`);
|
||||
throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -235,7 +235,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
|
||||
// Use process.cwd() in dev to find the icon reliably
|
||||
icon: inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/icon.png')
|
||||
: path.join(__dirname, '../assets/icon.png'),
|
||||
: path.join(process.resourcesPath, 'assets', 'icon.png'),
|
||||
});
|
||||
globalMainWindow = mainWindow;
|
||||
logger.info('createWindow: BrowserWindow instance created');
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Observable } from 'rxjs';
|
||||
|
||||
import { ProxyGuard } from './proxy.guard';
|
||||
import { ProxyService } from './proxy.service';
|
||||
import { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
||||
import type { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import { getAllDynamicModels } from '../../../lib/geminiNexus/ModelMapping';
|
||||
import { TokenManagerService } from './token-manager.service';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
|
||||
import { ProxyService } from './proxy.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
AnthropicChatRequest,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { ProxyService, ProxyRequestContext, ProxyConfigOverride } from './proxy.
|
||||
import { ProxyReplayService } from './proxy-replay.service';
|
||||
import { Observable, interval, map } from 'rxjs';
|
||||
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||
import {
|
||||
import type {
|
||||
OpenAIChatRequest,
|
||||
AnthropicChatRequest,
|
||||
OpenAIChatResponse,
|
||||
@@ -223,15 +223,7 @@ export class ProxyController {
|
||||
this.tokenManager?.getAllCollectedModels(),
|
||||
);
|
||||
|
||||
const capabilitiesMap: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {
|
||||
'gemini-3-pro': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: true },
|
||||
'gemini-3-pro-image': { vision: true, streaming: false, jsonMode: false, audio: false, imageGeneration: true },
|
||||
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'gemini-3-flash-lite': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-3-5-sonnet': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-3-5-haiku': { vision: false, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-3-opus': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
};
|
||||
const capabilitiesMap = this.buildCapabilitiesMap();
|
||||
|
||||
const data = modelIds.map((id) => {
|
||||
const baseId = Object.keys(capabilitiesMap).find((key) => id.includes(key)) ?? 'gemini-3-flash';
|
||||
@@ -265,6 +257,34 @@ export class ProxyController {
|
||||
}
|
||||
}
|
||||
|
||||
private buildCapabilitiesMap(): Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> {
|
||||
const allTokens = this.tokenManager?.getAllTokens() ?? [];
|
||||
const map: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {};
|
||||
|
||||
for (const [, tokenData] of allTokens) {
|
||||
for (const [modelId, modelInfo] of Object.entries(tokenData.quota?.models ?? {})) {
|
||||
if (map[modelId]) continue;
|
||||
const baseId = modelId.toLowerCase();
|
||||
const isProImage = baseId.includes('gemini-3-pro') && baseId.includes('image');
|
||||
map[baseId] = {
|
||||
vision: modelInfo.supports_images ?? false,
|
||||
streaming: isProImage ? false : true,
|
||||
jsonMode: isProImage ? false : true,
|
||||
audio: false,
|
||||
imageGeneration: baseId.includes('gemini-3-pro') && !baseId.includes('flash'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(map).length === 0) {
|
||||
return {
|
||||
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
@Post('batch/completions')
|
||||
async batchCompletions(
|
||||
@Body() body: { requests: OpenAIChatRequest[] },
|
||||
|
||||
@@ -180,6 +180,12 @@ export class RateLimitTracker {
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
for (const [key, entry] of this.failureCounts.entries()) {
|
||||
if (entry.lastFailureMs + FAILURE_COUNT_EXPIRY_MS <= now) {
|
||||
this.failureCounts.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { isString } from 'lodash-es';
|
||||
import { version } from '../../../../package.json';
|
||||
import { getGeminiNexusVersion } from '../../../utils/geminiNexusVersion';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const REMOTE_VERSION_URL = 'https://antigravity-auto-updater-974169037036.us-central1.run.app';
|
||||
const CHANGELOG_URL = 'https://antigravity.google/changelog';
|
||||
export const FALLBACK_VERSION = '1.22.2';
|
||||
export const FALLBACK_VERSION = version;
|
||||
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
|
||||
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RateLimitReason, RateLimitTracker } from './rate-limit-tracker';
|
||||
import { updateDynamicForwardingRules } from '../../../lib/geminiNexus/ModelMapping';
|
||||
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
|
||||
|
||||
interface TokenData {
|
||||
export interface TokenData {
|
||||
email: string;
|
||||
account_id: string;
|
||||
access_token: string;
|
||||
@@ -36,7 +36,7 @@ interface GetNextTokenOptions {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
type TokenEntry = [string, TokenData];
|
||||
export type TokenEntry = [string, TokenData];
|
||||
|
||||
interface CircuitBreakerEntry {
|
||||
failureCount: number;
|
||||
@@ -1158,6 +1158,10 @@ export class TokenManagerService implements OnModuleInit {
|
||||
return GoogleAPIService.normalizeRefreshedOAuthClientKey(currentToken, refreshedClientKey);
|
||||
}
|
||||
|
||||
getAllTokens(): TokenEntry[] {
|
||||
return Array.from(this.tokens.entries());
|
||||
}
|
||||
|
||||
getAllCollectedModels(): Set<string> {
|
||||
const allModels = new Set<string>();
|
||||
for (const tokenData of this.tokens.values()) {
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
getGeminiNexusDbPaths: () => ['mock-db'],
|
||||
getCloudAccountsDbPath: () => 'mock-cloud-db',
|
||||
getAgentDir: vi.fn(() => '/mock/agent/dir'),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
|
||||
+143
-79
@@ -1,26 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getDatabaseConnection,
|
||||
getCurrentAccountInfo,
|
||||
backupAccount,
|
||||
restoreAccount,
|
||||
} from '../../ipc/database/handler';
|
||||
import Database from 'better-sqlite3';
|
||||
import { openDrizzleConnection } from '../../ipc/database/dbConnection';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Mock paths to use a temp DB
|
||||
const tempDbPath = path.join(process.cwd(), 'temp_test.vscdb');
|
||||
const mockClose = vi.fn();
|
||||
const mockRun = vi.fn();
|
||||
|
||||
vi.mock('../../utils/paths', async () => {
|
||||
const path = await import('path');
|
||||
const tempDbPath = path.join(process.cwd(), 'temp_test.vscdb');
|
||||
return {
|
||||
getGeminiNexusDbPath: () => tempDbPath,
|
||||
getGeminiNexusDbPaths: () => [tempDbPath],
|
||||
getAgentDir: () => path.join(process.cwd(), 'temp_test_agent'),
|
||||
function createMockOrm(rows: Record<string, unknown>[] = []) {
|
||||
let callIndex = 0;
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(() => {
|
||||
const row = rows[callIndex] ?? null;
|
||||
callIndex++;
|
||||
return row ? [row] : [];
|
||||
}),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockReturnThis(),
|
||||
run: mockRun,
|
||||
};
|
||||
});
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(chain),
|
||||
insert: vi.fn().mockReturnValue(chain),
|
||||
transaction: vi.fn().mockImplementation((fn: (...args: any[]) => any) => {
|
||||
const tx = {
|
||||
insert: vi.fn().mockReturnValue(chain),
|
||||
};
|
||||
return fn(tx);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
logger: {
|
||||
@@ -31,85 +47,133 @@ vi.mock('../../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// NOTE: These tests are skipped because better-sqlite3 is compiled for Electron, not Node.js.
|
||||
// Run these tests manually in an Electron environment.
|
||||
describe.skip('Database Handler', () => {
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/test.vscdb']),
|
||||
}));
|
||||
|
||||
vi.mock('../../ipc/database/dbConnection', () => ({
|
||||
openDrizzleConnection: vi.fn().mockImplementation(() => ({
|
||||
raw: { close: mockClose } as any,
|
||||
orm: createMockOrm() as any,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/sqlite', () => ({
|
||||
parseRow: vi.fn().mockImplementation((_schema, row) => row ?? null),
|
||||
}));
|
||||
|
||||
vi.mock('../../ipc/database/schema', () => ({
|
||||
itemTable: {},
|
||||
}));
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe('Database Handler', () => {
|
||||
beforeEach(() => {
|
||||
// Create a fresh DB for each test
|
||||
const db = new Database(tempDbPath);
|
||||
db.exec('CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT)');
|
||||
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(
|
||||
'geminiNexusAuthStatus',
|
||||
JSON.stringify({
|
||||
user: { email: '[email protected]', name: 'Test User' },
|
||||
}),
|
||||
);
|
||||
db.close();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGeminiNexusDbPaths).mockReturnValue(['/tmp/test.vscdb']);
|
||||
vi.mocked(openDrizzleConnection).mockImplementation(() => ({
|
||||
raw: { close: mockClose } as any,
|
||||
orm: createMockOrm() as any,
|
||||
}));
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDbPath)) {
|
||||
fs.unlinkSync(tempDbPath);
|
||||
}
|
||||
describe('getDatabaseConnection', () => {
|
||||
it('should connect to database', () => {
|
||||
const conn = getDatabaseConnection();
|
||||
expect(conn).toBeDefined();
|
||||
expect(conn.raw).toBeDefined();
|
||||
expect(conn.orm).toBeDefined();
|
||||
conn.raw.close();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should connect to database', () => {
|
||||
const { raw } = getDatabaseConnection();
|
||||
expect(raw).toBeDefined();
|
||||
raw.close();
|
||||
describe('getCurrentAccountInfo', () => {
|
||||
it('should get current account info', () => {
|
||||
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
|
||||
raw: { close: mockClose } as any,
|
||||
orm: createMockOrm([
|
||||
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Test User' } }) },
|
||||
]) as any,
|
||||
});
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
expect(info.name).toBe('Test User');
|
||||
expect(info.isAuthenticated).toBe(true);
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get current account info', () => {
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
expect(info.name).toBe('Test User');
|
||||
expect(info.isAuthenticated).toBe(true);
|
||||
});
|
||||
describe('backupAccount', () => {
|
||||
it('should backup account', () => {
|
||||
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
|
||||
raw: { close: mockClose } as any,
|
||||
orm: createMockOrm([
|
||||
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Test User' } }) },
|
||||
{ value: JSON.stringify({ state: 'ready' }) },
|
||||
{ value: JSON.stringify({ token: 'abc' }) },
|
||||
]) as any,
|
||||
});
|
||||
|
||||
it('should backup account', () => {
|
||||
const account = {
|
||||
id: '123',
|
||||
name: 'Test User',
|
||||
email: '[email protected]',
|
||||
created_at: new Date().toISOString(),
|
||||
last_used: new Date().toISOString(),
|
||||
};
|
||||
const backup = backupAccount(account);
|
||||
expect(backup.account).toEqual(account);
|
||||
expect(backup.data['geminiNexusAuthStatus']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should restore account', () => {
|
||||
const backup = {
|
||||
version: '1.0',
|
||||
account: {
|
||||
const account = {
|
||||
id: '123',
|
||||
name: 'Restored User',
|
||||
email: 'restored@example.com',
|
||||
name: 'Test User',
|
||||
email: 't[email protected]',
|
||||
created_at: new Date().toISOString(),
|
||||
last_used: new Date().toISOString(),
|
||||
},
|
||||
data: {
|
||||
geminiNexusAuthStatus: JSON.stringify({
|
||||
user: { email: '[email protected]' },
|
||||
}),
|
||||
newKey: 'newValue',
|
||||
},
|
||||
};
|
||||
};
|
||||
const backup = backupAccount(account);
|
||||
expect(backup.account).toEqual(account);
|
||||
expect(backup.data['geminiNexusAuthStatus']).toEqual({
|
||||
user: { email: '[email protected]', name: 'Test User' },
|
||||
});
|
||||
expect(backup.data['account_email']).toBe('[email protected]');
|
||||
expect(backup.data['backup_time']).toBeDefined();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
restoreAccount(backup);
|
||||
describe('restoreAccount', () => {
|
||||
it('should restore account', () => {
|
||||
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
|
||||
raw: { close: mockClose } as any,
|
||||
orm: createMockOrm() as any,
|
||||
});
|
||||
|
||||
const db = new Database(tempDbPath);
|
||||
const row = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'geminiNexusAuthStatus'")
|
||||
.get() as { value: string };
|
||||
const value = JSON.parse(row.value);
|
||||
expect(value.user.email).toBe('[email protected]');
|
||||
const backup = {
|
||||
version: '1.0',
|
||||
account: {
|
||||
id: '123',
|
||||
name: 'Restored User',
|
||||
email: '[email protected]',
|
||||
created_at: new Date().toISOString(),
|
||||
last_used: new Date().toISOString(),
|
||||
},
|
||||
data: {
|
||||
geminiNexusAuthStatus: JSON.stringify({
|
||||
user: { email: '[email protected]' },
|
||||
}),
|
||||
newKey: 'newValue',
|
||||
},
|
||||
};
|
||||
|
||||
const newRow = db.prepare("SELECT value FROM ItemTable WHERE key = 'newKey'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect(newRow.value).toBe('newValue'); // JSON stringified
|
||||
db.close();
|
||||
expect(() => restoreAccount(backup as any)).not.toThrow();
|
||||
expect(mockRun).toHaveBeenCalled();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
@@ -55,7 +56,7 @@ function getLastQueryOptions() {
|
||||
return mockedUseQuery.mock.lastCall?.[0];
|
||||
}
|
||||
|
||||
function getLastMutationOptions() {
|
||||
function getLastMutationOptions(): any {
|
||||
return mockedUseMutation.mock.lastCall?.[0];
|
||||
}
|
||||
|
||||
@@ -120,7 +121,7 @@ describe('useCloudAccounts hooks', () => {
|
||||
];
|
||||
|
||||
options?.onSuccess?.(updatedAccount);
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as Function;
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (prev: CloudAccount[] | undefined) => CloudAccount[];
|
||||
const result = updater(oldData);
|
||||
expect(result).toEqual([oldData[0], updatedAccount]);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
@@ -132,7 +133,7 @@ describe('useCloudAccounts hooks', () => {
|
||||
const updatedAccount: CloudAccount = { id: 'acc-1' } as CloudAccount;
|
||||
|
||||
options?.onSuccess?.(updatedAccount);
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as Function;
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (prev: CloudAccount[] | undefined) => CloudAccount[];
|
||||
const result = updater(undefined);
|
||||
expect(result).toEqual([updatedAccount]);
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ vi.mock('../../../ipc/database/dbConnection', () => ({
|
||||
openDrizzleConnection: vi.fn().mockImplementation(() => ({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm(),
|
||||
})),
|
||||
}) as any),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -86,8 +86,8 @@ describe('Database Handler', () => {
|
||||
vi.mocked(getGeminiNexusDbPaths).mockReturnValue(['/tmp/test.vscdb']);
|
||||
vi.mocked(openDrizzleConnection).mockImplementation(() => ({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm() as any,
|
||||
}));
|
||||
orm: createMockOrm(),
|
||||
}) as any);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -146,8 +146,8 @@ describe('Database Handler', () => {
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm([
|
||||
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Auth User' } }) },
|
||||
]) as any,
|
||||
});
|
||||
]),
|
||||
} as any);
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
@@ -162,8 +162,8 @@ describe('Database Handler', () => {
|
||||
orm: createMockOrm([
|
||||
{ value: null },
|
||||
{ value: JSON.stringify({ user: { email: '[email protected]', name: 'Init User' } }) },
|
||||
]) as any,
|
||||
});
|
||||
]),
|
||||
} as any);
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('[email protected]');
|
||||
@@ -174,8 +174,8 @@ describe('Database Handler', () => {
|
||||
it('should return empty info when no keys found', () => {
|
||||
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm([{ value: null }, { value: null }, { value: null }, { value: null }]) as any,
|
||||
});
|
||||
orm: createMockOrm([{ value: null }, { value: null }, { value: null }, { value: null }]),
|
||||
} as any);
|
||||
|
||||
const info = getCurrentAccountInfo();
|
||||
expect(info.email).toBe('');
|
||||
@@ -192,8 +192,8 @@ describe('Database Handler', () => {
|
||||
{ value: JSON.stringify({ status: 'active' }) },
|
||||
{ value: JSON.stringify({ state: 'ready' }) },
|
||||
{ value: JSON.stringify({ token: 'abc' }) },
|
||||
]) as any,
|
||||
});
|
||||
]),
|
||||
} as any);
|
||||
|
||||
const account = {
|
||||
id: 'acc-1',
|
||||
@@ -219,8 +219,8 @@ describe('Database Handler', () => {
|
||||
it('should restore keys to existing database', () => {
|
||||
vi.mocked(openDrizzleConnection).mockReturnValueOnce({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm() as any,
|
||||
});
|
||||
orm: createMockOrm(),
|
||||
} as any);
|
||||
|
||||
const backup = {
|
||||
version: '1.0',
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
generateDeviceProfile,
|
||||
applyDeviceProfile,
|
||||
readCurrentDeviceProfile,
|
||||
} from '../../../ipc/device/handler';
|
||||
import { getGeminiNexusStoragePaths } from '../../../utils/paths';
|
||||
import fs from 'fs';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn().mockReturnValue('/home/test'),
|
||||
userInfo: vi.fn().mockReturnValue({ username: 'test' }),
|
||||
},
|
||||
homedir: vi.fn().mockReturnValue('/home/test'),
|
||||
userInfo: vi.fn().mockReturnValue({ username: 'test' }),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn().mockReturnValue('/tmp'),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/paths', () => ({
|
||||
getGeminiNexusStoragePaths: vi.fn().mockReturnValue(['/tmp/storage.json']),
|
||||
getGeminiNexusDbPaths: vi.fn().mockReturnValue(['/tmp/state.vscdb']),
|
||||
getAgentDir: vi.fn().mockReturnValue('/tmp/agent'),
|
||||
}));
|
||||
|
||||
vi.mock('better-sqlite3', () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
pragma: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn(),
|
||||
get: vi.fn(),
|
||||
all: vi.fn(),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Device Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGeminiNexusStoragePaths).mockReturnValue(['/tmp/storage.json']);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
||||
if (String(path).includes('storage.json')) {
|
||||
return JSON.stringify({
|
||||
telemetry: {
|
||||
machineId: 'auth0|user_abc123',
|
||||
macMachineId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
devDeviceId: '11111111-2222-3333-4444-555555555555',
|
||||
sqmId: '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDeviceProfile', () => {
|
||||
it('should return a device profile with expected keys', () => {
|
||||
const profile = generateDeviceProfile();
|
||||
expect(profile).toHaveProperty('machineId');
|
||||
expect(profile).toHaveProperty('macMachineId');
|
||||
expect(profile).toHaveProperty('devDeviceId');
|
||||
expect(profile).toHaveProperty('sqmId');
|
||||
expect(profile.machineId).toMatch(/^auth0\|user_[0-9a-f]{32}$/);
|
||||
expect(profile.macMachineId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(profile.devDeviceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(profile.sqmId).toMatch(/^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/);
|
||||
expect(profile.macMachineId).not.toBe(profile.devDeviceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDeviceProfile', () => {
|
||||
it('should throw when storage.json is not found', () => {
|
||||
vi.mocked(getGeminiNexusStoragePaths).mockReturnValueOnce([]);
|
||||
const profile = generateDeviceProfile();
|
||||
expect(() => applyDeviceProfile(profile)).toThrow('storage_json_not_found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCurrentDeviceProfile', () => {
|
||||
it('should read current profile from storage.json', () => {
|
||||
const profile = readCurrentDeviceProfile();
|
||||
expect(profile.machineId).toBe('auth0|user_abc123');
|
||||
expect(profile.macMachineId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
||||
expect(profile.devDeviceId).toBe('11111111-2222-3333-4444-555555555555');
|
||||
expect(profile.sqmId).toBe('{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}');
|
||||
});
|
||||
|
||||
it('should throw when device fingerprint fields are missing', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValueOnce(JSON.stringify({ telemetry: {} }));
|
||||
expect(() => readCurrentDeviceProfile()).toThrow('missing_device_fingerprint_fields');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,14 +31,33 @@ vi.mock('../../../ipc/database/proxyMetricsHandler', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../ipc/proxy-advanced/service-registry', () => ({
|
||||
proxyAdvancedRegistry: {
|
||||
vi.mock('../../../ipc/database/cloudHandler', () => ({
|
||||
CloudAccountRepo: {
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../ipc/proxy-advanced/service-registry', () => {
|
||||
const proxyAdvancedRegistry = {
|
||||
tokenManager: null,
|
||||
proxyMetrics: null,
|
||||
proxyReplay: null,
|
||||
proxyIdeConfig: null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
function getServiceOrThrow(key: string) {
|
||||
const service = (proxyAdvancedRegistry as Record<string, unknown>)[key];
|
||||
if (!service) {
|
||||
throw new Error(`${key} is not registered`);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
return {
|
||||
proxyAdvancedRegistry,
|
||||
getServiceOrThrow,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Proxy Advanced Handler', () => {
|
||||
beforeEach(() => {
|
||||
@@ -147,12 +166,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getParityCounters', () => {
|
||||
it('should return zero counters when tokenManager is null', async () => {
|
||||
it('should return error when tokenManager is null', async () => {
|
||||
const result = await getParityCounters();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.totalRequests).toBe(0);
|
||||
expect(result.data?.matchedRequests).toBe(0);
|
||||
expect(result.data?.parityViolations).toBe(0);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should return counters from tokenManager', async () => {
|
||||
@@ -173,11 +190,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getCircuitBreakerStatus', () => {
|
||||
it('should return empty states when tokenManager is null', async () => {
|
||||
it('should return error when tokenManager is null', async () => {
|
||||
const result = await getCircuitBreakerStatus();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.states).toEqual({});
|
||||
expect(result.data?.globalEnabled).toBe(true);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should return mapped status from tokenManager', async () => {
|
||||
@@ -196,11 +212,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getProxyMetrics', () => {
|
||||
it('should return zero metrics when proxyMetrics is null', async () => {
|
||||
it('should return error when proxyMetrics is null', async () => {
|
||||
const result = await getProxyMetrics();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.totalRequests).toBe(0);
|
||||
expect(result.data?.activeConnections).toBe(0);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should return metrics from proxyMetrics service', async () => {
|
||||
@@ -224,10 +239,10 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getRecentRequests', () => {
|
||||
it('should return empty array when proxyReplay is null', async () => {
|
||||
it('should return error when proxyReplay is null', async () => {
|
||||
const result = await getRecentRequests();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should return recent requests from proxyReplay', async () => {
|
||||
@@ -246,7 +261,7 @@ describe('Proxy Advanced Handler', () => {
|
||||
it('should return error when proxyReplay is null', async () => {
|
||||
const result = await replayRequest('req-1');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Replay service not available');
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should replay request through proxyReplay', async () => {
|
||||
@@ -265,16 +280,122 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('getModelCapabilities', () => {
|
||||
it('should return capabilities for all models', async () => {
|
||||
it('should return empty array when no accounts have quota', async () => {
|
||||
const { CloudAccountRepo } = await import('../../../ipc/database/cloudHandler');
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getModelCapabilities();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data!.length).toBeGreaterThan(0);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
const model = result.data!.find((m) => m.id === 'gemini-3-flash');
|
||||
expect(model).toBeDefined();
|
||||
expect(model!.capabilities.vision).toBe(true);
|
||||
expect(model!.capabilities.streaming).toBe(true);
|
||||
it('should aggregate unique models across accounts', async () => {
|
||||
const { CloudAccountRepo } = await import('../../../ipc/database/cloudHandler');
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {} as any,
|
||||
quota: {
|
||||
models: {
|
||||
'gemini-pro': {
|
||||
percentage: 80,
|
||||
resetTime: new Date().toISOString(),
|
||||
display_name: 'Gemini Pro',
|
||||
supports_images: true,
|
||||
supports_thinking: false,
|
||||
recommended: true,
|
||||
max_tokens: 1000000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
},
|
||||
},
|
||||
created_at: Date.now(),
|
||||
last_used: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'acc-2',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {} as any,
|
||||
quota: {
|
||||
models: {
|
||||
'gemini-pro': {
|
||||
percentage: 60,
|
||||
resetTime: new Date().toISOString(),
|
||||
display_name: 'Gemini Pro Duplicate',
|
||||
supports_images: true,
|
||||
supports_thinking: false,
|
||||
recommended: true,
|
||||
max_tokens: 1000000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
'gemini-flash': {
|
||||
percentage: 90,
|
||||
resetTime: new Date().toISOString(),
|
||||
display_name: 'Gemini Flash',
|
||||
supports_images: true,
|
||||
supports_thinking: true,
|
||||
thinking_budget: 24000,
|
||||
recommended: false,
|
||||
max_tokens: 500000,
|
||||
max_output_tokens: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
created_at: Date.now(),
|
||||
last_used: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getModelCapabilities();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toHaveLength(2);
|
||||
|
||||
const pro = result.data!.find((m) => m.id === 'gemini-pro');
|
||||
expect(pro).toBeDefined();
|
||||
expect(pro!.displayName).toBe('Gemini Pro');
|
||||
expect(pro!.capabilities.vision).toBe(true);
|
||||
expect(pro!.capabilities.thinking).toBe(false);
|
||||
expect(pro!.capabilities.streaming).toBe(true);
|
||||
expect(pro!.capabilities.jsonMode).toBe(true);
|
||||
expect(pro!.capabilities.recommended).toBe(true);
|
||||
expect(pro!.limits.maxTokens).toBe(1000000);
|
||||
expect(pro!.limits.maxOutputTokens).toBe(8192);
|
||||
|
||||
const flash = result.data!.find((m) => m.id === 'gemini-flash');
|
||||
expect(flash).toBeDefined();
|
||||
expect(flash!.displayName).toBe('Gemini Flash');
|
||||
expect(flash!.capabilities.thinking).toBe(true);
|
||||
expect(flash!.limits.thinkingBudget).toBe(24000);
|
||||
});
|
||||
|
||||
it('should handle accounts without quota gracefully', async () => {
|
||||
const { CloudAccountRepo } = await import('../../../ipc/database/cloudHandler');
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {} as any,
|
||||
created_at: Date.now(),
|
||||
last_used: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getModelCapabilities();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle errors from CloudAccountRepo', async () => {
|
||||
const { CloudAccountRepo } = await import('../../../ipc/database/cloudHandler');
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockRejectedValueOnce(new Error('DB failure'));
|
||||
|
||||
const result = await getModelCapabilities();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('DB failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,7 +403,7 @@ describe('Proxy Advanced Handler', () => {
|
||||
it('should return error when proxyIdeConfig is null', async () => {
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('IDE config service not available');
|
||||
expect(result.error).toBe('Proxy advanced service not initialized');
|
||||
});
|
||||
|
||||
it('should return error for unsupported IDE', async () => {
|
||||
|
||||
@@ -10,10 +10,11 @@ export class MockDatabase {
|
||||
prepare = vi.fn().mockImplementation((sql: string) => {
|
||||
const table = this._extractTable(sql);
|
||||
return {
|
||||
run: vi.fn().mockImplementation((params: any) => {
|
||||
run: vi.fn().mockImplementation((...params: any[]) => {
|
||||
const values = params.length === 1 && typeof params[0] === 'object' ? params[0] : params;
|
||||
const rows = this.data.get(table) || [];
|
||||
if (sql.toLowerCase().includes('insert')) {
|
||||
rows.push(params);
|
||||
rows.push(values);
|
||||
this.data.set(table, rows);
|
||||
return { lastInsertRowid: rows.length, changes: 1 };
|
||||
}
|
||||
@@ -25,17 +26,20 @@ export class MockDatabase {
|
||||
}
|
||||
return { changes: 0 };
|
||||
}),
|
||||
get: vi.fn().mockImplementation((params: any) => {
|
||||
get: vi.fn().mockImplementation((...params: any[]) => {
|
||||
const values = params.length === 1 && typeof params[0] === 'object' ? params[0] : params;
|
||||
const rows = this.data.get(table) || [];
|
||||
if (!values || (Array.isArray(values) && values.length === 0)) return rows[0] ?? null;
|
||||
return rows.find((row) =>
|
||||
Object.keys(params).every((key) => row[key] === params[key]),
|
||||
Object.keys(values).every((key) => row[key] === values[key]),
|
||||
);
|
||||
}),
|
||||
all: vi.fn().mockImplementation((params?: any) => {
|
||||
all: vi.fn().mockImplementation((...params: any[]) => {
|
||||
const values = params.length === 1 && typeof params[0] === 'object' ? params[0] : params;
|
||||
const rows = this.data.get(table) || [];
|
||||
if (!params) return rows;
|
||||
if (!values || (Array.isArray(values) && values.length === 0)) return rows;
|
||||
return rows.filter((row) =>
|
||||
Object.keys(params).every((key) => row[key] === params[key]),
|
||||
Object.keys(values).every((key) => row[key] === values[key]),
|
||||
);
|
||||
}),
|
||||
};
|
||||
@@ -68,3 +72,5 @@ export class MockDatabase {
|
||||
export function createMockDb() {
|
||||
return new MockDatabase();
|
||||
}
|
||||
|
||||
export default MockDatabase;
|
||||
+57
-134
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock find-process module
|
||||
vi.mock('find-process', () => ({
|
||||
default: vi.fn(),
|
||||
ProcessInfo: class {},
|
||||
// Mock server/main module
|
||||
vi.mock('../../server/main', () => ({
|
||||
isNestServerRunning: vi.fn(),
|
||||
stopNestServer: vi.fn(),
|
||||
bootstrapNestServer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger to avoid console output during tests
|
||||
@@ -16,160 +17,82 @@ vi.mock('../../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock paths module to avoid child_process issues
|
||||
vi.mock('../../utils/paths', () => ({
|
||||
getGeminiNexusExecutablePath: vi.fn(() => '/path/to/geminiNexus'),
|
||||
isWsl: vi.fn(() => false),
|
||||
// Mock ConfigManager
|
||||
vi.mock('../../ipc/config/manager', () => ({
|
||||
ConfigManager: {
|
||||
loadConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from '../../ipc/process/handler';
|
||||
import findProcess from 'find-process';
|
||||
import {
|
||||
isProcessRunning,
|
||||
closeGeminiNexus,
|
||||
startGeminiNexus,
|
||||
} from '../../ipc/process/handler';
|
||||
import {
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
bootstrapNestServer,
|
||||
} from '../../server/main';
|
||||
import { ConfigManager } from '../../ipc/config/manager';
|
||||
|
||||
describe('Process Handler', () => {
|
||||
const mockFindProcess = findProcess as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isProcessRunning', () => {
|
||||
it('should return true when Gemini Nexus main process is found on macOS', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12345,
|
||||
name: 'Gemini Nexus',
|
||||
cmd: '/Applications/GeminiNexus.app/Contents/MacOS/GeminiNexus',
|
||||
},
|
||||
]);
|
||||
it('should return true when NestJS server is running', async () => {
|
||||
vi.mocked(isNestServerRunning).mockReturnValue(true);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(true);
|
||||
expect(mockFindProcess).toHaveBeenCalledWith('name', 'Gemini Nexus', false);
|
||||
expect(isNestServerRunning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when only helper processes are found', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12346,
|
||||
name: 'Gemini Nexus Helper (Renderer)',
|
||||
cmd: '/Applications/GeminiNexus.app/Contents/Frameworks/Gemini Nexus Helper (Renderer).app --type=renderer',
|
||||
},
|
||||
{
|
||||
pid: 12347,
|
||||
name: 'Gemini Nexus Helper (GPU)',
|
||||
cmd: '/Applications/GeminiNexus.app/Contents/Frameworks/Gemini Nexus Helper (GPU).app --type=gpu-process',
|
||||
},
|
||||
]);
|
||||
it('should return false when NestJS server is not running', async () => {
|
||||
vi.mocked(isNestServerRunning).mockReturnValue(false);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
expect(isNestServerRunning).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeGeminiNexus', () => {
|
||||
it('should stop the NestJS server', async () => {
|
||||
vi.mocked(stopNestServer).mockResolvedValue(true);
|
||||
|
||||
await closeGeminiNexus();
|
||||
expect(stopNestServer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startGeminiNexus', () => {
|
||||
it('should start the NestJS server when not running and proxy config exists', async () => {
|
||||
vi.mocked(isNestServerRunning).mockReturnValue(false);
|
||||
vi.mocked(ConfigManager.loadConfig).mockReturnValue({
|
||||
proxy: { port: 8045, enabled: true },
|
||||
} as any);
|
||||
vi.mocked(bootstrapNestServer).mockResolvedValue(true);
|
||||
|
||||
await startGeminiNexus();
|
||||
expect(bootstrapNestServer).toHaveBeenCalledWith({ port: 8045, enabled: true });
|
||||
});
|
||||
|
||||
it('should return false when only manager process is found', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
it('should not start the server if already running', async () => {
|
||||
vi.mocked(isNestServerRunning).mockReturnValue(true);
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12348,
|
||||
name: 'Gemini Nexus',
|
||||
cmd: '/Applications/Gemini Nexus.app/Contents/MacOS/Gemini Nexus',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
await startGeminiNexus();
|
||||
expect(bootstrapNestServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when no processes are found', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
it('should throw if proxy configuration is missing', async () => {
|
||||
vi.mocked(isNestServerRunning).mockReturnValue(false);
|
||||
vi.mocked(ConfigManager.loadConfig).mockReturnValue({} as any);
|
||||
|
||||
mockFindProcess.mockResolvedValue([]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip self process', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 12345, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12345, // Same as current PID
|
||||
name: 'Gemini Nexus',
|
||||
cmd: '/Applications/GeminiNexus.app/Contents/MacOS/GeminiNexus',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when Gemini Nexus.exe is found on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12345,
|
||||
name: 'GeminiNexus.exe',
|
||||
cmd: 'C:\\Program Files\\GeminiNexus\\GeminiNexus.exe',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when geminiNexus is found on Linux', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12345,
|
||||
name: 'geminiNexus',
|
||||
cmd: '/usr/bin/geminiNexus',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle find-process errors gracefully', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockRejectedValue(new Error('Process enumeration failed'));
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should exclude processes with --type= argument', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
|
||||
mockFindProcess.mockResolvedValue([
|
||||
{
|
||||
pid: 12345,
|
||||
name: 'Gemini Nexus',
|
||||
cmd: '/Applications/GeminiNexus.app/Contents/MacOS/Gemini Nexus --type=utility',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await isProcessRunning();
|
||||
expect(result).toBe(false);
|
||||
await expect(startGeminiNexus()).rejects.toThrow('Proxy configuration not found');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProxyEventBus, ProxyEvent } from '@/server/modules/proxy/proxy-event-bus.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
|
||||
Vendored
+7
@@ -1,6 +1,8 @@
|
||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
|
||||
// plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
|
||||
// whether you're running in development or production).
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
const MAIN_WINDOW_VITE_NAME: string;
|
||||
@@ -14,4 +16,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user