godcrm/components/ContextIndicator.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

194 lines
7.8 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { Activity } from 'lucide-react';
import { cn } from '@/shared/utils/cn';
export interface ContextStats {
total_tokens_in: number;
total_tokens_out: number;
total_messages: number;
text_messages: number;
tool_calls: number;
thinking_steps: number;
last_prompt_tokens: number;
model_used: string | null;
context_window: number | null;
context_usage_percent: number | null;
}
interface ContextIndicatorProps {
stats: ContextStats | null;
className?: string;
}
/** Format token count to compact form: 1234 → "1.2K", 123456 → "123K" */
function formatTokens(n: number): string {
if (n === 0) return '0';
if (n < 1000) return String(n);
if (n < 10000) return (n / 1000).toFixed(1) + 'K';
if (n < 1000000) return Math.round(n / 1000) + 'K';
return (n / 1000000).toFixed(1) + 'M';
}
/** Get color class based on usage percentage */
function getUsageColor(percent: number | null): { bar: string; text: string; bg: string } {
if (percent === null || percent === 0) return { bar: 'bg-gray-500', text: 'text-gray-400', bg: 'bg-gray-500/10' };
if (percent < 50) return { bar: 'bg-emerald-500', text: 'text-emerald-400', bg: 'bg-emerald-500/10' };
if (percent < 75) return { bar: 'bg-yellow-500', text: 'text-yellow-400', bg: 'bg-yellow-500/10' };
if (percent < 90) return { bar: 'bg-orange-500', text: 'text-orange-400', bg: 'bg-orange-500/10' };
return { bar: 'bg-red-500', text: 'text-red-400', bg: 'bg-red-500/10' };
}
/** Format model name to short form */
function formatModel(model: string | null): string {
if (!model) return '—';
// Remove date suffixes like "-20241022"
const cleaned = model.replace(/-\d{8}$/, '');
// Short aliases
const shorts: Record<string, string> = {
'claude-3-5-sonnet': 'Sonnet 3.5',
'claude-3.5-sonnet': 'Sonnet 3.5',
'claude-sonnet-4': 'Sonnet 4',
'claude-4-sonnet': 'Sonnet 4',
'claude-opus-4': 'Opus 4',
'claude-4-opus': 'Opus 4',
'claude-3-opus': 'Opus 3',
'claude-3-haiku': 'Haiku 3',
'claude-3.5-haiku': 'Haiku 3.5',
'gpt-4o': 'GPT-4o',
'gpt-4o-mini': 'GPT-4o mini',
'gpt-4-turbo': 'GPT-4 Turbo',
'o1': 'o1',
'o3': 'o3',
'o3-mini': 'o3-mini',
'o4-mini': 'o4-mini',
};
const lower = cleaned.toLowerCase();
return shorts[lower] || cleaned;
}
export const ContextIndicator: React.FC<ContextIndicatorProps> = ({ stats, className }) => {
const [showTooltip, setShowTooltip] = useState(false);
const { percent, colors, tokenText, totalTokens } = useMemo(() => {
if (!stats) return { percent: null, colors: getUsageColor(null), tokenText: '', totalTokens: 0 };
const p = stats.context_usage_percent;
const c = getUsageColor(p);
const total = stats.total_tokens_in + stats.total_tokens_out;
let text = '';
if (stats.last_prompt_tokens > 0 && stats.context_window) {
text = `${formatTokens(stats.last_prompt_tokens)} / ${formatTokens(stats.context_window)}`;
} else if (total > 0) {
text = `${formatTokens(total)} tokens`;
}
return { percent: p, colors: c, tokenText: text, totalTokens: total };
}, [stats]);
if (!stats || stats.total_messages === 0) return null;
return (
<div
className={cn("relative inline-flex items-center", className)}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{/* Compact badge */}
<div className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium cursor-default transition-colors",
colors.bg, colors.text
)}>
{/* Mini progress bar */}
<div className="w-8 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500", colors.bar)}
style={{ width: `${Math.min(percent || 0, 100)}%` }}
/>
</div>
{/* Percentage or token text */}
{percent !== null ? (
<span>{percent}%</span>
) : tokenText ? (
<span>{tokenText}</span>
) : (
<span>{stats.total_messages} msg</span>
)}
</div>
{/* Tooltip */}
{showTooltip && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-56 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-primary)] shadow-xl text-xs">
{/* Arrow */}
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 rotate-45 bg-[var(--bg-secondary)] border-l border-t border-[var(--border-primary)]" />
<div className="relative space-y-2">
{/* Title */}
<div className="flex items-center gap-1.5 text-[var(--text-primary)] font-medium">
<Activity className="w-3.5 h-3.5" />
Context Usage
</div>
{/* Progress bar (full) */}
{percent !== null && (
<div>
<div className="flex justify-between text-[10px] text-[var(--text-secondary)] mb-1">
<span>{formatTokens(stats.last_prompt_tokens)} prompt</span>
<span className={colors.text}>{percent}%</span>
</div>
<div className="w-full h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500", colors.bar)}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<div className="text-[10px] text-[var(--text-tertiary)] mt-0.5 text-right">
{formatTokens(stats.context_window || 0)} window
</div>
</div>
)}
{/* Stats grid */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[10px]">
<div className="text-[var(--text-tertiary)]">Tokens in</div>
<div className="text-[var(--text-primary)] text-right font-mono">{formatTokens(stats.total_tokens_in)}</div>
<div className="text-[var(--text-tertiary)]">Tokens out</div>
<div className="text-[var(--text-primary)] text-right font-mono">{formatTokens(stats.total_tokens_out)}</div>
<div className="text-[var(--text-tertiary)]">Total tokens</div>
<div className="text-[var(--text-primary)] text-right font-mono">{formatTokens(totalTokens)}</div>
<div className="col-span-2 h-px bg-[var(--border-secondary)] my-0.5" />
<div className="text-[var(--text-tertiary)]">Messages</div>
<div className="text-[var(--text-primary)] text-right font-mono">{stats.text_messages}</div>
<div className="text-[var(--text-tertiary)]">Tool calls</div>
<div className="text-[var(--text-primary)] text-right font-mono">{stats.tool_calls}</div>
<div className="text-[var(--text-tertiary)]">Thinking</div>
<div className="text-[var(--text-primary)] text-right font-mono">{stats.thinking_steps}</div>
{stats.model_used && (
<>
<div className="col-span-2 h-px bg-[var(--border-secondary)] my-0.5" />
<div className="text-[var(--text-tertiary)]">Model</div>
<div className="text-[var(--text-primary)] text-right truncate">{formatModel(stats.model_used)}</div>
</>
)}
</div>
{/* Cost estimate */}
{totalTokens > 0 && (
<div className="pt-1 border-t border-[var(--border-secondary)] text-[10px] text-[var(--text-tertiary)]">
~${((stats.total_tokens_in * 0.003 + stats.total_tokens_out * 0.015) / 1000).toFixed(3)} estimated cost
</div>
)}
</div>
</div>
)}
</div>
);
};