NeerajCodz commited on
Commit
04d655f
·
1 Parent(s): 9cdb79f

fix: made download background and ui sub menu for model select

Browse files
Dockerfile CHANGED
@@ -59,5 +59,5 @@ EXPOSE 7860
59
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
60
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
61
 
62
- # Entrypoint: download models from HF Hub if absent, then start the server
63
- CMD ["sh", "-c", "python scripts/download_models.py && uvicorn api.main:app --host 0.0.0.0 --port 7860 --workers 1"]
 
59
  HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
60
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
61
 
62
+ # Entrypoint: start API immediately; model bootstrap runs in FastAPI lifespan
63
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
api/main.py CHANGED
@@ -68,20 +68,11 @@ _FRONTEND_DIST = _HERE.parent / "frontend" / "dist"
68
  # ── Lifespan ─────────────────────────────────────────────────────────────────
69
  @asynccontextmanager
70
  async def lifespan(app: FastAPI):
71
- """Load only top v3 models on startup; v1/v2 loaded on-demand."""
72
  log.info("Loading model registries …")
73
- startup_models = select_top_models("v3")
74
- if startup_models:
75
- registry_v3.load_all(only_models=set(startup_models))
76
- log.info(
77
- "v3 registry ready — %d models loaded (startup top-%d set: %s)",
78
- registry_v3.model_count,
79
- DEFAULT_STARTUP_TOP_MODELS,
80
- ", ".join(startup_models),
81
- )
82
- else:
83
- registry_v3.load_all()
84
- log.warning("Top-5 selection unavailable; loaded full v3 registry")
85
  # v1 and v2 are NOT loaded at startup — download + load on-demand via
86
  # POST /api/versions/{v}/load to reduce startup time and memory usage.
87
  if _version_loaded("v1"):
@@ -89,6 +80,9 @@ async def lifespan(app: FastAPI):
89
  if _version_loaded("v2"):
90
  log.info("v2 artifacts present on disk (not loaded — use API to activate)")
91
  yield
 
 
 
92
  log.info("Shutting down battery-lifecycle API")
93
 
94
 
@@ -195,6 +189,45 @@ async def _bg_load_version(version: str) -> None:
195
  log.error("Failed to load version %s: %s", version, exc)
196
 
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  async def _bg_load_model(version: str, model_name: str) -> None:
199
  import sys as _sys
200
 
 
68
  # ── Lifespan ─────────────────────────────────────────────────────────────────
69
  @asynccontextmanager
70
  async def lifespan(app: FastAPI):
71
+ """Start API immediately; bootstrap top v3 models in background."""
72
  log.info("Loading model registries …")
73
+ _version_status["v3"] = "downloading"
74
+ app.state.v3_bootstrap_task = asyncio.create_task(_bg_bootstrap_v3())
75
+ log.info("v3 bootstrap started in background — API is available immediately")
 
 
 
 
 
 
 
 
 
76
  # v1 and v2 are NOT loaded at startup — download + load on-demand via
77
  # POST /api/versions/{v}/load to reduce startup time and memory usage.
78
  if _version_loaded("v1"):
 
80
  if _version_loaded("v2"):
81
  log.info("v2 artifacts present on disk (not loaded — use API to activate)")
82
  yield
83
+ t = getattr(app.state, "v3_bootstrap_task", None)
84
+ if t and not t.done():
85
+ t.cancel()
86
  log.info("Shutting down battery-lifecycle API")
87
 
88
 
 
189
  log.error("Failed to load version %s: %s", version, exc)
190
 
191
 
192
+ async def _bg_bootstrap_v3() -> None:
193
+ """Download metadata + top startup models, then load them into memory."""
194
+ import sys as _sys
195
+
196
+ try:
197
+ proc = await asyncio.create_subprocess_exec(
198
+ _sys.executable,
199
+ "scripts/download_models.py",
200
+ stdout=asyncio.subprocess.PIPE,
201
+ stderr=asyncio.subprocess.STDOUT,
202
+ )
203
+ await proc.wait()
204
+ if proc.returncode != 0:
205
+ _version_status["v3"] = "error"
206
+ log.error("Startup model download failed for v3")
207
+ return
208
+
209
+ startup_models = select_top_models("v3")
210
+ if startup_models:
211
+ registry_v3.load_all(only_models=set(startup_models))
212
+ log.info(
213
+ "v3 registry ready — %d models loaded (startup top-%d set: %s)",
214
+ registry_v3.model_count,
215
+ DEFAULT_STARTUP_TOP_MODELS,
216
+ ", ".join(startup_models),
217
+ )
218
+ else:
219
+ registry_v3.load_all()
220
+ log.warning("Top model selection unavailable; loaded full v3 registry")
221
+
222
+ _version_status["v3"] = "ready"
223
+ except asyncio.CancelledError:
224
+ log.info("v3 bootstrap task cancelled")
225
+ raise
226
+ except Exception as exc:
227
+ _version_status["v3"] = "error"
228
+ log.error("Failed during v3 bootstrap: %s", exc)
229
+
230
+
231
  async def _bg_load_model(version: str, model_name: str) -> None:
