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 } : {}),
}); });
} }
} }
+87 -18
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,6 +1147,7 @@ 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>
{!sftConfigured ? (
<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">
<Beaker size={10} /> Supervised Fine-Tuning <Beaker size={10} /> Supervised Fine-Tuning
@@ -1124,8 +1178,14 @@ function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearc
Enable SFT Enable SFT
</button> </button>
</div> </div>
) : (
<div className="flex items-center gap-1 text-xs text-success">
<Beaker size={10} /> SFT configured
</div>
)}
{hasAlignmentResearch && ( {!alignmentConfigured ? (
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 <Shield size={10} /> Alignment
@@ -1163,6 +1223,15 @@ function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearc
Enable Alignment Enable Alignment
</button> </button>
</div> </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>
); );
+91 -21
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,11 +915,14 @@ export const useGameStore = create<Store>()(
// --- Non-infrastructure actions (unchanged) --- // --- Non-infrastructure actions (unchanged) ---
startTrainingPipeline: (config) => set((s) => { startTrainingPipeline: (config) => {
let created = false;
set((s) => {
const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length; const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length;
const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1; const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return s; if (activeCount >= maxSlots) return s;
created = true;
const familyId = uuid(); const familyId = uuid();
const pipelineId = uuid(); const pipelineId = uuid();
const generation = s.models.families.length + 1; const generation = s.models.families.length + 1;
@@ -959,9 +970,15 @@ export const useGameStore = create<Store>()(
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline], 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) => ({ configureSFT: (pipelineId, specializations) => {
set((s) => ({
models: { models: {
...s.models, ...s.models,
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
@@ -979,9 +996,12 @@ export const useGameStore = create<Store>()(
} : p, } : p,
), ),
}, },
})), }));
get().addNotification({ title: 'SFT Configured', message: `${specializations.join(', ')} specializations enabled.`, type: 'success', tick: get().meta.tickCount });
},
configureAlignment: (pipelineId, method, safetyWeight) => set((s) => ({ configureAlignment: (pipelineId, method, safetyWeight) => {
set((s) => ({
models: { models: {
...s.models, ...s.models,
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
@@ -1001,11 +1021,16 @@ export const useGameStore = create<Store>()(
} : p, } : p,
), ),
}, },
})), }));
get().addNotification({ title: 'Alignment Configured', message: `${method.toUpperCase()} alignment enabled.`, type: 'success', tick: get().meta.tickCount });
},
createDistillation: (baseModelId, targetParameters, variantName) => set((s) => { createDistillation: (baseModelId, targetParameters, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s; if (!base) return s;
created = true;
const job: VariantCreationJob = { const job: VariantCreationJob = {
id: uuid(), id: uuid(),
familyId: base.familyId, familyId: base.familyId,
@@ -1018,11 +1043,19 @@ export const useGameStore = create<Store>()(
status: 'active', status: 'active',
}; };
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; 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 });
}
},
createFineTune: (baseModelId, specialization, variantName) => set((s) => { createFineTune: (baseModelId, specialization, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s; if (!base) return s;
created = true;
const job: VariantCreationJob = { const job: VariantCreationJob = {
id: uuid(), id: uuid(),
familyId: base.familyId, familyId: base.familyId,
@@ -1035,11 +1068,19 @@ export const useGameStore = create<Store>()(
status: 'active', status: 'active',
}; };
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; 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 });
}
},
createQuantization: (baseModelId, level, variantName) => set((s) => { createQuantization: (baseModelId, level, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId); const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s; if (!base) return s;
created = true;
const job: VariantCreationJob = { const job: VariantCreationJob = {
id: uuid(), id: uuid(),
familyId: base.familyId, familyId: base.familyId,
@@ -1052,11 +1093,19 @@ export const useGameStore = create<Store>()(
status: 'active', status: 'active',
}; };
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; 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 });
}
},
startEvaluation: (modelId, benchmarkIds) => set((s) => { startEvaluation: (modelId, benchmarkIds) => {
let created = false;
set((s) => {
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id)); const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
if (benchmarks.length === 0) return s; if (benchmarks.length === 0) return s;
created = true;
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0); const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0); const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
const job: EvalJob = { const job: EvalJob = {
@@ -1070,9 +1119,16 @@ export const useGameStore = create<Store>()(
results: [], results: [],
}; };
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } }; 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 });
}
},
deployModel: (modelId) => set((s) => ({ deployModel: (modelId) => {
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
set((s) => ({
models: { models: {
...s.models, ...s.models,
baseModels: s.models.baseModels.map(m => baseModels: s.models.baseModels.map(m =>
@@ -1086,9 +1142,13 @@ export const useGameStore = create<Store>()(
...s.market, ...s.market,
obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount), obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount),
}, },
})), }));
get().addNotification({ title: 'Model Deployed', message: `${modelName} is now serving all product lines.`, type: 'success', tick: get().meta.tickCount });
set({ modelsTab: 'products' as ModelsTab });
},
deployVariant: (familyId, variantId) => set((s) => ({ deployVariant: (familyId, variantId) => {
set((s) => ({
models: { models: {
...s.models, ...s.models,
families: s.models.families.map(f => families: s.models.families.map(f =>
@@ -1097,7 +1157,10 @@ export const useGameStore = create<Store>()(
: f, : f,
), ),
}, },
})), }));
get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
set({ modelsTab: 'products' as ModelsTab });
},
setProductPricing: (productLineId, field, value) => set((s) => ({ setProductPricing: (productLineId, field, value) => set((s) => ({
models: { models: {
@@ -1188,8 +1251,11 @@ export const useGameStore = create<Store>()(
}; };
}), }),
openSourceModel: (modelId) => set((s) => { openSourceModel: (modelId) => {
let opened = false;
set((s) => {
if (s.market.openSourcedModels.includes(modelId)) return s; if (s.market.openSourcedModels.includes(modelId)) return s;
opened = true;
return { return {
market: { market: {
...s.market, ...s.market,
@@ -1201,7 +1267,11 @@ export const useGameStore = create<Store>()(
publicPerception: Math.min(100, s.reputation.publicPerception + 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);