mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
Feat: Add z.ai usage tracking
This commit is contained in:
@@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
},
|
||||
glm: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
|
||||
// Official Z.ai/GLM logo from lobehub/lobe-icons (GLM/Zhipu provider)
|
||||
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
||||
fill: '#3B82F6', // z.ai brand blue
|
||||
},
|
||||
bigpickle: {
|
||||
viewBox: '0 0 24 24',
|
||||
@@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
|
||||
fill="currentColor"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Z.ai icon is the same as GLM (Zhipu AI)
|
||||
export const ZaiIcon = GlmIcon;
|
||||
|
||||
export function BigPickleIcon({
|
||||
className,
|
||||
title,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -27,9 +27,9 @@ type UsageError = {
|
||||
|
||||
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
||||
|
||||
// Helper to format reset time for Codex
|
||||
function formatCodexResetTime(unixTimestamp: number): string {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds)
|
||||
function formatResetTime(unixTimestamp: number, isMilliseconds = false): string {
|
||||
const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
@@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string {
|
||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// Legacy alias for Codex
|
||||
function formatCodexResetTime(unixTimestamp: number): string {
|
||||
return formatResetTime(unixTimestamp, false);
|
||||
}
|
||||
|
||||
// Helper to format window duration for Codex
|
||||
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||
if (durationMins < 60) {
|
||||
@@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
|
||||
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
|
||||
// Helper to format large numbers with K/M suffixes
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) {
|
||||
return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
}
|
||||
if (num >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function UsagePopover() {
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude');
|
||||
|
||||
// Check authentication status
|
||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
const isZaiAuthenticated = zaiAuthStatus?.authenticated;
|
||||
|
||||
// Use React Query hooks for usage data
|
||||
// Only enable polling when popover is open AND the tab is active
|
||||
@@ -87,6 +108,14 @@ export function UsagePopover() {
|
||||
refetch: refetchCodex,
|
||||
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
|
||||
|
||||
const {
|
||||
data: zaiUsage,
|
||||
isLoading: zaiLoading,
|
||||
error: zaiQueryError,
|
||||
dataUpdatedAt: zaiUsageLastUpdated,
|
||||
refetch: refetchZai,
|
||||
} = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated);
|
||||
|
||||
// Parse errors into structured format
|
||||
const claudeError = useMemo((): UsageError | null => {
|
||||
if (!claudeQueryError) return null;
|
||||
@@ -116,14 +145,28 @@ export function UsagePopover() {
|
||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||
}, [codexQueryError]);
|
||||
|
||||
const zaiError = useMemo((): UsageError | null => {
|
||||
if (!zaiQueryError) return null;
|
||||
const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError);
|
||||
if (message.includes('not configured') || message.includes('API token')) {
|
||||
return { code: ERROR_CODES.NOT_AVAILABLE, message };
|
||||
}
|
||||
if (message.includes('API bridge')) {
|
||||
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||
}
|
||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||
}, [zaiQueryError]);
|
||||
|
||||
// Determine which tab to show by default
|
||||
useEffect(() => {
|
||||
if (isClaudeAuthenticated) {
|
||||
setActiveTab('claude');
|
||||
} else if (isCodexAuthenticated) {
|
||||
setActiveTab('codex');
|
||||
} else if (isZaiAuthenticated) {
|
||||
setActiveTab('zai');
|
||||
}
|
||||
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
||||
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale = useMemo(() => {
|
||||
@@ -134,9 +177,14 @@ export function UsagePopover() {
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
const isZaiStale = useMemo(() => {
|
||||
return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [zaiUsageLastUpdated]);
|
||||
|
||||
// Refetch functions for manual refresh
|
||||
const fetchClaudeUsage = () => refetchClaude();
|
||||
const fetchCodexUsage = () => refetchCodex();
|
||||
const fetchZaiUsage = () => refetchZai();
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
@@ -251,26 +299,33 @@ export function UsagePopover() {
|
||||
const indicatorInfo =
|
||||
activeTab === 'claude'
|
||||
? {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
title: `Usage (${codexWindowLabel})`,
|
||||
};
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: activeTab === 'codex' ? {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
title: `Usage (${codexWindowLabel})`,
|
||||
} : activeTab === 'zai' ? {
|
||||
icon: ZaiIcon,
|
||||
percentage: zaiMaxPercentage,
|
||||
isStale: isZaiStale,
|
||||
title: `Usage (z.ai)`,
|
||||
} : null;
|
||||
|
||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
||||
const ProviderIcon = indicatorInfo.icon;
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
{(claudeUsage || codexUsage) && <ProviderIcon className={cn('w-4 h-4', statusColor)} />}
|
||||
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||
<ProviderIcon className={cn('w-4 h-4', statusColor)} />
|
||||
)}
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{(claudeUsage || codexUsage) && (
|
||||
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||
<div
|
||||
title={indicatorInfo.title}
|
||||
className={cn(
|
||||
@@ -293,6 +348,8 @@ export function UsagePopover() {
|
||||
// Determine which tabs to show
|
||||
const showClaudeTab = isClaudeAuthenticated;
|
||||
const showCodexTab = isCodexAuthenticated;
|
||||
const showZaiTab = isZaiAuthenticated;
|
||||
const tabCount = [showClaudeTab, showCodexTab, showZaiTab].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -302,18 +359,37 @@ export function UsagePopover() {
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'claude' | 'codex')}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai')}
|
||||
>
|
||||
{/* Tabs Header */}
|
||||
{showClaudeTab && showCodexTab && (
|
||||
<TabsList className="grid w-full grid-cols-2 rounded-none border-b border-border/50">
|
||||
<TabsTrigger value="claude" className="gap-2">
|
||||
<AnthropicIcon className="w-3.5 h-3.5" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="gap-2">
|
||||
<OpenAIIcon className="w-3.5 h-3.5" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
{tabCount > 1 && (
|
||||
<TabsList
|
||||
className={cn(
|
||||
'grid w-full rounded-none border-b border-border/50',
|
||||
tabCount === 2 && 'grid-cols-2',
|
||||
tabCount === 3 && 'grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{showClaudeTab && (
|
||||
<TabsTrigger value="claude" className="gap-2">
|
||||
<AnthropicIcon className="w-3.5 h-3.5" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showCodexTab && (
|
||||
<TabsTrigger value="codex" className="gap-2">
|
||||
<OpenAIIcon className="w-3.5 h-3.5" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showZaiTab && (
|
||||
<TabsTrigger value="zai" className="gap-2">
|
||||
<ZaiIcon className="w-3.5 h-3.5" />
|
||||
z.ai
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
@@ -552,6 +628,122 @@ export function UsagePopover() {
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* z.ai Tab Content */}
|
||||
<TabsContent value="zai" className="m-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<ZaiIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">z.ai Usage</span>
|
||||
</div>
|
||||
{zaiError && zaiError.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', zaiLoading && 'opacity-80')}
|
||||
onClick={() => !zaiLoading && fetchZaiUsage()}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{zaiError ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">
|
||||
{zaiError.code === ERROR_CODES.NOT_AVAILABLE
|
||||
? 'z.ai not configured'
|
||||
: zaiError.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||
<>
|
||||
Set <code className="font-mono bg-muted px-1 rounded">Z_AI_API_KEY</code>{' '}
|
||||
environment variable to enable z.ai usage tracking
|
||||
</>
|
||||
) : (
|
||||
<>Check your z.ai API key configuration</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !zaiUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : zaiUsage.quotaLimits &&
|
||||
(zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? (
|
||||
<>
|
||||
{zaiUsage.quotaLimits.tokens && (
|
||||
<UsageCard
|
||||
title="Token Quota"
|
||||
subtitle={`${formatNumber(zaiUsage.quotaLimits.tokens.used)} / ${formatNumber(zaiUsage.quotaLimits.tokens.limit)} tokens`}
|
||||
percentage={zaiUsage.quotaLimits.tokens.usedPercent}
|
||||
resetText={
|
||||
zaiUsage.quotaLimits.tokens.nextResetTime
|
||||
? formatResetTime(zaiUsage.quotaLimits.tokens.nextResetTime, true)
|
||||
: undefined
|
||||
}
|
||||
isPrimary={true}
|
||||
stale={isZaiStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{zaiUsage.quotaLimits.mcp && (
|
||||
<UsageCard
|
||||
title="MCP Quota"
|
||||
subtitle={`${formatNumber(zaiUsage.quotaLimits.mcp.used)} / ${formatNumber(zaiUsage.quotaLimits.mcp.limit)} calls`}
|
||||
percentage={zaiUsage.quotaLimits.mcp.usedPercent}
|
||||
resetText={
|
||||
zaiUsage.quotaLimits.mcp.nextResetTime
|
||||
? formatResetTime(zaiUsage.quotaLimits.mcp.nextResetTime, true)
|
||||
: undefined
|
||||
}
|
||||
stale={isZaiStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && (
|
||||
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() +
|
||||
zaiUsage.quotaLimits.planType.slice(1)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<p className="text-sm font-medium mt-3">No usage data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://z.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
z.ai <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -81,6 +81,7 @@ export function BoardHeader({
|
||||
(state) => state.setAddFeatureUseSelectedWorktreeBranch
|
||||
);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||
|
||||
// Worktree panel visibility (per-project)
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
@@ -112,6 +113,9 @@ export function BoardHeader({
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
// z.ai usage tracking visibility logic
|
||||
const showZaiUsage = !!zaiAuthStatus?.authenticated;
|
||||
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||
@@ -158,8 +162,10 @@ export function BoardHeader({
|
||||
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
{/* Usage Popover - show if any provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||
<UsagePopover />
|
||||
)}
|
||||
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
@@ -178,6 +184,7 @@ export function BoardHeader({
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
showZaiUsage={showZaiUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ interface HeaderMobileMenuProps {
|
||||
// Usage bar visibility
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
showZaiUsage?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
@@ -47,18 +48,23 @@ export function HeaderMobileMenu({
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
showZaiUsage = false,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
{/* Usage Bar - show if any provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<MobileUsageBar
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
showZaiUsage={showZaiUsage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
showZaiUsage?: boolean;
|
||||
}
|
||||
|
||||
// Helper to get progress bar color based on percentage
|
||||
@@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// Helper to format large numbers with K/M suffixes
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000_000) {
|
||||
return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
}
|
||||
if (num >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// Helper to format reset time
|
||||
function formatResetTime(unixTimestamp: number, isMilliseconds = false): string {
|
||||
const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.ceil(diff / 60000);
|
||||
return `Resets in ${mins}m`;
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||
return `Resets in ${hours}h${mins > 0 ? ` ${mins}m` : ''}`;
|
||||
}
|
||||
return `Resets ${date.toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
// Individual usage bar component
|
||||
function UsageBar({
|
||||
label,
|
||||
percentage,
|
||||
isStale,
|
||||
details,
|
||||
resetText,
|
||||
}: {
|
||||
label: string;
|
||||
percentage: number;
|
||||
isStale: boolean;
|
||||
details?: string;
|
||||
resetText?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1.5 first:mt-0">
|
||||
@@ -58,6 +95,14 @@ function UsageBar({
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{(details || resetText) && (
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{details && <span className="text-[9px] text-muted-foreground">{details}</span>}
|
||||
{resetText && (
|
||||
<span className="text-[9px] text-muted-foreground ml-auto">{resetText}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,16 +148,23 @@ function UsageItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
||||
export function MobileUsageBar({
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
showZaiUsage = false,
|
||||
}: MobileUsageBarProps) {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore();
|
||||
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
const [isZaiLoading, setIsZaiLoading] = useState(false);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale =
|
||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||
|
||||
const fetchClaudeUsage = useCallback(async () => {
|
||||
setIsClaudeLoading(true);
|
||||
@@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
||||
}
|
||||
}, [setCodexUsage]);
|
||||
|
||||
const fetchZaiUsage = useCallback(async () => {
|
||||
setIsZaiLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.zai) return;
|
||||
const data = await api.zai.getUsage();
|
||||
if (!('error' in data)) {
|
||||
setZaiUsage(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - usage display is optional
|
||||
} finally {
|
||||
setIsZaiLoading(false);
|
||||
}
|
||||
}, [setZaiUsage]);
|
||||
|
||||
const getCodexWindowLabel = (durationMins: number) => {
|
||||
if (durationMins < 60) return `${durationMins}m Window`;
|
||||
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
||||
@@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
||||
}
|
||||
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showZaiUsage && isZaiStale) {
|
||||
fetchZaiUsage();
|
||||
}
|
||||
}, [showZaiUsage, isZaiStale, fetchZaiUsage]);
|
||||
|
||||
// Don't render if there's nothing to show
|
||||
if (!showClaudeUsage && !showCodexUsage) {
|
||||
if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
|
||||
{showZaiUsage && (
|
||||
<UsageItem icon={ZaiIcon} label="z.ai" isLoading={isZaiLoading} onRefresh={fetchZaiUsage}>
|
||||
{zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? (
|
||||
<>
|
||||
{zaiUsage.quotaLimits.tokens && (
|
||||
<UsageBar
|
||||
label="Tokens"
|
||||
percentage={zaiUsage.quotaLimits.tokens.usedPercent}
|
||||
isStale={isZaiStale}
|
||||
details={`${formatNumber(zaiUsage.quotaLimits.tokens.used)} / ${formatNumber(zaiUsage.quotaLimits.tokens.limit)}`}
|
||||
resetText={
|
||||
zaiUsage.quotaLimits.tokens.nextResetTime
|
||||
? formatResetTime(zaiUsage.quotaLimits.tokens.nextResetTime, true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{zaiUsage.quotaLimits.mcp && (
|
||||
<UsageBar
|
||||
label="MCP"
|
||||
percentage={zaiUsage.quotaLimits.mcp.usedPercent}
|
||||
isStale={isZaiStale}
|
||||
details={`${formatNumber(zaiUsage.quotaLimits.mcp.used)} / ${formatNumber(zaiUsage.quotaLimits.mcp.limit)} calls`}
|
||||
resetText={
|
||||
zaiUsage.quotaLimits.mcp.nextResetTime
|
||||
? formatResetTime(zaiUsage.quotaLimits.mcp.nextResetTime, true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : zaiUsage ? (
|
||||
<p className="text-[10px] text-muted-foreground italic">No usage data from z.ai API</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @ts-nocheck - API key management state with validation and persistence
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore, type ZaiAuthMethod } from '@/store/setup-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
const logger = createLogger('ApiKeyManagement');
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -16,6 +20,7 @@ interface ApiKeyStatus {
|
||||
hasAnthropicKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
hasOpenaiKey: boolean;
|
||||
hasZaiKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,16 +29,20 @@ interface ApiKeyStatus {
|
||||
*/
|
||||
export function useApiKeyManagement() {
|
||||
const { apiKeys, setApiKeys } = useAppStore();
|
||||
const { setZaiAuthStatus } = useSetupStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// API key values
|
||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
||||
const [zaiKey, setZaiKey] = useState(apiKeys.zai);
|
||||
|
||||
// Visibility toggles
|
||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||
const [showZaiKey, setShowZaiKey] = useState(false);
|
||||
|
||||
// Test connection states
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
@@ -42,6 +51,8 @@ export function useApiKeyManagement() {
|
||||
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
|
||||
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
||||
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
|
||||
const [testingZaiConnection, setTestingZaiConnection] = useState(false);
|
||||
const [zaiTestResult, setZaiTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
// API key status from environment
|
||||
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
||||
@@ -54,6 +65,7 @@ export function useApiKeyManagement() {
|
||||
setAnthropicKey(apiKeys.anthropic);
|
||||
setGoogleKey(apiKeys.google);
|
||||
setOpenaiKey(apiKeys.openai);
|
||||
setZaiKey(apiKeys.zai);
|
||||
}, [apiKeys]);
|
||||
|
||||
// Check API key status from environment on mount
|
||||
@@ -68,6 +80,7 @@ export function useApiKeyManagement() {
|
||||
hasAnthropicKey: status.hasAnthropicKey,
|
||||
hasGoogleKey: status.hasGoogleKey,
|
||||
hasOpenaiKey: status.hasOpenaiKey,
|
||||
hasZaiKey: status.hasZaiKey || false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -173,13 +186,89 @@ export function useApiKeyManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
// Test z.ai connection
|
||||
const handleTestZaiConnection = async () => {
|
||||
setTestingZaiConnection(true);
|
||||
setZaiTestResult(null);
|
||||
|
||||
// Validate input first
|
||||
if (!zaiKey || zaiKey.trim().length === 0) {
|
||||
setZaiTestResult({
|
||||
success: false,
|
||||
message: 'Please enter an API key to test.',
|
||||
});
|
||||
setTestingZaiConnection(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
// Use the verify endpoint to test the key without storing it
|
||||
const response = await api.zai?.verify(zaiKey);
|
||||
|
||||
if (response?.success && response?.authenticated) {
|
||||
setZaiTestResult({
|
||||
success: true,
|
||||
message: response.message || 'Connection successful! z.ai API responded.',
|
||||
});
|
||||
} else {
|
||||
setZaiTestResult({
|
||||
success: false,
|
||||
message: response?.error || 'Failed to connect to z.ai API.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setZaiTestResult({
|
||||
success: false,
|
||||
message: 'Network error. Please check your connection.',
|
||||
});
|
||||
} finally {
|
||||
setTestingZaiConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Save API keys
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
setApiKeys({
|
||||
anthropic: anthropicKey,
|
||||
google: googleKey,
|
||||
openai: openaiKey,
|
||||
zai: zaiKey,
|
||||
});
|
||||
|
||||
// Configure z.ai service on the server with the new key
|
||||
if (zaiKey && zaiKey.trim().length > 0) {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.zai.configure(zaiKey.trim());
|
||||
|
||||
if (result.success || result.isAvailable) {
|
||||
// Update z.ai auth status in the store
|
||||
setZaiAuthStatus({
|
||||
authenticated: true,
|
||||
method: 'api_key' as ZaiAuthMethod,
|
||||
hasApiKey: true,
|
||||
hasEnvApiKey: false,
|
||||
});
|
||||
// Invalidate the z.ai usage query so it refetches with the new key
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||
logger.info('z.ai API key configured successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to configure z.ai API key:', error);
|
||||
}
|
||||
} else {
|
||||
// Clear z.ai auth status if key is removed
|
||||
setZaiAuthStatus({
|
||||
authenticated: false,
|
||||
method: 'none' as ZaiAuthMethod,
|
||||
hasApiKey: false,
|
||||
hasEnvApiKey: false,
|
||||
});
|
||||
// Invalidate the query to clear any cached data
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
@@ -214,6 +303,15 @@ export function useApiKeyManagement() {
|
||||
onTest: handleTestOpenaiConnection,
|
||||
result: openaiTestResult,
|
||||
},
|
||||
zai: {
|
||||
value: zaiKey,
|
||||
setValue: setZaiKey,
|
||||
show: showZaiKey,
|
||||
setShow: setShowZaiKey,
|
||||
testing: testingZaiConnection,
|
||||
onTest: handleTestZaiConnection,
|
||||
result: zaiTestResult,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user