232
  import sys as _sys
233
 
api/routers/predict.py CHANGED
@@ -33,7 +33,7 @@ async def predict(req: PredictRequest):
33
  features["avg_temp"] = features["ambient_temperature"] + 8.0
34
 
35
  try:
36
- result = registry.predict(features)
37
  except Exception as exc:
38
  raise HTTPException(status_code=500, detail=str(exc))
39
 
@@ -225,7 +225,7 @@ async def predict_v1(req: PredictRequest):
225
  if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
226
  features["avg_temp"] = features["ambient_temperature"] + 8.0
227
  try:
228
- result = registry_v1.predict(features)
229
  except Exception as exc:
230
  raise HTTPException(status_code=500, detail=str(exc))
231
  return PredictResponse(
 
33
  features["avg_temp"] = features["ambient_temperature"] + 8.0
34
 
35
  try:
36
+ result = registry.predict(features, req.model_name)
37
  except Exception as exc:
38
  raise HTTPException(status_code=500, detail=str(exc))
39
 
 
225
  if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
226
  features["avg_temp"] = features["ambient_temperature"] + 8.0
227
  try:
228
+ result = registry_v1.predict(features, req.model_name)
229
  except Exception as exc:
230
  raise HTTPException(status_code=500, detail=str(exc))
231
  return PredictResponse(
api/routers/predict_v2.py CHANGED
@@ -32,7 +32,7 @@ async def predict_v2(req: PredictRequest):
32
  # v2 FIX: no avg_temp auto-correction — trust the user's input
33
 
34
  try:
35
- result = registry_v2.predict(features)
36
  except Exception as exc:
37
  raise HTTPException(status_code=500, detail=str(exc))
38
 
@@ -115,7 +115,7 @@ async def recommend_v2(req: RecommendationRequest):
115
  feat = {**base_features, "ambient_temperature": t, "avg_current": c,
116
  "min_voltage": v, "voltage_range": 4.19 - v,
117
  "avg_temp": t + 8.0}
118
- result = registry_v2.predict(feat)
119
  rul = result.get("rul_cycles", 0) or 0
120
  candidates.append((rul, t, c, v, result["soh_pct"]))
121
 
 
32
  # v2 FIX: no avg_temp auto-correction — trust the user's input
33
 
34
  try:
35
+ result = registry_v2.predict(features, req.model_name)
36
  except Exception as exc:
37
  raise HTTPException(status_code=500, detail=str(exc))
38
 
 
115
  feat = {**base_features, "ambient_temperature": t, "avg_current": c,
116
  "min_voltage": v, "voltage_range": 4.19 - v,
117
  "avg_temp": t + 8.0}
118
+ result = registry_v2.predict(feat, req.model_name)
119
  rul = result.get("rul_cycles", 0) or 0
120
  candidates.append((rul, t, c, v, result["soh_pct"]))
121
 
api/routers/predict_v3.py CHANGED
@@ -31,7 +31,7 @@ async def predict_v3(req: PredictRequest):
31
  features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
32
 
33
  try:
34
- result = registry_v3.predict(features)
35
  except Exception as exc:
36
  raise HTTPException(status_code=500, detail=str(exc))
37
 
@@ -107,7 +107,7 @@ async def recommend_v3(req: RecommendationRequest):
107
  feat = {**base_features, "ambient_temperature": t, "avg_current": c,
108
  "min_voltage": v, "voltage_range": 4.19 - v,
109
  "avg_temp": t + 8.0}
110
- result = registry_v3.predict(feat)
111
  rul = result.get("rul_cycles", 0) or 0
112
  candidates.append((rul, t, c, v, result["soh_pct"]))
113
 
 
31
  features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
32
 
33
  try:
34
+ result = registry_v3.predict(features, req.model_name)
35
  except Exception as exc:
36
  raise HTTPException(status_code=500, detail=str(exc))
37
 
 
107
  feat = {**base_features, "ambient_temperature": t, "avg_current": c,
108
  "min_voltage": v, "voltage_range": 4.19 - v,
109
  "avg_temp": t + 8.0}
110
+ result = registry_v3.predict(feat, req.model_name)
111
  rul = result.get("rul_cycles", 0) or 0
112
  candidates.append((rul, t, c, v, result["soh_pct"]))
113
 
api/schemas.py CHANGED
@@ -25,6 +25,7 @@ class PredictRequest(BaseModel):
25
  Re: float = Field(default=0.045, description="Electrolyte resistance (Ω) — training range 0.027–0.156")
26
  Rct: float = Field(default=0.069, description="Charge transfer resistance (Ω) — training range 0.04–0.27")
27
  delta_capacity: float = Field(default=-0.005, description="Capacity change from last cycle (Ah)")
 
28
 
29
 
30
  class PredictResponse(BaseModel):
@@ -61,6 +62,7 @@ class RecommendationRequest(BaseModel):
61
  current_soh: float = Field(..., ge=0, le=100)
62
  ambient_temperature: float = Field(default=24.0)
63
  top_k: int = Field(default=5, ge=1, le=20)
 
64
 
65
 
66
  class SingleRecommendation(BaseModel):
 
25
  Re: float = Field(default=0.045, description="Electrolyte resistance (Ω) — training range 0.027–0.156")
26
  Rct: float = Field(default=0.069, description="Charge transfer resistance (Ω) — training range 0.04–0.27")
27
  delta_capacity: float = Field(default=-0.005, description="Capacity change from last cycle (Ah)")
28
+ model_name: Optional[str] = Field(default=None, description="Optional model key override")
29
 
30
 
31
  class PredictResponse(BaseModel):
 
62
  current_soh: float = Field(..., ge=0, le=100)
63
  ambient_temperature: float = Field(default=24.0)
64
  top_k: int = Field(default=5, ge=1, le=20)
65
+ model_name: Optional[str] = Field(default=None, description="Optional model key override")
66
 
67
 
68
  class SingleRecommendation(BaseModel):
frontend/src/App.tsx CHANGED
@@ -79,7 +79,7 @@ export default function App() {
79
  {/* Content */}
80
  <main className="max-w-7xl mx-auto px-4 py-6">
81
  {activeTab === "simulation" && <SimulationPanel />}
82
- {activeTab === "predict" && <PredictionForm />}
83
  {activeTab === "graphs" && <GraphPanel />}
84
  {activeTab === "recommend" && <RecommendationPanel apiVersion={apiVersion} />}
85
  {activeTab === "metrics" && <MetricsPanel />}
 
79
  {/* Content */}
80
  <main className="max-w-7xl mx-auto px-4 py-6">
81
  {activeTab === "simulation" && <SimulationPanel />}
82
+ {activeTab === "predict" && <PredictionForm apiVersion={apiVersion} />}
83
  {activeTab === "graphs" && <GraphPanel />}
84
  {activeTab === "recommend" && <RecommendationPanel apiVersion={apiVersion} />}
85
  {activeTab === "metrics" && <MetricsPanel />}
frontend/src/components/PredictionForm.tsx CHANGED
@@ -28,7 +28,11 @@ const familyColour = (family: string) => {
28
  }
29
  };
30
 
31
- export default function PredictionForm() {
 
 
 
 
32
  const [form, setForm] = useState<PredictRequest>(DEFAULTS);
33
  const [models, setModels] = useState<ModelInfo[]>([]);
34
  const [result, setResult] = useState<PredictResponse | null>(null);
@@ -39,15 +43,22 @@ export default function PredictionForm() {
39
  useEffect(() => {
40
  fetchModels()
41
  .then((ms) => {
42
- // Prefer loaded models; sort by r2 desc
43
- const sorted = [...ms].sort((a, b) => {
44
- if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
 
45
  return (b.r2 ?? 0) - (a.r2 ?? 0);
46
- });
47
  setModels(sorted);
 
 
 
 
 
 
48
  })
49
  .catch(() => {/* silently ignore — model list is optional */});
50
- }, []);
51
 
52
  const handleChange = (key: keyof PredictRequest, val: string) => {
53
  setForm((prev) => ({
@@ -136,10 +147,9 @@ export default function PredictionForm() {
136
  >
137
  <option value="">— Registry Default —</option>
138
  {models.map((m) => (
139
- <option key={m.name} value={m.name} disabled={!m.loaded}>
140
  {m.display_name ?? m.name}
141
  {m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
142
- {!m.loaded ? " [unavailable]" : ""}
143
  </option>
144
  ))}
145
  </select>
 
28
  }
29
  };
30
 
31
+ type Props = {
32
+ apiVersion: "v1" | "v2" | "v3";
33
+ };
34
+
35
+ export default function PredictionForm({ apiVersion }: Props) {
36
  const [form, setForm] = useState<PredictRequest>(DEFAULTS);
37
  const [models, setModels] = useState<ModelInfo[]>([]);
38
  const [result, setResult] = useState<PredictResponse | null>(null);
 
43
  useEffect(() => {
44
  fetchModels()
45
  .then((ms) => {
46
+ // Loaded-only list for stable UX
47
+ const sorted = [...ms]
48
+ .filter((m) => m.loaded)
49
+ .sort((a, b) => {
50
  return (b.r2 ?? 0) - (a.r2 ?? 0);
51
+ });
52
  setModels(sorted);
53
+ if (sorted.length > 0) {
54
+ setForm((prev) => {
55
+ const keep = prev.model_name && sorted.some((m) => m.name === prev.model_name);
56
+ return { ...prev, model_name: keep ? prev.model_name : null };
57
+ });
58
+ }
59
  })
60
  .catch(() => {/* silently ignore — model list is optional */});
61
+ }, [apiVersion]);
62
 
63
  const handleChange = (key: keyof PredictRequest, val: string) => {
64
  setForm((prev) => ({
 
147
  >
148
  <option value="">— Registry Default —</option>
149
  {models.map((m) => (
150
+ <option key={m.name} value={m.name}>
151
  {m.display_name ?? m.name}
152
  {m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
 
153
  </option>
154
  ))}
155
  </select>
frontend/src/components/SimulationPanel.tsx CHANGED
@@ -29,7 +29,13 @@ import {
29
  TableProperties, ScrollText, CheckCircle2, WifiOff,
30
  Server, FlaskConical, Copy, Download,
31
  } from "lucide-react";
32
- import { simulateBatteries, BatterySimConfig, BatterySimResult } from "../api";
 
 
 
 
 
 
33
  import { useToast } from "./Toast";
34
 
35
  // ── Constants ─────────────────────────────────────────────────────────────
@@ -620,6 +626,7 @@ export default function SimulationPanel() {
620
  const [isSimulating, setIsSimulating] = useState(false);
621
  const [modelUsed, setModelUsed] = useState<string | null>(null);
622
  const [selectedModel, setSelectedModel] = useState<string>("best_ensemble");
 
623
 
624
  // --- Playback ---
625
  const [playIndex, setPlayIndex] = useState(0);
@@ -637,6 +644,26 @@ export default function SimulationPanel() {
637
 
638
  const totalSteps = results.length > 0 ? results[0].soh_history.length : 0;
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  // current per-battery snapshot at playIndex
641
  const currentSohs = useMemo(() =>
642
  results.map((r) => ({
@@ -1032,15 +1059,15 @@ export default function SimulationPanel() {
1032
  onChange={(e) => setSelectedModel(e.target.value)}
1033
  className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500"
1034
  >
1035
- <option value="best_ensemble">Best Ensemble (RF+ET+LGB)</option>
1036
- <option value="random_forest">Random Forest</option>
1037
- <option value="extra_trees">Extra Trees</option>
1038
- <option value="lightgbm">LightGBM</option>
1039
- <option value="ridge">Ridge Regression</option>
1040
- <option value="svr">SVR</option>
1041
- <option value="batterygpt">BatteryGPT</option>
1042
- <option value="tft">TFT</option>
1043
- <option value="vae_lstm">VAE-LSTM</option>
1044
  </select>
1045
  </div>
1046
 
 
29
  TableProperties, ScrollText, CheckCircle2, WifiOff,
30
  Server, FlaskConical, Copy, Download,
31
  } from "lucide-react";
32
+ import {
33
+ simulateBatteries,
34
+ BatterySimConfig,
35
+ BatterySimResult,
36
+ fetchVersionModels,
37
+ VersionModelInfo,
38
+ } from "../api";
39
  import { useToast } from "./Toast";
40
 
41
  // ── Constants ─────────────────────────────────────────────────────────────
 
626
  const [isSimulating, setIsSimulating] = useState(false);
627
  const [modelUsed, setModelUsed] = useState<string | null>(null);
628
  const [selectedModel, setSelectedModel] = useState<string>("best_ensemble");
629
+ const [loadedModels, setLoadedModels] = useState<VersionModelInfo[]>([]);
630
 
631
  // --- Playback ---
632
  const [playIndex, setPlayIndex] = useState(0);
 
644
 
645
  const totalSteps = results.length > 0 ? results[0].soh_history.length : 0;
646
 
647
+ useEffect(() => {
648
+ const refreshLoadedModels = () => {
649
+ fetchVersionModels("v3")
650
+ .then((rows) => {
651
+ const loaded = rows
652
+ .filter((m) => m.loaded)
653
+ .sort((a, b) => (b.r2 ?? -1) - (a.r2 ?? -1));
654
+ setLoadedModels(loaded);
655
+ if (loaded.length > 0 && !loaded.some((m) => m.name === selectedModel)) {
656
+ setSelectedModel(loaded[0].name);
657
+ }
658
+ })
659
+ .catch(() => setLoadedModels([]));
660
+ };
661
+
662
+ refreshLoadedModels();
663
+ const id = setInterval(refreshLoadedModels, 3000);
664
+ return () => clearInterval(id);
665
+ }, []);
666
+
667
  // current per-battery snapshot at playIndex
668
  const currentSohs = useMemo(() =>
669
  results.map((r) => ({
 
1059
  onChange={(e) => setSelectedModel(e.target.value)}
1060
  className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-green-500"
1061
  >
1062
+ {loadedModels.length === 0 && (
1063
+ <option value={selectedModel}>No loaded models</option>
1064
+ )}
1065
+ {loadedModels.map((m) => (
1066
+ <option key={m.name} value={m.name}>
1067
+ {m.display_name}
1068
+ {m.r2 != null ? ` (R²=${m.r2.toFixed(3)})` : ""}
1069
+ </option>
1070
+ ))}
1071
  </select>
1072
  </div>
1073
 
frontend/src/components/VersionSelector.tsx CHANGED
@@ -138,6 +138,15 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
138
  }
139
  };
140
 
 
 
 
 
 
 
 
 
 
141
  // Auto-switch when download completes
142
  useEffect(() => {
143
  if (busy) {
@@ -153,8 +162,6 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
153
  const activeDisplay = versions.find((v) => v.id === activeVersion)?.display
154
  ?? `v${activeVersion[1]}.0`;
155
 
156
- const others = versions.filter((v) => v.id !== activeVersion);
157
-
158
  const versionColor = (id: string) => {
159
  if (id === "v3") return "bg-green-600 text-white hover:bg-green-500";
160
  if (id === "v2") return "bg-blue-600 text-white hover:bg-blue-500";
@@ -179,7 +186,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
179
 
180
  {open && (
181
  <div
182
- className="absolute right-0 top-full mt-2 w-64
183
  bg-gray-900 border border-gray-700 rounded-xl shadow-2xl z-50
184
  overflow-hidden"
185
  >
@@ -187,27 +194,9 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
187
  Model Versions
188
  </div>
189
 
190
- {/* Active version row */}
191
- <div className="flex items-center justify-between px-3 py-2.5 bg-gray-800/50">
192
- <div>
193
- <span className="text-sm font-semibold text-white">{activeDisplay}</span>
194
- <span className="ml-2 text-xs text-green-400">active</span>
195
- </div>
196
- <Check className="w-4 h-4 text-green-400 shrink-0" />
197
- </div>
198
-
199
- {/* Other versions */}
200
- {others.length === 0 && (
201
- <div className="px-3 py-3 text-xs text-gray-500 text-center">
202
- No other versions available
203
- </div>
204
- )}
205
- {others.map((v) => {
206
- const isDownloading = v.status === "downloading" || busy === v.id;
207
- const isError = v.status === "error";
208
- const isLoaded = v.loaded && v.model_count > 0;
209
- const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
210
- const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
211
  const isExpanded = expandedVersion === v.id;
212
  const models = versionModels[v.id] ?? [];
213
 
@@ -218,7 +207,10 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
218
  hover:bg-gray-800/60 transition-colors"
219
  >
220
  <div className="flex-1 min-w-0">
221
- <span className="text-sm font-medium text-gray-200">{v.display}</span>
 
 
 
222
  {v.features && (
223
  <span className="ml-2 text-xs text-gray-500">
224
  {v.features}f
@@ -246,7 +238,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
246
  </div>
247
 
248
  <div className="flex items-center gap-1 shrink-0 ml-2">
249
- {isLoaded && (
250
  <button
251
  onClick={() => handleSwitch(v.id)}
252
  className="px-2.5 py-1 rounded text-xs font-medium
@@ -299,10 +291,10 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
299
 
300
  <button
301
  onClick={() => handleExpandVersion(v.id)}
302
- className="px-2 py-1 rounded text-xs font-medium bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors"
303
- title="Show model-level controls"
304
  >
305
- {isExpanded ? "Hide Models" : "Models"}
306
  </button>
307
  </div>
308
  </div>
@@ -311,7 +303,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
311
  <div className="px-3 pb-3">
312
  <div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
313
  <div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
314
- Download / load models individually
315
  </div>
316
  <div className="max-h-44 overflow-auto">
317
  {models.length === 0 && (
@@ -336,7 +328,7 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
336
  </div>
337
  </div>
338
  <div className="flex items-center gap-1">
339
- {loaded && <span className="text-[11px] text-green-400">ready</span>}
340
  {!loaded && canDownload && !downloading && (
341
  <button
342
  onClick={() => handleLoadModel(v.id, m.name)}
 
138
  }
139
  };
140
 
141
+ const statusForVersion = (v: VersionInfo) => {
142
+ const isDownloading = v.status === "downloading" || busy === v.id;
143
+ const isError = v.status === "error";
144
+ const isLoaded = v.loaded && v.model_count > 0;
145
+ const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
146
+ const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
147
+ return { isDownloading, isError, isLoaded, isOnDisk, isNotDownloaded };
148
+ };
149
+
150
  // Auto-switch when download completes
151
  useEffect(() => {
152
  if (busy) {
 
162
  const activeDisplay = versions.find((v) => v.id === activeVersion)?.display
163
  ?? `v${activeVersion[1]}.0`;
164
 
 
 
165
  const versionColor = (id: string) => {
166
  if (id === "v3") return "bg-green-600 text-white hover:bg-green-500";
167
  if (id === "v2") return "bg-blue-600 text-white hover:bg-blue-500";
 
186
 
187
  {open && (
188
  <div
189
+ className="absolute right-0 top-full mt-2 w-72
190
  bg-gray-900 border border-gray-700 rounded-xl shadow-2xl z-50
191
  overflow-hidden"
192
  >
 
194
  Model Versions
195
  </div>
196
 
197
+ {versions.map((v) => {
198
+ const { isDownloading, isError, isLoaded, isOnDisk, isNotDownloaded } = statusForVersion(v);
199
+ const isActiveVersion = v.id === activeVersion;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  const isExpanded = expandedVersion === v.id;
201
  const models = versionModels[v.id] ?? [];
202
 
 
207
  hover:bg-gray-800/60 transition-colors"
208
  >
209
  <div className="flex-1 min-w-0">
210
+ <span className={`text-sm font-medium ${isActiveVersion ? "text-white" : "text-gray-200"}`}>{v.display}</span>
211
+ {isActiveVersion && (
212
+ <span className="ml-2 text-xs text-green-400 italic">Active Version</span>
213
+ )}
214
  {v.features && (
215
  <span className="ml-2 text-xs text-gray-500">
216
  {v.features}f
 
238
  </div>
239
 
240
  <div className="flex items-center gap-1 shrink-0 ml-2">
241
+ {isLoaded && !isActiveVersion && (
242
  <button
243
  onClick={() => handleSwitch(v.id)}
244
  className="px-2.5 py-1 rounded text-xs font-medium
 
291
 
292
  <button
293
  onClick={() => handleExpandVersion(v.id)}
294
+ className="p-1.5 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors"
295
+ title="Open model submenu"
296
  >
297
+ <ChevronRight className={`w-3.5 h-3.5 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
298
  </button>
299
  </div>
300
  </div>
 
303
  <div className="px-3 pb-3">
304
  <div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
305
  <div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
306
+ {v.display} models
307
  </div>
308
  <div className="max-h-44 overflow-auto">
309
  {models.length === 0 && (
 
328
  </div>
329
  </div>
330
  <div className="flex items-center gap-1">
331
+ {loaded && <span className="text-[11px] text-green-400 italic font-medium">Active</span>}
332
  {!loaded && canDownload && !downloading && (
333
  <button
334
  onClick={() => handleLoadModel(v.id, m.name)}