bbb69a315c
Model quality for market segments and product lines now derives from deployed model capabilities (coding, reasoning, agents, etc.) instead of requiring a separate manual benchmark evaluation step. This eliminates an unbounded benchmarkResults[] array that was scanned 5x per tick and removes ~480 lines of dead-weight UI, types, and engine code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
988 lines
48 KiB
TypeScript
988 lines
48 KiB
TypeScript
import { useState } from 'react';
|
|
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Zap } from 'lucide-react';
|
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
|
import { useGameStore } from '@/store';
|
|
import {
|
|
formatNumber, formatPercent, formatDuration,
|
|
VRAM_REQUIREMENTS_BY_GENERATION, DEFAULT_DATA_MIX,
|
|
ALIGNMENT_METHODS,
|
|
QUANTIZATION_CONFIGS,
|
|
PARAMETER_OPTIONS,
|
|
SIZE_TIER_MAP,
|
|
SIZE_TIER_LABELS,
|
|
SFT_SPECIALIZATION_BONUSES,
|
|
PRETRAINING_BASE_TICKS,
|
|
} from '@ai-tycoon/shared';
|
|
import type {
|
|
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
|
|
DataDomain, QuantizationLevel, BaseModel, ModelVariant,
|
|
SizeTier, ModelFamily,
|
|
} from '@ai-tycoon/shared';
|
|
|
|
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
|
|
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
|
|
'code-focused': { label: 'Code-Focused', mix: { web: 0.15, books: 0.05, code: 0.40, scientific: 0.15, conversation: 0.08, multilingual: 0.02, images: 0.03, video: 0.02, audio: 0.02, synthetic: 0.08 } },
|
|
creative: { label: 'Creative', mix: { web: 0.15, books: 0.30, code: 0.05, scientific: 0.05, conversation: 0.25, multilingual: 0.05, images: 0.05, video: 0.03, audio: 0.02, synthetic: 0.05 } },
|
|
research: { label: 'Research', mix: { web: 0.15, books: 0.10, code: 0.15, scientific: 0.35, conversation: 0.05, multilingual: 0.03, images: 0.02, video: 0.02, audio: 0.02, synthetic: 0.11 } },
|
|
};
|
|
|
|
const SFT_OPTIONS: { value: SFTSpecialization; label: string }[] = [
|
|
{ value: 'general', label: 'General' },
|
|
{ value: 'code', label: 'Code' },
|
|
{ value: 'math', label: 'Math' },
|
|
{ value: 'creative', label: 'Creative' },
|
|
{ value: 'multilingual', label: 'Multilingual' },
|
|
{ value: 'tool-use', label: 'Tool Use' },
|
|
];
|
|
|
|
const DOMAIN_LABELS: Record<DataDomain, string> = {
|
|
web: 'Web', books: 'Books', code: 'Code', scientific: 'Scientific',
|
|
conversation: 'Conversation', multilingual: 'Multilingual',
|
|
images: 'Images', video: 'Video', audio: 'Audio', synthetic: 'Synthetic',
|
|
};
|
|
|
|
const QUANT_LABELS: Record<QuantizationLevel, string> = {
|
|
fp16: 'FP16', int8: 'INT8', int4: 'INT4', int2: 'INT2',
|
|
};
|
|
|
|
export function ModelsPage() {
|
|
const baseModels = useGameStore((s) => s.models.baseModels);
|
|
const families = useGameStore((s) => s.models.families);
|
|
const pipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
|
const variantJobs = useGameStore((s) => s.models.variantJobs);
|
|
const productLines = useGameStore((s) => s.models.productLines);
|
|
const totalFlops = useGameStore((s) => s.compute.totalFlops);
|
|
const totalVramGB = useGameStore((s) => s.compute.totalVramGB);
|
|
const trainingAlloc = useGameStore((s) => s.compute.trainingAllocation);
|
|
const totalData = useGameStore((s) => s.data.totalTrainingTokens);
|
|
const currentEra = useGameStore((s) => s.meta.currentEra);
|
|
const startTrainingPipeline = useGameStore((s) => s.startTrainingPipeline);
|
|
const deployModel = useGameStore((s) => s.deployModel);
|
|
const deployVariant = useGameStore((s) => s.deployVariant);
|
|
const createQuantization = useGameStore((s) => s.createQuantization);
|
|
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
|
|
const openSourceModel = useGameStore((s) => s.openSourceModel);
|
|
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
|
|
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
|
|
|
const modelsTab = useGameStore((s) => s.modelsTab);
|
|
const setModelsTab = useGameStore((s) => s.setModelsTab);
|
|
const [modelName, setModelName] = useState('');
|
|
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
|
const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null);
|
|
const [parameterCount, setParameterCount] = useState(7);
|
|
const [contextWindow, setContextWindow] = useState(8);
|
|
const [archType, setArchType] = useState<'dense' | 'moe'>('dense');
|
|
const [dataMix, setDataMix] = useState<DataMixAllocation>({ ...DEFAULT_DATA_MIX });
|
|
const [dataMixPreset, setDataMixPreset] = useState('balanced');
|
|
|
|
// New model lifecycle state
|
|
const [familyMode, setFamilyMode] = useState<'new' | 'existing'>('new');
|
|
const [selectedFamilyId, setSelectedFamilyId] = useState<string | null>(null);
|
|
const [isPointRelease, setIsPointRelease] = useState(false);
|
|
const [sourceModelId, setSourceModelId] = useState<string | null>(null);
|
|
const [sftSpecs, setSftSpecs] = useState<SFTSpecialization[]>(['general']);
|
|
const [alignMethod, setAlignMethod] = useState<AlignmentMethod>('rlhf');
|
|
const [safetyWeight, setSafetyWeight] = useState(0.5);
|
|
|
|
const trainingFlops = totalFlops * trainingAlloc;
|
|
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(PRETRAINING_BASE_TICKS / (1 + trainingFlops * 0.1))) : Infinity;
|
|
const estimatedCapability = Math.min(95, Math.sqrt(trainingFlops) * 5 + Math.log10(1 + totalData / 1e8) * 10);
|
|
|
|
const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
|
|
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
|
|
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
|
|
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0;
|
|
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
|
|
|
|
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
|
|
const currentEraIdx = eraOrder.indexOf(currentEra);
|
|
const hasAlignmentResearch = completedResearch.some(r =>
|
|
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
|
|
);
|
|
|
|
// Computed size tier
|
|
const sizeTier: SizeTier = SIZE_TIER_MAP[parameterCount] ?? 'small';
|
|
|
|
// Model name preview
|
|
const familyNameForPreview = familyMode === 'new'
|
|
? (modelName.trim() || `Family ${families.length + 1}`)
|
|
: (families.find(f => f.id === selectedFamilyId)?.name ?? 'Family');
|
|
const nextVersion = (() => {
|
|
if (!isPointRelease || !sourceModelId) return 1.0;
|
|
const src = baseModels.find(m => m.id === sourceModelId);
|
|
return src ? Math.round((src.version + 0.1) * 10) / 10 : 1.0;
|
|
})();
|
|
const modelNamePreview = `${familyNameForPreview} ${SIZE_TIER_LABELS[sizeTier]} v${nextVersion.toFixed(1)}`;
|
|
|
|
const handleStartTraining = () => {
|
|
if (trainingFlops === 0) return;
|
|
|
|
const architecture: ModelArchitecture = {
|
|
type: archType,
|
|
totalParameters: parameterCount,
|
|
activeParameters: archType === 'moe' ? Math.ceil(parameterCount * 0.25) : parameterCount,
|
|
contextWindow,
|
|
vocabularySize: 32000,
|
|
...(archType === 'moe' ? { expertCount: 8, expertTopK: 2 } : {}),
|
|
};
|
|
|
|
startTrainingPipeline({
|
|
...(familyMode === 'new'
|
|
? { familyName: modelName.trim() || `Family ${families.length + 1}` }
|
|
: { familyId: selectedFamilyId! }),
|
|
architecture,
|
|
dataMix,
|
|
allocatedComputeFraction: 1.0,
|
|
targetTokens: totalData,
|
|
totalTicks: estimatedTicks,
|
|
sftSpecializations: sftSpecs,
|
|
alignmentMethod: alignMethod,
|
|
alignmentSafetyWeight: safetyWeight,
|
|
isPointRelease,
|
|
sourceModelId: sourceModelId ?? undefined,
|
|
});
|
|
setModelName('');
|
|
setIsPointRelease(false);
|
|
setSourceModelId(null);
|
|
};
|
|
|
|
const handlePresetChange = (presetKey: string) => {
|
|
setDataMixPreset(presetKey);
|
|
const preset = DATA_MIX_PRESETS[presetKey];
|
|
if (preset) setDataMix({ ...preset.mix });
|
|
};
|
|
|
|
const handleMixSlider = (domain: DataDomain, value: number) => {
|
|
const newMix = { ...dataMix, [domain]: value / 100 };
|
|
const total = Object.values(newMix).reduce((s, v) => s + v, 0);
|
|
if (total > 0) {
|
|
for (const key of Object.keys(newMix) as DataDomain[]) {
|
|
newMix[key] = newMix[key] / total;
|
|
}
|
|
}
|
|
setDataMix(newMix);
|
|
setDataMixPreset('custom');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h2 className="text-2xl font-bold">Models</h2>
|
|
|
|
<TutorialHint id="models-intro">
|
|
Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue.
|
|
</TutorialHint>
|
|
|
|
<div className="flex gap-1 border-b border-surface-700 pb-px">
|
|
{([
|
|
{ id: 'overview' as const, label: 'Overview' },
|
|
{ id: 'train' as const, label: 'Train New' },
|
|
{ id: 'models' as const, label: `Families${families.length > 0 ? ` (${families.length})` : ''}` },
|
|
{ id: 'products' as const, label: 'Products' },
|
|
]).map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setModelsTab(tab.id)}
|
|
className={`px-4 py-2 text-sm rounded-t-lg transition-colors flex items-center gap-1 ${
|
|
modelsTab === tab.id
|
|
? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
|
|
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
{tab.id === 'overview' && hasActiveJobs && (
|
|
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
|
)}
|
|
{tab.id === 'models' && undeployedCount > 0 && (
|
|
<span className="px-1.5 py-0.5 text-[9px] rounded-full bg-warning/20 text-warning">{undeployedCount}</span>
|
|
)}
|
|
{tab.id === 'products' && noModelDeployed && (
|
|
<span className="w-2 h-2 rounded-full bg-warning" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Compute Allocation — always visible */}
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="font-semibold mb-3">Compute Allocation</h3>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-surface-400 w-20">Training</span>
|
|
<input
|
|
type="range" min={0} max={100}
|
|
value={trainingAlloc * 100}
|
|
onChange={(e) => setTrainingAllocation(Number(e.target.value) / 100)}
|
|
className="flex-1 accent-accent"
|
|
/>
|
|
<span className="text-sm text-surface-400 w-20 text-right">Inference</span>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-surface-500 mt-1">
|
|
<span>{formatPercent(trainingAlloc)}</span>
|
|
<span>{formatPercent(1 - trainingAlloc)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active Training Pipelines */}
|
|
{modelsTab === 'overview' && activePipelines.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h3 className="font-semibold">Active Training</h3>
|
|
{activePipelines.map(pipeline => {
|
|
const stage = pipeline.currentStage === 'pretraining' ? pipeline.stages.pretraining
|
|
: pipeline.currentStage === 'sft' ? pipeline.stages.sft
|
|
: pipeline.stages.alignment;
|
|
if (!stage) return null;
|
|
const progress = stage.progressTicks / stage.totalTicks;
|
|
const generation = families.find(f => f.id === pipeline.familyId)?.generation ?? 1;
|
|
const reqVram = VRAM_REQUIREMENTS_BY_GENERATION[generation] ?? 0;
|
|
const isStalled = pipeline.status === 'stalled';
|
|
const isExpanded = expandedPipeline === pipeline.id;
|
|
|
|
const stageLabel = pipeline.currentStage === 'pretraining' ? 'Pre-training'
|
|
: pipeline.currentStage === 'sft' ? 'SFT' : 'Alignment';
|
|
|
|
const recentEvents = pipeline.events.slice(-3).reverse();
|
|
|
|
return (
|
|
<div key={pipeline.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setExpandedPipeline(isExpanded ? null : pipeline.id)} className="text-surface-400 hover:text-surface-200">
|
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
<div>
|
|
<span className="text-sm font-medium">{pipeline.modelName}</span>
|
|
<span className="text-xs text-surface-500 ml-2">
|
|
{pipeline.architecture.totalParameters}B {pipeline.architecture.type.toUpperCase()} · {pipeline.architecture.contextWindow}K ctx
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs px-2 py-0.5 rounded bg-surface-700 text-surface-300">{stageLabel}</span>
|
|
<span className="text-sm text-surface-400">
|
|
{isStalled ? <span className="text-error">Stalled</span> : `${formatPercent(progress)}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1 mb-2">
|
|
<StageBar label="Pre" active={pipeline.currentStage === 'pretraining'} complete={pipeline.stages.pretraining.isComplete} progress={pipeline.stages.pretraining.progressTicks / pipeline.stages.pretraining.totalTicks} />
|
|
<StageBar label="SFT" active={pipeline.currentStage === 'sft'} complete={pipeline.stages.sft.isComplete} progress={pipeline.stages.sft.progressTicks / pipeline.stages.sft.totalTicks} />
|
|
<StageBar label="Align" active={pipeline.currentStage === 'alignment'} complete={pipeline.stages.alignment.isComplete} progress={pipeline.stages.alignment.progressTicks / pipeline.stages.alignment.totalTicks} />
|
|
</div>
|
|
|
|
<div className="h-2 bg-surface-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-300 ${isStalled ? 'bg-error' : 'bg-accent'}`}
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-surface-500 mt-1">
|
|
{isStalled
|
|
? `Requires ${formatNumber(reqVram)} GB VRAM (have ${formatNumber(totalVramGB)} GB)`
|
|
: `ETA: ${formatDuration(stage.totalTicks - stage.progressTicks)}`}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-3 pt-3 border-t border-surface-700 space-y-2">
|
|
{pipeline.currentStage === 'pretraining' && (
|
|
<div className="text-xs text-surface-400">
|
|
Loss: <span className="font-mono text-surface-300">{pipeline.stages.pretraining.lossValue.toFixed(3)}</span>
|
|
{' · '}Chinchilla ratio: <span className="font-mono text-surface-300">{pipeline.stages.pretraining.chinchillaRatio.toFixed(1)}</span>
|
|
</div>
|
|
)}
|
|
|
|
{recentEvents.length > 0 && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-surface-500 font-medium">Recent Events</span>
|
|
{recentEvents.map(event => (
|
|
<div key={event.id} className={`text-xs px-2 py-1 rounded ${
|
|
event.type === 'breakthrough' || event.type === 'emergent_capability' ? 'bg-success/10 text-success' :
|
|
event.type === 'loss_spike' || event.type === 'instability' ? 'bg-error/10 text-error' :
|
|
'bg-warning/10 text-warning'
|
|
}`}>
|
|
{event.description}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Variant Jobs */}
|
|
{modelsTab === 'overview' && activeVariantJobs.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h3 className="font-semibold">Variant Jobs</h3>
|
|
{activeVariantJobs.map(job => {
|
|
const base = baseModels.find(m => m.id === job.baseModelId);
|
|
const progress = job.progressTicks / job.totalTicks;
|
|
return (
|
|
<div key={job.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm">{('variantName' in job.config ? (job.config as { variantName: string }).variantName : base?.name) ?? 'Variant'}</span>
|
|
<span className="text-xs px-2 py-0.5 rounded bg-surface-700 text-surface-300 capitalize">{job.jobType}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
|
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
|
|
</div>
|
|
<div className="text-xs text-surface-500 mt-1">
|
|
{formatPercent(progress)} · ETA: {formatDuration(job.totalTicks - job.progressTicks)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Train New Model */}
|
|
{modelsTab === 'train' && <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
|
<h3 className="font-semibold">Train New Model</h3>
|
|
|
|
{isPointRelease && sourceModelId && (
|
|
<div className="bg-accent/10 border border-accent/30 rounded-lg px-3 py-2 flex items-center justify-between">
|
|
<div className="text-sm text-accent-light">
|
|
Point Release — iterating on <span className="font-mono">{baseModels.find(m => m.id === sourceModelId)?.name ?? 'model'}</span>
|
|
<span className="text-xs text-surface-400 ml-2">(40% training time)</span>
|
|
</div>
|
|
<button onClick={() => { setIsPointRelease(false); setSourceModelId(null); }} className="text-xs text-surface-400 hover:text-surface-200">Cancel</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
|
|
{/* Family selector */}
|
|
<div>
|
|
<label className="block text-xs text-surface-400 mb-1">Model Family</label>
|
|
<div className="flex gap-2 mb-2">
|
|
<button
|
|
onClick={() => { setFamilyMode('new'); setIsPointRelease(false); setSourceModelId(null); }}
|
|
className={`flex-1 px-3 py-2 rounded text-sm border transition-colors ${familyMode === 'new' ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-300'}`}
|
|
>
|
|
New Family
|
|
</button>
|
|
<button
|
|
onClick={() => setFamilyMode('existing')}
|
|
disabled={families.length === 0}
|
|
className={`flex-1 px-3 py-2 rounded text-sm border transition-colors ${familyMode === 'existing' ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-300'} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
Add to Family
|
|
</button>
|
|
</div>
|
|
{familyMode === 'new' ? (
|
|
<input
|
|
type="text" value={modelName}
|
|
onChange={(e) => setModelName(e.target.value)}
|
|
placeholder={`Family ${families.length + 1}`}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
|
/>
|
|
) : (
|
|
<select
|
|
value={selectedFamilyId ?? ''}
|
|
onChange={(e) => setSelectedFamilyId(e.target.value || null)}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
|
>
|
|
<option value="">Select a family...</option>
|
|
{families.map(f => (
|
|
<option key={f.id} value={f.id}>{f.name} (Gen {f.generation})</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Architecture & Parameters */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-surface-400 mb-1">Architecture</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setArchType('dense')}
|
|
className={`flex-1 px-3 py-2 rounded text-sm border transition-colors ${archType === 'dense' ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-300'}`}
|
|
>
|
|
Dense
|
|
</button>
|
|
<button
|
|
onClick={() => setArchType('moe')}
|
|
className={`flex-1 px-3 py-2 rounded text-sm border transition-colors ${archType === 'moe' ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-300'}`}
|
|
>
|
|
MoE
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-surface-400 mb-1">Context Window</label>
|
|
<select
|
|
value={contextWindow}
|
|
onChange={(e) => setContextWindow(Number(e.target.value))}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
|
>
|
|
{[4, 8, 32, 128, 256, 1024].map(c => (
|
|
<option key={c} value={c}>{c >= 1024 ? `${c / 1024}M` : `${c}K`}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Parameters with size tier indicator */}
|
|
<div>
|
|
<label className="block text-xs text-surface-400 mb-1">Parameters (Billions)</label>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={parameterCount}
|
|
onChange={(e) => setParameterCount(Number(e.target.value))}
|
|
className="flex-1 bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
|
>
|
|
{PARAMETER_OPTIONS.map(p => (
|
|
<option key={p} value={p}>{p}B</option>
|
|
))}
|
|
</select>
|
|
<span className="text-xs px-2 py-1 rounded bg-accent/10 text-accent-light border border-accent/30">
|
|
{SIZE_TIER_LABELS[sizeTier]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data Mix */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-xs text-surface-400">Training Data Mix</label>
|
|
<div className="flex gap-1">
|
|
{Object.entries(DATA_MIX_PRESETS).map(([key, preset]) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => handlePresetChange(key)}
|
|
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
|
dataMixPreset === key ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-400'
|
|
}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<p className="text-[10px] text-surface-500 mb-1">Total must equal 100% — other values adjust proportionally.</p>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
{(Object.keys(DOMAIN_LABELS) as DataDomain[]).map(domain => (
|
|
<div key={domain} className="flex items-center gap-2">
|
|
<span className="text-[10px] text-surface-400 w-16">{DOMAIN_LABELS[domain]}</span>
|
|
<input
|
|
type="range" min={0} max={100}
|
|
value={Math.round(dataMix[domain] * 100)}
|
|
onChange={(e) => handleMixSlider(domain, Number(e.target.value))}
|
|
className="flex-1 accent-accent h-1"
|
|
/>
|
|
<span className="text-[10px] font-mono text-surface-500 w-8 text-right">{Math.round(dataMix[domain] * 100)}%</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SFT Configuration */}
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-2">
|
|
<Beaker size={12} className="text-surface-400" />
|
|
<label className="text-xs text-surface-400">SFT Specializations</label>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{SFT_OPTIONS.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setSftSpecs(prev =>
|
|
prev.includes(opt.value)
|
|
? prev.filter(s => s !== opt.value)
|
|
: [...prev, opt.value]
|
|
)}
|
|
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
|
sftSpecs.includes(opt.value)
|
|
? 'bg-accent/20 border-accent text-accent-light'
|
|
: 'bg-surface-800 border-surface-600 text-surface-400'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{sftSpecs.length > 0 && (
|
|
<div className="mt-2 text-[10px] text-surface-500">
|
|
<span className="font-medium text-surface-400">Bonus preview: </span>
|
|
{sftSpecs.map(spec => {
|
|
const bonuses = SFT_SPECIALIZATION_BONUSES[spec];
|
|
if (!bonuses) return null;
|
|
const positives = Object.entries(bonuses).filter(([, v]) => v > 0).map(([k, v]) => `${k} +${v}`);
|
|
const negatives = Object.entries(bonuses).filter(([, v]) => v < 0).map(([k, v]) => `${k} ${v}`);
|
|
return (
|
|
<span key={spec} className="inline-block mr-2">
|
|
<span className="text-accent-light">{spec}</span>
|
|
{positives.length > 0 && <span className="text-success ml-1">{positives.join(', ')}</span>}
|
|
{negatives.length > 0 && <span className="text-error ml-1">{negatives.join(', ')}</span>}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Alignment Configuration */}
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-2">
|
|
<Shield size={12} className="text-surface-400" />
|
|
<label className="text-xs text-surface-400">Alignment</label>
|
|
</div>
|
|
{hasAlignmentResearch ? (
|
|
<div className="space-y-2">
|
|
<div className="flex gap-1">
|
|
{(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => {
|
|
const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch);
|
|
return (
|
|
<button
|
|
key={method}
|
|
disabled={!isAvailable}
|
|
onClick={() => setAlignMethod(method)}
|
|
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
|
alignMethod === method ? 'bg-accent/20 border-accent text-accent-light' :
|
|
!isAvailable ? 'bg-surface-800 border-surface-700 text-surface-600 cursor-not-allowed' :
|
|
'bg-surface-800 border-surface-600 text-surface-400'
|
|
}`}
|
|
>
|
|
{method.toUpperCase()}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-surface-400">Safety</span>
|
|
<input type="range" min={0} max={100} value={safetyWeight * 100}
|
|
onChange={(e) => setSafetyWeight(Number(e.target.value) / 100)}
|
|
className="flex-1 accent-accent h-1" />
|
|
<span className="text-[10px] text-surface-400">Helpful</span>
|
|
<span className="text-[10px] font-mono text-surface-500 w-8 text-right">{Math.round(safetyWeight * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-surface-500 flex items-center gap-1">
|
|
<Shield size={10} /> Requires alignment research — defaults to RLHF
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-3 text-sm">
|
|
<div className="bg-surface-800 rounded-lg p-3">
|
|
<div className="text-xs text-surface-400">Training Compute</div>
|
|
<div className="font-mono">{formatNumber(trainingFlops)} FLOPS</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-3">
|
|
<div className="text-xs text-surface-400">Available VRAM</div>
|
|
<div className="font-mono">{formatNumber(totalVramGB)} GB</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-3">
|
|
<div className="text-xs text-surface-400">Training Data</div>
|
|
<div className="font-mono">{formatNumber(totalData)} tokens</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-3">
|
|
<div className="text-xs text-surface-400">Est. Time</div>
|
|
<div className="font-mono">{trainingFlops > 0 ? formatDuration(estimatedTicks) : 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-surface-400">
|
|
Estimated capability: <span className="text-accent-light font-mono">{estimatedCapability.toFixed(1)}/100</span>
|
|
{archType === 'moe' && <span className="ml-2 text-xs text-accent">(+15% MoE bonus)</span>}
|
|
</div>
|
|
|
|
{/* Model name preview */}
|
|
<div className="bg-surface-800/50 rounded-lg px-3 py-2 flex items-center gap-2">
|
|
<span className="text-[10px] text-surface-500">Model name:</span>
|
|
<span className="text-sm font-mono text-surface-300">{modelNamePreview}</span>
|
|
</div>
|
|
|
|
{/* Start button */}
|
|
<div>
|
|
<button
|
|
onClick={handleStartTraining}
|
|
disabled={trainingFlops === 0 || (familyMode === 'existing' && !selectedFamilyId)}
|
|
className="flex items-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Play size={16} />
|
|
{isPointRelease ? 'Start Point Release' : 'Start Training'}
|
|
</button>
|
|
{trainingFlops === 0 && totalFlops === 0 && (
|
|
<p className="text-xs text-warning mt-1">Build a data center and order racks first</p>
|
|
)}
|
|
{trainingFlops === 0 && totalFlops > 0 && (
|
|
<p className="text-xs text-warning mt-1">Allocate compute to training above</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>}
|
|
|
|
{/* Model Families & Trained Models */}
|
|
{modelsTab === 'models' && families.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h3 className="font-semibold">Model Families</h3>
|
|
{families.map(family => {
|
|
const familyModels = baseModels.filter(m => m.familyId === family.id);
|
|
const variants = family.variants;
|
|
const isExpanded = expandedModel === family.id;
|
|
|
|
return (
|
|
<div key={family.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
{/* Family header */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setExpandedModel(isExpanded ? null : family.id)} className="text-surface-400 hover:text-surface-200">
|
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
<h4 className="font-medium">{family.name} <span className="text-xs text-surface-500">Gen {family.generation}</span></h4>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => { setModelsTab('train'); setFamilyMode('existing'); setSelectedFamilyId(family.id); }} className="text-xs text-accent hover:text-accent-light">
|
|
+ Train New Size
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model rows */}
|
|
{familyModels.map(model => (
|
|
<div key={model.id} className="flex items-center justify-between py-2 border-t border-surface-800 text-sm">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-medium">{model.name}</span>
|
|
<span className="text-xs text-surface-500">{model.architecture.totalParameters}B</span>
|
|
<span className="text-xs text-surface-500">Cap: {model.rawCapability.toFixed(1)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{model.isDeployed ? (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-success/20 text-success">Deployed</span>
|
|
) : (
|
|
<button onClick={() => deployModel(model.id)} className="text-xs bg-accent hover:bg-accent-dark text-white rounded px-2 py-1">Deploy</button>
|
|
)}
|
|
<button onClick={() => {
|
|
setModelsTab('train');
|
|
setFamilyMode('existing');
|
|
setSelectedFamilyId(family.id);
|
|
setIsPointRelease(true);
|
|
setSourceModelId(model.id);
|
|
setParameterCount(model.architecture.totalParameters);
|
|
setArchType(model.architecture.type);
|
|
setContextWindow(model.architecture.contextWindow);
|
|
setDataMix(model.dataMix);
|
|
setSftSpecs(model.sftSpecializations);
|
|
setAlignMethod(model.alignmentMethod ?? 'rlhf');
|
|
}} className="text-xs text-surface-400 hover:text-accent">Iterate</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{familyModels.length === 0 && (
|
|
<p className="text-xs text-surface-500 py-2">Training in progress...</p>
|
|
)}
|
|
|
|
{/* Expanded: details, quantize, eval for each model */}
|
|
{isExpanded && familyModels.length > 0 && (
|
|
<div className="mt-4 pt-4 border-t border-surface-700 space-y-4">
|
|
{familyModels.map(model => (
|
|
<div key={model.id} className="space-y-3">
|
|
<h5 className="text-sm font-medium text-surface-300">{model.name}</h5>
|
|
<ModelDetails model={model} />
|
|
<QuantizationCreator model={model} completedResearch={completedResearch} onQuantize={createQuantization} />
|
|
</div>
|
|
))}
|
|
|
|
{variants.length > 0 && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-surface-300">Quantized Variants</span>
|
|
{variants.map(variant => (
|
|
<VariantCard
|
|
key={variant.id}
|
|
variant={variant}
|
|
familyId={family.id}
|
|
onDeploy={() => deployVariant(family.id, variant.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Product Lines */}
|
|
{modelsTab === 'products' && <div className="space-y-3">
|
|
<h3 className="font-semibold">Product Lines</h3>
|
|
{noModelDeployed && (
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 text-center space-y-3">
|
|
<p className="text-surface-400 text-sm">No model deployed yet. Deploy a model to start earning revenue.</p>
|
|
<button onClick={() => setModelsTab('models')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
|
|
<Rocket size={16} /> Go to Families
|
|
</button>
|
|
</div>
|
|
)}
|
|
{productLines.map(pl => (
|
|
<div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">{pl.name}</h4>
|
|
<div className="text-xs text-surface-400">
|
|
{pl.modelId ? `Running: ${baseModels.find(m => m.id === pl.modelId)?.name ?? 'Unknown'}` : 'No model deployed'}
|
|
</div>
|
|
</div>
|
|
<span className={`text-xs px-2 py-1 rounded-full ${pl.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}>
|
|
{pl.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>}
|
|
|
|
{modelsTab === 'models' && families.length === 0 && (
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
|
|
<p className="text-surface-400 text-sm">No model families yet. Train your first model to get started.</p>
|
|
<button onClick={() => setModelsTab('train')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
|
|
<Play size={16} /> Train Your First Model
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{modelsTab === 'overview' && !hasActiveJobs && (
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
|
|
<p className="text-surface-400 text-sm">No active training or evaluation jobs.</p>
|
|
<div className="flex justify-center gap-3">
|
|
<button onClick={() => setModelsTab('train')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
|
|
<Play size={16} /> {families.length === 0 ? 'Train Your First Model' : 'Train New Model'}
|
|
</button>
|
|
{families.length > 0 && (
|
|
<button onClick={() => setModelsTab('models')} className="inline-flex items-center gap-2 bg-surface-700 hover:bg-surface-600 text-surface-200 px-4 py-2 rounded-lg text-sm">
|
|
View Families ({families.length})
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: {
|
|
model: BaseModel;
|
|
isOpenSourced: boolean;
|
|
onDeploy: () => void;
|
|
onOpenSource: () => void;
|
|
}) {
|
|
const [confirmAction, setConfirmAction] = useState<'deploy' | 'opensource' | null>(null);
|
|
|
|
return (
|
|
<>
|
|
{!isOpenSourced && model.isDeployed && (
|
|
<button onClick={() => setConfirmAction('opensource')}
|
|
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs">
|
|
<Globe size={12} /> Open Source
|
|
</button>
|
|
)}
|
|
{model.isDeployed ? (
|
|
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
|
) : (
|
|
<button onClick={() => setConfirmAction('deploy')}
|
|
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs">
|
|
<Rocket size={12} /> Deploy
|
|
</button>
|
|
)}
|
|
{confirmAction === 'deploy' && (
|
|
<ConfirmModal
|
|
title="Deploy Model"
|
|
message={`Deploy "${model.name}" to production? All product lines will use this model for inference.`}
|
|
confirmLabel="Deploy"
|
|
onConfirm={() => { onDeploy(); setConfirmAction(null); }}
|
|
onCancel={() => setConfirmAction(null)}
|
|
/>
|
|
)}
|
|
{confirmAction === 'opensource' && (
|
|
<ConfirmModal
|
|
title="Open Source Model"
|
|
message={`Open source "${model.name}"? This will make the model publicly available. Your competitors will benefit from it. This cannot be undone.`}
|
|
confirmLabel="Open Source"
|
|
danger
|
|
onConfirm={() => { onOpenSource(); setConfirmAction(null); }}
|
|
onCancel={() => setConfirmAction(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ModelDetails({ model }: { model: BaseModel }) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Architecture</span>
|
|
<div className="font-mono mt-0.5">{model.architecture.totalParameters}B {model.architecture.type}</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Context</span>
|
|
<div className="font-mono mt-0.5">{model.architecture.contextWindow >= 1024 ? `${model.architecture.contextWindow / 1024}M` : `${model.architecture.contextWindow}K`}</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Stages</span>
|
|
<div className="font-mono mt-0.5">{model.trainingStagesCompleted.join(' + ')}</div>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
|
{(['reasoning', 'coding', 'creative', 'math', 'knowledge', 'multimodal', 'agents', 'speed', 'contextUtilization'] as const).map(cap => (
|
|
<div key={cap} className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400 capitalize">{cap === 'contextUtilization' ? 'Context Util.' : cap}</span>
|
|
<div className="font-mono mt-0.5">{model.capabilities[cap].toFixed(1)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Safety</span>
|
|
<div className="font-mono mt-0.5">{model.safetyProfile.overallSafety.toFixed(1)}</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Harm Avoidance</span>
|
|
<div className="font-mono mt-0.5">{model.safetyProfile.harmAvoidance.toFixed(1)}</div>
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<span className="text-surface-400">Refusal Rate</span>
|
|
<div className="font-mono mt-0.5">{formatPercent(model.safetyProfile.refusalRate)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QuantizationCreator({ model, completedResearch, onQuantize }: {
|
|
model: BaseModel;
|
|
completedResearch: string[];
|
|
onQuantize: (baseModelId: string, level: QuantizationLevel, name: string) => void;
|
|
}) {
|
|
const [showCreator, setShowCreator] = useState(false);
|
|
const [quantLevel, setQuantLevel] = useState<QuantizationLevel>('int8');
|
|
const hasQuantization = completedResearch.includes('quantization') || completedResearch.includes('model-compression');
|
|
|
|
if (!hasQuantization) return null;
|
|
|
|
if (!showCreator) {
|
|
return (
|
|
<button
|
|
onClick={() => setShowCreator(true)}
|
|
className="flex items-center gap-1 text-xs text-accent hover:text-accent-light"
|
|
>
|
|
<Zap size={12} /> Create Quantized Variant
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-surface-800/50 rounded-lg p-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-surface-300">Quantize {model.name}</span>
|
|
<button onClick={() => setShowCreator(false)} className="text-xs text-surface-500 hover:text-surface-300">Close</button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div>
|
|
<label className="text-[10px] text-surface-400">Quantization Level</label>
|
|
<div className="flex gap-1 mt-0.5">
|
|
{(Object.keys(QUANTIZATION_CONFIGS) as QuantizationLevel[]).map(level => {
|
|
const cfg = QUANTIZATION_CONFIGS[level];
|
|
return (
|
|
<button key={level} onClick={() => setQuantLevel(level)}
|
|
className={`flex-1 px-2 py-1 rounded text-[10px] border ${quantLevel === level ? 'bg-accent/20 border-accent text-accent-light' : 'bg-surface-800 border-surface-600 text-surface-400'}`}>
|
|
<div>{QUANT_LABELS[level]}</div>
|
|
<div className="text-[9px] text-surface-500">{(cfg.qualityRetention * 100).toFixed(0)}% · {cfg.speedMultiplier}x</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<button onClick={() => { onQuantize(model.id, quantLevel, `${model.name}-Turbo`); setShowCreator(false); }}
|
|
className="bg-accent hover:bg-accent-dark text-white rounded px-3 py-1 text-xs">
|
|
Start Quantization
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VariantCard({ variant, familyId, onDeploy }: {
|
|
variant: ModelVariant;
|
|
familyId: string;
|
|
onDeploy: () => void;
|
|
}) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="bg-surface-800/50 rounded-lg p-3 ml-4 border-l-2 border-surface-600">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setIsExpanded(!isExpanded)} className="text-surface-400 hover:text-surface-200">
|
|
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
<div>
|
|
<span className="text-sm font-medium">{variant.name}</span>
|
|
<span className="text-[10px] ml-2 text-green-400">Quantized</span>
|
|
{variant.quantization && <span className="text-[10px] text-surface-500 ml-1">{variant.quantization.toUpperCase()}</span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-surface-500">
|
|
{variant.costMultiplier < 1 ? `${(variant.costMultiplier * 100).toFixed(0)}% cost` : ''}
|
|
{variant.speedMultiplier > 1 ? ` ${variant.speedMultiplier.toFixed(1)}x speed` : ''}
|
|
</span>
|
|
{variant.isDeployed ? (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-success/20 text-success">Deployed</span>
|
|
) : (
|
|
<button onClick={onDeploy}
|
|
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-[10px]">
|
|
<Rocket size={10} /> Deploy
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-3 pt-2 border-t border-surface-700 space-y-2">
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
{(['reasoning', 'coding', 'creative', 'math', 'knowledge', 'speed'] as const).map(cap => (
|
|
<div key={cap} className="bg-surface-800 rounded p-1.5">
|
|
<span className="text-surface-400 capitalize text-[10px]">{cap}</span>
|
|
<div className="font-mono text-[11px]">{variant.capabilities[cap].toFixed(1)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StageBar({ label, active, complete, progress }: {
|
|
label: string; active: boolean; complete: boolean; progress: number;
|
|
}) {
|
|
return (
|
|
<div className="flex-1">
|
|
<div className="text-[9px] text-center mb-0.5 text-surface-500">
|
|
{label}
|
|
</div>
|
|
<div className={`h-1 rounded-full ${
|
|
complete ? 'bg-success' :
|
|
active ? 'bg-accent' :
|
|
'bg-surface-700'
|
|
}`}>
|
|
{active && !complete && (
|
|
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|