diff options
| -rw-r--r-- | app/backends/servers.py | 8 | ||||
| -rw-r--r-- | app/backends/sglomni/__init__.py | 11 | ||||
| -rw-r--r-- | app/backends/sglomni/catalog.py | 20 | ||||
| -rw-r--r-- | app/backends/sglomni/models.py | 41 | ||||
| -rw-r--r-- | app/docs/backend-qwen.md | 2 | ||||
| -rw-r--r-- | app/docs/backend-sglomni.md | 7 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 8 | ||||
| -rw-r--r-- | app/tests/test_backends_sglomni.py | 78 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 244 | ||||
| -rw-r--r-- | app/ui/hub.py | 160 |
10 files changed, 541 insertions, 38 deletions
diff --git a/app/backends/servers.py b/app/backends/servers.py index 7815f30..d352717 100644 --- a/app/backends/servers.py +++ b/app/backends/servers.py @@ -54,6 +54,14 @@ _BOOT_HINTS = ( ("fp8e4nv not supported", "the server crashed compiling an FP8 MoE kernel: FP8 needs compute " "capability 8.9+ (RTX 4090/5090, Hopper) and cannot run on this GPU"), + # A model companion package missing from the backend venv (e.g. the + # sglang-omni Qwen3-TTS models need qwen-tts; a shared-cache weight + # install or a failed pip run leaves it absent). + ("ModuleNotFoundError", + "the backend venv is missing a Python module this model needs — " + "re-install the model via Configure Backends (checking the model " + "installs its companion packages), or pip-install it into the " + "backend's venv manually"), ) # Progress callback: called with an event dict. KIND is one of: diff --git a/app/backends/sglomni/__init__.py b/app/backends/sglomni/__init__.py index 688e488..be94a0e 100644 --- a/app/backends/sglomni/__init__.py +++ b/app/backends/sglomni/__init__.py @@ -38,6 +38,7 @@ from .catalog import ( entries_by_keys, entry_by_key, entry_by_repo, + extra_import_name, install_tree_families, ) from .pythonenv import ( @@ -47,9 +48,11 @@ from .pythonenv import ( ) from .models import ( delete_model_weights, + install_companions, install_model, installed_entries, installed_keys, + missing_companions, model_installed, preset_voices, repo_dir, @@ -82,12 +85,14 @@ __all__ = [ # catalog "CAPABILITY_CLONE", "CAPABILITY_DESIGN", "CAPABILITY_SPEAKER", "ENTRIES", "ModelEntry", "config_path", "entries_by_keys", - "entry_by_key", "entry_by_repo", "install_tree_families", + "entry_by_key", "entry_by_repo", "extra_import_name", + "install_tree_families", # pythonenv "env_compatible", "env_version", "prepare_env", # models - "delete_model_weights", "install_model", "installed_entries", - "installed_keys", "model_installed", "preset_voices", "repo_dir", + "delete_model_weights", "install_companions", "install_model", + "installed_entries", "installed_keys", "missing_companions", + "model_installed", "preset_voices", "repo_dir", "resolve_model", "uninstall_model", # status "build_spec", "detect", "gpu_fallback_note", "launch_config_path", diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py index 5ff0c81..61cb11c 100644 --- a/app/backends/sglomni/catalog.py +++ b/app/backends/sglomni/catalog.py @@ -96,6 +96,26 @@ _DAC_EXTRAS: Tuple[Extra, ...] = ( ("descript-audiotools==0.7.2", False), ("descript-audio-codec==1.0.0", False)) +# Companion distributions whose top-level import name differs from the pip +# name's plain dash-to-underscore normalization (verified against their +# top_level.txt). Anything absent here normalizes: qwen-tts -> qwen_tts. +_EXTRA_IMPORT_OVERRIDES = { + "descript-audiotools": "audiotools", + "descript-audio-codec": "dac", +} + + +def extra_import_name(spec: str) -> str: + """The Python module an extras requirement SPEC provides. + + Takes the distribution name portion of the pip requirement (so + ``qwen-tts==0.1.1`` -> ``qwen_tts``) — the name a venv probe must + import to prove the companion is installed.""" + base = spec.split("=")[0].split("<")[0].split(">")[0].strip() + if base in _EXTRA_IMPORT_OVERRIDES: + return _EXTRA_IMPORT_OVERRIDES[base] + return base.replace("-", "_") + ENTRIES: Tuple[ModelEntry, ...] = ( ModelEntry( key="qwen3_tts_0_6b_customvoice", diff --git a/app/backends/sglomni/models.py b/app/backends/sglomni/models.py index a5211f6..607e36a 100644 --- a/app/backends/sglomni/models.py +++ b/app/backends/sglomni/models.py @@ -22,7 +22,8 @@ from pathlib import Path from typing import List, Optional from backends import common, envs -from backends.sglomni.catalog import ModelEntry, entry_by_key, entry_by_repo +from backends.sglomni.catalog import ModelEntry, entry_by_key, \ + entry_by_repo, extra_import_name from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env @@ -129,6 +130,42 @@ def system_dep_missing(entry: ModelEntry) -> Optional[str]: return None +def missing_companions(entry: ModelEntry) -> List[Extra]: + """ENTRY's companion packages absent from the sglang-omni venv. + + One import probe per extra (its top-level module, see + ``extra_import_name``) against the venv's interpreter, so a model whose + weights landed in the shared HF cache by another route — or whose + companions failed to pip-install during setup, which install_model only + warns about — is detected before the server dies on the import. A venv + that does not exist at all yields no verdict: the start flow fails on + the missing ``sgl-omni`` executable anyway.""" + if not entry.extras or not envs.env_exists(SGLOMNI_ENV): + return [] + return [extra for extra in entry.extras + if not envs.module_available(extra_import_name(extra[0]), + SGLOMNI_ENV)] + + +def install_companions(entry: ModelEntry, *, emit=None, cancel=None) -> int: + """pip-install ENTRY's missing companion packages into the venv. + + The same recipe ``install_model`` runs (the catalog's ``--no-deps`` + flags preserved — the Qwen3-TTS companions must not replace the pinned + Transformers 5 stack), limited to what the import probe found absent, + so a start-time heal touches as little of the pinned environment as + possible. Returns the first failing exit code, 0 when all present.""" + for spec, no_deps in missing_companions(entry): + args = ["--no-deps"] if no_deps else None + rc = common.pip_install([spec], emit=emit, cancel=cancel, + env_dir=SGLOMNI_ENV, extra_args=args) + if rc != 0: + print(f"[ERROR] pip install {spec} failed (exit {rc}); " + f"install it into {SGLOMNI_ENV} manually") + return rc + return 0 + + def install_model(key: str, *, emit=None, cancel=None) -> int: """Install a catalog model: companion packages, then its weights. @@ -157,7 +194,7 @@ def install_model(key: str, *, emit=None, cancel=None) -> int: note = sg_status.gpu_fallback_note(entry) if note: print(f"[WARNING] {note}") - for spec, no_deps in entry.extras: + for spec, no_deps in missing_companions(entry): args = ["--no-deps"] if no_deps else None rc = common.pip_install([spec], emit=emit, cancel=cancel, env_dir=SGLOMNI_ENV, extra_args=args) diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index cb01561..31af47c 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,6 +1,6 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (switching models while a managed server is up restarts it with the newly-selected model; an autostart boots exactly the model the run picked). You can also start the server from the hub's **Start/Stop Backend Servers** menu (a fresh start runs CustomVoice), or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (switching models while a managed server is up restarts it with the newly-selected model; an autostart boots exactly the model the run picked). You can also start the server from the hub's **Start/Stop Backend Servers** menu (a fresh start asks which of CustomVoice, Base or VoiceDesign to load; stopping never asks), or let a conversion start it automatically. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI. diff --git a/app/docs/backend-sglomni.md b/app/docs/backend-sglomni.md index 2aac93e..bf7d3a9 100644 --- a/app/docs/backend-sglomni.md +++ b/app/docs/backend-sglomni.md @@ -118,6 +118,13 @@ hosts one model, a run whose selected model differs from the hosted one restarts a server this tool started — a foreign server hosting another model refuses the run with an actionable message instead. +The hub's **Start/Stop Backend Servers** menu asks which *downloaded* model +to load when starting the server fresh (with none downloaded the entry says +so and points at the Configure screen; a single model starts without +asking). A start also auto-installs a model's missing companion packages +first, so a boot never dies on their import even when the weights arrived +via the shared HuggingFace cache or an earlier install's pip run failed. + The server boots a multi-stage pipeline (preprocessing → TTS generation → vocoder) and may pull companion weights on first start, so its start timeout is larger than the other backends' (20 minutes). Pre-downloading diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py index 4065a34..569c7bd 100644 --- a/app/tests/test_backends_servers.py +++ b/app/tests/test_backends_servers.py @@ -160,6 +160,14 @@ class StartTests(unittest.TestCase): 'architecture")', "y"])) self.assertIsNone(servers._boot_hint([])) + def test_boot_hint_names_missing_companion_packages(self): + # A model whose companion pip packages are absent from the backend + # venv (e.g. sglang-omni's Qwen3-TTS models need qwen_tts) dies on + # the import; the hint says what to do about it. + self.assertIn("companion", servers._boot_hint( + ['File "...", in resolve_checkpoint', + "ModuleNotFoundError: No module named 'qwen_tts'"])) + def test_console_progress_prints_the_hint(self): out = io.StringIO() with redirect_stdout(out): diff --git a/app/tests/test_backends_sglomni.py b/app/tests/test_backends_sglomni.py index a11aa92..1fd5f5a 100644 --- a/app/tests/test_backends_sglomni.py +++ b/app/tests/test_backends_sglomni.py @@ -11,7 +11,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch -from backends import envs, probe, servers +from backends import common, envs, probe, servers from backends.sglomni import catalog, constants, models, pythonenv, status from backends.sglomni.catalog import CAPABILITY_CLONE, CAPABILITY_DESIGN, \ CAPABILITY_SPEAKER, ENTRIES, config_path, entry_by_key, entry_by_repo, \ @@ -184,6 +184,82 @@ class ModelInstallStateTests(unittest.TestCase): self.assertIn("--model", str(ctx.exception)) +class CompanionPackageTests(unittest.TestCase): + """The venv probe and start-time heal for a model's companion packages. + + Weights can reach the shared HF cache by another route (another + backend's install) or outlive a failed companion pip run — which + install_model only warns about — so the probe is what keeps a + "installed" model from booting into a ModuleNotFoundError. + """ + + def test_extra_import_names(self): + self.assertEqual(catalog.extra_import_name("sox"), "sox") + self.assertEqual(catalog.extra_import_name("einops"), "einops") + self.assertEqual(catalog.extra_import_name("qwen-tts==0.1.1"), + "qwen_tts") + self.assertEqual( + catalog.extra_import_name("descript-audiotools==0.7.2"), + "audiotools") + self.assertEqual( + catalog.extra_import_name("descript-audio-codec==1.0.0"), "dac") + + def test_missing_companions_reports_absent_modules(self): + entry = entry_by_key("qwen3_tts_1_7b_base") + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "module_available", + side_effect=lambda name, env_dir: + name != "qwen_tts"): + missing = models.missing_companions(entry) + self.assertEqual([spec for spec, _no_deps in missing], + ["qwen-tts==0.1.1"]) + + def test_missing_companions_skips_every_extra_that_imports(self): + entry = entry_by_key("qwen3_tts_1_7b_base") + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "module_available", return_value=True): + self.assertEqual(models.missing_companions(entry), []) + + def test_missing_companions_no_verdict_without_a_venv(self): + # A missing venv cannot be healed here: the start flow fails on + # the missing sgl-omni executable instead. + entry = entry_by_key("qwen3_tts_1_7b_base") + with patch.object(envs, "env_exists", return_value=False): + self.assertEqual(models.missing_companions(entry), []) + + def test_install_companions_installs_only_missing_with_no_deps(self): + entry = entry_by_key("qwen3_tts_1_7b_base") + calls = [] + + def fake_pip_install(specs, **kwargs): + calls.append((list(specs), kwargs.get("extra_args"))) + return 0 + + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "module_available", + side_effect=lambda name, env_dir: + name not in ("sox", "qwen_tts")), \ + patch.object(common, "pip_install", + side_effect=fake_pip_install): + self.assertEqual(models.install_companions(entry), 0) + self.assertEqual([specs for specs, _args in calls], + [["sox"], ["qwen-tts==0.1.1"]]) + self.assertTrue(all(args == ["--no-deps"] for _specs, args in calls)) + + def test_install_companions_stops_at_the_first_failure(self): + entry = entry_by_key("qwen3_tts_1_7b_base") + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "module_available", + return_value=False), \ + patch.object(common, "pip_install", return_value=23): + self.assertEqual(models.install_companions(entry), 23) + + def test_models_with_no_extras_need_nothing(self): + entry = entry_by_key("higgs_audio_v3_tts") + self.assertEqual(entry.extras, ()) + self.assertEqual(models.missing_companions(entry), []) + + class PythonEnvTests(unittest.TestCase): """Interpreter selection for the version-pinned venv.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 8d5edd9..8e71104 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -805,6 +805,176 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual(len(flashed), 1) self.assertIn("No backend is installed", flashed[0]) + def test_server_menu_flashes_when_sglang_omni_has_no_models(self): + # An installed-but-model-less SGLang-Omni backend stays listed so + # selecting it can explain itself instead of being absent. + captured = {} + flashed = [] + st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True, + configured=False) + + def fake_menu(stdscr, title, options, **kwargs): + captured["title"] = title + captured["options"] = options + captured.update(kwargs) + return options[0][1] # select the SGLang-Omni entry + + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.tui, "flash", + lambda *a, **k: flashed.append(a[1])), \ + patch.object(hub, "detect_all", return_value=[st]), \ + patch.object(hub.shutil, "which", return_value="/x"): + handler = hub._Hub(None).screen_server() + # The no-models handler flashes and lands back on the list. + self.assertIs(handler(), tui.Wizard.BACK) + self.assertEqual(captured["table_rows"], + [("SGLang-Omni", "no models", "err", "body")]) + self.assertIn("No SGLang-Omni models", flashed[-1]) + self.assertIn("Configure Backends", flashed[-1]) + + def test_server_menu_asks_which_sglang_omni_model_to_load(self): + entries = [hub.sglomni_backend.ENTRIES[0], + hub.sglomni_backend.ENTRIES[4]] + spec = hub.sglomni_backend.build_spec(entries[0]) + st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True, + configured=True, servers=[spec]) + menus = [] + started = [] + answers = [(hub.BACKEND_SGLOMNI, spec), entries[1]] + + def fake_menu(stdscr, title, options, **kwargs): + menus.append((title, options)) + return answers.pop(0) + + def fake_run_steps(scr, title, steps, **kwargs): + for step in steps: + step.work(None, None) + return 0 + + with tempfile.TemporaryDirectory() as td, \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.tui, "flash", lambda *a, **k: None), \ + patch.object(hub.common, "server_running", + return_value=False), \ + patch.object(hub.servers, "LOG_DIR", Path(td)), \ + patch.object(hub.servers, "start", + side_effect=lambda spec, **k: + started.append(spec) or True), \ + patch.object(hub.taskview, "run_steps", fake_run_steps), \ + patch.object(hub.sglomni_backend, "installed_entries", + return_value=entries), \ + patch.object(hub.sglomni_backend, "missing_companions", + return_value=[]), \ + patch.object(hub, "detect_all", return_value=[st]), \ + patch.object(hub.shutil, "which", return_value="/x"): + toggle = hub._Hub(None).screen_server() + toggle() # the wizard runs the returned screen + self.assertEqual([title for title, _options in menus], + ["Start / Stop A Server", + "Load Which SGLang-Omni Model?"]) + self.assertEqual([opt[0] for opt in menus[1][1]], + [entry.label for entry in entries]) + # The server boots the picked model's spec, not the menu's default + # (the first installed catalog model). + self.assertEqual(len(started), 1) + self.assertIn(entries[1].repo, started[0].argv) + + def test_server_menu_starts_a_single_sglang_omni_model_without_asking(self): + entry = hub.sglomni_backend.ENTRIES[4] + spec = hub.sglomni_backend.build_spec(entry) + st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True, + configured=True, servers=[spec]) + menus = [] + started = [] + answer = (hub.BACKEND_SGLOMNI, spec) + + def fake_menu(stdscr, title, options, **kwargs): + menus.append((title, options)) + return answer + + def fake_run_steps(scr, title, steps, **kwargs): + for step in steps: + step.work(None, None) + return 0 + + with tempfile.TemporaryDirectory() as td, \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.tui, "flash", lambda *a, **k: None), \ + patch.object(hub.common, "server_running", + return_value=False), \ + patch.object(hub.servers, "LOG_DIR", Path(td)), \ + patch.object(hub.servers, "start", + side_effect=lambda spec, **k: + started.append(spec) or True), \ + patch.object(hub.taskview, "run_steps", fake_run_steps), \ + patch.object(hub.sglomni_backend, "installed_entries", + return_value=[entry]), \ + patch.object(hub, "detect_all", return_value=[st]), \ + patch.object(hub.shutil, "which", return_value="/x"): + toggle = hub._Hub(None).screen_server() + toggle() + self.assertEqual(len(menus), 1) # no model picker for one model + self.assertEqual(len(started), 1) + self.assertIs(started[0], spec) + + def test_server_menu_asks_which_qwen_model_to_load(self): + spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"]) + st = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, servers=[spec]) + menus = [] + started = [] + answers = [("qwen", spec), "Base"] + + def fake_menu(stdscr, title, options, **kwargs): + menus.append((title, options)) + return answers.pop(0) + + def fake_run_steps(scr, title, steps, **kwargs): + for step in steps: + step.work(None, None) + return 0 + + with tempfile.TemporaryDirectory() as td, \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.tui, "flash", lambda *a, **k: None), \ + patch.object(hub.common, "server_running", + return_value=False), \ + patch.object(hub.servers, "LOG_DIR", Path(td)), \ + patch.object(hub.servers, "start", + side_effect=lambda spec, **k: + started.append(spec) or True), \ + patch.object(hub.taskview, "run_steps", fake_run_steps), \ + patch.object(hub, "detect_all", return_value=[st]), \ + patch.object(hub.shutil, "which", return_value="/x"): + toggle = hub._Hub(None).screen_server() + toggle() + self.assertEqual(menus[1][0], "Load Which qwen-tts Model?") + self.assertEqual([opt[0] for opt in menus[1][1]], + list(hub.qwen_backend.MODEL_REPOS)) + self.assertEqual(len(started), 1) + self.assertEqual(started[0].argv[1], + hub.qwen_backend.MODEL_REPOS["Base"]) + self.assertEqual(started[0].identity, + hub.backend_probe.IDENTITY_QWEN_CLONE) + + def test_start_model_picker_cancel_keeps_the_server_stopped(self): + entry = hub.sglomni_backend.ENTRIES[4] + spec = hub.sglomni_backend.build_spec(entry) + answers = [tui.Wizard.BACK] # Esc in the model picker + + def fake_menu(stdscr, title, options, **kwargs): + return answers.pop(0) + + with patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.sglomni_backend, "installed_entries", + return_value=[entry, entry]): + result = hub._pick_start_model(None, hub.BACKEND_SGLOMNI, spec) + self.assertIsNone(result) + + def test_pick_start_model_passes_other_backends_through(self): + spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["server"]) + self.assertIs(hub._pick_start_model(None, "audiocpp", spec), spec) + def test_submenu_repeats_ffmpeg_warning(self): captured = {} infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] @@ -820,6 +990,65 @@ class SubmenuStatusTableTests(unittest.TestCase): [("Warning: ffmpeg not installed!", "err")]) +class CompanionPrepTests(unittest.TestCase): + """The start step's companion-package heal for sglang-omni specs.""" + + def test_prep_none_for_other_backends_and_for_stops(self): + spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"]) + self.assertIsNone(hub._companion_prep(spec, "start")) + sgl = hub.sglomni_backend.build_spec( + hub.sglomni_backend.ENTRIES[4]) + self.assertIsNone(hub._companion_prep(sgl, "stop")) + + def test_prep_none_when_every_companion_imports(self): + sgl = hub.sglomni_backend.build_spec( + hub.sglomni_backend.ENTRIES[0]) + with patch.object(hub.sglomni_backend, "missing_companions", + return_value=[]): + self.assertIsNone(hub._companion_prep(sgl, "start")) + + def test_prep_installs_the_missing_companions(self): + entry = hub.sglomni_backend.ENTRIES[0] + sgl = hub.sglomni_backend.build_spec(entry) + with patch.object(hub.sglomni_backend, "missing_companions", + return_value=[("qwen-tts==0.1.1", True)]), \ + patch.object(hub.sglomni_backend, "install_companions", + return_value=0) as install: + prep = hub._companion_prep(sgl, "start") + self.assertIsNotNone(prep) + self.assertEqual(prep(None, None), 0) + install.assert_called_once() + + def test_start_step_runs_prep_before_the_spawn(self): + spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"]) + order = [] + + def prep(emit, cancel): + order.append("prep") + return 0 + + with tempfile.TemporaryDirectory() as td, \ + patch.object(hub.servers, "LOG_DIR", Path(td)), \ + patch.object(hub.servers, "start", + side_effect=lambda *a, **k: + order.append("start") or True): + step, _log = hub._server_action_step(spec, "start", prep=prep) + self.assertEqual(step.work(None, None), 0) + self.assertEqual(order, ["prep", "start"]) + + def test_start_step_aborts_when_prep_fails(self): + # The task view keeps going past a failed step, so the gate has to + # live in the start step's own work: no spawn after a failed heal. + spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"]) + with tempfile.TemporaryDirectory() as td, \ + patch.object(hub.servers, "LOG_DIR", Path(td)), \ + patch.object(hub.servers, "start") as start: + step, _log = hub._server_action_step( + spec, "start", prep=lambda emit, cancel: 23) + self.assertEqual(step.work(None, None), 1) + start.assert_not_called() + + class ConvertFlowTests(unittest.TestCase): """_convert_form / screen_convert: one form whose first field is the Backend picker, followed by that backend's options (local config or live @@ -4444,7 +4673,8 @@ class HubNavigationTests(unittest.TestCase): configured=True, servers=specs) registry = [self._info("qwen", "qwen-tts")] titles = [] - script = ["configure_backends", "server", specs[0], + script = ["configure_backends", "server", ("qwen", specs[0]), + "CustomVoice", tui.Wizard.BACK, tui.Wizard.BACK, tui.Wizard.BACK] def menu(stdscr, title, options, **kwargs): @@ -4461,14 +4691,16 @@ class HubNavigationTests(unittest.TestCase): patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=None): hub._Hub(None).run() - # Selecting a server toggles it directly (no action sub-menu), then - # Esc steps back one screen at a time: server list → Configure - # Backends → main menu. + # Selecting a server asks which model to load (one process hosts + # one), toggles it directly (no action sub-menu), then Esc steps + # back one screen at a time: model picker → server list → + # Configure Backends → main menu. self.assertEqual( titles, ["tts-audiobook-generator", "Configure Backends", - "Start / Stop A Server", "Start / Stop A Server", - "Configure Backends", "tts-audiobook-generator"]) + "Start / Stop A Server", "Load Which qwen-tts Model?", + "Start / Stop A Server", "Configure Backends", + "tts-audiobook-generator"]) def test_esc_on_main_menu_quits(self): titles = self._drive([tui.Wizard.BACK], [], []) diff --git a/app/ui/hub.py b/app/ui/hub.py index f9151f8..faf2291 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -534,9 +534,12 @@ class _Hub: toggles it directly (starts a stopped server, stops a running one) without an extra action menu. The state lives in the table, not on the entries, because the menu's selection bar would cover inline - colors. Each server is labelled by its backend's name; qwen hosts - one model at a time (its default start runs CustomVoice — a - Generate-audiobooks run needing another model restarts it). + colors. Each server is labelled by its backend's name; the + one-model-per-port backends (qwen-tts, SGLang-Omni) ask which model + to load on a fresh start (a Generate-audiobooks run needing another + model restarts the server with its own pick), and an SGLang-Omni + backend without downloaded models stays listed so selecting it can + say so instead of the backend silently missing from the menu. """ statuses = detect_all() candidates = [st for st in statuses if st.installed] @@ -548,11 +551,15 @@ class _Hub: rows = [] for st in candidates: specs = st.servers + if st.key == BACKEND_SGLOMNI and not specs and not st.remote: + options.append((st.label, (st.key, _SGLOMNI_NO_MODELS))) + rows.append((st.label, "no models", "err", "body")) + continue for spec in specs: running = common.server_running(spec.url) label = st.label if len(specs) == 1 \ else f"{st.label} — {spec.name}" - options.append((label, spec)) + options.append((label, (st.key, spec))) rows.append((label, "running" if running else "stopped", "ok" if running else "err", "body")) @@ -560,31 +567,48 @@ class _Hub: tui.flash(self.stdscr, "No backend server is configured yet — " "use 'Configure Backends' first.") return tui.Wizard.BACK - spec = tui.menu(self.stdscr, "Start / Stop A Server", options, - back_value=tui.Wizard.BACK, - help_lines=["Start/stop local servers manually.", - "'Generate Audiobooks' handles this " - "automatically."], - table_rows=rows, - notice_lines=_notice_lines()) - if spec is tui.Wizard.BACK: + chosen = tui.menu(self.stdscr, "Start / Stop A Server", options, + back_value=tui.Wizard.BACK, + help_lines=["Start/stop local servers manually.", + "'Generate Audiobooks' handles this " + "automatically."], + table_rows=rows, + notice_lines=_notice_lines()) + if chosen is tui.Wizard.BACK: return tui.Wizard.BACK - return functools.partial(self._server_toggle, spec) + status_key, target = chosen + if target is _SGLOMNI_NO_MODELS: + def no_models(): + tui.flash(self.stdscr, "No SGLang-Omni models are " + "downloaded — install one via Configure " + "Backends → SGLang-Omni (Configure).", "err") + return tui.Wizard.BACK + return no_models + return functools.partial(self._server_toggle, status_key, target) - def _server_toggle(self, spec): + def _server_toggle(self, status_key, spec): """Start SPEC's server when stopped, stop it when running. - Runs inside the task view (no console drop); the server module's - plain-console output is tee'd to a log file under ``app/logs`` so - nothing is lost, and on failure a flash points the user at that - file. Returns BACK so the stack lands back on the server list, - which re-reads each server's live state. + Starting one of the one-model-per-port backends (qwen-tts, + SGLang-Omni) asks which model to load first — their detect() specs + aim at a default the menu must not silently boot (see + _pick_start_model). Runs inside the task view (no console drop); + the server module's plain-console output is tee'd to a log file + under ``app/logs`` so nothing is lost, and on failure a flash + points the user at that file. Returns BACK so the stack lands back + on the server list, which re-reads each server's live state. """ running = common.server_running(spec.url) action = "stop" if running else "start" - step, log_path = _server_action_step(spec, action) + if action == "start": + picked = _pick_start_model(self.stdscr, status_key, spec) + if picked is None: + return tui.Wizard.BACK + spec = picked + step, log_path = _server_action_step( + spec, action, prep=_companion_prep(spec, action)) taskview.run_steps(self.stdscr, f"{action.capitalize()} " - f"{spec.name} server", [step], + f"{spec.name} server", [step], wait_on_finish=False) # Re-check the server instead of trusting the step's exit code # (cancel and failure both come back non-zero): did the toggle take? @@ -599,15 +623,18 @@ class _Hub: return tui.Wizard.BACK -def _server_action_step(spec, action: str): +def _server_action_step(spec, action: str, prep=None): """Build a task step that starts/stops SPEC's server, logged to a file. ACTION is "start" or "stop". The step runs inside the task view (no console drop): the server module's output is tee'd to a timestamped ``<name>_<action>_*.log`` artifact under ``servers.LOG_DIR`` (see - ``logging_kit.run_artifact``) and to the view's log tail. Returns - ``(TaskStep, log_path)`` so the caller can point the user at the file - on failure. + ``logging_kit.run_artifact``) and to the view's log tail. PREP, when + given, runs inside a start step before the spawn (the companion-package + heal) — the task view keeps going past a failed step, so the gate has + to live in the step's own work: a non-zero prep result aborts the start. + Returns ``(TaskStep, log_path)`` so the caller can point the user at + the file on failure. """ title = (f"Start {spec.name} server" if action == "start" else f"Stop {spec.name} server") @@ -620,6 +647,9 @@ def _server_action_step(spec, action: str): with contextlib.redirect_stdout( logging_kit.TeeWriter(logf, inner)): if action == "start": + if prep is not None and prep(emit, cancel) != 0: + print("[ERROR] the server was not started") + return 1 ok = servers.start(spec, cancel=cancel) else: ok = servers.stop(spec.name) @@ -630,6 +660,86 @@ def _server_action_step(spec, action: str): return taskview.TaskStep(title, work), log_path +# Selecting the SGLang-Omni entry while it has no downloaded models: the +# handler flashes the remediation instead of toggling anything. +_SGLOMNI_NO_MODELS = object() + + +def _pick_start_model(stdscr, status_key: str, spec): + """The spec a manual start from the Start/Stop menu should boot. + + One process hosts one model on the qwen-tts and SGLang-Omni backends, + and their detect() specs aim at a backend default — so a fresh start + asks which model to load instead of silently booting that default: + SGLang-Omni offers its downloaded catalog models (a model without + weights cannot boot), qwen-tts its three demos (a first boot downloads + the weights, like the Generate form's voice picker). SPEC returns + unchanged for every other backend, a running server (the toggle is a + stop), and a single installed SGLang-Omni model (it is the default + already); None means the user cancelled the picker. + """ + if status_key == BACKEND_SGLOMNI: + entries = sglomni_backend.installed_entries() + if len(entries) < 2: + return spec + chosen = tui.menu(stdscr, "Load Which SGLang-Omni Model?", + [(entry.label, entry) for entry in entries], + back_value=tui.Wizard.BACK, + help_lines=["One server process hosts one model;", + "stop it from this menu to load a", + "different one."]) + if chosen is tui.Wizard.BACK: + return None + return sglomni_backend.build_spec(chosen) + if status_key == BACKEND_QWEN: + chosen = tui.menu(stdscr, "Load Which qwen-tts Model?", + [(model, model) for model in + qwen_backend.MODEL_REPOS], + back_value=tui.Wizard.BACK, + help_lines=["One demo process hosts one model;", + "stop it from this menu to load a", + "different one."]) + if chosen is tui.Wizard.BACK: + return None + return qwen_backend.build_spec(chosen) + return spec + + +def _sglomni_spec_entry(spec): + """The catalog entry SPEC's --model-path hosts (None when not sglomni's).""" + if spec.name != sglomni_backend.SERVER_NAME: + return None + argv = list(spec.argv) + try: + repo = argv[argv.index("--model-path") + 1] + except (ValueError, IndexError): + return None + return sglomni_backend.entry_by_repo(repo) + + +def _companion_prep(spec, action: str): + """A start-step callable healing the spec's venv, or None. + + An sglang-omni model whose companion packages are absent from the + backend venv (weights present via the shared HuggingFace cache, or a + failed install-time pip run — which only warns) boots into a + ``ModuleNotFoundError``. When the probe finds any missing, the returned + callable pip-installs exactly those first (the model's own recipe) so + the start aborts the boot rather than spawning a server that cannot + load its model. None when this spec is not sglang-omni's, hosts no + catalog model, or its venv already holds every companion. + """ + if action != "start": + return None + entry = _sglomni_spec_entry(spec) + if entry is None or not sglomni_backend.missing_companions(entry): + return None + def prep(emit, cancel): + return sglomni_backend.install_companions(entry, emit=emit, + cancel=cancel) + return prep + + def _configurable(info) -> bool: """True when INFO has a configure screen worth running from the hub. |
