Overhaul Models tab UX: action feedback, post-training flow, guided navigation
CI / build-and-push (push) Successful in 29s

- Lift modelsTab state into Zustand store so actions can navigate tabs
- Add toast notifications + auto-tab-switch to all 10 model actions
  (train, configure SFT/alignment, distill, fine-tune, quantize, eval, deploy, open-source)
- Add actionable toast buttons with navigation (e.g., "Go to Families" on training complete)
- Fix post-training config: remove 50% deadline, show until pretraining completes,
  always-visible warning prompt outside card expand, engine reminder at 75%
- PostTrainingConfig now hides already-configured sections independently
- Add tab badges: pulsing dot for active jobs, count for undeployed models, warning for no deployment
- Replace empty states with actionable buttons guiding next steps
- Stage bars show "(skip)" in warning color for unconfigured SFT/Alignment stages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 10:20:00 -04:00
parent fdedd6f4d0
commit 775c6a4fa5
6 changed files with 436 additions and 270 deletions
@@ -61,6 +61,19 @@ export function ToastContainer() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium">{toast.title}</div> <div className="text-sm font-medium">{toast.title}</div>
<div className="text-xs text-surface-400">{toast.message}</div> <div className="text-xs text-surface-400">{toast.message}</div>
{toast.action && (
<button
onClick={() => {
if (toast.action!.page) useGameStore.getState().setActivePage(toast.action!.page);
if (toast.action!.modelsTab) useGameStore.getState().setModelsTab(toast.action!.modelsTab);
dismissNotification(toast.id);
setToasts(prev => prev.filter(t => t.id !== toast.id));
}}
className="text-xs text-accent hover:text-accent-light underline mt-1"
>
{toast.action.label}
</button>
)}
</div> </div>
<button <button
onClick={() => { onClick={() => {
+1
View File
@@ -45,6 +45,7 @@ export function useGameLoop(skip = false) {
message: n.message, message: n.message,
type: n.type, type: n.type,
tick: store.meta.tickCount, tick: store.meta.tickCount,
...(n.action ? { action: n.action as any } : {}),
}); });
} }
} }
+146 -77
View File
@@ -69,7 +69,8 @@ export function ModelsPage() {
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels); const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
const completedResearch = useGameStore((s) => s.research.completedResearch); const completedResearch = useGameStore((s) => s.research.completedResearch);
const [modelsTab, setModelsTab] = useState<'overview' | 'train' | 'models' | 'benchmarks' | 'products'>('overview'); const modelsTab = useGameStore((s) => s.modelsTab);
const setModelsTab = useGameStore((s) => s.setModelsTab);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [expandedModel, setExpandedModel] = useState<string | null>(null); const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null); const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null);
@@ -84,6 +85,11 @@ export function ModelsPage() {
const estimatedCapability = Math.min(95, Math.sqrt(trainingFlops) * 5 + Math.log10(1 + totalData / 1e8) * 10); 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 activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0 || activeEvalJobs.length > 0;
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const; const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
const currentEraIdx = eraOrder.indexOf(currentEra); const currentEraIdx = eraOrder.indexOf(currentEra);
@@ -135,9 +141,6 @@ export function ModelsPage() {
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai', r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
); );
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-bold">Models</h2> <h2 className="text-2xl font-bold">Models</h2>
@@ -157,13 +160,22 @@ export function ModelsPage() {
<button <button
key={tab.id} key={tab.id}
onClick={() => setModelsTab(tab.id)} onClick={() => setModelsTab(tab.id)}
className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${ className={`px-4 py-2 text-sm rounded-t-lg transition-colors flex items-center gap-1 ${
modelsTab === tab.id modelsTab === tab.id
? 'bg-surface-800 text-surface-100 border-b-2 border-accent' ? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50' : 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
}`} }`}
> >
{tab.label} {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> </button>
))} ))}
</div> </div>
@@ -247,6 +259,19 @@ export function ModelsPage() {
: `ETA: ${formatDuration(stage.totalTicks - stage.progressTicks)}`} : `ETA: ${formatDuration(stage.totalTicks - stage.progressTicks)}`}
</div> </div>
{pipeline.currentStage === 'pretraining' && !pipeline.stages.sft && !pipeline.stages.alignment && (
<div className="mt-2 flex items-center gap-2 text-xs text-warning">
<Beaker size={12} />
<span>
Post-training not configured {' '}
<button onClick={() => setExpandedPipeline(pipeline.id)} className="text-accent hover:text-accent-light underline">
configure SFT &amp; Alignment
</button>
{' '}or they'll be skipped.
</span>
</div>
)}
{isExpanded && ( {isExpanded && (
<div className="mt-3 pt-3 border-t border-surface-700 space-y-2"> <div className="mt-3 pt-3 border-t border-surface-700 space-y-2">
{pipeline.currentStage === 'pretraining' && ( {pipeline.currentStage === 'pretraining' && (
@@ -256,8 +281,8 @@ export function ModelsPage() {
</div> </div>
)} )}
{!pipeline.stages.sft && pipeline.stages.pretraining.progressTicks < pipeline.stages.pretraining.totalTicks * 0.5 && ( {pipeline.currentStage === 'pretraining' && !pipeline.stages.pretraining.isComplete && (!pipeline.stages.sft || !pipeline.stages.alignment) && (
<PostTrainingConfig pipelineId={pipeline.id} hasAlignmentResearch={hasAlignmentResearch} completedResearch={completedResearch} configureSFT={configureSFT} configureAlignment={configureAlignment} /> <PostTrainingConfig pipelineId={pipeline.id} hasAlignmentResearch={hasAlignmentResearch} completedResearch={completedResearch} configureSFT={configureSFT} configureAlignment={configureAlignment} sftConfigured={!!pipeline.stages.sft} alignmentConfigured={!!pipeline.stages.alignment} />
)} )}
{recentEvents.length > 0 && ( {recentEvents.length > 0 && (
@@ -567,6 +592,14 @@ export function ModelsPage() {
{/* Product Lines */} {/* Product Lines */}
{modelsTab === 'products' && <div className="space-y-3"> {modelsTab === 'products' && <div className="space-y-3">
<h3 className="font-semibold">Product Lines</h3> <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 => ( {productLines.map(pl => (
<div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -584,17 +617,28 @@ export function ModelsPage() {
))} ))}
</div>} </div>}
{/* Empty state for Models tab */}
{modelsTab === 'models' && families.length === 0 && ( {modelsTab === 'models' && families.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
No model families yet. Train your first model from the Train New tab. <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> </div>
)} )}
{/* Empty state for Overview when nothing is active */} {modelsTab === 'overview' && !hasActiveJobs && (
{modelsTab === 'overview' && activePipelines.length === 0 && activeVariantJobs.length === 0 && activeEvalJobs.length === 0 && ( <div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm"> <p className="text-surface-400 text-sm">No active training or evaluation jobs.</p>
No active jobs. Start a training pipeline from the Train New tab. <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>
)} )}
</div> </div>
@@ -1069,8 +1113,15 @@ function StageBar({ label, active, complete, progress, configured = true }: {
}) { }) {
return ( return (
<div className="flex-1"> <div className="flex-1">
<div className="text-[9px] text-center text-surface-500 mb-0.5">{label}</div> <div className={`text-[9px] text-center mb-0.5 ${!configured ? 'text-warning' : 'text-surface-500'}`}>
<div className={`h-1 rounded-full ${!configured ? 'bg-surface-800' : complete ? 'bg-success' : active ? 'bg-accent' : 'bg-surface-700'}`}> {label}{!configured && ' (skip)'}
</div>
<div className={`h-1 rounded-full ${
!configured ? 'bg-surface-800 border border-dashed border-warning/30' :
complete ? 'bg-success' :
active ? 'bg-accent' :
'bg-surface-700'
}`}>
{active && !complete && ( {active && !complete && (
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${progress * 100}%` }} /> <div className="h-full bg-accent rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
)} )}
@@ -1079,12 +1130,14 @@ function StageBar({ label, active, complete, progress, configured = true }: {
); );
} }
function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearch, configureSFT, configureAlignment }: { function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearch, configureSFT, configureAlignment, sftConfigured, alignmentConfigured }: {
pipelineId: string; pipelineId: string;
hasAlignmentResearch: boolean; hasAlignmentResearch: boolean;
completedResearch: string[]; completedResearch: string[];
configureSFT: (pipelineId: string, specializations: SFTSpecialization[]) => void; configureSFT: (pipelineId: string, specializations: SFTSpecialization[]) => void;
configureAlignment: (pipelineId: string, method: AlignmentMethod, safetyWeight: number) => void; configureAlignment: (pipelineId: string, method: AlignmentMethod, safetyWeight: number) => void;
sftConfigured: boolean;
alignmentConfigured: boolean;
}) { }) {
const [selectedSpecs, setSelectedSpecs] = useState<SFTSpecialization[]>(['general']); const [selectedSpecs, setSelectedSpecs] = useState<SFTSpecialization[]>(['general']);
const [alignMethod, setAlignMethod] = useState<AlignmentMethod>('rlhf'); const [alignMethod, setAlignMethod] = useState<AlignmentMethod>('rlhf');
@@ -1094,75 +1147,91 @@ function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearc
<div className="space-y-3 bg-surface-800/50 rounded-lg p-3"> <div className="space-y-3 bg-surface-800/50 rounded-lg p-3">
<div className="text-xs font-medium text-surface-300">Configure Post-Training (optional)</div> <div className="text-xs font-medium text-surface-300">Configure Post-Training (optional)</div>
<div className="space-y-1"> {!sftConfigured ? (
<div className="flex items-center gap-1 text-xs text-surface-400">
<Beaker size={10} /> Supervised Fine-Tuning
</div>
<div className="flex flex-wrap gap-1">
{SFT_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setSelectedSpecs(prev =>
prev.includes(opt.value)
? prev.filter(s => s !== opt.value)
: [...prev, opt.value]
)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
selectedSpecs.includes(opt.value)
? 'bg-accent/20 border-accent text-accent-light'
: 'bg-surface-800 border-surface-600 text-surface-400'
}`}
>
{opt.label}
</button>
))}
</div>
<button
onClick={() => configureSFT(pipelineId, selectedSpecs)}
className="text-[10px] text-accent hover:text-accent-light"
>
Enable SFT
</button>
</div>
{hasAlignmentResearch && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-surface-400"> <div className="flex items-center gap-1 text-xs text-surface-400">
<Shield size={10} /> Alignment <Beaker size={10} /> Supervised Fine-Tuning
</div> </div>
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
{(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => { {SFT_OPTIONS.map(opt => (
const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch); <button
return ( key={opt.value}
<button onClick={() => setSelectedSpecs(prev =>
key={method} prev.includes(opt.value)
disabled={!isAvailable} ? prev.filter(s => s !== opt.value)
onClick={() => setAlignMethod(method)} : [...prev, opt.value]
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${ )}
alignMethod === method ? 'bg-accent/20 border-accent text-accent-light' : className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
!isAvailable ? 'bg-surface-800 border-surface-700 text-surface-600 cursor-not-allowed' : selectedSpecs.includes(opt.value)
'bg-surface-800 border-surface-600 text-surface-400' ? 'bg-accent/20 border-accent text-accent-light'
}`} : 'bg-surface-800 border-surface-600 text-surface-400'
> }`}
{method.toUpperCase()} >
</button> {opt.label}
); </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>
</div> </div>
<button <button
onClick={() => configureAlignment(pipelineId, alignMethod, safetyWeight)} onClick={() => configureSFT(pipelineId, selectedSpecs)}
className="text-[10px] text-accent hover:text-accent-light" className="text-[10px] text-accent hover:text-accent-light"
> >
Enable Alignment Enable SFT
</button> </button>
</div> </div>
) : (
<div className="flex items-center gap-1 text-xs text-success">
<Beaker size={10} /> SFT configured
</div>
)}
{!alignmentConfigured ? (
hasAlignmentResearch ? (
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-surface-400">
<Shield size={10} /> Alignment
</div>
<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-0.5 rounded text-[10px] 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>
</div>
<button
onClick={() => configureAlignment(pipelineId, alignMethod, safetyWeight)}
className="text-[10px] text-accent hover:text-accent-light"
>
Enable Alignment
</button>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-surface-500">
<Shield size={10} /> Alignment requires research
</div>
)
) : (
<div className="flex items-center gap-1 text-xs text-success">
<Shield size={10} /> Alignment configured
</div>
)} )}
</div> </div>
); );
+260 -190
View File
@@ -58,10 +58,13 @@ export interface InfraNav {
datacenterId?: string; datacenterId?: string;
} }
type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products';
interface UIState { interface UIState {
activePage: ActivePage; activePage: ActivePage;
notifications: GameNotification[]; notifications: GameNotification[];
infraNav: InfraNav; infraNav: InfraNav;
modelsTab: ModelsTab;
} }
export interface GameNotification { export interface GameNotification {
@@ -71,6 +74,7 @@ export interface GameNotification {
type: 'info' | 'success' | 'warning' | 'danger'; type: 'info' | 'success' | 'warning' | 'danger';
tick: number; tick: number;
read: boolean; read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
} }
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> { function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
@@ -86,6 +90,7 @@ function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks'
interface Actions { interface Actions {
setActivePage: (page: ActivePage) => void; setActivePage: (page: ActivePage) => void;
setInfraNav: (nav: InfraNav) => void; setInfraNav: (nav: InfraNav) => void;
setModelsTab: (tab: ModelsTab) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void; addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void; dismissNotification: (id: string) => void;
removeNotification: (id: string) => void; removeNotification: (id: string) => void;
@@ -289,11 +294,14 @@ export const useGameStore = create<Store>()(
activePage: 'dashboard' as ActivePage, activePage: 'dashboard' as ActivePage,
notifications: [], notifications: [],
infraNav: { level: 'clusters' } as InfraNav, infraNav: { level: 'clusters' } as InfraNav,
modelsTab: 'overview' as ModelsTab,
setActivePage: (page) => set({ activePage: page }), setActivePage: (page) => set({ activePage: page }),
setInfraNav: (nav) => set({ infraNav: nav }), setInfraNav: (nav) => set({ infraNav: nav }),
setModelsTab: (tab) => set({ modelsTab: tab }),
addNotification: (n) => set((s) => ({ addNotification: (n) => set((s) => ({
notifications: [ notifications: [
{ ...n, id: uuid(), read: false }, { ...n, id: uuid(), read: false },
@@ -907,197 +915,252 @@ export const useGameStore = create<Store>()(
// --- Non-infrastructure actions (unchanged) --- // --- Non-infrastructure actions (unchanged) ---
startTrainingPipeline: (config) => set((s) => { startTrainingPipeline: (config) => {
const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length; let created = false;
const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1; set((s) => {
if (activeCount >= maxSlots) return s; const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length;
const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return s;
const familyId = uuid(); created = true;
const pipelineId = uuid(); const familyId = uuid();
const generation = s.models.families.length + 1; const pipelineId = uuid();
const generation = s.models.families.length + 1;
const family: ModelFamily = { const family: ModelFamily = {
id: familyId, id: familyId,
name: config.modelName, name: config.modelName,
generation, generation,
baseModelId: null, baseModelId: null,
variants: [], variants: [],
createdAtTick: s.meta.tickCount, createdAtTick: s.meta.tickCount,
}; };
const pipeline: TrainingPipeline = { const pipeline: TrainingPipeline = {
id: pipelineId, id: pipelineId,
familyId, familyId,
modelName: config.modelName, modelName: config.modelName,
architecture: config.architecture, architecture: config.architecture,
dataMix: config.dataMix, dataMix: config.dataMix,
currentStage: 'pretraining', currentStage: 'pretraining',
stages: { stages: {
pretraining: { pretraining: {
targetTokens: config.targetTokens, targetTokens: config.targetTokens,
processedTokens: 0, processedTokens: 0,
computeAllocated: 0, computeAllocated: 0,
progressTicks: 0, progressTicks: 0,
totalTicks: config.totalTicks, totalTicks: config.totalTicks,
lossValue: 10, lossValue: 10,
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9), chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
isComplete: false, isComplete: false,
},
sft: null,
alignment: null,
}, },
sft: null, status: 'active',
alignment: null, allocatedComputeFraction: config.allocatedComputeFraction,
}, events: [],
status: 'active', startedAtTick: s.meta.tickCount,
allocatedComputeFraction: config.allocatedComputeFraction, };
events: [],
startedAtTick: s.meta.tickCount,
};
return { return {
models: {
...s.models,
families: [...s.models.families, family],
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline],
},
};
});
if (created) {
get().addNotification({ title: 'Training Started', message: `${config.modelName} pre-training has begun.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
configureSFT: (pipelineId, specializations) => {
set((s) => ({
models: { models: {
...s.models, ...s.models,
families: [...s.models.families, family], activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline], p.id === pipelineId ? {
...p,
stages: {
...p.stages,
sft: {
specializations,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10),
isComplete: false,
},
},
} : p,
),
}, },
}; }));
}), get().addNotification({ title: 'SFT Configured', message: `${specializations.join(', ')} specializations enabled.`, type: 'success', tick: get().meta.tickCount });
},
configureSFT: (pipelineId, specializations) => set((s) => ({ configureAlignment: (pipelineId, method, safetyWeight) => {
models: { set((s) => ({
...s.models, models: {
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => ...s.models,
p.id === pipelineId ? { activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
...p, p.id === pipelineId ? {
stages: { ...p,
...p.stages, stages: {
sft: { ...p.stages,
specializations, alignment: {
progressTicks: 0, method,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10), safetyWeight,
isComplete: false, helpfulnessWeight: 1 - safetyWeight,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08),
isComplete: false,
},
}, },
}, } : p,
} : p, ),
), },
}, }));
})), get().addNotification({ title: 'Alignment Configured', message: `${method.toUpperCase()} alignment enabled.`, type: 'success', tick: get().meta.tickCount });
},
configureAlignment: (pipelineId, method, safetyWeight) => set((s) => ({ createDistillation: (baseModelId, targetParameters, variantName) => {
models: { let created = false;
...s.models, set((s) => {
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => const base = s.models.baseModels.find(m => m.id === baseModelId);
p.id === pipelineId ? { if (!base) return s;
...p, created = true;
stages: { const job: VariantCreationJob = {
...p.stages, id: uuid(),
alignment: { familyId: base.familyId,
method, baseModelId,
safetyWeight, jobType: 'distillation',
helpfulnessWeight: 1 - safetyWeight, config: { targetParameters, targetArchitecture: base.architecture.type, variantName },
progressTicks: 0, progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08), totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30),
isComplete: false, allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION,
}, status: 'active',
}, };
} : p, return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
), });
}, if (created) {
})), get().addNotification({ title: 'Distillation Started', message: `${variantName} distillation in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createDistillation: (baseModelId, targetParameters, variantName) => set((s) => { createFineTune: (baseModelId, specialization, variantName) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); let created = false;
if (!base) return s; set((s) => {
const job: VariantCreationJob = { const base = s.models.baseModels.find(m => m.id === baseModelId);
id: uuid(), if (!base) return s;
familyId: base.familyId, created = true;
baseModelId, const job: VariantCreationJob = {
jobType: 'distillation', id: uuid(),
config: { targetParameters, targetArchitecture: base.architecture.type, variantName }, familyId: base.familyId,
progressTicks: 0, baseModelId,
totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30), jobType: 'fine-tuning',
allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION, config: { specialization, datasetIds: [], variantName },
status: 'active', progressTicks: 0,
}; totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120),
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION,
}), status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Fine-Tuning Started', message: `${variantName} fine-tuning in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createFineTune: (baseModelId, specialization, variantName) => set((s) => { createQuantization: (baseModelId, level, variantName) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); let created = false;
if (!base) return s; set((s) => {
const job: VariantCreationJob = { const base = s.models.baseModels.find(m => m.id === baseModelId);
id: uuid(), if (!base) return s;
familyId: base.familyId, created = true;
baseModelId, const job: VariantCreationJob = {
jobType: 'fine-tuning', id: uuid(),
config: { specialization, datasetIds: [], variantName }, familyId: base.familyId,
progressTicks: 0, baseModelId,
totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120), jobType: 'quantization',
allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION, config: { level, variantName },
status: 'active', progressTicks: 0,
}; totalTicks: QUANTIZATION_TICKS,
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; allocatedComputeFraction: 0,
}), status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Quantization Started', message: `${variantName} quantization in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createQuantization: (baseModelId, level, variantName) => set((s) => { startEvaluation: (modelId, benchmarkIds) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); let created = false;
if (!base) return s; set((s) => {
const job: VariantCreationJob = { const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
id: uuid(), if (benchmarks.length === 0) return s;
familyId: base.familyId, created = true;
baseModelId, const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
jobType: 'quantization', const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
config: { level, variantName }, const job: EvalJob = {
progressTicks: 0, id: uuid(),
totalTicks: QUANTIZATION_TICKS, modelId,
allocatedComputeFraction: 0, benchmarkIds,
status: 'active', progressTicks: 0,
}; totalTicks,
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; computeAllocated: computeCost,
}), status: 'active',
results: [],
};
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Evaluation Started', message: `${benchmarkIds.length} benchmark${benchmarkIds.length > 1 ? 's' : ''} queued.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
startEvaluation: (modelId, benchmarkIds) => set((s) => { deployModel: (modelId) => {
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id)); const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
if (benchmarks.length === 0) return s; set((s) => ({
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0); models: {
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0); ...s.models,
const job: EvalJob = { baseModels: s.models.baseModels.map(m =>
id: uuid(), m.id === modelId ? { ...m, isDeployed: true } : m,
modelId, ),
benchmarkIds, productLines: s.models.productLines.map(pl => ({
progressTicks: 0, ...pl, modelId, isActive: true,
totalTicks, })),
computeAllocated: computeCost, },
status: 'active', market: {
results: [], ...s.market,
}; obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount),
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } }; },
}), }));
get().addNotification({ title: 'Model Deployed', message: `${modelName} is now serving all product lines.`, type: 'success', tick: get().meta.tickCount });
set({ modelsTab: 'products' as ModelsTab });
},
deployModel: (modelId) => set((s) => ({ deployVariant: (familyId, variantId) => {
models: { set((s) => ({
...s.models, models: {
baseModels: s.models.baseModels.map(m => ...s.models,
m.id === modelId ? { ...m, isDeployed: true } : m, families: s.models.families.map(f =>
), f.id === familyId
productLines: s.models.productLines.map(pl => ({ ? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
...pl, modelId, isActive: true, : f,
})), ),
}, },
market: { }));
...s.market, get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount), set({ modelsTab: 'products' as ModelsTab });
}, },
})),
deployVariant: (familyId, variantId) => set((s) => ({
models: {
...s.models,
families: s.models.families.map(f =>
f.id === familyId
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
: f,
),
},
})),
setProductPricing: (productLineId, field, value) => set((s) => ({ setProductPricing: (productLineId, field, value) => set((s) => ({
models: { models: {
@@ -1188,20 +1251,27 @@ export const useGameStore = create<Store>()(
}; };
}), }),
openSourceModel: (modelId) => set((s) => { openSourceModel: (modelId) => {
if (s.market.openSourcedModels.includes(modelId)) return s; let opened = false;
return { set((s) => {
market: { if (s.market.openSourcedModels.includes(modelId)) return s;
...s.market, opened = true;
openSourcedModels: [...s.market.openSourcedModels, modelId], return {
}, market: {
reputation: { ...s.market,
...s.reputation, openSourcedModels: [...s.market.openSourcedModels, modelId],
score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST), },
publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST), reputation: {
}, ...s.reputation,
}; score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST),
}), publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST),
},
};
});
if (opened) {
get().addNotification({ title: 'Model Open Sourced', message: 'Reputation boosted! Competitors may benefit.', type: 'success', tick: get().meta.tickCount });
}
},
setOverloadPolicy: (policy) => set((s) => ({ setOverloadPolicy: (policy) => set((s) => ({
market: { market: {
@@ -1382,7 +1452,7 @@ export const useGameStore = create<Store>()(
name: 'ai-tycoon-save', name: 'ai-tycoon-save',
version: SAVE_VERSION, version: SAVE_VERSION,
partialize: (state) => { partialize: (state) => {
const { activePage, notifications, infraNav, ...rest } = state; const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
return rest; return rest;
}, },
migrate: (_persisted, version) => { migrate: (_persisted, version) => {
@@ -26,7 +26,7 @@ import type { ResearchBonuses } from './researchBonuses';
export interface ModelTickResult { export interface ModelTickResult {
modelsState: ModelsState; modelsState: ModelsState;
completedModels: BaseModel[]; completedModels: BaseModel[];
notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' | 'danger' }[]; notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' | 'danger'; action?: { label: string; page?: string; modelsTab?: string } }[];
reputationHit: number; reputationHit: number;
legalCosts: number; legalCosts: number;
} }
@@ -101,6 +101,16 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
stage.computeAllocated = effectiveFlops; stage.computeAllocated = effectiveFlops;
stage.lossValue = Math.max(0.01, 10 * Math.exp(-stage.progressTicks / stage.totalTicks * 3)); stage.lossValue = Math.max(0.01, 10 * Math.exp(-stage.progressTicks / stage.totalTicks * 3));
const progressRatio = stage.progressTicks / stage.totalTicks;
if (progressRatio >= 0.75 && progressRatio < 0.78 && !pipeline.stages.sft && !pipeline.stages.alignment) {
notifications.push({
title: 'Post-Training Reminder',
message: `${pipeline.modelName} is 75% pre-trained. Configure SFT/Alignment now or they'll be skipped!`,
type: 'warning',
action: { label: 'Configure Now', page: 'models', modelsTab: 'overview' },
});
}
if (stage.progressTicks >= stage.totalTicks) { if (stage.progressTicks >= stage.totalTicks) {
stage.isComplete = true; stage.isComplete = true;
stage.progressTicks = stage.totalTicks; stage.progressTicks = stage.totalTicks;
@@ -174,8 +184,9 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
); );
notifications.push({ notifications.push({
title: 'Variant Created', title: 'Variant Created',
message: `${variant.name} (${variant.variantType}) is ready!`, message: `${variant.name} (${variant.variantType}) is ready! Deploy it to use it.`,
type: 'success', type: 'success',
action: { label: 'View in Families', page: 'models', modelsTab: 'models' },
}); });
} }
+3 -1
View File
@@ -23,6 +23,7 @@ export interface TickNotification {
title: string; title: string;
message: string; message: string;
type: 'info' | 'success' | 'warning' | 'danger'; type: 'info' | 'success' | 'warning' | 'danger';
action?: { label: string; page?: string; modelsTab?: string };
} }
let cachedAchievementDefs: AchievementDefinition[] | null = null; let cachedAchievementDefs: AchievementDefinition[] | null = null;
@@ -45,8 +46,9 @@ export function processTick(state: GameState): Partial<GameState> {
for (const completed of modelResult.completedModels) { for (const completed of modelResult.completedModels) {
notifications.push({ notifications.push({
title: 'Training Complete', title: 'Training Complete',
message: `${completed.name} is ready! Capability: ${completed.rawCapability.toFixed(1)}/100`, message: `${completed.name} is ready! Capability: ${completed.rawCapability.toFixed(1)}/100. Deploy it to start earning revenue.`,
type: 'success', type: 'success',
action: { label: 'Go to Families', page: 'models', modelsTab: 'models' },
}); });
} }
notifications.push(...modelResult.notifications); notifications.push(...modelResult.notifications);