mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-18 13:33:53 +08:00
Improve model selection and downloads in Galaxy
This commit is contained in:
@@ -937,6 +937,7 @@ class ModelManager:
|
||||
try:
|
||||
self._download_model(model_to_download, allow_gpu_without_gpu)
|
||||
finally:
|
||||
self.downloading_model = False
|
||||
self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM)
|
||||
|
||||
def _download_artifact_to_path(self, model_key: str, file_path: Path, remote_filename: str,
|
||||
@@ -1057,8 +1058,8 @@ class ModelManager:
|
||||
self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!")
|
||||
return True
|
||||
finally:
|
||||
self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
|
||||
self.downloading_model = False
|
||||
self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM)
|
||||
|
||||
def _download_model(self, model_to_download: str, allow_gpu_without_gpu: bool):
|
||||
self.downloading_model = True
|
||||
@@ -1129,6 +1130,7 @@ class ModelManager:
|
||||
try:
|
||||
self._download_all_models(allow_gpu_without_gpu)
|
||||
finally:
|
||||
self.downloading_model = False
|
||||
self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM)
|
||||
|
||||
def _download_all_models(self, allow_gpu_without_gpu: bool):
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Local logical payload sizes. No HEAD requests, hashing, or model mutation."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_DECLARED_BYTES = 1 << 50
|
||||
|
||||
|
||||
def positive_size(value):
|
||||
return value if type(value) is int and 0 < value <= MAX_DECLARED_BYTES else None
|
||||
|
||||
|
||||
def artifact_size(path, declared=None):
|
||||
path = Path(path)
|
||||
declared = positive_size(declared)
|
||||
result = {'fileSizeBytes': None, 'declaredSizeBytes': declared, 'downloadedBytes': None,
|
||||
'sizeSource': 'metadata' if declared else 'unknown',
|
||||
'sizeStatus': 'declared' if declared else 'unknown'}
|
||||
try:
|
||||
if path.is_file():
|
||||
size = path.stat().st_size
|
||||
result.update(fileSizeBytes=size if size > 0 else None, downloadedBytes=size,
|
||||
sizeSource='installed', sizeStatus='complete' if size > 0 else 'partial')
|
||||
else:
|
||||
manifest = Path(str(path) + '.chunkmanifest')
|
||||
chunks = [p for p in path.parent.glob(path.name + '.chunk*')
|
||||
if re.fullmatch(re.escape(path.name) + r'\.chunk\d+of\d+', p.name) and p.is_file()]
|
||||
if not manifest.is_file() and not chunks:
|
||||
return result
|
||||
count = None
|
||||
if manifest.is_file():
|
||||
try:
|
||||
text = manifest.read_text().strip()
|
||||
count = int(text) if text.isdecimal() and len(text) <= 5 else None
|
||||
except (ValueError, UnicodeError):
|
||||
pass
|
||||
expected = [Path(f'{path}.chunk{i + 1:02d}of{count:02d}') for i in range(count)] if count and count <= 10000 else []
|
||||
complete = bool(expected) and set(chunks) == set(expected)
|
||||
downloaded = sum(p.stat().st_size for p in chunks)
|
||||
result.update(downloadedBytes=downloaded, fileSizeBytes=downloaded if complete and downloaded > 0 else None,
|
||||
sizeSource='installed' if complete else 'partial',
|
||||
sizeStatus='complete' if complete and downloaded > 0 else 'partial')
|
||||
if result['fileSizeBytes'] is not None and declared and declared != result['fileSizeBytes']:
|
||||
result['sizeStatus'] = 'mismatch'
|
||||
except OSError:
|
||||
result.update(fileSizeBytes=None, downloadedBytes=None, sizeSource='unknown', sizeStatus='unknown')
|
||||
return result
|
||||
|
||||
|
||||
class ModelSizes:
|
||||
"""Bounded cache for local catalogue size reads, independent of statistics."""
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.sizes = {}
|
||||
|
||||
def size(self, path, declared):
|
||||
# Short TTL avoids repeated directory scans on status polls, not stale sizes
|
||||
# across completed downloads. Bad metadata is treated as unknown by helper.
|
||||
key = (str(path), repr(declared))
|
||||
with self.lock:
|
||||
cached = self.sizes.get(key)
|
||||
if cached is None or time.monotonic() >= cached[0]:
|
||||
cached = (time.monotonic() + 2, artifact_size(path, declared))
|
||||
if len(self.sizes) > 1000:
|
||||
self.sizes.clear()
|
||||
self.sizes[key] = cached
|
||||
return dict(cached[1])
|
||||
|
||||
def annotate(self, models, models_path, builtin_path, metadata, accelerator_filename):
|
||||
for model in models:
|
||||
key = model['value']
|
||||
entry = metadata.get(key, {})
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
path = builtin_path if model['builtin'] else Path(models_path) / f'{key}_driving_tinygrad.pkl'
|
||||
model.update(self.size(path, entry.get('artifact_size')))
|
||||
variants = entry.get('accelerator_artifacts', {})
|
||||
variant = variants.get('chestnut') if isinstance(variants, dict) else None
|
||||
if isinstance(variant, dict):
|
||||
model['modelLabFileSize'] = self.size(Path(models_path) / accelerator_filename(key), variant.get('artifact_size'))
|
||||
return models
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Source-class fault seam; native imports and real Params are not exercised."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def manager_class():
|
||||
tree = ast.parse((Path(__file__).parents[1] / 'model_manager.py').read_text())
|
||||
names = {'MODEL_LAB_ACCELERATOR', 'ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM',
|
||||
'DOWNLOAD_PROGRESS_PARAM', 'MODEL_DOWNLOAD_PARAM', 'MODEL_LAB_DOWNLOAD_PARAM',
|
||||
'DEFAULT_MODEL_KEY', 'MODEL_KEY_CANONICAL_MAP'}
|
||||
nodes = [n for n in tree.body if
|
||||
isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in n.targets) or
|
||||
isinstance(n, ast.FunctionDef) and n.name in {'canonical_model_key', 'is_builtin_model_key'} or
|
||||
isinstance(n, ast.ClassDef) and n.name == 'ModelManager']
|
||||
ns = {'Path': Path, 'model_accelerator_artifact_metadata': lambda *args: {}}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'model_manager.py', 'exec'), ns)
|
||||
return ns['ModelManager']
|
||||
|
||||
|
||||
class FaultParams:
|
||||
def __init__(self, cleanup_fault=False):
|
||||
self.cleanup_fault = cleanup_fault
|
||||
def get_bool(self, key):
|
||||
return False
|
||||
def put(self, *args):
|
||||
raise OSError('synthetic Params write failure')
|
||||
def remove(self, *args):
|
||||
if self.cleanup_fault:
|
||||
raise OSError('synthetic Params removal failure')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cleanup_fault', [False, True])
|
||||
@pytest.mark.parametrize('operation', ['single', 'all', 'accelerator'])
|
||||
def test_failed_download_releases_refresh_gate(cleanup_fault, operation):
|
||||
cls = manager_class()
|
||||
manager = cls.__new__(cls)
|
||||
manager.downloading_model = False
|
||||
manager.params_memory = FaultParams(cleanup_fault)
|
||||
if operation == 'all':
|
||||
manager._download_all_models = lambda allow: manager._download_model('rdf43', allow)
|
||||
call = manager.download_all_models
|
||||
elif operation == 'accelerator':
|
||||
# Failure at upstream artifact metadata lookup; cleanup must release the gate.
|
||||
def fail():
|
||||
raise OSError('synthetic hardware lookup failure')
|
||||
cls.download_model_accelerator.__globals__['model_accelerator_artifact_metadata'] = lambda *args: fail()
|
||||
call = lambda: manager.download_model_accelerator('gpu')
|
||||
else:
|
||||
call = lambda: manager.download_model('rdf43')
|
||||
with pytest.raises(OSError):
|
||||
call()
|
||||
assert manager.downloading_model is False
|
||||
@@ -0,0 +1,99 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.assets.model_sizes import artifact_size, positive_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [None, True, False, 0, -1, '123', 'abc', 1.2, 2**60])
|
||||
def test_invalid_declared_size(value):
|
||||
assert positive_size(value) is None
|
||||
|
||||
|
||||
def test_monolithic_actual_size_precedes_chunks_and_reports_mismatch(tmp_path):
|
||||
path = tmp_path / 'model.pkl'
|
||||
path.write_bytes(b'actual model')
|
||||
Path(str(path) + '.chunkmanifest').write_text('1')
|
||||
Path(str(path) + '.chunk01of01').write_bytes(b'old chunk')
|
||||
result = artifact_size(path, 2)
|
||||
assert result['fileSizeBytes'] == len(b'actual model')
|
||||
assert result['downloadedBytes'] == len(b'actual model')
|
||||
assert result['sizeStatus'] == 'mismatch'
|
||||
|
||||
|
||||
def test_complete_chunks_exclude_manifest_and_missing_chunk_is_partial(tmp_path):
|
||||
path = tmp_path / 'model.pkl'
|
||||
Path(str(path) + '.chunkmanifest').write_text('2')
|
||||
first, second = Path(str(path) + '.chunk01of02'), Path(str(path) + '.chunk02of02')
|
||||
first.write_bytes(b'abc')
|
||||
second.write_bytes(b'defg')
|
||||
assert artifact_size(path, 7)['fileSizeBytes'] == 7
|
||||
assert artifact_size(path, 7)['sizeStatus'] == 'complete'
|
||||
second.unlink()
|
||||
result = artifact_size(path, 7)
|
||||
assert result['sizeStatus'] == 'partial'
|
||||
assert result['fileSizeBytes'] is None
|
||||
assert result['downloadedBytes'] == 3 and result['declaredSizeBytes'] == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize('manifest', ['0', '-1', 'bad', '100000000000000', '', '2.0'])
|
||||
def test_malformed_chunk_manifest_is_not_complete(tmp_path, manifest):
|
||||
path = tmp_path / 'model.pkl'
|
||||
Path(str(path) + '.chunkmanifest').write_text(manifest)
|
||||
assert artifact_size(path)['fileSizeBytes'] is None
|
||||
assert artifact_size(path)['sizeStatus'] == 'partial'
|
||||
|
||||
|
||||
def test_missing_manifest_and_missing_file(tmp_path):
|
||||
path = tmp_path / 'model.pkl'
|
||||
assert artifact_size(path, 20)['sizeStatus'] == 'declared'
|
||||
assert artifact_size(path)['sizeStatus'] == 'unknown'
|
||||
Path(str(path) + '.chunk01of02').write_bytes(b'partial')
|
||||
assert artifact_size(path)['sizeStatus'] == 'partial'
|
||||
assert artifact_size(path)['downloadedBytes'] == 7
|
||||
|
||||
|
||||
def test_stat_failure_is_unknown(tmp_path, monkeypatch):
|
||||
path = tmp_path / 'model.pkl'
|
||||
path.write_bytes(b'x')
|
||||
def failed(*args, **kwargs):
|
||||
raise PermissionError('test denied')
|
||||
monkeypatch.setattr(Path, 'stat', failed)
|
||||
assert artifact_size(path, 10)['sizeStatus'] == 'unknown'
|
||||
|
||||
|
||||
def test_zero_byte_artifact_is_not_valid_size(tmp_path):
|
||||
path = tmp_path / 'model.pkl'
|
||||
path.touch()
|
||||
assert artifact_size(path)['fileSizeBytes'] is None
|
||||
assert artifact_size(path)['sizeStatus'] == 'partial'
|
||||
|
||||
|
||||
def test_catalog_annotation_is_independent_of_statistics(tmp_path):
|
||||
from openpilot.starpilot.assets.model_sizes import ModelSizes
|
||||
(tmp_path / 'builtin.pkl').write_bytes(b'123')
|
||||
(tmp_path / 'gpu_driving_tinygrad.pkl').write_bytes(b'12345')
|
||||
(tmp_path / 'gpu_accel.pkl').write_bytes(b'1234567')
|
||||
models = [{'value': 'stock', 'builtin': True}, {'value': 'gpu', 'builtin': False}]
|
||||
result = ModelSizes().annotate(models, tmp_path, tmp_path / 'builtin.pkl',
|
||||
{'gpu': {'artifact_size': 5, 'accelerator_artifacts': {'chestnut': {'artifact_size': 7}}}}, lambda key: key + '_accel.pkl')
|
||||
assert result[0]['fileSizeBytes'] == 3
|
||||
assert result[1]['fileSizeBytes'] == 5
|
||||
assert result[1]['modelLabFileSize']['fileSizeBytes'] == 7
|
||||
assert all('stats' not in model for model in result)
|
||||
|
||||
|
||||
def test_size_cache_refresh_and_bound(tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.assets import model_sizes
|
||||
now = [0]
|
||||
monkeypatch.setattr(model_sizes.time, 'monotonic', lambda: now[0])
|
||||
service = model_sizes.ModelSizes()
|
||||
path = tmp_path / 'model.pkl'
|
||||
assert service.size(path, None)['fileSizeBytes'] is None
|
||||
path.write_bytes(b'123')
|
||||
assert service.size(path, None)['fileSizeBytes'] is None
|
||||
now[0] = 3
|
||||
assert service.size(path, None)['fileSizeBytes'] == 3
|
||||
for index in range(1002):
|
||||
service.size(tmp_path / str(index), None)
|
||||
assert len(service.sizes) <= 1001
|
||||
@@ -0,0 +1,52 @@
|
||||
// Presentation only. No Params writes, telemetry collection or inferred mileage.
|
||||
const FILTER_KEY = "galaxy.modelManager.hardwareFilter";
|
||||
const FILTERS = new Set(["both", "gpu", "comma"]);
|
||||
|
||||
export function readHardwareFilter() {
|
||||
try {
|
||||
const value = localStorage.getItem(FILTER_KEY);
|
||||
return FILTERS.has(value) ? value : "both";
|
||||
} catch {
|
||||
return "both";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHardwareFilter(value) {
|
||||
const filter = FILTERS.has(value) ? value : "both";
|
||||
try { localStorage.setItem(FILTER_KEY, filter); } catch { /* View still works without storage. */ }
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function matchesHardware(model, filter) {
|
||||
if (filter === "gpu") return model?.requiresGpu === true;
|
||||
if (filter === "comma") return model?.requiresGpu === false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hardwareLabel(model) {
|
||||
if (model?.requiresGpu === true) return "GPU · External";
|
||||
if (model?.requiresGpu === false) return "Comma · On-device";
|
||||
return "Hardware unknown";
|
||||
}
|
||||
|
||||
function bytes(value) {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function fileSizeText(model) {
|
||||
// modelSize is an architecture class, never a byte count.
|
||||
const installed = bytes(model?.fileSizeBytes);
|
||||
const declared = bytes(model?.declaredSizeBytes);
|
||||
const downloaded = bytes(model?.downloadedBytes);
|
||||
if (model?.partial || model?.sizeStatus === "partial") {
|
||||
return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`;
|
||||
}
|
||||
if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`;
|
||||
if (installed) return formatBytes(installed);
|
||||
if (declared) return `${formatBytes(declared)} · declared`;
|
||||
return "Unavailable";
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const state = reactive({
|
||||
refreshing: false,
|
||||
error: "",
|
||||
actionBusy: false,
|
||||
selectionUncertain: true,
|
||||
sortMode: "release_date",
|
||||
communityFavoriteFilter: "all",
|
||||
userFavoriteFilter: "all",
|
||||
@@ -27,7 +28,10 @@ const state = reactive({
|
||||
|
||||
let initialized = false;
|
||||
let pollingHandle = null;
|
||||
let statusInFlight = false;
|
||||
let statusInFlight = null;
|
||||
let statusGeneration = 0;
|
||||
let viewGeneration = 0;
|
||||
let selectionWrite = null;
|
||||
let lastStatusSignature = "";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20000;
|
||||
@@ -216,11 +220,16 @@ async function fetchJson(url, options = {}) {
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
if (statusInFlight) return;
|
||||
statusInFlight = true;
|
||||
const generation = statusGeneration;
|
||||
if (statusInFlight === generation) return;
|
||||
statusInFlight = generation;
|
||||
|
||||
try {
|
||||
// Remount readback must follow settlement of an already sent selection.
|
||||
if (selectionWrite) await selectionWrite.catch(() => {});
|
||||
if (generation !== statusGeneration || !isModelRouteActive()) return;
|
||||
const payload = await fetchJson("/api/models/status");
|
||||
if (generation !== statusGeneration || !isModelRouteActive()) return;
|
||||
|
||||
const models = Array.isArray(payload.models)
|
||||
? payload.models.filter(model => model && typeof model === "object")
|
||||
@@ -249,6 +258,15 @@ async function fetchStatus() {
|
||||
};
|
||||
|
||||
state.error = "";
|
||||
state.selectionUncertain = false;
|
||||
// selected attributes cannot reset a select's dirty native value after a user edit.
|
||||
queueMicrotask(() => {
|
||||
if (generation !== statusGeneration || !isModelRouteActive()) return;
|
||||
for (const profile of ["small", "big"]) {
|
||||
const select = document.getElementById(`mm-active-${profile}-model-select`);
|
||||
if (select) select.value = profile === "big" ? state.activeBigModel : state.activeSmallModel;
|
||||
}
|
||||
});
|
||||
|
||||
const signature = [
|
||||
state.models.length,
|
||||
@@ -271,12 +289,15 @@ async function fetchStatus() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== statusGeneration || !isModelRouteActive()) return;
|
||||
state.error = error?.message || String(error);
|
||||
logDebug("Status fetch failed", state.error);
|
||||
} finally {
|
||||
statusInFlight = false;
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
if (statusInFlight === generation) statusInFlight = null;
|
||||
if (generation === statusGeneration && isModelRouteActive()) {
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +317,9 @@ async function refreshAll(showToast = false) {
|
||||
function ensurePolling() {
|
||||
if (pollingHandle) return;
|
||||
|
||||
const generation = viewGeneration;
|
||||
const poll = async () => {
|
||||
if (!isModelRouteActive()) {
|
||||
if (generation !== viewGeneration || !isModelRouteActive()) {
|
||||
pollingHandle = null;
|
||||
return;
|
||||
}
|
||||
@@ -307,7 +329,7 @@ function ensurePolling() {
|
||||
await fetchStatus();
|
||||
nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
||||
} finally {
|
||||
pollingHandle = setTimeout(poll, nextDelay);
|
||||
if (generation === viewGeneration) pollingHandle = setTimeout(poll, nextDelay);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -317,13 +339,13 @@ function ensurePolling() {
|
||||
async function setActiveModel(modelKey, profile = "") {
|
||||
const model = state.models.find(entry => safeText(entry?.value, "") === safeText(modelKey, ""));
|
||||
const resolvedProfile = profile || (model?.requiresGpu ? "big" : "small");
|
||||
const payload = await fetchJson("/api/models/active", {
|
||||
selectionWrite = fetchJson("/api/models/active", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: resolvedProfile, model: modelKey }),
|
||||
});
|
||||
|
||||
notify(payload.message || `Selected "${modelKey}".`);
|
||||
try { return await selectionWrite; } finally { selectionWrite = null; }
|
||||
}
|
||||
|
||||
async function startDownload(modelKey) {
|
||||
@@ -391,6 +413,9 @@ async function refreshManifest() {
|
||||
}
|
||||
|
||||
async function runAction(action, modelKey = "") {
|
||||
const selecting = ["select", "select-small", "select-big"].includes(action);
|
||||
if (!isModelRouteActive() || (selecting && state.selectionUncertain)) return;
|
||||
const generation = viewGeneration;
|
||||
if (state.actionBusy) {
|
||||
notify("Please wait for the current action to finish.", "error");
|
||||
return;
|
||||
@@ -411,9 +436,14 @@ async function runAction(action, modelKey = "") {
|
||||
}
|
||||
|
||||
if (action === "select" || action === "select-small" || action === "select-big") {
|
||||
if (!modelKey) return;
|
||||
// Empty Active Big explicitly disables that profile; other selections require a model.
|
||||
if (!modelKey && action !== "select-big") return;
|
||||
const profile = action === "select-small" ? "small" : action === "select-big" ? "big" : "";
|
||||
await setActiveModel(modelKey, profile);
|
||||
state.selectionUncertain = true;
|
||||
++statusGeneration; // Invalidate pre-write polls, including their finalisers.
|
||||
const payload = await setActiveModel(modelKey, profile);
|
||||
if (generation !== viewGeneration || !isModelRouteActive()) return;
|
||||
notify(payload.message || `Selected "${modelKey}".`);
|
||||
} else if (action === "download") {
|
||||
if (!modelKey) return;
|
||||
await startDownload(modelKey);
|
||||
@@ -434,18 +464,21 @@ async function runAction(action, modelKey = "") {
|
||||
await setUserFavorite(modelKey, false);
|
||||
}
|
||||
|
||||
if (generation !== viewGeneration || !isModelRouteActive()) return;
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
if (generation !== viewGeneration || !isModelRouteActive()) return;
|
||||
notify(error?.message || String(error), "error");
|
||||
// A failed response does not establish whether the server accepted the write.
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
state.actionBusy = false;
|
||||
if (generation === viewGeneration && isModelRouteActive()) state.actionBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindDomHandlers() {
|
||||
if (window.__modelManagerHandlersBound) return;
|
||||
window.__modelManagerHandlersBound = true;
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
if (!isModelRouteActive()) return;
|
||||
|
||||
@@ -475,6 +508,8 @@ function bindDomHandlers() {
|
||||
const modelKey = safeText(target.value, "");
|
||||
const profile = target.id === "mm-active-big-model-select" ? "big" : "small";
|
||||
if (!modelKey && profile !== "big") return;
|
||||
// Native selects change before their event; display only verified state.
|
||||
target.value = profile === "big" ? state.activeBigModel : state.activeSmallModel;
|
||||
runAction(`select-${profile}`, modelKey).catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -532,7 +567,7 @@ function renderActions(model) {
|
||||
|
||||
if (model.installed) {
|
||||
return html`
|
||||
<button class="mm-btn mm-btn-secondary" data-mm-action="select-${profile}" data-model="${modelKey}">Set Active ${profile === "big" ? "Big" : "Small"}</button>
|
||||
<button class="mm-btn mm-btn-secondary" disabled="${() => state.actionBusy || state.selectionUncertain}" data-mm-action="select-${profile}" data-model="${modelKey}">Set Active ${profile === "big" ? "Big" : "Small"}</button>
|
||||
${model.builtin
|
||||
? ""
|
||||
: html`<button class="mm-btn mm-btn-danger" data-mm-action="delete" data-model="${modelKey}">Delete</button>`}
|
||||
@@ -599,19 +634,41 @@ function renderSeriesSection(seriesName, models) {
|
||||
`;
|
||||
}
|
||||
|
||||
function ensureModelView() {
|
||||
if (document.querySelector(".mm-wrapper")) return;
|
||||
const generation = ++viewGeneration;
|
||||
++statusGeneration;
|
||||
clearTimeout(pollingHandle);
|
||||
pollingHandle = null;
|
||||
// Arrow has no unmount hook. Observe this mount's removal, not just pathname:
|
||||
// a quick leave-and-return must not revive an old write/readback continuation.
|
||||
queueMicrotask(() => {
|
||||
if (generation !== viewGeneration) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (generation !== viewGeneration) { observer.disconnect(); return; }
|
||||
// Arrow may replace the wrapper during an ordinary reactive render.
|
||||
if (document.querySelector(".mm-wrapper") && isModelRouteActive()) return;
|
||||
observer.disconnect();
|
||||
if (generation !== viewGeneration) return;
|
||||
++viewGeneration;
|
||||
++statusGeneration;
|
||||
clearTimeout(pollingHandle);
|
||||
pollingHandle = null;
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
state.actionBusy = false;
|
||||
state.selectionUncertain = true;
|
||||
refreshAll();
|
||||
ensurePolling();
|
||||
});
|
||||
}
|
||||
|
||||
export function ModelManager() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
bindDomHandlers();
|
||||
logDebug("Initializing component");
|
||||
refreshAll().catch(error => {
|
||||
state.error = error?.message || String(error);
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
logDebug("Initial refresh failed", state.error);
|
||||
});
|
||||
}
|
||||
ensurePolling();
|
||||
ensureModelView();
|
||||
|
||||
return html`
|
||||
<div class="mm-wrapper">
|
||||
@@ -643,14 +700,14 @@ export function ModelManager() {
|
||||
<span class="mm-chip">Loaded: ${() => getCurrentModelName()}</span>
|
||||
<span class="mm-chip mm-chip-device-gpu">Active Small: ${() => getModelName(state.activeSmallModel)}</span>
|
||||
<span class="mm-chip mm-chip-egpu">Active Big: ${() => getModelName(state.activeBigModel)}</span>
|
||||
<span class="mm-chip">Progress: ${safeText(state.status.progress, "Idle")}</span>
|
||||
<span class="mm-chip">Progress: ${() => safeText(state.status.progress, "Idle")}</span>
|
||||
<span class="mm-chip">${() => getUserFavoriteModels(false).length} personal favorites</span>
|
||||
${() => state.status.isOnroad ? html`<span class="mm-chip mm-chip-warning">Onroad: actions disabled</span>` : ""}
|
||||
</div>
|
||||
|
||||
<div class="mm-filters">
|
||||
<label class="mm-filter-label" for="mm-active-small-model-select">Active Small</label>
|
||||
<select class="mm-select" id="mm-active-small-model-select">
|
||||
<select class="mm-select" id="mm-active-small-model-select" disabled="${() => state.actionBusy || state.selectionUncertain}">
|
||||
${() => {
|
||||
const orderedInstalled = getInstalledModels("small").sort((a, b) => {
|
||||
const aCurrent = safeText(a.value) === state.activeSmallModel ? 0 : 1;
|
||||
@@ -669,7 +726,7 @@ export function ModelManager() {
|
||||
</select>
|
||||
|
||||
<label class="mm-filter-label" for="mm-active-big-model-select">Active Big</label>
|
||||
<select class="mm-select" id="mm-active-big-model-select">
|
||||
<select class="mm-select" id="mm-active-big-model-select" disabled="${() => state.actionBusy || state.selectionUncertain}">
|
||||
${() => {
|
||||
const orderedInstalled = getInstalledModels("big").sort((a, b) => {
|
||||
const aCurrent = safeText(a.value) === state.activeBigModel ? 0 : 1;
|
||||
@@ -692,7 +749,7 @@ export function ModelManager() {
|
||||
</select>
|
||||
|
||||
<label class="mm-filter-label" for="mm-favorite-model-select">Favorite Models</label>
|
||||
<select class="mm-select" id="mm-favorite-model-select" disabled="${() => getUserFavoriteModels(true).length === 0}">
|
||||
<select class="mm-select" id="mm-favorite-model-select" disabled="${() => state.actionBusy || state.selectionUncertain || getUserFavoriteModels(true).length === 0}">
|
||||
${(() => {
|
||||
const favorites = getUserFavoriteModels(true);
|
||||
return favorites.length > 0
|
||||
@@ -746,7 +803,7 @@ export function ModelManager() {
|
||||
|
||||
${() => !state.loading ? html`
|
||||
<div class="mm-list">
|
||||
${(() => {
|
||||
${() => {
|
||||
if (state.sortMode === "release_date") {
|
||||
const models = getReleaseOrderedModels();
|
||||
return models.length === 0
|
||||
@@ -758,7 +815,7 @@ export function ModelManager() {
|
||||
return seriesNames.length === 0
|
||||
? html`<div class="mm-empty">No models available.</div>`
|
||||
: seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName]));
|
||||
})()}
|
||||
}}
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* Download status changes must not move the viewport to another card. */
|
||||
.gx-model-manager { overflow-anchor: none; }
|
||||
.gx-model-manager .gx-row .gx-field { min-width: 0; }
|
||||
|
||||
.gx-personalities { overflow-anchor: none; }
|
||||
#gx-personality-settings { padding: 0 var(--sp-4) var(--sp-4); }
|
||||
.gx-personalities__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Shared confirmation dialog for model downloads.
|
||||
let activeDialog = null
|
||||
let dialogId = 0
|
||||
function installStyle() {
|
||||
if (document.getElementById("gx-feature-help-style")) return
|
||||
const style = document.createElement("style")
|
||||
style.id = "gx-feature-help-style"
|
||||
style.textContent = `
|
||||
.gx-help-dialog {box-sizing:border-box;width:min(640px,calc(100% - 24px));max-width:calc(100% - 24px);max-height:var(--gx-help-height,85dvh);margin:auto;padding:0;border:1px solid var(--outline-variant,#667085);border-radius:var(--radius-lg,18px);background:var(--background,#20252e);color:var(--on-surface,#edf0f5);box-shadow:0 16px 64px #0008;overflow:auto;overscroll-behavior:contain;overflow-wrap:anywhere;font-family:inherit}
|
||||
.gx-help-dialog::backdrop {background:#0009}
|
||||
.gx-help-dialog__heading {margin:0;padding:20px 20px 8px;font-size:1.25rem;line-height:1.35}
|
||||
.gx-help-dialog__body {padding:0 20px;line-height:1.6}
|
||||
.gx-help-dialog__body p {white-space:pre-line;margin:12px 0}
|
||||
.gx-help-dialog__body h4 {font-size:1rem;margin:20px 0 8px}
|
||||
.gx-help-dialog__actions {display:flex;justify-content:flex-end;flex-wrap:wrap;gap:12px;padding:16px 20px;position:sticky;bottom:0;background:var(--background,#20252e);border-top:1px solid var(--outline-variant,#667085)}
|
||||
.gx-help-dialog__actions button {min-height:44px}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Native modal provides inert background, Escape and focus restoration. No settings writes.
|
||||
export function openGalaxyHelpDialog({ title, paragraphs = [], troubleshooting = [], confirmLabel = "Close", cancelLabel = "" }) {
|
||||
installStyle()
|
||||
activeDialog?.()
|
||||
return new Promise(resolve => {
|
||||
const opener = document.activeElement
|
||||
const dialog = document.createElement("dialog")
|
||||
dialog.className = "gx-help-dialog"
|
||||
dialog.setAttribute("aria-modal", "true")
|
||||
const titleId = `gx-help-title-${++dialogId}`
|
||||
dialog.setAttribute("aria-labelledby", titleId)
|
||||
const heading = document.createElement("h3")
|
||||
heading.id = titleId
|
||||
heading.className = "gx-help-dialog__heading"
|
||||
heading.textContent = title
|
||||
const body = document.createElement("div")
|
||||
body.className = "gx-help-dialog__body"
|
||||
const paragraph = text => { const p = document.createElement("p"); p.textContent = text; body.appendChild(p) }
|
||||
paragraphs.forEach(paragraph)
|
||||
if (troubleshooting.length) {
|
||||
const h = document.createElement("h4"); h.textContent = "Troubleshooting"; body.appendChild(h)
|
||||
troubleshooting.forEach(paragraph)
|
||||
}
|
||||
const actions = document.createElement("div")
|
||||
actions.className = "gx-help-dialog__actions"
|
||||
let settled = false
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.removeEventListener("hashchange", cancel)
|
||||
window.removeEventListener("resize", resize)
|
||||
window.visualViewport?.removeEventListener("resize", resize)
|
||||
if (activeDialog === cancel) activeDialog = null
|
||||
dialog.close()
|
||||
dialog.remove()
|
||||
if (opener?.isConnected) opener.focus({ preventScroll: true })
|
||||
resolve(result)
|
||||
}
|
||||
const cancel = () => finish(false)
|
||||
const resize = () => {
|
||||
// CSS zoom changes available CSS-pixel space independently of device scale.
|
||||
let zoom = 1
|
||||
for (let node = document.body; node; node = node.parentElement) zoom *= parseFloat(getComputedStyle(node).zoom) || 1
|
||||
dialog.style.setProperty("--gx-help-height", `${Math.max(100, ((window.visualViewport?.height || innerHeight) - 24) / zoom)}px`)
|
||||
}
|
||||
const button = (label, result, secondary) => {
|
||||
const b = document.createElement("button"); b.type = "button"; b.textContent = label
|
||||
b.className = `gx-btn${secondary ? " gx-btn--tonal" : ""}`
|
||||
b.addEventListener("click", () => finish(result)); actions.appendChild(b)
|
||||
}
|
||||
if (cancelLabel) button(cancelLabel, false, true)
|
||||
button(confirmLabel, true, false)
|
||||
dialog.append(heading, body, actions)
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); cancel() })
|
||||
dialog.addEventListener("click", event => {
|
||||
const box = dialog.getBoundingClientRect()
|
||||
if (event.target === dialog && (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom)) cancel()
|
||||
})
|
||||
dialog.addEventListener("keydown", event => {
|
||||
if (event.key !== "Tab") return
|
||||
const buttons = [...actions.querySelectorAll("button")]
|
||||
const first = buttons[0], last = buttons.at(-1)
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
})
|
||||
window.addEventListener("hashchange", cancel)
|
||||
window.addEventListener("resize", resize)
|
||||
window.visualViewport?.addEventListener("resize", resize)
|
||||
document.body.appendChild(dialog)
|
||||
activeDialog = cancel
|
||||
resize()
|
||||
dialog.showModal()
|
||||
actions.querySelector("button").focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { api, showSnackbar } from "../api.js"
|
||||
import { usePolling } from "../composables.js"
|
||||
import { GalaxyConfirm } from "../components/GalaxyModal.js"
|
||||
import { openGalaxyHelpDialog } from "../components/FeatureHelp.js"
|
||||
import { readHardwareFilter, saveHardwareFilter, matchesHardware, hardwareLabel, fileSizeText } from "/assets/components/tools/model_hardware.js"
|
||||
|
||||
function text(value, fallback = "") {
|
||||
return value === null || value === undefined ? fallback : String(value)
|
||||
@@ -11,6 +13,9 @@ function releasedTs(value) {
|
||||
return Number.isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
// Survives SPA unmount: a new instance must read after a sent write settles.
|
||||
let selectionWrite = null
|
||||
|
||||
export const ModelManager = {
|
||||
name: "ModelManager",
|
||||
data() {
|
||||
@@ -18,10 +23,13 @@ export const ModelManager = {
|
||||
loading: true,
|
||||
error: "",
|
||||
busy: "",
|
||||
selectionUncertain: true,
|
||||
statusGeneration: 0,
|
||||
disposed: false,
|
||||
sortMode: "release_date",
|
||||
userFilter: "all",
|
||||
communityFilter: "all",
|
||||
allowGpu: false,
|
||||
hardwareFilter: readHardwareFilter(),
|
||||
models: [],
|
||||
currentModel: "",
|
||||
activeSmallModel: "",
|
||||
@@ -53,6 +61,7 @@ export const ModelManager = {
|
||||
const mode = this.sortMode
|
||||
const rows = (this.models || [])
|
||||
.filter((m) => m && typeof m === "object")
|
||||
.filter((m) => matchesHardware(m, this.hardwareFilter))
|
||||
.filter((m) => this.userFilter === "all" ? true : !!m.userFavorite === (this.userFilter === "yes"))
|
||||
.filter((m) => this.communityFilter === "all" ? true : !!m.communityFavorite === (this.communityFilter === "yes"))
|
||||
rows.sort((a, b) => {
|
||||
@@ -64,9 +73,6 @@ export const ModelManager = {
|
||||
})
|
||||
return rows
|
||||
},
|
||||
anyGpuBlocked() {
|
||||
return (this.models || []).some((m) => !!m.requiresGpu && !m.gpuAvailable)
|
||||
},
|
||||
downloadTargetLabel() {
|
||||
if (this.status.downloadAll) return "all missing models"
|
||||
const key = text(this.status.modelToDownload, "")
|
||||
@@ -78,10 +84,16 @@ export const ModelManager = {
|
||||
this.poll = usePolling(() => this.refresh(), { interval: 2000 })
|
||||
this.poll.start()
|
||||
},
|
||||
beforeUnmount() { this.poll?.destroy() },
|
||||
beforeUnmount() {
|
||||
this.disposed = true
|
||||
++this.statusGeneration
|
||||
this.poll?.destroy()
|
||||
},
|
||||
methods: {
|
||||
gpuBlocked(model) {
|
||||
return !!model.requiresGpu && !model.gpuAvailable && !this.allowGpu
|
||||
hardwareLabel,
|
||||
fileSizeText,
|
||||
setHardwareFilter(value) {
|
||||
this.hardwareFilter = saveHardwareFilter(value)
|
||||
},
|
||||
rowState(model) {
|
||||
const key = text(model.value, "")
|
||||
@@ -99,8 +111,12 @@ export const ModelManager = {
|
||||
return !this.status.isOnroad
|
||||
},
|
||||
async refresh() {
|
||||
const generation = ++this.statusGeneration
|
||||
try {
|
||||
if (selectionWrite) await selectionWrite.catch(() => {})
|
||||
if (this.disposed || generation !== this.statusGeneration) return
|
||||
const p = await api.getModelStatus()
|
||||
if (this.disposed || generation !== this.statusGeneration) return
|
||||
this.models = Array.isArray(p.models) ? p.models.filter((m) => m && typeof m === "object") : []
|
||||
this.currentModel = text(p.currentModel, "")
|
||||
this.activeSmallModel = text(p.activeSmallModel, "")
|
||||
@@ -119,13 +135,17 @@ export const ModelManager = {
|
||||
isOnroad: !!p.isOnroad,
|
||||
}
|
||||
this.error = ""
|
||||
this.selectionUncertain = false
|
||||
this.loading = false
|
||||
} catch (e) {
|
||||
if (this.disposed || generation !== this.statusGeneration) return
|
||||
this.error = e?.message || String(e)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async runAction(action, model = null) {
|
||||
const selecting = action === "select-small" || action === "select-big"
|
||||
if (this.disposed || (selecting && this.selectionUncertain)) return
|
||||
if (this.busy) {
|
||||
showSnackbar("Please wait for the current action to finish.", "error")
|
||||
return
|
||||
@@ -138,16 +158,41 @@ export const ModelManager = {
|
||||
const label = text(model && model.label, key || "model")
|
||||
this.busy = `${action}:${key}`
|
||||
try {
|
||||
let allowGpu = false
|
||||
const needsGpuPrompt = action === "download"
|
||||
? model?.requiresGpu && !model.gpuAvailable
|
||||
: action === "downloadAll" && this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable)
|
||||
if (needsGpuPrompt) {
|
||||
allowGpu = await openGalaxyHelpDialog({
|
||||
title: "No external GPU detected",
|
||||
paragraphs: [action === "downloadAll"
|
||||
? "This download includes models that require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected."
|
||||
: `“${label}” requires an external GPU. You can download it now, but it cannot run until a compatible GPU is connected and detected.`,
|
||||
"GPU model files can be large. Downloading does not activate a model or change the model currently in use."],
|
||||
confirmLabel: "Download anyway", cancelLabel: "Cancel",
|
||||
})
|
||||
if (!allowGpu || this.disposed) return
|
||||
// The car can change state while a confirmation is open.
|
||||
await this.refresh()
|
||||
if (this.disposed || this.error || !this.actionAllowedOnroad(action) || this.status.downloading) {
|
||||
if (!this.disposed) showSnackbar("Download not started. Check the device status and try again while parked.", "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
let msg = ""
|
||||
if (action === "select-small" || action === "select-big") {
|
||||
const profile = action === "select-big" ? "big" : "small"
|
||||
const p = await api.setActiveModel(profile, key)
|
||||
this.selectionUncertain = true
|
||||
++this.statusGeneration // Pre-write polls cannot reconcile this selection.
|
||||
selectionWrite = api.setActiveModel(profile, key)
|
||||
let p
|
||||
try { p = await selectionWrite } finally { selectionWrite = null }
|
||||
msg = p?.message || `Selected "${label}".`
|
||||
} else if (action === "download") {
|
||||
const p = await api.startModelDownload(key, this.allowGpu)
|
||||
const p = await api.startModelDownload(key, allowGpu)
|
||||
msg = p?.message || `Downloading "${label}"...`
|
||||
} else if (action === "downloadAll") {
|
||||
const p = await api.downloadAllModels(this.allowGpu)
|
||||
const p = await api.downloadAllModels(allowGpu)
|
||||
msg = p?.message || "Started downloading all models."
|
||||
} else if (action === "cancel") {
|
||||
const p = await api.postAction("/api/models/cancel")
|
||||
@@ -175,17 +220,21 @@ export const ModelManager = {
|
||||
const p = await api.postAction("/api/models/refresh_manifest")
|
||||
msg = p?.message || "Model manifest refreshed."
|
||||
}
|
||||
if (this.disposed) return
|
||||
if (msg) showSnackbar(msg)
|
||||
await this.refresh()
|
||||
} catch (e) {
|
||||
if (this.disposed) return
|
||||
showSnackbar(e?.message || String(e), "error")
|
||||
// Reconcile a possibly accepted request before releasing the action lock.
|
||||
await this.refresh()
|
||||
} finally {
|
||||
this.busy = ""
|
||||
if (!this.disposed) this.busy = ""
|
||||
}
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view">
|
||||
<div class="gx-view gx-model-manager">
|
||||
<div v-if="loading" class="gx-card">
|
||||
<div class="gx-loading" style="padding: var(--sp-4);">Loading models...</div>
|
||||
</div>
|
||||
@@ -200,7 +249,7 @@ export const ModelManager = {
|
||||
<span class="gx-chip">{{ summary.installed }} installed</span>
|
||||
<span class="gx-chip">{{ summary.missing }} missing</span>
|
||||
<span class="gx-chip">{{ summary.total }} total</span>
|
||||
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Active: {{ currentLabel }}</span>
|
||||
<span class="gx-chip" style="background:var(--primary);color:var(--on-primary);">Selected: {{ currentLabel }}</span>
|
||||
</div>
|
||||
<div style="padding: 0 var(--sp-3) var(--sp-3);">
|
||||
<div v-if="error" class="gx-alert gx-alert--warn" style="border:none; margin:0 0 8px;">
|
||||
@@ -226,23 +275,23 @@ export const ModelManager = {
|
||||
<i class="bi bi-sliders"></i>
|
||||
<span class="gx-section__title">Controls</span>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:grid; gap:12px;">
|
||||
<div style="padding: var(--sp-3); display:grid; grid-template-columns:minmax(0, 1fr); gap:12px;">
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button v-if="status.downloading" type="button" class="gx-btn gx-btn--danger" :disabled="!!busy" @click="runAction('cancel')"><i class="bi bi-stop-circle"></i> Cancel Download</button>
|
||||
<button v-else type="button" class="gx-btn" :disabled="!!busy" @click="runAction('downloadAll')"><i class="bi bi-download"></i> Download All Missing</button>
|
||||
<button v-else type="button" class="gx-btn" :disabled="!!busy" @click="runAction('downloadAll')"><i class="bi bi-download"></i> Download all missing — entire catalogue</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy" @click="runAction('refresh')"><i v-if="busy === 'refresh:'" class="bi bi-arrow-repeat gx-spin"></i><i v-else class="bi bi-arrow-clockwise"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="gx-row" style="border-top:none;">
|
||||
<span class="gx-row__label">Active Small</span>
|
||||
<select class="gx-field" style="flex:1;" :value="activeSmallModel" :disabled="!!busy || status.isOnroad" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
|
||||
<select class="gx-field" style="flex:1;" :value="activeSmallModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
|
||||
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value">{{ m.label || m.value }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="gx-row" style="border-top:none;">
|
||||
<span class="gx-row__label">Active Big</span>
|
||||
<select class="gx-field" style="flex:1;" :value="activeBigModel" :disabled="!!busy || status.isOnroad" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
|
||||
<select class="gx-field" style="flex:1;" :value="activeBigModel" :disabled="!!busy || selectionUncertain || status.isOnroad" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
|
||||
<option value="">None — always use Active Small</option>
|
||||
<option v-for="m in installedBigModels" :key="m.value" :value="m.value">{{ m.label || m.value }}</option>
|
||||
</select>
|
||||
@@ -256,6 +305,15 @@ export const ModelManager = {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="gx-row" style="border-top:none;">
|
||||
<label class="gx-row__label" for="gx-model-hardware">Model hardware</label>
|
||||
<select id="gx-model-hardware" class="gx-field" style="flex:1; min-width:0;" :value="hardwareFilter" @change="setHardwareFilter($event.target.value)">
|
||||
<option value="both">Both</option>
|
||||
<option value="gpu">GPU models only</option>
|
||||
<option value="comma">Comma models only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<select class="gx-field" style="flex:1; min-width:140px;" :value="userFilter" @change="userFilter = $event.target.value">
|
||||
<option value="all">Your Favorite: All</option>
|
||||
@@ -269,22 +327,11 @@ export const ModelManager = {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="gx-row" style="border-top:none;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">Download GPU models without GPU</span>
|
||||
<span v-if="anyGpuBlocked" class="gx-row__desc">GPU models are very large and will not run without an external GPU.</span>
|
||||
</div>
|
||||
<label class="gx-switch">
|
||||
<input type="checkbox" v-model="allowGpu" />
|
||||
<span class="gx-switch__track"></span>
|
||||
<span class="gx-switch__thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="!sorted.length">
|
||||
<div class="gx-card"><div class="gx-empty">No models available.</div></div>
|
||||
<div class="gx-card"><div class="gx-empty">No models match these filters. Try Both or clear the favourite filters.</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="gx-card-grid">
|
||||
@@ -299,10 +346,11 @@ export const ModelManager = {
|
||||
<div style="margin-top:6px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<span class="gx-chip">{{ m.value }}</span>
|
||||
<span v-if="m.builtin" class="gx-chip">Built-in</span>
|
||||
<span class="gx-chip">{{ m.requiresGpu ? 'eGPU' : 'On-device GPU' }}</span>
|
||||
<span class="gx-chip">{{ hardwareLabel(m) }}</span>
|
||||
<span v-if="m.version" class="gx-chip">Version {{ m.version }}</span>
|
||||
<span v-if="m.released" class="gx-chip">Released {{ m.released }}</span>
|
||||
<span v-if="m.partial" class="gx-chip">Partial Files</span>
|
||||
<span class="gx-chip">File size: {{ fileSizeText(m) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="gx-icon-btn" :disabled="!!busy" :title="m.userFavorite ? 'Remove from your favorites' : 'Add to your favorites'" @click="runAction(m.userFavorite ? 'unfavorite' : 'favorite', m)">
|
||||
@@ -320,11 +368,11 @@ export const ModelManager = {
|
||||
<button type="button" class="gx-btn gx-btn--danger" :disabled="!!busy" @click="runAction('cancel', m)"><i class="bi bi-x-circle"></i> Cancel</button>
|
||||
</template>
|
||||
<template v-else-if="rowState(m) === 'installed'">
|
||||
<button type="button" class="gx-btn" :disabled="!!busy" @click="runAction(m.requiresGpu ? 'select-big' : 'select-small', m)"><i class="bi bi-play-fill"></i> Set Active {{ m.requiresGpu ? 'Big' : 'Small' }}</button>
|
||||
<button type="button" class="gx-btn" :disabled="!!busy || selectionUncertain" @click="runAction(m.requiresGpu ? 'select-big' : 'select-small', m)"><i class="bi bi-play-fill"></i> Set Active {{ m.requiresGpu ? 'Big' : 'Small' }}</button>
|
||||
<button v-if="!m.builtin" type="button" class="gx-btn gx-btn--tonal" style="color:var(--error);" :disabled="!!busy" @click="runAction('delete', m)"><i class="bi bi-trash"></i> Delete</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="gx-btn" :disabled="!!busy || gpuBlocked(m)" @click="runAction('download', m)"><i class="bi bi-download"></i> {{ gpuBlocked(m) ? 'GPU Required' : 'Download' }}</button>
|
||||
<button type="button" class="gx-btn" :disabled="!!busy || status.isOnroad" @click="runAction('download', m)"><i class="bi bi-download"></i> Download</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Catalogue sizes stay available without importing model statistics."""
|
||||
from test_dashboard_stats import _load_server_module, FakeParams
|
||||
|
||||
|
||||
def test_catalog_status_has_independent_sizes(monkeypatch, tmp_path):
|
||||
server = _load_server_module()
|
||||
assert server._import_galaxy_web_symbols()
|
||||
app = server.Flask('model_sizes_test')
|
||||
server.setup(app)
|
||||
class CatalogParams(FakeParams):
|
||||
def get_default_value(self, key):
|
||||
return {'Model': 'fixture-default', 'ModelName': 'Fixture default'}.get(key)
|
||||
monkeypatch.setattr(server, 'params', CatalogParams({'IsOnroad': False}))
|
||||
monkeypatch.setattr(server, 'params_memory', FakeParams({}))
|
||||
monkeypatch.setattr(server, 'MODELS_PATH', tmp_path)
|
||||
response = app.test_client().get('/api/models/status')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
result = response.get_json()
|
||||
assert result['models']
|
||||
assert 'statistics' not in result
|
||||
for model in result['models']:
|
||||
assert {'fileSizeBytes', 'declaredSizeBytes', 'downloadedBytes', 'sizeSource', 'sizeStatus'} <= model.keys()
|
||||
assert 'stats' not in model
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readHardwareFilter,saveHardwareFilter,matchesHardware,hardwareLabel,fileSizeText} from '../assets/components/tools/model_hardware.js';
|
||||
assert.equal(readHardwareFilter(),'both');
|
||||
const stored=new Map();globalThis.localStorage={getItem:k=>stored.get(k),setItem:(k,v)=>stored.set(k,v)};
|
||||
for (const value of ['both','gpu','comma']) {assert.equal(saveHardwareFilter(value),value);assert.equal(readHardwareFilter(),value);}
|
||||
assert.equal(saveHardwareFilter('invalid'),'both');
|
||||
assert.equal(matchesHardware({},'gpu'),false);assert.equal(matchesHardware({},'comma'),false);assert.equal(matchesHardware({},'both'),true);
|
||||
assert.equal(matchesHardware({requiresGpu:true},'gpu'),true);assert.equal(matchesHardware({requiresGpu:false},'comma'),true);
|
||||
assert.equal(hardwareLabel({}),'Hardware unknown');
|
||||
assert.equal(fileSizeText({modelSize:'big'}),'Unavailable');
|
||||
assert.equal(fileSizeText({fileSizeBytes:1500000000}),'1.50 GB');
|
||||
assert.equal(fileSizeText({declaredSizeBytes:1200000}),'1.2 MB · declared');
|
||||
assert.equal(fileSizeText({fileSizeBytes:1500000000,declaredSizeBytes:1000000000}),'1.50 GB · size mismatch');
|
||||
assert.equal(fileSizeText({partial:true,downloadedBytes:1200000,declaredSizeBytes:3000000}),'Partial: 1.2 MB / 3.0 MB');
|
||||
for(const size of [-1,0,NaN,Infinity,'123',true])assert.equal(fileSizeText({fileSizeBytes:size}),'Unavailable');
|
||||
globalThis.localStorage={getItem:()=>{throw Error('blocked')},setItem:()=>{throw Error('blocked')}};assert.equal(readHardwareFilter(),'both');assert.equal(saveHardwareFilter('gpu'),'gpu');
|
||||
console.log('PASS: hardware filtering, persistence, unknown/mismatch/partial/declared sizes');
|
||||
@@ -0,0 +1,17 @@
|
||||
// Existing classic UI: verify only selection/lifecycle compatibility fixes.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
const source=readFileSync(new URL('../assets/components/tools/model_manager.js',import.meta.url),'utf8');
|
||||
const notices=[];globalThis.window={location:{pathname:'/manage_models'},showSnackbar:(...a)=>notices.push(a)};
|
||||
const selects={};globalThis.document={getElementById:id=>selects[id]??=({value:''})};
|
||||
const api=new Function('reactive','html',source.replace(/^import .*$/gm,'').replace('export function ModelManager','function ModelManager')+';return {state,fetchStatus,runAction};')(x=>x,()=>{});
|
||||
const payload=(active='gpu')=>({models:[{value:'gpu',installed:true,requiresGpu:true}],activeBigModel:active,activeSmallModel:'small',currentModel:'small',summary:{},isOnroad:false});
|
||||
const response=(data,ok=true)=>({ok,status:ok?200:503,json:async()=>data});
|
||||
let active='gpu',failWrite=false,failRead=false,hold=null,writes=[];
|
||||
globalThis.fetch=async(url,opts)=>{if(opts.method==='PUT'){writes.push(JSON.parse(opts.body));if(failWrite)return response({error:'uncertain'},false);active=writes.at(-1).model;return response({message:'selected'});}if(url==='/api/models/cancel'){writes.push('cancel');return response({});}if(hold){const value=hold;hold=null;return value;}return response(failRead?{error:'read failed'}:payload(active),!failRead);};
|
||||
await api.fetchStatus();assert.equal(api.state.selectionUncertain,false);assert.equal(selects['mm-active-big-model-select'].value,'gpu');
|
||||
let release;hold=new Promise(resolve=>release=resolve);const old=api.fetchStatus();await api.runAction('select-big','');release(response(payload('gpu')));await old;assert.equal(api.state.activeBigModel,'');assert.equal(selects['mm-active-big-model-select'].value,'');assert.deepEqual(writes,[{profile:'big',model:''}]);
|
||||
failWrite=true;failRead=true;await api.runAction('select-big','gpu');assert.equal(api.state.selectionUncertain,true);assert.equal(api.state.actionBusy,false);await api.runAction('select-big','gpu');assert.equal(writes.length,2);await api.runAction('cancel');assert.equal(writes.length,3);
|
||||
failRead=false;await api.fetchStatus();assert.equal(api.state.selectionUncertain,false);assert.equal(api.state.activeBigModel,'');
|
||||
window.location.pathname='/elsewhere';await api.runAction('select-big','gpu');assert.equal(writes.length,3);
|
||||
console.log('PASS: legacy explicit None, stale poll, readback failure lock, retry, cancellation and route guard');
|
||||
@@ -0,0 +1,104 @@
|
||||
// Real Chromium, shipped components, synthetic API only. No device/network fall-through.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const root = (process.env.REPO || fileURLToPath(new URL('../../../../', import.meta.url))) + '/starpilot/system/the_galaxy';
|
||||
const out = process.env.EVIDENCE;
|
||||
if (out) mkdirSync(out, {recursive:true});
|
||||
const browser = await chromium.launch({executablePath:process.env.BROWSER_EXECUTABLE, args:['--no-sandbox']});
|
||||
const results=[];
|
||||
const tick=()=>new Promise(r=>setTimeout(r,100));
|
||||
try {
|
||||
for (const surface of ['classic','mobile']) for (const scenario of ['stale-poll','reject-read-failure','abort-read-failure','leave-write','leave-readback']) {
|
||||
const context=await browser.newContext({viewport:{width:1200,height:850}});
|
||||
const writes=[], unexpected=[], errors=[]; let active='gpu-a', heldGet=null, heldPut=null, holdNext=false, failReads=false, gets=0;
|
||||
const models=['gpu-a','gpu-b','small'].map(value=>({value,label:value,installed:true,requiresGpu:value!=='small',gpuAvailable:true}));
|
||||
const payload=()=>({models,activeBigModel:active,activeSmallModel:'small',currentModel:'small',summary:{installed:3,total:3},isOnroad:false,downloading:true,modelToDownload:'other'});
|
||||
const mobile=surface==='mobile';
|
||||
const mount=mobile ? `import {createApp} from 'vue';import {ModelManager} from '/assets/mobile/js/views/ModelManager.js';let app;window.mount=()=>{app=createApp(ModelManager);window.vm=app.mount('#app')};window.leave=()=>app.unmount();window.poll=()=>window.vm.refresh();window.act=(a)=>window.vm.runAction(a);` : `import {html} from '/assets/vendor/arrow-core.js';import {ModelManager} from '/assets/components/tools/model_manager.js';window.mount=()=>html\`\${()=>ModelManager()}\`(document.querySelector('#app'));window.leave=()=>document.querySelector('#app').replaceChildren();`;
|
||||
await context.route('**/*',async route=>{
|
||||
const req=route.request(),u=new URL(req.url());
|
||||
if(u.origin!=='http://galaxy.invalid') {unexpected.push(req.url());return route.abort();}
|
||||
if(u.pathname==='/api/models/status' && req.method()==='GET') {
|
||||
gets++; const json=payload();
|
||||
if(holdNext) {holdNext=false;heldGet={route,json};return;}
|
||||
return route.fulfill(failReads?{status:503,json:{error:'Synthetic read failure'}}:{json});
|
||||
}
|
||||
if(req.method()!=='GET') {
|
||||
writes.push({method:req.method(),path:u.pathname,body:req.postData()?req.postDataJSON():null});
|
||||
if(u.pathname==='/api/models/cancel') return route.fulfill({json:{message:'cancelled'}});
|
||||
if(u.pathname!=='/api/models/active') {unexpected.push(req.url());return route.abort();}
|
||||
if(scenario==='leave-write') {heldPut=route;return;}
|
||||
if(scenario!=='reject-read-failure') active=writes.at(-1).body.model;
|
||||
if(scenario.includes('read-failure')) {failReads=true;return scenario.startsWith('abort')?route.abort('connectionreset'):route.fulfill({status:503,json:{error:'Synthetic rejection'}});}
|
||||
if(scenario==='leave-readback') holdNext=true;
|
||||
return route.fulfill({json:{message:'selected'}});
|
||||
}
|
||||
if(u.pathname.startsWith('/assets/')) {
|
||||
try {
|
||||
let body=readFileSync(root+u.pathname);
|
||||
if(u.pathname.endsWith('/tools/model_manager.js')) body=body.toString()+ '\nwindow.poll=()=>fetchStatus();window.act=(a)=>runAction(a);';
|
||||
return route.fulfill({body,contentType:u.pathname.endsWith('.css')?'text/css':'text/javascript'});
|
||||
} catch(e) {unexpected.push(u.pathname);return route.abort();}
|
||||
}
|
||||
if(u.pathname!=='/manage_models') {unexpected.push(u.pathname);return route.abort();}
|
||||
return route.fulfill({contentType:'text/html',body:`<script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script><div id="snackbar_wrapper"></div><main id="app"></main><script type="module">${mount}window.mount();</script>`});
|
||||
});
|
||||
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
|
||||
// Capture snackbar effects without changing the shipped API module.
|
||||
await page.addInitScript(()=>{
|
||||
// Hold periodic polling; every race uses explicitly released real requests.
|
||||
const timeout=window.setTimeout;window.setTimeout=(fn,ms,...args)=>timeout(fn,[1000,2000,4000].includes(ms)?60000:ms,...args);
|
||||
window.notices=[];window.showSnackbar=(...a)=>window.notices.push(a);
|
||||
new MutationObserver(records=>{for(const r of records) if(r.target.id==='snackbar_wrapper') for(const n of r.addedNodes) window.notices.push(n.textContent);}).observe(document,{childList:true,subtree:true});
|
||||
});
|
||||
const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select');
|
||||
const until=async(fn,msg)=>{for(let i=0;i<80;i++){if(await fn())return;await tick();}throw new Error(msg);};
|
||||
let failure=null;
|
||||
try {
|
||||
await page.goto('http://galaxy.invalid/manage_models');
|
||||
await until(async()=>await select.count() && await select.inputValue()==='gpu-a','initial selection');
|
||||
if(scenario==='stale-poll') {holdNext=true;await page.evaluate(()=>{window.poll()});await until(()=>heldGet,'held prewrite poll');}
|
||||
await select.selectOption('');
|
||||
await until(()=>writes.length===1,'selection PUT');
|
||||
assert.deepEqual(writes,[{method:'PUT',path:'/api/models/active',body:{profile:'big',model:''}}]);
|
||||
if(scenario==='stale-poll') {
|
||||
await tick(); await heldGet.route.fulfill({json:heldGet.json});heldGet=null;await tick();
|
||||
assert.equal(await select.inputValue(),'','old poll must not overwrite accepted selection');
|
||||
assert.equal(await select.isDisabled(),false,'post-write readback unlocks');
|
||||
} else if(scenario.includes('read-failure')) {
|
||||
await until(()=>gets>=2,'failed readback requested');await tick();
|
||||
assert.equal(await select.isDisabled(),true,'uncertain selection stays locked');
|
||||
await page.evaluate(()=>window.act('select-big'));await tick();assert.equal(writes.length,1,'programmatic second selection blocked');
|
||||
await page.evaluate(()=>window.act('cancel'));await until(()=>writes.length===2,'cancel remains usable');
|
||||
assert.deepEqual(writes[1],{method:'POST',path:'/api/models/cancel',body:null});
|
||||
failReads=false;await page.evaluate(()=>window.poll());
|
||||
await until(async()=>!(await select.isDisabled()),'authoritative retry unlocks');
|
||||
assert.equal(await select.inputValue(),scenario.startsWith('abort')?'':'gpu-a');
|
||||
} else {
|
||||
await until(()=>scenario==='leave-write'?heldPut:heldGet,'held write/readback');
|
||||
await page.evaluate(()=>{history.pushState({},'', '/elsewhere');window.leave();});await tick();
|
||||
const before=await page.evaluate(()=>window.notices.length);
|
||||
if(scenario==='leave-readback') active='gpu-b';
|
||||
await page.evaluate(()=>{history.pushState({},'', '/manage_models');window.mount();});await tick();
|
||||
if(heldPut) {
|
||||
// A remount GET before this acceptance cannot establish the final selection.
|
||||
active='';await heldPut.fulfill({json:{message:'selected'}});heldPut=null;
|
||||
} else {active='gpu-b';await heldGet.route.fulfill({json:heldGet.json});heldGet=null;}
|
||||
await tick();await tick();
|
||||
assert.equal(await page.evaluate(()=>window.notices.length),before,'old generation must not notify');
|
||||
await until(async()=>await select.count() && await select.inputValue()===active && !(await select.isDisabled()),'remount must read current state after pending write');
|
||||
assert.equal(writes.length,1,'navigation must not duplicate writes');
|
||||
}
|
||||
assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]);
|
||||
} catch(e) {failure=e.stack;}
|
||||
results.push({surface,scenario,passed:!failure,failure,gets,writes,unexpected,errors});
|
||||
if(heldGet) await heldGet.route.abort().catch(()=>{});
|
||||
if(heldPut) await heldPut.abort().catch(()=>{});
|
||||
await context.close();
|
||||
}
|
||||
} finally {await browser.close();}
|
||||
if(out)writeFileSync(out+'/results.json',JSON.stringify(results,null,2));
|
||||
console.log(JSON.stringify(results,null,2));
|
||||
assert.equal(results.length,10);assert.equal(results.filter(r=>!r.passed).length,0);
|
||||
@@ -0,0 +1,65 @@
|
||||
const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {mkdtempSync, mkdirSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
const root = (process.env.REPO || fileURLToPath(new URL('../../../../', import.meta.url))) + '/starpilot/system/the_galaxy';
|
||||
const out = process.env.EVIDENCE || mkdtempSync(tmpdir() + '/model-manager-browser-');
|
||||
mkdirSync(out, {recursive:true});
|
||||
const browser = await chromium.launch({executablePath:process.env.BROWSER_EXECUTABLE || undefined, args:['--no-sandbox']});
|
||||
const errors = [], results = [];
|
||||
const baseline = !!process.env.BASELINE;
|
||||
try {
|
||||
for (const surface of (process.env.MODEL_SURFACE ? [process.env.MODEL_SURFACE] : ['classic', 'mobile'])) for (const width of [1200,390]) {
|
||||
const mobile=surface==='mobile';
|
||||
const context=await browser.newContext({viewport:{width,height:850}});
|
||||
let downloading=false, target='', polls=0, activeBig='fixture-0';
|
||||
const writes=[];
|
||||
const models=Array.from({length:36},(_,i)=>({value:`fixture-${i}`,label:`UI fixture model ${String(i).padStart(2,'0')}`,series:`Series ${i%3}`,released:`2026-08-${String(28-i%28).padStart(2,'0')}`,requiresGpu:i%2===0,gpuAvailable:true,installed:i===0 || i===2,userFavorite:i%3===0}));
|
||||
const mount = mobile ? `import {createApp} from 'vue';import {ModelManager} from '/assets/mobile/js/views/ModelManager.js';createApp(ModelManager).mount('#app');` : `import {html} from '/assets/vendor/arrow-core.js';import {ModelManager} from '/assets/components/tools/model_manager.js';window.showSnackbar=()=>{};html\`\${()=>ModelManager()}\`(document.querySelector('#app'));`;
|
||||
const preview=`<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="${mobile?'/assets/mobile/css/material.css':'/assets/components/tools/model_manager.css'}"><script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script><style>:root{--text-color:#e9eaf2;--text-muted:#aab0c1;--card-bg:#1b2030;--secondary-bg:#262c3c;--sidebar-border-color:#353e54;--input-bg:#171c29;--success-bg:#81d4b1;--color-black:#10251c;--danger-bg:#883644;--sidebar-active-bg:#384560;--font-size-base:14px;--padding-base:16px;--margin-base:16px;--gap-xs:4px;--gap-sm:8px;--gap-md:12px;--gap-lg:20px;--border-radius-base:8px;--border-radius-lg:12px}body{background:#101420;color:#e9eaf2;font:14px system-ui;margin:16px}*{box-sizing:border-box}</style></head><body><p style="color:#aab0c1">UI DEVELOPMENT PREVIEW — SYNTHETIC CATALOGUE, NO DEVICE CONNECTION</p><main id="app"></main><script type="module">${mount}</script></body></html>`;
|
||||
await context.route('**/*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.origin!=='http://galaxy.invalid') return route.abort();
|
||||
if(url.pathname.startsWith('/api/')) {
|
||||
if(route.request().method()!=='GET') {
|
||||
writes.push({path:url.pathname,body:route.request().postDataJSON()});
|
||||
if(url.pathname==='/api/models/active') { assert.equal(writes.at(-1).body.profile,'big'); if (!(process.env.MODEL_FAULT === 'reject' && writes.length === 1)) activeBig=writes.at(-1).body.model;
|
||||
if (process.env.MODEL_FAULT && writes.length === 1) return route.fulfill({status:503,json:{error:'Synthetic uncertain response'}});
|
||||
}
|
||||
else throw new Error('Unexpected write '+url.pathname);
|
||||
return route.fulfill({json:{message:'Synthetic UI fixture action'}});
|
||||
}
|
||||
assert.equal(url.pathname,'/api/models/status');polls++;
|
||||
return route.fulfill({json:{models,currentModel:'fixture-0',activeBigModel:activeBig,activeSmallModel:'',summary:{installed:1,missing:35,total:36},downloading,modelToDownload:target,progress:downloading?`${polls}%`:'',isOnroad:false}});
|
||||
}
|
||||
if(url.pathname.startsWith('/assets/')) {
|
||||
try {return route.fulfill({body:readFileSync(root+url.pathname),contentType:url.pathname.endsWith('.css')?'text/css':'text/javascript'});} catch(e) {errors.push(String(e));return route.abort();}
|
||||
}
|
||||
return route.fulfill({contentType:'text/html',body:preview});
|
||||
});
|
||||
const page=await context.newPage();page.on('pageerror',e=>{errors.push(e.message); console.error('PAGEERROR',e.message);});
|
||||
await page.goto('http://galaxy.invalid/manage_models');
|
||||
const rowSelector=mobile?'.gx-card-grid > section':'.mm-row';
|
||||
const rows=page.locator(rowSelector);
|
||||
await rows.first().waitFor();
|
||||
const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select');
|
||||
assert.equal(await select.inputValue(),'fixture-0');
|
||||
await select.selectOption('');
|
||||
await new Promise(r=>setTimeout(r,350));
|
||||
assert.deepEqual(writes,[{path:'/api/models/active',body:{profile:'big',model:''}}],`${surface}: None must disable Active Big through API`);
|
||||
assert.equal(await select.inputValue(),process.env.MODEL_FAULT === 'reject' ? 'fixture-0' : '', 'Selection must match authoritative status after uncertain response');
|
||||
if (process.env.MODEL_FAULT) assert.ok(polls >= 2, 'Uncertain write requires immediate authoritative readback');
|
||||
await select.selectOption('fixture-2');
|
||||
await new Promise(r=>setTimeout(r,350));
|
||||
assert.equal(writes.length,2);
|
||||
assert.deepEqual(writes[1],{path:'/api/models/active',body:{profile:'big',model:'fixture-2'}});
|
||||
await page.reload();await rows.first().waitFor();
|
||||
assert.equal(await select.inputValue(),'fixture-2');
|
||||
results.push({surface,width,writes});
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log(JSON.stringify({results,errors},null,2));
|
||||
} finally {await browser.close();}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Exercise shipped component methods with deferred requests; no device I/O.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
const source = readFileSync(new URL('../assets/mobile/js/views/ModelManager.js', import.meta.url), 'utf8');
|
||||
const hardware = await import('../assets/components/tools/model_hardware.js');
|
||||
let status, write, prompt, requests, notices;
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a,b)=>{resolve=a;reject=b}); return {promise,resolve,reject}; };
|
||||
const payload = (overrides={}) => ({models:[{value:'gpu',requiresGpu:true,gpuAvailable:false,installed:false},{value:'small',requiresGpu:false,installed:true}],activeBigModel:'gpu',activeSmallModel:'small',currentModel:'small',summary:{},...overrides});
|
||||
const api = {
|
||||
getModelStatus: () => status(),
|
||||
setActiveModel: (profile,model) => { requests.push({profile,model}); return write(); },
|
||||
startModelDownload: (model,allow) => { requests.push({model,allow});return write(); },
|
||||
downloadAllModels: allow => { requests.push({all:true,allow});return write(); },
|
||||
postAction: action => { requests.push({action});return write(); },
|
||||
};
|
||||
const component = new Function('api','showSnackbar','usePolling','GalaxyConfirm','openGalaxyHelpDialog',...Object.keys(hardware), source.replace(/^import .*$/gm,'').replace('export const ModelManager', 'const ModelManager')+';return ModelManager;')(api,(...a)=>notices.push(a),()=>{},()=>{},()=>prompt(),...Object.values(hardware));
|
||||
function instance() { const vm=component.data();for(const [key,value] of Object.entries(component.methods))vm[key]=value.bind(vm);return vm; }
|
||||
async function reset() {requests=[];notices=[];status=async()=>payload();write=async()=>({});prompt=async()=>true;const vm=instance();await vm.refresh();return vm;}
|
||||
// Filters combine without altering the catalogue or selection.
|
||||
let vm=await reset();vm.hardwareFilter='comma';vm.userFilter='all';vm.communityFilter='all';assert.deepEqual(component.computed.sorted.call(vm).map(m=>m.value),['small']);assert.equal(vm.activeBigModel,'gpu');assert.deepEqual(requests,[]);
|
||||
// GPU approval is per action; cancellation sends no write and releases busy.
|
||||
prompt=async()=>false;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,'');
|
||||
prompt=async()=>true;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[{model:'gpu',allow:true}]);
|
||||
// Approval must recheck road state, competing downloads and status failures.
|
||||
for(const changed of [{isOnroad:true},{downloading:true},null]) { vm=await reset();status=changed?async()=>payload(changed):async()=>{throw Error('read failed')};await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,''); }
|
||||
// Ordinary downloads never reuse approval, and failed writes release busy.
|
||||
vm=await reset();write=async()=>{throw Error('download failed')};await vm.runAction('download',vm.models[1]);assert.deepEqual(requests,[{model:'small',allow:false}]);assert.equal(vm.busy,'');
|
||||
vm=await reset();await vm.runAction('downloadAll');assert.deepEqual(requests,[{all:true,allow:true}]);
|
||||
// A pre-write poll cannot overwrite authoritative post-write selection.
|
||||
vm=await reset();let old=deferred();status=()=>old.promise;let poll=vm.refresh();write=async()=>({});status=async()=>payload({activeBigModel:''});await vm.runAction('select-big');old.resolve(payload());await poll;assert.equal(vm.activeBigModel,'');assert.equal(vm.selectionUncertain,false);
|
||||
// An uncertain write plus failed readback locks only selection until retry.
|
||||
vm=await reset();write=async()=>{throw Error('uncertain write')};status=async()=>{throw Error('read failure')};await vm.runAction('select-big');assert.equal(vm.selectionUncertain,true);await vm.runAction('select-big');assert.equal(requests.length,1);await vm.runAction('cancel');assert.equal(requests.length,2);status=async()=>payload({activeBigModel:''});await vm.refresh();assert.equal(vm.selectionUncertain,false);assert.equal(vm.activeBigModel,'');
|
||||
// A remount must wait for an already sent write and ignore old notifications.
|
||||
vm=await reset();const pending=deferred();write=()=>pending.promise;const action=vm.runAction('select-big');component.beforeUnmount.call(vm);const next=instance();let reads=0;status=async()=>{reads++;return payload({activeBigModel:''})};const refreshed=next.refresh();await Promise.resolve();assert.equal(reads,0);pending.resolve({message:'old success'});await action;await refreshed;assert.equal(next.activeBigModel,'');assert.equal(next.selectionUncertain,false);assert.deepEqual(notices,[]);
|
||||
// Unmounted confirmation and overlapping action cannot submit another request.
|
||||
vm=await reset();const confirmation=deferred();prompt=()=>confirmation.promise;const downloading=vm.runAction('download',vm.models[0]);await vm.runAction('downloadAll');assert.deepEqual(requests,[]);component.beforeUnmount.call(vm);confirmation.resolve(true);await downloading;assert.deepEqual(requests,[]);
|
||||
// Two concurrent polls resolve newest-first.
|
||||
vm=await reset();const a=deferred(),b=deferred();status=()=>a.promise;const first=vm.refresh();status=()=>b.promise;const second=vm.refresh();b.resolve(payload({activeBigModel:'new'}));await second;a.resolve(payload({activeBigModel:'old'}));await first;assert.equal(vm.activeBigModel,'new');
|
||||
assert.ok(!source.includes('GalaxySelect'));assert.ok(!source.includes('model_metrics'));assert.ok(!source.includes('model_stats'));assert.ok(source.includes('selectionUncertain || status.isOnroad'));
|
||||
console.log('PASS: 15 hardware/download/selection/lifecycle scenarios against shipped methods');
|
||||
|
||||
const {compile} = await import('../assets/vendor/vue/vue.esm-browser.js');
|
||||
assert.equal(typeof compile(component.template, {onError(error) {throw error;}}), 'function');
|
||||
console.log('PASS: shipped Vue compiler accepts the native-select Model Manager template');
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Exercise shipped Flask handler with the existing isolated Params harness."""
|
||||
import pytest
|
||||
from test_dashboard_stats import _load_server_module, FakeParams
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [
|
||||
{'profile': 'big'}, {'profile': 'big', 'model': None},
|
||||
{'profile': 'big', 'model': False}, {'profile': 'big', 'model': 0},
|
||||
{'profile': 'big', 'model': []}, {'profile': 'big', 'model': {}},
|
||||
[], ['big'], True, 1,
|
||||
])
|
||||
def test_malformed_selection_never_disables_big(monkeypatch, payload):
|
||||
server = _load_server_module()
|
||||
assert server._import_galaxy_web_symbols()
|
||||
app = server.Flask('model_payload_test')
|
||||
server.setup(app)
|
||||
params = FakeParams({'IsOnroad': False, 'ActiveBigModel': 'installed-big',
|
||||
'ActiveBigModelName': 'Installed Big', 'ActiveBigModelVersion': 'v16'})
|
||||
monkeypatch.setattr(server, 'params', params)
|
||||
before = params.values.copy()
|
||||
response = app.test_client().put('/api/models/active', json=payload)
|
||||
assert response.status_code == 400
|
||||
assert params.values == before
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Actual Flask Active Big contract with in-memory Params, no native/device I/O."""
|
||||
import pytest
|
||||
from test_personality_profiles_api import _client, the_galaxy
|
||||
|
||||
@pytest.mark.parametrize('profile,onroad,expected', [('big',False,200),('small',False,400),('big',True,403),('invalid',False,400)])
|
||||
def test_active_big_empty_contract(monkeypatch, profile, onroad, expected):
|
||||
client, params = _client(monkeypatch, {'IsOnroad':onroad})
|
||||
calls=[]
|
||||
monkeypatch.setattr(the_galaxy,'disable_big_model_profile',lambda p:calls.append(p))
|
||||
monkeypatch.setattr(the_galaxy,'normalize_model_lab_config',lambda raw:{'enabled':False})
|
||||
response=client.put('/api/models/active',json={'profile':profile,'model':''})
|
||||
assert response.status_code == expected, response.get_json()
|
||||
assert len(calls) == (expected == 200)
|
||||
if expected == 200:
|
||||
assert response.get_json()['model'] == ''
|
||||
assert response.get_json()['profile'] == 'big'
|
||||
@@ -5171,6 +5171,8 @@ class GalaxySlugMiddleware:
|
||||
|
||||
|
||||
def setup(app):
|
||||
from openpilot.starpilot.assets.model_sizes import ModelSizes
|
||||
model_sizes = ModelSizes()
|
||||
if not isinstance(app.wsgi_app, GalaxySlugMiddleware):
|
||||
app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app)
|
||||
|
||||
@@ -6887,7 +6889,10 @@ def setup(app):
|
||||
if params.get_bool("IsOnroad"):
|
||||
return jsonify({"error": "Cannot change active models while driving."}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
data = request.get_json(silent=True)
|
||||
# An explicit empty model disables Active Big; malformed values must not.
|
||||
if not isinstance(data, dict) or not isinstance(data.get("model"), str):
|
||||
return jsonify({"error": "An explicit model string is required."}), 400
|
||||
profile = str(data.get("profile") or "").strip().lower()
|
||||
if profile not in ("small", "big"):
|
||||
return jsonify({"error": "Model profile must be 'small' or 'big'."}), 400
|
||||
@@ -7529,7 +7534,9 @@ def setup(app):
|
||||
})
|
||||
|
||||
models.sort(key=lambda model: (model["series"].lower(), model["label"].lower()))
|
||||
return models
|
||||
return model_sizes.annotate(models, MODELS_PATH,
|
||||
Path(__file__).resolve().parents[3] / "selfdrive/modeld/models/driving_tinygrad.pkl",
|
||||
artifact_metadata, model_accelerator_artifact_filename)
|
||||
|
||||
@app.route("/api/routes", methods=["GET"])
|
||||
def list_routes():
|
||||
|
||||
Reference in New Issue
Block a user