Feat: Add z.ai usage tracking

This commit is contained in:
eclipxe
2026-01-20 14:34:15 -08:00
committed by gsxdsm
parent 1662c6bf0b
commit 7765a12868
23 changed files with 1331 additions and 55 deletions

View File

@@ -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}
/>
)}

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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 {