Overhaul Models tab UX: action feedback, post-training flow, guided navigation
CI / build-and-push (push) Successful in 29s
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:
+260
-190
@@ -58,10 +58,13 @@ export interface InfraNav {
|
||||
datacenterId?: string;
|
||||
}
|
||||
|
||||
type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products';
|
||||
|
||||
interface UIState {
|
||||
activePage: ActivePage;
|
||||
notifications: GameNotification[];
|
||||
infraNav: InfraNav;
|
||||
modelsTab: ModelsTab;
|
||||
}
|
||||
|
||||
export interface GameNotification {
|
||||
@@ -71,6 +74,7 @@ export interface GameNotification {
|
||||
type: 'info' | 'success' | 'warning' | 'danger';
|
||||
tick: number;
|
||||
read: boolean;
|
||||
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
|
||||
}
|
||||
|
||||
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
|
||||
@@ -86,6 +90,7 @@ function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks'
|
||||
interface Actions {
|
||||
setActivePage: (page: ActivePage) => void;
|
||||
setInfraNav: (nav: InfraNav) => void;
|
||||
setModelsTab: (tab: ModelsTab) => void;
|
||||
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
|
||||
dismissNotification: (id: string) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
@@ -289,11 +294,14 @@ export const useGameStore = create<Store>()(
|
||||
activePage: 'dashboard' as ActivePage,
|
||||
notifications: [],
|
||||
infraNav: { level: 'clusters' } as InfraNav,
|
||||
modelsTab: 'overview' as ModelsTab,
|
||||
|
||||
setActivePage: (page) => set({ activePage: page }),
|
||||
|
||||
setInfraNav: (nav) => set({ infraNav: nav }),
|
||||
|
||||
setModelsTab: (tab) => set({ modelsTab: tab }),
|
||||
|
||||
addNotification: (n) => set((s) => ({
|
||||
notifications: [
|
||||
{ ...n, id: uuid(), read: false },
|
||||
@@ -907,197 +915,252 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
// --- Non-infrastructure actions (unchanged) ---
|
||||
|
||||
startTrainingPipeline: (config) => set((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;
|
||||
startTrainingPipeline: (config) => {
|
||||
let created = false;
|
||||
set((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();
|
||||
const pipelineId = uuid();
|
||||
const generation = s.models.families.length + 1;
|
||||
created = true;
|
||||
const familyId = uuid();
|
||||
const pipelineId = uuid();
|
||||
const generation = s.models.families.length + 1;
|
||||
|
||||
const family: ModelFamily = {
|
||||
id: familyId,
|
||||
name: config.modelName,
|
||||
generation,
|
||||
baseModelId: null,
|
||||
variants: [],
|
||||
createdAtTick: s.meta.tickCount,
|
||||
};
|
||||
const family: ModelFamily = {
|
||||
id: familyId,
|
||||
name: config.modelName,
|
||||
generation,
|
||||
baseModelId: null,
|
||||
variants: [],
|
||||
createdAtTick: s.meta.tickCount,
|
||||
};
|
||||
|
||||
const pipeline: TrainingPipeline = {
|
||||
id: pipelineId,
|
||||
familyId,
|
||||
modelName: config.modelName,
|
||||
architecture: config.architecture,
|
||||
dataMix: config.dataMix,
|
||||
currentStage: 'pretraining',
|
||||
stages: {
|
||||
pretraining: {
|
||||
targetTokens: config.targetTokens,
|
||||
processedTokens: 0,
|
||||
computeAllocated: 0,
|
||||
progressTicks: 0,
|
||||
totalTicks: config.totalTicks,
|
||||
lossValue: 10,
|
||||
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
|
||||
isComplete: false,
|
||||
const pipeline: TrainingPipeline = {
|
||||
id: pipelineId,
|
||||
familyId,
|
||||
modelName: config.modelName,
|
||||
architecture: config.architecture,
|
||||
dataMix: config.dataMix,
|
||||
currentStage: 'pretraining',
|
||||
stages: {
|
||||
pretraining: {
|
||||
targetTokens: config.targetTokens,
|
||||
processedTokens: 0,
|
||||
computeAllocated: 0,
|
||||
progressTicks: 0,
|
||||
totalTicks: config.totalTicks,
|
||||
lossValue: 10,
|
||||
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
|
||||
isComplete: false,
|
||||
},
|
||||
sft: null,
|
||||
alignment: null,
|
||||
},
|
||||
sft: null,
|
||||
alignment: null,
|
||||
},
|
||||
status: 'active',
|
||||
allocatedComputeFraction: config.allocatedComputeFraction,
|
||||
events: [],
|
||||
startedAtTick: s.meta.tickCount,
|
||||
};
|
||||
status: 'active',
|
||||
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: {
|
||||
...s.models,
|
||||
families: [...s.models.families, family],
|
||||
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline],
|
||||
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
|
||||
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) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
|
||||
p.id === pipelineId ? {
|
||||
...p,
|
||||
stages: {
|
||||
...p.stages,
|
||||
sft: {
|
||||
specializations,
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10),
|
||||
isComplete: false,
|
||||
configureAlignment: (pipelineId, method, safetyWeight) => {
|
||||
set((s) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
|
||||
p.id === pipelineId ? {
|
||||
...p,
|
||||
stages: {
|
||||
...p.stages,
|
||||
alignment: {
|
||||
method,
|
||||
safetyWeight,
|
||||
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) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
|
||||
p.id === pipelineId ? {
|
||||
...p,
|
||||
stages: {
|
||||
...p.stages,
|
||||
alignment: {
|
||||
method,
|
||||
safetyWeight,
|
||||
helpfulnessWeight: 1 - safetyWeight,
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08),
|
||||
isComplete: false,
|
||||
},
|
||||
},
|
||||
} : p,
|
||||
),
|
||||
},
|
||||
})),
|
||||
createDistillation: (baseModelId, targetParameters, variantName) => {
|
||||
let created = false;
|
||||
set((s) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
created = true;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'distillation',
|
||||
config: { targetParameters, targetArchitecture: base.architecture.type, variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30),
|
||||
allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION,
|
||||
status: 'active',
|
||||
};
|
||||
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) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'distillation',
|
||||
config: { targetParameters, targetArchitecture: base.architecture.type, variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30),
|
||||
allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION,
|
||||
status: 'active',
|
||||
};
|
||||
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
|
||||
}),
|
||||
createFineTune: (baseModelId, specialization, variantName) => {
|
||||
let created = false;
|
||||
set((s) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
created = true;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'fine-tuning',
|
||||
config: { specialization, datasetIds: [], variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120),
|
||||
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) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'fine-tuning',
|
||||
config: { specialization, datasetIds: [], variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120),
|
||||
allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION,
|
||||
status: 'active',
|
||||
};
|
||||
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
|
||||
}),
|
||||
createQuantization: (baseModelId, level, variantName) => {
|
||||
let created = false;
|
||||
set((s) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
created = true;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'quantization',
|
||||
config: { level, variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: QUANTIZATION_TICKS,
|
||||
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) => {
|
||||
const base = s.models.baseModels.find(m => m.id === baseModelId);
|
||||
if (!base) return s;
|
||||
const job: VariantCreationJob = {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId,
|
||||
jobType: 'quantization',
|
||||
config: { level, variantName },
|
||||
progressTicks: 0,
|
||||
totalTicks: QUANTIZATION_TICKS,
|
||||
allocatedComputeFraction: 0,
|
||||
status: 'active',
|
||||
};
|
||||
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
|
||||
}),
|
||||
startEvaluation: (modelId, benchmarkIds) => {
|
||||
let created = false;
|
||||
set((s) => {
|
||||
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
|
||||
if (benchmarks.length === 0) return s;
|
||||
created = true;
|
||||
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
|
||||
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
|
||||
const job: EvalJob = {
|
||||
id: uuid(),
|
||||
modelId,
|
||||
benchmarkIds,
|
||||
progressTicks: 0,
|
||||
totalTicks,
|
||||
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) => {
|
||||
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
|
||||
if (benchmarks.length === 0) return s;
|
||||
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
|
||||
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
|
||||
const job: EvalJob = {
|
||||
id: uuid(),
|
||||
modelId,
|
||||
benchmarkIds,
|
||||
progressTicks: 0,
|
||||
totalTicks,
|
||||
computeAllocated: computeCost,
|
||||
status: 'active',
|
||||
results: [],
|
||||
};
|
||||
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
|
||||
}),
|
||||
deployModel: (modelId) => {
|
||||
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
|
||||
set((s) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
baseModels: s.models.baseModels.map(m =>
|
||||
m.id === modelId ? { ...m, isDeployed: true } : m,
|
||||
),
|
||||
productLines: s.models.productLines.map(pl => ({
|
||||
...pl, modelId, isActive: true,
|
||||
})),
|
||||
},
|
||||
market: {
|
||||
...s.market,
|
||||
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 });
|
||||
},
|
||||
|
||||
deployModel: (modelId) => set((s) => ({
|
||||
models: {
|
||||
...s.models,
|
||||
baseModels: s.models.baseModels.map(m =>
|
||||
m.id === modelId ? { ...m, isDeployed: true } : m,
|
||||
),
|
||||
productLines: s.models.productLines.map(pl => ({
|
||||
...pl, modelId, isActive: true,
|
||||
})),
|
||||
},
|
||||
market: {
|
||||
...s.market,
|
||||
obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount),
|
||||
},
|
||||
})),
|
||||
|
||||
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,
|
||||
),
|
||||
},
|
||||
})),
|
||||
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,
|
||||
),
|
||||
},
|
||||
}));
|
||||
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) => ({
|
||||
models: {
|
||||
@@ -1188,20 +1251,27 @@ export const useGameStore = create<Store>()(
|
||||
};
|
||||
}),
|
||||
|
||||
openSourceModel: (modelId) => set((s) => {
|
||||
if (s.market.openSourcedModels.includes(modelId)) return s;
|
||||
return {
|
||||
market: {
|
||||
...s.market,
|
||||
openSourcedModels: [...s.market.openSourcedModels, modelId],
|
||||
},
|
||||
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),
|
||||
},
|
||||
};
|
||||
}),
|
||||
openSourceModel: (modelId) => {
|
||||
let opened = false;
|
||||
set((s) => {
|
||||
if (s.market.openSourcedModels.includes(modelId)) return s;
|
||||
opened = true;
|
||||
return {
|
||||
market: {
|
||||
...s.market,
|
||||
openSourcedModels: [...s.market.openSourcedModels, modelId],
|
||||
},
|
||||
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) => ({
|
||||
market: {
|
||||
@@ -1382,7 +1452,7 @@ export const useGameStore = create<Store>()(
|
||||
name: 'ai-tycoon-save',
|
||||
version: SAVE_VERSION,
|
||||
partialize: (state) => {
|
||||
const { activePage, notifications, infraNav, ...rest } = state;
|
||||
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
migrate: (_persisted, version) => {
|
||||
|
||||
Reference in New Issue
Block a user