mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 18:17:26 +00:00
Merge pull request #122 from kaitranntt/dev
feat(release): v6.1.0 - UI enhancements and account flow improvements
This commit is contained in:
@@ -5,6 +5,6 @@
|
||||
"ANTHROPIC_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash"
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "6.1.0",
|
||||
"version": "6.1.0-dev.6",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -13,20 +13,6 @@ import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
|
||||
|
||||
/**
|
||||
* Check if model is a Claude model routed via Antigravity
|
||||
* Claude models require MAX_THINKING_TOKENS < 8192 for thinking to work
|
||||
*/
|
||||
function isClaudeModel(modelId: string): boolean {
|
||||
return modelId.includes('claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* Max thinking tokens for Claude models via Antigravity
|
||||
* Must be < 8192 due to Google protocol conversion limitations
|
||||
*/
|
||||
const CLAUDE_MAX_THINKING_TOKENS = '8191';
|
||||
|
||||
/** CCS directory */
|
||||
const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs');
|
||||
|
||||
@@ -163,8 +149,7 @@ export async function configureProviderModel(
|
||||
|
||||
// Build settings with selective merge:
|
||||
// - Preserve ALL user settings (top-level and env vars)
|
||||
// - Only update CCS-controlled fields (model selection + thinking toggle for Claude)
|
||||
const isClaude = isClaudeModel(selectedModel);
|
||||
// - Only update CCS-controlled fields (model selection)
|
||||
|
||||
// CCS-controlled env vars (always override with our values)
|
||||
const ccsControlledEnv: Record<string, string> = {
|
||||
@@ -176,22 +161,12 @@ export async function configureProviderModel(
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
|
||||
};
|
||||
|
||||
// Claude models require MAX_THINKING_TOKENS < 8192 for thinking to work
|
||||
if (isClaude) {
|
||||
ccsControlledEnv.MAX_THINKING_TOKENS = CLAUDE_MAX_THINKING_TOKENS;
|
||||
}
|
||||
|
||||
// Merge: user env vars (preserved) + CCS controlled (override)
|
||||
const mergedEnv = {
|
||||
...existingEnv,
|
||||
...ccsControlledEnv,
|
||||
};
|
||||
|
||||
// Remove MAX_THINKING_TOKENS when switching away from Claude model
|
||||
if (!isClaude && mergedEnv.MAX_THINKING_TOKENS) {
|
||||
delete mergedEnv.MAX_THINKING_TOKENS;
|
||||
}
|
||||
|
||||
// Build final settings: preserve user top-level settings + update env
|
||||
const settings: Record<string, unknown> = {
|
||||
...existingSettings,
|
||||
@@ -222,15 +197,6 @@ export async function configureProviderModel(
|
||||
console.error(dim(` ${reason}`));
|
||||
console.error(dim(' Consider using a non-deprecated model for better compatibility.'));
|
||||
}
|
||||
|
||||
// Show info for Claude models about thinking token limit
|
||||
if (isClaude) {
|
||||
console.error('');
|
||||
console.error(
|
||||
info(`MAX_THINKING_TOKENS set to ${CLAUDE_MAX_THINKING_TOKENS} (required < 8192)`)
|
||||
);
|
||||
console.error(dim(' Google protocol conversion requires this limit for thinking to work.'));
|
||||
}
|
||||
console.error('');
|
||||
|
||||
return true;
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('Model Config', () => {
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('Model Config', () => {
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
* Cache Efficiency Card Component
|
||||
*
|
||||
* Displays cache usage metrics including hit rate, savings estimate,
|
||||
* and cache read/write breakdown.
|
||||
* and cache read/write breakdown. Respects privacy mode to blur sensitive data.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Database, TrendingUp, Zap } from 'lucide-react';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface CacheEfficiencyCardProps {
|
||||
data: UsageSummary | undefined;
|
||||
@@ -19,6 +20,8 @@ interface CacheEfficiencyCardProps {
|
||||
}
|
||||
|
||||
export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (!data) return null;
|
||||
|
||||
@@ -93,7 +96,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-emerald-600 dark:text-emerald-400">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
<span className="text-2xl font-bold">${metrics.estimatedSavings.toFixed(2)}</span>
|
||||
<span className={cn('text-2xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${metrics.estimatedSavings.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
Estimated Savings
|
||||
@@ -106,21 +111,30 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-lg font-bold">{metrics.cacheHitRate.toFixed(0)}%</span>
|
||||
<span className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{metrics.cacheHitRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Hit Rate</p>
|
||||
</div>
|
||||
|
||||
{/* Cache Cost */}
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<span className="text-lg font-bold">${metrics.cacheCost.toFixed(2)}</span>
|
||||
<span className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${metrics.cacheCost.toFixed(2)}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Cache Cost</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache breakdown bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between text-[10px] text-muted-foreground',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
<span>Reads: {formatCompact(metrics.totalCacheReads)}</span>
|
||||
<span>Writes: {formatCompact(metrics.totalCacheWrites)}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
* Date Range Filter Component
|
||||
*
|
||||
* Provides date range selection with preset options for analytics.
|
||||
* Uses react-day-picker for date selection UI.
|
||||
* Uses react-day-picker for date selection UI within a Popover.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value?: DateRange;
|
||||
@@ -26,70 +28,94 @@ interface DateRangeFilterProps {
|
||||
export function DateRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
presets = [],
|
||||
presets = [
|
||||
{
|
||||
label: 'Last 7 days',
|
||||
range: {
|
||||
from: subDays(new Date(), 7),
|
||||
to: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 30 days',
|
||||
range: {
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 90 days',
|
||||
range: {
|
||||
from: subDays(new Date(), 90),
|
||||
to: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
className,
|
||||
}: DateRangeFilterProps) {
|
||||
const handlePresetClick = (range: DateRange) => {
|
||||
onChange(range);
|
||||
};
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const from = e.target.value ? new Date(e.target.value) : undefined;
|
||||
onChange({ from, to: value?.to });
|
||||
};
|
||||
// Helper to check if a preset is currently selected
|
||||
const isPresetSelected = (presetRange: DateRange) => {
|
||||
if (!value || !value.from || !value.to || !presetRange.from || !presetRange.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const to = e.target.value ? new Date(e.target.value) : undefined;
|
||||
onChange({ from: value?.from, to });
|
||||
// Compare dates (ignoring time components if needed, but day-picker usually returns start of day)
|
||||
// Simple comparison using formatted strings or timestamps
|
||||
return (
|
||||
format(value.from, 'yyyy-MM-dd') === format(presetRange.from, 'yyyy-MM-dd') &&
|
||||
format(value.to, 'yyyy-MM-dd') === format(presetRange.to, 'yyyy-MM-dd')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||
{/* Preset Buttons */}
|
||||
{presets.map((preset, index) => (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={isSameRange(value, preset.range) ? 'default' : 'outline'}
|
||||
key={preset.label}
|
||||
variant={isPresetSelected(preset.range) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset.range)}
|
||||
onClick={() => onChange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Custom Date Range Inputs */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="date"
|
||||
value={value?.from ? format(value.from, 'yyyy-MM-dd') : ''}
|
||||
onChange={handleFromChange}
|
||||
placeholder="From"
|
||||
className="w-40"
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant={'outline'}
|
||||
className={cn(
|
||||
'w-auto min-w-[240px] justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value?.from ? (
|
||||
value.to ? (
|
||||
<>
|
||||
{format(value.from, 'LLL dd, y')} - {format(value.to, 'LLL dd, y')}
|
||||
</>
|
||||
) : (
|
||||
format(value.from, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value}
|
||||
onSelect={onChange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground">to</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={value?.to ? format(value.to, 'yyyy-MM-dd') : ''}
|
||||
onChange={handleToChange}
|
||||
placeholder="To"
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to compare date ranges
|
||||
function isSameRange(a?: DateRange, b?: DateRange): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
|
||||
const fromA = a.from?.getTime() ?? 0;
|
||||
const fromB = b.from?.getTime() ?? 0;
|
||||
const toA = a.to?.getTime() ?? 0;
|
||||
const toB = b.to?.getTime() ?? 0;
|
||||
|
||||
return fromA === fromB && toA === toB;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Model Breakdown Chart Component
|
||||
*
|
||||
* Displays usage distribution by model using pie chart.
|
||||
* Shows tokens, cost, and percentage breakdown.
|
||||
* Shows tokens, cost, and percentage breakdown. Respects privacy mode.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -10,6 +10,7 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
import { cn, getModelColor } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface ModelBreakdownChartProps {
|
||||
data: ModelUsage[];
|
||||
@@ -18,6 +19,8 @@ interface ModelBreakdownChartProps {
|
||||
}
|
||||
|
||||
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
@@ -54,10 +57,12 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
|
||||
<p className="font-medium mb-1">{item.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
<p className={cn('text-muted-foreground', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatNumber(item.value)} ({item.percentage.toFixed(1)}%)
|
||||
</p>
|
||||
<p className="text-muted-foreground">${item.cost.toFixed(4)}</p>
|
||||
<p className={cn('text-muted-foreground', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${item.cost.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-react';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModelDetailsContentProps {
|
||||
model: ModelUsage;
|
||||
}
|
||||
|
||||
export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
|
||||
|
||||
return (
|
||||
@@ -32,11 +35,15 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<p className="text-lg font-bold">${model.cost.toFixed(2)}</p>
|
||||
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${model.cost.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Cost</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<p className="text-lg font-bold">{formatCompactNumber(model.tokens)}</p>
|
||||
<p className={cn('text-lg font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatCompactNumber(model.tokens)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +53,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
<h5 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Token Breakdown
|
||||
</h5>
|
||||
<div className="space-y-1">
|
||||
<div className={cn('space-y-1', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
<TokenRow
|
||||
label="Input"
|
||||
tokens={model.inputTokens}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Session Stats Card Component
|
||||
*
|
||||
* Displays session usage metrics including active sessions, average duration,
|
||||
* and session cost breakdown.
|
||||
* and session cost breakdown. Respects privacy mode to blur sensitive data.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -12,6 +12,7 @@ import { Clock, Users, Zap, Terminal } from 'lucide-react';
|
||||
import type { PaginatedSessions } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface SessionStatsCardProps {
|
||||
data: PaginatedSessions | undefined;
|
||||
@@ -20,6 +21,8 @@ interface SessionStatsCardProps {
|
||||
}
|
||||
|
||||
export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.sessions || data.sessions.length === 0) return null;
|
||||
|
||||
@@ -104,7 +107,9 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span className="text-xl font-bold">${stats.avgCost.toFixed(2)}</span>
|
||||
<span className={cn('text-xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
${stats.avgCost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
Avg Cost/Session
|
||||
@@ -132,7 +137,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
{formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
<div className={cn('text-right shrink-0 ml-2', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
<div className="font-mono">${session.cost.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatCompact(session.inputTokens + session.outputTokens)} toks
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Token Breakdown Chart Component
|
||||
*
|
||||
* Displays token usage breakdown by type (input, output, cache).
|
||||
* Shows stacked bar chart with cost breakdown.
|
||||
* Shows stacked bar chart with cost breakdown. Respects privacy mode.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { TokenBreakdown } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface TokenBreakdownChartProps {
|
||||
data?: TokenBreakdown;
|
||||
@@ -34,6 +35,8 @@ const COLORS = {
|
||||
};
|
||||
|
||||
export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
@@ -151,7 +154,12 @@ export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdo
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Cost breakdown summary */}
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.fill }} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Displays key metrics in a card grid layout.
|
||||
* Shows total tokens, cost, cache tokens, and average cost per day.
|
||||
* Respects privacy mode to blur sensitive financial data.
|
||||
*/
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -10,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
data?: UsageSummary;
|
||||
@@ -17,6 +19,8 @@ interface UsageSummaryCardsProps {
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
@@ -100,9 +104,20 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
|
||||
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
|
||||
<p
|
||||
className={cn('text-xl font-bold truncate', privacyMode && PRIVACY_BLUR_CLASS)}
|
||||
>
|
||||
{card.format(card.value)}
|
||||
</p>
|
||||
{card.subtitle && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{card.subtitle}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{card.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Displays usage trends over time with tokens and cost.
|
||||
* Supports daily, hourly, and monthly granularity with interactive tooltips.
|
||||
* Respects privacy mode to blur sensitive data.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -19,6 +20,7 @@ import { format } from 'date-fns';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
type ChartData = DailyUsage | HourlyUsage;
|
||||
|
||||
@@ -35,6 +37,8 @@ export function UsageTrendChart({
|
||||
granularity = 'daily',
|
||||
className,
|
||||
}: UsageTrendChartProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
@@ -66,6 +70,35 @@ export function UsageTrendChart({
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tick component for privacy-aware axis labels
|
||||
const PrivacyTick = ({
|
||||
x,
|
||||
y,
|
||||
payload,
|
||||
isRight,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: string | number };
|
||||
isRight?: boolean;
|
||||
}) => {
|
||||
const displayValue = isRight ? `$${payload.value}` : formatNumber(Number(payload.value));
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={4}
|
||||
textAnchor={isRight ? 'start' : 'end'}
|
||||
fontSize={12}
|
||||
fill="currentColor"
|
||||
className={cn('fill-muted-foreground', privacyMode && 'blur-[4px]')}
|
||||
>
|
||||
{displayValue}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full h-full', className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -93,19 +126,17 @@ export function UsageTrendChart({
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={(props) => <PrivacyTick {...props} isRight={false} />}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
tickFormatter={(value) => formatNumber(value)}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
tick={(props) => <PrivacyTick {...props} isRight={true} />}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
@@ -117,7 +148,11 @@ export function UsageTrendChart({
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
<p
|
||||
key={index}
|
||||
className={cn('text-sm', privacyMode && PRIVACY_BLUR_CLASS)}
|
||||
style={{ color: entry.color }}
|
||||
>
|
||||
{entry.name}:{' '}
|
||||
{entry.name === 'Tokens'
|
||||
? formatNumber(Number(entry.value) || 0)
|
||||
@@ -125,7 +160,12 @@ export function UsageTrendChart({
|
||||
</p>
|
||||
))}
|
||||
{'requests' in tooltipData && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground mt-1',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
Requests: {tooltipData.requests}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ClaudeKit Badge Button
|
||||
*
|
||||
* "Powered by ClaudeKit" badge for navbar, inspired by landing page design.
|
||||
* Compact version optimized for header placement.
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN';
|
||||
|
||||
export function ClaudeKitBadge() {
|
||||
return (
|
||||
<a
|
||||
href={CLAUDEKIT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 px-3 py-1.5 rounded-lg',
|
||||
'bg-accent/10 border-2 border-accent/40',
|
||||
'hover:bg-accent hover:border-accent',
|
||||
'transition-all duration-200 shadow-sm hover:shadow-md'
|
||||
)}
|
||||
title="Powered by ClaudeKit Framework"
|
||||
>
|
||||
<img src="/logos/claudekit-logo.png" alt="ClaudeKit" className="w-5 h-5" />
|
||||
<span className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium uppercase tracking-wide',
|
||||
'text-muted-foreground group-hover:text-accent-foreground/80',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
Powered by
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-bold text-foreground',
|
||||
'group-hover:text-accent-foreground',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
ClaudeKit
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* - Token usage with cost estimation
|
||||
* - Model breakdown with usage distribution
|
||||
* - Session history
|
||||
* Respects privacy mode to blur sensitive data.
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -26,12 +27,14 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface CliproxyStatsOverviewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||
|
||||
@@ -216,8 +219,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(totalTokens)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className={cn('text-2xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatNumber(totalTokens)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
~${estimateCost(totalTokens).toFixed(2)} estimated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { DocsLink } from '@/components/docs-link';
|
||||
import { ConnectionIndicator } from '@/components/connection-indicator';
|
||||
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ClaudeKitBadge } from '@/components/claudekit-badge';
|
||||
import { SponsorButton } from '@/components/sponsor-button';
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -25,7 +27,10 @@ export function Layout() {
|
||||
<AppSidebar />
|
||||
<main className="flex-1 flex flex-col min-h-0 overflow-hidden bg-background">
|
||||
<header className="flex h-14 items-center justify-between px-6 border-b shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="font-semibold text-lg tracking-tight">CCS Config</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClaudeKitBadge />
|
||||
<SponsorButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectionIndicator />
|
||||
<DocsLink />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Sponsor Button
|
||||
*
|
||||
* GitHub Sponsors button for navbar.
|
||||
* Heart icon with hover animation.
|
||||
*/
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt';
|
||||
|
||||
export function SponsorButton() {
|
||||
return (
|
||||
<a
|
||||
href={SPONSOR_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'bg-pink-500/10 border-2 border-pink-500/40',
|
||||
'hover:bg-pink-400 hover:border-pink-400',
|
||||
'transition-all duration-200 shadow-sm hover:shadow-md'
|
||||
)}
|
||||
title="Sponsor this project on GitHub"
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
'w-4 h-4 text-pink-500',
|
||||
'group-hover:text-white group-hover:fill-white',
|
||||
'group-hover:animate-pulse',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-bold text-pink-600 dark:text-pink-300',
|
||||
'group-hover:text-white dark:group-hover:text-white',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
Sponsor
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button-variants';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-2', className)}
|
||||
classNames={{
|
||||
root: 'relative',
|
||||
months: 'flex flex-col sm:flex-row gap-2 sm:gap-4',
|
||||
month: 'flex flex-col gap-2',
|
||||
month_caption: 'flex justify-center pt-1 relative items-center h-8',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'absolute inset-x-0 top-2 flex items-center justify-between px-1 z-10',
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100 hover:bg-accent rounded-md'
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100 hover:bg-accent rounded-md'
|
||||
),
|
||||
month_grid: 'w-full border-collapse',
|
||||
weekdays: 'flex w-full',
|
||||
weekday: 'text-muted-foreground w-8 font-normal text-xs text-center',
|
||||
weeks: 'flex flex-col',
|
||||
week: 'flex w-full',
|
||||
day: cn(
|
||||
'relative h-8 w-8 p-0 text-center text-xs font-normal focus-within:relative focus-within:z-20',
|
||||
'hover:bg-accent hover:text-accent-foreground rounded-full select-none'
|
||||
),
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-8 w-8 p-0 font-normal rounded-full text-xs',
|
||||
'aria-selected:opacity-100'
|
||||
),
|
||||
// Range Selection Styles
|
||||
range_start: cn(
|
||||
'aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
'aria-selected:rounded-l-full aria-selected:rounded-r-none',
|
||||
'aria-selected:hover:bg-primary aria-selected:hover:text-primary-foreground'
|
||||
),
|
||||
range_end: cn(
|
||||
'aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
'aria-selected:rounded-r-full aria-selected:rounded-l-none',
|
||||
'aria-selected:hover:bg-primary aria-selected:hover:text-primary-foreground'
|
||||
),
|
||||
range_middle: cn(
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
'aria-selected:rounded-none'
|
||||
),
|
||||
selected: cn(
|
||||
'bg-primary text-primary-foreground rounded-full',
|
||||
'hover:bg-primary hover:text-primary-foreground',
|
||||
'focus:bg-primary focus:text-primary-foreground'
|
||||
),
|
||||
today: 'bg-accent text-accent-foreground rounded-full',
|
||||
outside:
|
||||
'text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
disabled: 'text-muted-foreground opacity-50',
|
||||
hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation, className, ...props }) => {
|
||||
const Icon =
|
||||
orientation === 'left'
|
||||
? ChevronLeft
|
||||
: orientation === 'right'
|
||||
? ChevronRight
|
||||
: orientation === 'up'
|
||||
? ChevronUp
|
||||
: ChevronDown;
|
||||
return <Icon className={cn('h-4 w-4', className)} {...props} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
import { getModelColor } from '@/lib/utils';
|
||||
import { getModelColor, cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
// Format token count to human-readable (K/M/B)
|
||||
function formatTokens(num: number): string {
|
||||
@@ -41,6 +42,8 @@ function formatTokens(num: number): string {
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
from: subDays(new Date(), 30),
|
||||
@@ -248,11 +251,21 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Token count */}
|
||||
<span className="text-[10px] text-muted-foreground w-14 text-right shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{formatTokens(model.tokens)}
|
||||
</span>
|
||||
{/* Total cost */}
|
||||
<span className="font-mono font-medium w-16 text-right shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-medium w-16 text-right shrink-0',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
${model.cost.toFixed(2)}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
|
||||
|
||||
@@ -23,16 +23,31 @@ export default defineConfig({
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'radix-ui': [
|
||||
'@radix-ui/react-alert-dialog',
|
||||
'@radix-ui/react-checkbox',
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-dialog',
|
||||
'@radix-ui/react-dropdown-menu',
|
||||
'@radix-ui/react-label',
|
||||
'@radix-ui/react-popover',
|
||||
'@radix-ui/react-scroll-area',
|
||||
'@radix-ui/react-select',
|
||||
'@radix-ui/react-separator',
|
||||
'@radix-ui/react-slot',
|
||||
'@radix-ui/react-switch',
|
||||
'@radix-ui/react-tabs',
|
||||
'@radix-ui/react-tooltip',
|
||||
],
|
||||
'tanstack': ['@tanstack/react-query', '@tanstack/react-table'],
|
||||
'form-utils': ['react-hook-form', '@hookform/resolvers', 'zod'],
|
||||
'icons': ['lucide-react'],
|
||||
// Charts - large library, separate chunk
|
||||
'charts': ['recharts'],
|
||||
// Code editor / syntax highlighting
|
||||
'code-highlight': ['prism-react-renderer'],
|
||||
// Notifications
|
||||
'notifications': ['sonner'],
|
||||
// Utilities
|
||||
'utils': ['date-fns', 'clsx', 'class-variance-authority', 'tailwind-merge', 'yaml'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user