diff --git a/backend/native_dialogs.py b/backend/native_dialogs.py new file mode 100644 index 0000000..4c73489 --- /dev/null +++ b/backend/native_dialogs.py @@ -0,0 +1,67 @@ +"""Native OS file/folder picker capability (Qt-removal plan R7.4c). + +The one genuinely NEW capability gap R7.4 scoping identified (not just a +port): Llama.cpp's GGUF picker and Ollama's folder-scan picker both need an +actual on-disk PATH string (llama.cpp's C++ bindings and Ollama's manifest +walker both need a real filesystem path, not file bytes) - a plain HTML + only gives the browser-side bytes, never a path, so +this has to be a NATIVE OS dialog. + +graphlink_desktop.py's webview.create_window(...) call discards its return +value, but that's a non-issue: pywebview's own webview.windows is a plain +module-level list that create_window() appends the new Window to +UNCONDITIONALLY, the moment it runs - reachable from anywhere afterward via +webview.windows[0], with zero plumbing changes needed in graphlink_desktop.py +itself. Window.create_file_dialog(...) is safe to call from a worker thread +(pywebview's own docs/exposed-JS-API pattern already does this) - confirmed +directly via inspect.signature and the @_shown_call wrapper it goes through, +which just waits on a plain threading.Event, not anything main-thread-only. + +NO WINDOW is a normal, expected, gracefully-handled condition, not an error: +create_window() never runs under bare `uvicorn`/pytest (the packaged desktop +entry point is the only caller), so webview.windows is simply `[]` there. +Both functions below return None in that case - callers must treat a None +return exactly like a user-cancelled dialog (nothing was picked), which is +also what pywebview itself returns on cancel. +""" + +from __future__ import annotations + +import asyncio +from typing import Sequence + +import webview + + +def _active_window(): + return webview.windows[0] if webview.windows else None + + +async def pick_file(file_types: Sequence[str] = (), directory: str = "") -> str | None: + """Opens a native OPEN file dialog. Returns the selected path, or None + if no window exists (bare uvicorn/tests) or the user cancelled. + + `directory` seeds the dialog's starting location - matches legacy's own + _pick_gguf_file, which always computed a real starting directory + (the staged path's own folder, or a saved scan path, or home) rather + than leaving it to whatever the OS defaults to.""" + window = _active_window() + if window is None: + return None + result = await asyncio.to_thread( + window.create_file_dialog, + webview.FileDialog.OPEN, + directory=directory, + file_types=tuple(file_types), + ) + return result[0] if result else None + + +async def pick_folder(directory: str = "") -> str | None: + """Opens a native FOLDER dialog. Returns the selected path, or None if + no window exists or the user cancelled.""" + window = _active_window() + if window is None: + return None + result = await asyncio.to_thread(window.create_file_dialog, webview.FileDialog.FOLDER, directory=directory) + return result[0] if result else None diff --git a/backend/settings.py b/backend/settings.py index c847c57..944fcb3 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -1,5 +1,5 @@ -"""Settings dialog: General + Integrations + API-provider + Ollama pages -(Qt-removal plan R2.5d, extended R7.4a, extended R7.4b). +"""Settings dialog: General + Integrations + API-provider + Ollama + +Llama.cpp pages (Qt-removal plan R2.5d, extended R7.4a, R7.4b, R7.4c). Unlike composer.py/plugins.py this is a genuine REUSE, not a reimplementation: SettingsManager (graphlink_licensing.py) and its own @@ -11,22 +11,27 @@ hard, always-installed dependency (pyproject.toml), not the optional llama-cpp-python extra. -Scope (doc/QT_REMOVAL_PLAN.md R2.5d, R7.4a, R7.4b): the General/Appearance -page, the Integrations page (GitHub token, write-only), the API-provider -page (OpenAI-Compatible/Anthropic/Gemini), and now the Ollama page -(reasoning mode, system model scan, per-task model assignment, model pull) -are real here. Llama.cpp remains deferred - R7.4c - and the update-check -pair (checkForUpdates/openRepository) needs a native browser-open -capability that doesn't exist yet in graphlink_desktop.py. The SPA renders -those sections disabled with an explicit "lands in R7.4c" label rather -than faking them. - -The Ollama page's "Scan Folder..." button is ALSO deferred, narrower than -the whole page: it needs the same native folder-picker capability R7.4c -builds for Llama.cpp's GGUF file browse (both are pywebview's -create_file_dialog, just OPEN_DIALOG vs FOLDER_DIALOG) - deliberately not -duplicated here ahead of that build. "System Scan" needs no picker and is -fully real. +Scope (doc/QT_REMOVAL_PLAN.md R2.5d, R7.4a-c): the General/Appearance page, +the Integrations page (GitHub token, write-only), the API-provider page +(OpenAI-Compatible/Anthropic/Gemini), the Ollama page (reasoning mode, +system model scan, per-task model assignment, model pull), and now the +Llama.cpp page (reasoning mode, runtime tunables, GGUF model scan/browse, +chat/naming model paths) are all real here - this closes every settings +page R2.5d originally deferred. The update-check pair +(checkForUpdates/openRepository) still needs a native browser-open +capability that doesn't exist yet in graphlink_desktop.py and is out of +this file's scope (a separate R7.5 gap, not a settings page). + +R7.4c's own scope was the LAST genuinely NEW capability gap this whole +settings surface needed: a native OS file/folder picker, since llama.cpp's +C++ bindings and Ollama's manifest walker both need a real on-disk PATH +string, not file bytes (a plain HTML only ever gives +bytes) - see backend/native_dialogs.py's own docstring for the mechanism +(pywebview's webview.windows list, populated at create_window() time with +zero plumbing changes needed in graphlink_desktop.py). Building it also +retroactively un-defers the Ollama page's own "Scan Folder..." button, +which was deliberately left disabled pending exactly this capability - see +pick_ollama_scan_folder below. The API-provider page deliberately does NOT replicate one piece of legacy behavior: the old QWidget pre-filled its API-key field with the saved @@ -51,6 +56,7 @@ from __future__ import annotations import asyncio +import os import threading from typing import Any, Callable @@ -61,6 +67,7 @@ from graphlink_licensing import SettingsManager from graphlink_model_catalog import AUTO_MODEL, INHERIT_MODEL +from backend import native_dialogs from backend.events import SessionBus from backend.notifications import NotificationState @@ -98,6 +105,12 @@ ) _OLLAMA_REASONING_MODES = ("Thinking", "Quick") +# Same 2-mode set, distinct constant deliberately (not reused) - Llama.cpp +# and Ollama have entirely separate SettingsManager fields/api_provider +# global state for reasoning mode, so keeping the constants separate avoids +# an accidental coupling if one provider's valid-mode set ever diverges. +_LLAMA_CPP_REASONING_MODES = ("Thinking", "Quick") + # Every persistence-touching mutation runs in a worker thread # (asyncio.to_thread) so SettingsManager._save_state's json-dump + fsync + # atomic-replace never stalls the event loop (and with it, every session's @@ -114,6 +127,19 @@ def _apply(mutation: Callable[..., None], *args: Any) -> None: mutation(*args) +def _locked_llama_cpp_settings(manager: SettingsManager) -> dict[str, Any]: + # Adversarial-review finding: SettingsManager.get_llama_cpp_settings() + # does 7 separate unsynchronized dict reads - calling it outside + # _manager_lock let a concurrent setLlamaCppNCtx/NGpuLayers/NThreads/ + # ChatFormat call (each correctly _apply-locked) interleave mid-read, + # so a live-reapply could combine e.g. a brand-new n_ctx with a stale + # chat_format. Doesn't corrupt the persisted file (this is a read, never + # written back), but it's exactly the class of race this file's own + # comments already claim to guard against elsewhere. + with _manager_lock: + return manager.get_llama_cpp_settings() + + def _redact(text: str, secret: Any) -> str: # Post-review fix: some HTTP client libraries embed request parameters # (including a rejected API key) directly in exception text - the @@ -180,22 +206,32 @@ def _ollama_scan_summary(manager: SettingsManager) -> str: cached_models = manager.get_ollama_scanned_models() has_saved_scan = bool(scan_mode or scan_path or manager.get_ollama_model_scan_locations()) if not has_saved_scan: - return "No saved scan yet. Run a system scan to build the local model list." + return "No saved scan yet. Run a system scan or choose a folder to build the local model list." if not cached_models: return "The last scan is saved, but it did not find any Ollama models." if scan_mode == "folder" and scan_path: - # Folder scanning isn't wired in THIS page yet - it needs the same - # native-picker capability R7.4c builds for Llama.cpp's GGUF file - # browse, deliberately deferred out of this increment's scope (see - # doc/QT_REMOVAL_PLAN.md's R7.4 scoping). This branch only DISPLAYS - # a scan a user already saved via the legacy Qt app sharing the - # same session.dat - it doesn't let this page create one. return f"Using saved scan from folder: {scan_path}" if scan_mode == "system": return "Using saved system scan results from local Ollama locations." return "Using saved scanned model list." +def _llama_cpp_scan_summary(manager: SettingsManager) -> str: + scan_mode = manager.get_llama_cpp_model_scan_mode() + scan_path = manager.get_llama_cpp_model_scan_path() + cached_models = manager.get_llama_cpp_scanned_models() + has_saved_scan = bool(scan_mode or scan_path or manager.get_llama_cpp_model_scan_locations()) + if not has_saved_scan: + return "No saved GGUF scan yet. Run a system scan or choose a folder to build the local model list." + if not cached_models: + return "The last GGUF scan is saved, but it did not find any models." + if scan_mode == "folder" and scan_path: + return f"Using saved scan from folder: {scan_path}" + if scan_mode == "system": + return "Using saved system scan results from local GGUF locations." + return "Using saved scanned GGUF model list." + + def settings_payload(manager: SettingsManager) -> dict[str, Any]: return { "theme": manager.get_theme(), @@ -255,6 +291,21 @@ def _catalog_state_for(provider: str) -> dict[str, str]: ollama_pull_status = {"value": "idle"} ollama_notice = {"value": ""} + # Llama.cpp mirrors Ollama's flat-cells shape (still exactly one page, + # still no per-provider keying needed) plus two EXTRA cells with no + # Ollama analog: the staged chat/title GGUF paths. Legacy's own bridge + # kept these in-memory on the bridge instance and only persisted them + # on an explicit saveLlamaCppSettings() call - Browse/select just + # updates the staged draft, exactly like the API-provider page's own + # draftApiKey/draftBaseUrl/draftModels (a local edit buffer, not + # write-through) rather than Ollama's immediate-persist intents. Seeded + # from the manager's already-persisted value so a fresh session opens + # showing whatever was last saved, matching legacy's own __init__. + llama_scan_status = {"value": "idle"} + llama_notice = {"value": ""} + llama_staged_chat_path = {"value": manager.get_llama_cpp_chat_model_path()} + llama_staged_title_path = {"value": manager.get_llama_cpp_title_model_override_path()} + def build_payload() -> dict[str, Any]: payload = settings_payload(manager) payload["activeSection"] = active_section["value"] @@ -288,6 +339,22 @@ def build_payload() -> dict[str, Any]: payload["ollamaScanStatus"] = ollama_scan_status["value"] payload["ollamaPullStatus"] = ollama_pull_status["value"] payload["ollamaNotice"] = ollama_notice["value"] + + payload["llamaCppReasoningMode"] = manager.get_llama_cpp_reasoning_mode() + # Staged (session-local), NOT manager.get_llama_cpp_chat_model_path() + # - the field must show the in-progress draft, not the last-saved + # value, exactly like the API-provider page's draftBaseUrl/ + # draftModels never reading back from the payload once edited. + payload["llamaCppChatModelPath"] = llama_staged_chat_path["value"] + payload["llamaCppTitleModelPath"] = llama_staged_title_path["value"] + payload["llamaCppChatFormat"] = manager.get_llama_cpp_chat_format() + payload["llamaCppNCtx"] = manager.get_llama_cpp_n_ctx() + payload["llamaCppNGpuLayers"] = manager.get_llama_cpp_n_gpu_layers() + payload["llamaCppNThreads"] = manager.get_llama_cpp_n_threads() + payload["llamaCppScannedModels"] = manager.get_llama_cpp_scanned_models() + payload["llamaCppScanSummary"] = _llama_cpp_scan_summary(manager) + payload["llamaCppScanStatus"] = llama_scan_status["value"] + payload["llamaCppNotice"] = llama_notice["value"] return payload bus.register_topic("app-settings", build_payload) @@ -653,12 +720,11 @@ def _persist() -> None: await asyncio.to_thread(_apply, _persist) await bus.publish("app-settings") - async def scan_ollama_system(): - # Check-then-set with no await between them - safe under asyncio's - # single-threaded event loop (unlike the two genuinely async-gapped - # races above), matching legacy's own isRunning()-guarded no-op. - if ollama_scan_status["value"] == "running": - return + async def _run_ollama_scan(scan_path: str | None) -> None: + # Extracted (R7.4c) from what used to be scan_ollama_system's own + # monolithic body, unchanged in behavior - factored out so + # pick_ollama_scan_folder (below) can share it with a real path + # instead of duplicating the scan/persist/report sequence. ollama_scan_status["value"] = "running" ollama_notice["value"] = "" await bus.publish("app-settings") @@ -670,7 +736,7 @@ async def scan_ollama_system(): # surfaced either; matched here, not newly dropped). Only a # genuine, unexpected exception (e.g. a filesystem error # walking a manifest folder) reaches this except. - results = await asyncio.to_thread(api_provider.scan_local_ollama_models, None) + results = await asyncio.to_thread(api_provider.scan_local_ollama_models, scan_path) except Exception as exc: # noqa: BLE001 - no secret in this path to redact ollama_scan_status["value"] = "error" ollama_notice["value"] = f"Scan failed: {exc}" @@ -708,6 +774,47 @@ def _persist() -> None: ollama_scan_status["value"] = "done" await bus.publish("app-settings") + async def scan_ollama_system(): + # Check-then-set with no await between them - safe under asyncio's + # single-threaded event loop (unlike the two genuinely async-gapped + # races above), matching legacy's own isRunning()-guarded no-op. + if ollama_scan_status["value"] == "running": + return + await _run_ollama_scan(None) + + async def pick_ollama_scan_folder(): + # R7.4c: the retroactive un-defer of this page's own "Scan + # Folder..." button, now that native_dialogs exists. Matches + # legacy's pickOllamaScanFolder(): a cancelled dialog is a quiet + # no-op, not an error. The reentrancy guard is set BEFORE the + # (blocking, potentially long-lived-until-the-user-decides) native + # dialog opens, not after - two near-simultaneous clicks must not + # both reach a native file-dialog call. + if ollama_scan_status["value"] == "running": + return + ollama_scan_status["value"] = "running" + await bus.publish("app-settings") + directory = manager.get_ollama_model_scan_path() or os.path.expanduser("~") + try: + # Adversarial-review finding: the native dialog call itself can + # raise (a per-platform GTK/COM/file-type-parsing failure inside + # pywebview's create_file_dialog - confirmed reachable via its + # own source, not theoretical). Uncaught, this would strand the + # reentrancy gate at "running" forever - the exact class of bug + # already fixed twice in this file for the SCAN/PERSIST steps, + # reintroduced here via the dialog call that precedes them. + folder = await native_dialogs.pick_folder(directory=directory) + except Exception as exc: # noqa: BLE001 - a local folder path, not a credential + ollama_scan_status["value"] = "error" + ollama_notice["value"] = f"Could not open the folder picker: {exc}" + await bus.publish("app-settings") + return + if not folder: + ollama_scan_status["value"] = "idle" + await bus.publish("app-settings") + return + await _run_ollama_scan(folder) + async def pull_ollama_model(model_name: str): model_name = str(model_name).strip() if not model_name: @@ -758,6 +865,264 @@ def _persist() -> None: ollama_notice["value"] = "" await bus.publish("app-settings") + async def set_llama_cpp_reasoning_mode(mode: str): + mode = str(mode) + if mode not in _LLAMA_CPP_REASONING_MODES: + return + await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode) + + def _reapply_if_llama_cpp_is_still_the_live_provider() -> None: + # Same shape as set_ollama_reasoning_mode's own live re-apply: + # gated on is_local_llama_cpp_mode(), checked and applied inside + # this SAME to_thread hop so a concurrent provider-mode switch + # can't be clobbered back to Llama.cpp. Unlike Ollama's version, + # this genuinely CAN fail: initialize_local_provider's + # Llama.cpp branch re-validates chat_model_path (must still be + # a real, existing .gguf file) every time it runs - if that + # file was deleted/moved from under an already-active session + # since Llama.cpp was last activated, this raises. The mode is + # already persisted above regardless, so a failure here only + # means it takes effect on the next mode switch/restart instead + # of immediately - not a lost setting. + if api_provider.is_local_llama_cpp_mode(): + settings = _locked_llama_cpp_settings(manager) + settings["reasoning_mode"] = mode + api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings) + + try: + await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider) + except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential) + llama_notice["value"] = f"Reasoning mode saved, but could not be applied to the live model: {exc}" + await bus.publish("app-settings") + + async def set_llama_cpp_chat_format(chat_format: str): + await asyncio.to_thread(_apply, manager.set_llama_cpp_chat_format, str(chat_format)) + await bus.publish("app-settings") + + def _set_llama_cpp_runtime_field(field: str, value: int) -> None: + # Read-modify-write entirely inside this one _apply-locked closure - + # SettingsManager.set_llama_cpp_runtime requires all 4 kwargs every + # call (no partial-update variant), so the "current" read must + # happen in the SAME locked critical section as the write, not + # before scheduling it - exactly the class of race the R7.4a + # save_api_configuration bug and R7.4b's model-assignment test both + # already covered for other read-modify-writes in this file. + current = manager.get_llama_cpp_settings() + current[field] = value + manager.set_llama_cpp_runtime( + n_ctx=current["n_ctx"], + n_gpu_layers=current["n_gpu_layers"], + n_threads=current["n_threads"], + chat_format=current["chat_format"], + ) + + async def set_llama_cpp_n_ctx(n_ctx: int): + try: + n_ctx = int(n_ctx) + except (TypeError, ValueError): + return + await asyncio.to_thread(_apply, _set_llama_cpp_runtime_field, "n_ctx", n_ctx) + await bus.publish("app-settings") + + async def set_llama_cpp_n_gpu_layers(n_gpu_layers: int): + try: + n_gpu_layers = int(n_gpu_layers) + except (TypeError, ValueError): + return + await asyncio.to_thread(_apply, _set_llama_cpp_runtime_field, "n_gpu_layers", n_gpu_layers) + await bus.publish("app-settings") + + async def set_llama_cpp_n_threads(n_threads: int): + try: + n_threads = int(n_threads) + except (TypeError, ValueError): + return + await asyncio.to_thread(_apply, _set_llama_cpp_runtime_field, "n_threads", n_threads) + await bus.publish("app-settings") + + def _initial_gguf_directory(staged_path: str) -> str: + # Matches legacy's own _pick_gguf_file: prefer the staged path's + # own directory (so re-browsing starts where the current selection + # already lives), else a saved scan path, else home - never leaves + # it to whatever the OS defaults to. + if staged_path: + directory = os.path.dirname(staged_path) + if directory: + return directory + return manager.get_llama_cpp_model_scan_path() or os.path.expanduser("~") + + async def pick_llama_cpp_chat_model_file(): + # Stages only - matches legacy's pickLlamaCppChatModelFile(), which + # never persists until the user clicks Save. A cancelled dialog + # (path is None) is a quiet no-op. + directory = _initial_gguf_directory(llama_staged_chat_path["value"]) + path = await native_dialogs.pick_file( + file_types=("GGUF files (*.gguf)", "All Files (*.*)"), directory=directory + ) + if path: + llama_staged_chat_path["value"] = path + await bus.publish("app-settings") + + async def pick_llama_cpp_title_model_file(): + # Legacy's own initial-dir fallback order for the TITLE picker + # specifically: the staged title path, else the staged CHAT path + # (not the title one) - matches _pick_gguf_file's caller passing + # `self._llama_title_model_path or self._llama_chat_model_path`. + directory = _initial_gguf_directory( + llama_staged_title_path["value"] or llama_staged_chat_path["value"] + ) + path = await native_dialogs.pick_file( + file_types=("GGUF files (*.gguf)", "All Files (*.*)"), directory=directory + ) + if path: + llama_staged_title_path["value"] = path + await bus.publish("app-settings") + + async def set_llama_cpp_chat_model_path(path: str): + # The non-native counterpart (selecting from the scanned-models + # dropdown) - also stages only, matching pick_llama_cpp_chat_model_file. + llama_staged_chat_path["value"] = str(path).strip() + await bus.publish("app-settings") + + async def set_llama_cpp_title_model_path(path: str): + llama_staged_title_path["value"] = str(path).strip() + await bus.publish("app-settings") + + async def _run_llama_cpp_scan(scan_path: str | None) -> None: + llama_scan_status["value"] = "running" + llama_notice["value"] = "" + await bus.publish("app-settings") + + try: + # Unlike scan_local_ollama_models, this DOES raise for a real, + # reachable failure: an explicit scan_path that doesn't exist or + # isn't a directory (api_provider.py's own scan_local_llama_cpp_models). + results = await asyncio.to_thread(api_provider.scan_local_llama_cpp_models, scan_path) + except Exception as exc: # noqa: BLE001 - no secret in this path (a local folder path, not a credential) + llama_scan_status["value"] = "error" + llama_notice["value"] = f"Scan failed: {exc}" + await bus.publish("app-settings") + return + + def _persist() -> None: + manager.set_llama_cpp_model_scan_cache( + results.get("models", []), + results.get("scan_mode", ""), + results.get("scan_path", ""), + results.get("locations", []), + ) + + try: + # Same reentrancy-gate-recovery fix as scan_ollama_system's own + # persist step - a disk-write failure here must not strand this + # scan status at "running" forever. + await asyncio.to_thread(_apply, _persist) + except Exception as exc: # noqa: BLE001 - no secret in this path + llama_scan_status["value"] = "error" + llama_notice["value"] = f"Scan failed: {exc}" + await bus.publish("app-settings") + return + + llama_scan_status["value"] = "done" + if results.get("truncated"): + # A deliberate small improvement over legacy (never surfaced + # this): the scan collector is bounded (50k directories / 30s - + # see api_provider.py's _GGUF_SCAN_MAX_DIRECTORIES/_MAX_SECONDS) + # specifically because the default system-wide roots include + # the user's whole Downloads/Documents/Desktop trees, which can + # be huge. Silently reporting an incomplete scan as complete + # would be misleading; this doesn't change any persisted field. + llama_notice["value"] = "Scan stopped early (too many folders or took too long) - results may be incomplete." + await bus.publish("app-settings") + + async def scan_llama_cpp_system(): + if llama_scan_status["value"] == "running": + return + await _run_llama_cpp_scan(None) + + async def pick_llama_cpp_scan_folder(): + if llama_scan_status["value"] == "running": + return + llama_scan_status["value"] = "running" + await bus.publish("app-settings") + directory = manager.get_llama_cpp_model_scan_path() or os.path.expanduser("~") + try: + # Same reentrancy-gate hazard fixed above for pick_ollama_scan_folder. + folder = await native_dialogs.pick_folder(directory=directory) + except Exception as exc: # noqa: BLE001 - a local folder path, not a credential + llama_scan_status["value"] = "error" + llama_notice["value"] = f"Could not open the folder picker: {exc}" + await bus.publish("app-settings") + return + if not folder: + llama_scan_status["value"] = "idle" + await bus.publish("app-settings") + return + await _run_llama_cpp_scan(folder) + + async def save_llama_cpp_settings(): + # Sequencing matches legacy's saveLlamaCppSettings() exactly: + # (1) validate the staged paths locally - chat is required, title + # is optional but validated if non-empty; (2) if Llama.cpp is the + # CURRENTLY LIVE provider, re-initialize it with the new settings, + # aborting without persisting anything on failure (a real, useful + # abort: this is what catches "that .gguf file doesn't actually + # exist" before it's saved); (3) only then persist. + chat_path = llama_staged_chat_path["value"].strip() + title_path = llama_staged_title_path["value"].strip() + + # Legacy-parity fix: restores the 5 distinct legacy error messages + # (graphlink_settings_bridge.py's saveLlamaCppSettings) instead of 2 + # generic ones - a user gets the ACTUAL problem (empty vs. not-found + # vs. wrong-extension), not just "must be a real .gguf file" for all + # three. + if not chat_path: + llama_notice["value"] = "Chat Model File cannot be empty." + await bus.publish("app-settings") + return + if not os.path.isfile(chat_path): + llama_notice["value"] = f"Chat model file was not found: {chat_path}" + await bus.publish("app-settings") + return + if not chat_path.lower().endswith(".gguf"): + llama_notice["value"] = "Chat Model File must point to a .gguf file." + await bus.publish("app-settings") + return + if title_path: + if not os.path.isfile(title_path): + llama_notice["value"] = f"Chat naming model file was not found: {title_path}" + await bus.publish("app-settings") + return + if not title_path.lower().endswith(".gguf"): + llama_notice["value"] = "Chat Naming File must point to a .gguf file." + await bus.publish("app-settings") + return + + def _maybe_reapply_live() -> None: + # Checked and applied inside the SAME to_thread hop - the same + # race-preemption shape as set_llama_cpp_reasoning_mode's own + # live re-apply above. + if api_provider.is_local_llama_cpp_mode(): + settings = _locked_llama_cpp_settings(manager) + settings["chat_model_path"] = chat_path + settings["title_model_path"] = title_path + api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings) + + try: + await asyncio.to_thread(_maybe_reapply_live) + except Exception as exc: # noqa: BLE001 - no secret in this path (local file paths, not credentials) + llama_notice["value"] = f"Invalid Llama.cpp configuration: {exc}" + await bus.publish("app-settings") + return + + def _persist() -> None: + manager.set_llama_cpp_chat_model_path(chat_path) + manager.set_llama_cpp_title_model_path(title_path) + + await asyncio.to_thread(_apply, _persist) + llama_notice["value"] = "" + await bus.publish("app-settings") + bus.register_intent("app-settings", "setActiveSection", set_active_section) bus.register_intent("app-settings", "setTheme", set_theme) bus.register_intent("app-settings", "setShowTokenCounter", set_show_token_counter) @@ -773,3 +1138,16 @@ def _persist() -> None: bus.register_intent("app-settings", "setOllamaModelAssignment", set_ollama_model_assignment) bus.register_intent("app-settings", "scanOllamaSystem", scan_ollama_system) bus.register_intent("app-settings", "pullOllamaModel", pull_ollama_model) + bus.register_intent("app-settings", "pickOllamaScanFolder", pick_ollama_scan_folder) + bus.register_intent("app-settings", "setLlamaCppReasoningMode", set_llama_cpp_reasoning_mode) + bus.register_intent("app-settings", "setLlamaCppChatFormat", set_llama_cpp_chat_format) + bus.register_intent("app-settings", "setLlamaCppNCtx", set_llama_cpp_n_ctx) + bus.register_intent("app-settings", "setLlamaCppNGpuLayers", set_llama_cpp_n_gpu_layers) + bus.register_intent("app-settings", "setLlamaCppNThreads", set_llama_cpp_n_threads) + bus.register_intent("app-settings", "pickLlamaCppChatModelFile", pick_llama_cpp_chat_model_file) + bus.register_intent("app-settings", "pickLlamaCppTitleModelFile", pick_llama_cpp_title_model_file) + bus.register_intent("app-settings", "setLlamaCppChatModelPath", set_llama_cpp_chat_model_path) + bus.register_intent("app-settings", "setLlamaCppTitleModelPath", set_llama_cpp_title_model_path) + bus.register_intent("app-settings", "scanLlamaCppSystem", scan_llama_cpp_system) + bus.register_intent("app-settings", "pickLlamaCppScanFolder", pick_llama_cpp_scan_folder) + bus.register_intent("app-settings", "saveLlamaCppSettings", save_llama_cpp_settings) diff --git a/backend/tests/test_native_dialogs.py b/backend/tests/test_native_dialogs.py new file mode 100644 index 0000000..85bf490 --- /dev/null +++ b/backend/tests/test_native_dialogs.py @@ -0,0 +1,112 @@ +"""Tests for backend/native_dialogs.py (Qt-removal plan R7.4c). + +webview.windows is monkeypatched directly (a plain list, per pywebview's +own source) rather than mocking a whole Window class where not needed - +the "no window" branch only cares that the list is empty; the "window +exists" branch needs just enough of a fake Window to satisfy +create_file_dialog's call signature. +""" + +import asyncio + +import webview + +from backend import native_dialogs + + +class _FakeWindow: + def __init__(self, return_value): + self.return_value = return_value + self.calls = [] + + def create_file_dialog(self, dialog_type=10, directory="", allow_multiple=False, save_filename="", file_types=()): + self.calls.append( + { + "dialog_type": dialog_type, + "directory": directory, + "allow_multiple": allow_multiple, + "save_filename": save_filename, + "file_types": file_types, + } + ) + return self.return_value + + +def test_pick_file_returns_none_when_no_window_exists(monkeypatch): + monkeypatch.setattr(webview, "windows", []) + + result = asyncio.run(native_dialogs.pick_file(file_types=("GGUF files (*.gguf)",))) + + assert result is None + + +def test_pick_file_calls_open_dialog_with_file_types_and_returns_first_path(monkeypatch): + fake = _FakeWindow(return_value=("C:/models/a.gguf",)) + monkeypatch.setattr(webview, "windows", [fake]) + + result = asyncio.run(native_dialogs.pick_file(file_types=("GGUF files (*.gguf)",))) + + assert result == "C:/models/a.gguf" + assert fake.calls == [ + { + "dialog_type": webview.FileDialog.OPEN, + "directory": "", + "allow_multiple": False, + "save_filename": "", + "file_types": ("GGUF files (*.gguf)",), + } + ] + + +def test_pick_file_returns_none_when_the_user_cancels(monkeypatch): + fake = _FakeWindow(return_value=None) + monkeypatch.setattr(webview, "windows", [fake]) + + result = asyncio.run(native_dialogs.pick_file()) + + assert result is None + + +def test_pick_folder_returns_none_when_no_window_exists(monkeypatch): + monkeypatch.setattr(webview, "windows", []) + + result = asyncio.run(native_dialogs.pick_folder()) + + assert result is None + + +def test_pick_folder_calls_folder_dialog_and_returns_first_path(monkeypatch): + fake = _FakeWindow(return_value=("C:/models",)) + monkeypatch.setattr(webview, "windows", [fake]) + + result = asyncio.run(native_dialogs.pick_folder()) + + assert result == "C:/models" + assert fake.calls[0]["dialog_type"] == webview.FileDialog.FOLDER + + +def test_pick_folder_returns_none_when_the_user_cancels(monkeypatch): + fake = _FakeWindow(return_value=None) + monkeypatch.setattr(webview, "windows", [fake]) + + result = asyncio.run(native_dialogs.pick_folder()) + + assert result is None + + +def test_uses_the_first_window_when_multiple_exist(): + # webview.windows can only ever grow (create_window appends, nothing + # removes) - confirms the module reads index 0, not "the last one" or + # anything order-sensitive beyond that documented convention. + first = _FakeWindow(return_value=("first-picked.gguf",)) + second = _FakeWindow(return_value=("second-picked.gguf",)) + import webview as wv + + original = wv.windows + try: + wv.windows = [first, second] + result = asyncio.run(native_dialogs.pick_file()) + assert result == "first-picked.gguf" + assert second.calls == [] + finally: + wv.windows = original diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 12ff217..bbcda7a 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -4,11 +4,13 @@ import ollama import pytest +import webview from graphlink_licensing import SettingsManager import api_provider import graphlink_task_config as config import backend.settings as settings_module +from backend import native_dialogs from backend.events import SessionBus from backend.notifications import NotificationState from backend.settings import register_settings, settings_payload @@ -1123,3 +1125,607 @@ def _boom(name): monkeypatch.setattr(api_provider, "invalidate_ollama_capability_cache", lambda name: calls.append(name)) asyncio.run(bus.dispatch_intent("app-settings", "pullOllamaModel", ["qwen3:8b"])) assert calls == ["qwen3:8b"] + + +# -- R7.4c: Llama.cpp settings page (reasoning mode, runtime tunables, +# -- GGUF model scan/browse, chat/naming model paths) plus the retroactive +# -- un-defer of the Ollama page's own "Scan Folder..." button, now that +# -- native_dialogs.py exists. + + +def test_pick_ollama_scan_folder_scans_the_picked_folder(manager, monkeypatch): + async def _fake_pick_folder(directory=""): + return "C:/models/ollama" + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr( + api_provider, + "scan_local_ollama_models", + lambda scan_path: {"models": ["llama3.2:3b"], "scan_mode": "folder", "scan_path": scan_path, "locations": [scan_path]}, + ) + bus = SessionBus("settings-pick-ollama-scan-folder-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickOllamaScanFolder", [])) + + assert manager.get_ollama_scanned_models() == ["llama3.2:3b"] + payload = recorder.messages[-1]["payload"] + assert payload["ollamaScanStatus"] == "done" + + +def test_pick_ollama_scan_folder_is_a_no_op_when_cancelled(manager, monkeypatch): + async def _fake_pick_folder(directory=""): + return None + + calls = [] + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr(api_provider, "scan_local_ollama_models", lambda scan_path: calls.append(1) or {"models": []}) + bus = SessionBus("settings-pick-ollama-scan-folder-cancel-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickOllamaScanFolder", [])) + + assert calls == [] + assert recorder.messages[-1]["payload"]["ollamaScanStatus"] == "idle" + + +def test_pick_ollama_scan_folder_is_a_no_op_while_already_running(manager, monkeypatch): + picker_calls = [] + + async def _fake_pick_folder(directory=""): + picker_calls.append(1) + return "C:/models" + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr(api_provider, "scan_local_ollama_models", lambda scan_path: {"models": []}) + bus = SessionBus("settings-pick-ollama-scan-folder-no-op-test") + register_settings(bus, manager) + + async def _run(): + first = asyncio.create_task(bus.dispatch_intent("app-settings", "pickOllamaScanFolder", [])) + await asyncio.sleep(0) + await bus.dispatch_intent("app-settings", "pickOllamaScanFolder", []) + await first + + asyncio.run(_run()) + assert len(picker_calls) == 1 + + +def test_pick_ollama_scan_folder_dialog_failure_reports_error_and_does_not_strand_the_running_gate(manager, monkeypatch): + # Adversarial-review finding: native_dialogs.pick_folder() itself can + # raise (a per-platform dialog failure inside pywebview's + # create_file_dialog - confirmed reachable, not theoretical). Before + # this fix, that exception propagated uncaught, leaving + # ollama_scan_status["value"] stuck at "running" forever - the same + # reentrancy-gate hazard already fixed twice elsewhere in this file for + # the scan/persist steps, reintroduced here via the dialog call itself. + async def _boom(directory=""): + raise RuntimeError("native dialog backend crashed") + + monkeypatch.setattr(native_dialogs, "pick_folder", _boom) + bus = SessionBus("settings-pick-ollama-scan-folder-dialog-failure-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickOllamaScanFolder", [])) + + payload = recorder.messages[-1]["payload"] + assert payload["ollamaScanStatus"] == "error" + assert "native dialog backend crashed" in payload["ollamaNotice"] + + # The gate must not be stranded - a second call must actually reach the + # picker again, not silently no-op against a status stuck at "running". + calls = [] + + async def _fake_pick_folder(directory=""): + calls.append(1) + return None + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + asyncio.run(bus.dispatch_intent("app-settings", "pickOllamaScanFolder", [])) + assert calls == [1] + + +def test_set_llama_cpp_reasoning_mode_persists_and_rejects_unknown_modes(manager): + bus = SessionBus("settings-llama-cpp-reasoning-mode-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"])) + assert manager.get_llama_cpp_reasoning_mode() == "Quick" + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["not-a-real-mode"])) + assert manager.get_llama_cpp_reasoning_mode() == "Quick" + + +def test_set_llama_cpp_reasoning_mode_reapplies_live_only_when_llama_cpp_is_the_active_provider(manager, monkeypatch): + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + bus = SessionBus("settings-llama-cpp-reasoning-not-active-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"])) + assert calls == [] + + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Thinking"])) + assert len(calls) == 1 + assert calls[0][0] == config.LOCAL_PROVIDER_LLAMACPP + assert calls[0][1]["reasoning_mode"] == "Thinking" + + +def test_set_llama_cpp_reasoning_mode_reapply_failure_reports_a_notice_without_crashing(manager, monkeypatch): + # Unlike Ollama's reasoning-mode reapply, this one has a REAL failure + # mode: initialize_local_provider re-validates chat_model_path every + # call, which can raise if the persisted GGUF file no longer exists. + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + + def _boom(*a, **k): + raise RuntimeError("Llama.cpp model file was not found: C:/gone.gguf") + + monkeypatch.setattr(api_provider, "initialize_local_provider", _boom) + bus = SessionBus("settings-llama-cpp-reasoning-reapply-failure-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppReasoningMode", ["Quick"])) + + # The mode is still persisted even though the live reapply failed. + assert manager.get_llama_cpp_reasoning_mode() == "Quick" + payload = recorder.messages[-1]["payload"] + assert "could not be applied to the live model" in payload["llamaCppNotice"] + + +def test_set_llama_cpp_chat_format_persists(manager): + bus = SessionBus("settings-llama-cpp-chat-format-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatFormat", ["chatml"])) + + assert manager.get_llama_cpp_chat_format() == "chatml" + + +def test_set_llama_cpp_n_ctx_persists_and_rejects_non_numeric(manager): + bus = SessionBus("settings-llama-cpp-n-ctx-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppNCtx", [8192])) + assert manager.get_llama_cpp_n_ctx() == 8192 + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppNCtx", ["not-a-number"])) + assert manager.get_llama_cpp_n_ctx() == 8192 # unchanged, not coerced to garbage + + +def test_set_llama_cpp_n_gpu_layers_persists(manager): + bus = SessionBus("settings-llama-cpp-n-gpu-layers-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppNGpuLayers", [20])) + + assert manager.get_llama_cpp_n_gpu_layers() == 20 + + +def test_set_llama_cpp_n_threads_persists(manager): + bus = SessionBus("settings-llama-cpp-n-threads-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppNThreads", [4])) + + assert manager.get_llama_cpp_n_threads() == 4 + + +def test_set_llama_cpp_runtime_fields_read_and_write_atomically_across_concurrent_calls(manager, monkeypatch): + # Mirrors R7.4b's set_ollama_model_assignment atomicity regression test: + # set_llama_cpp_runtime requires ALL FOUR kwargs every call (no partial + # update), so a stale pre-await read of the "current" values would + # silently revert a concurrent change to a DIFFERENT runtime field + # landing in the window between this call's read and its write. + real_apply = settings_module._apply + injected = {"done": False} + + def _apply_with_a_concurrent_runtime_change_in_the_window(mutation, *args): + if not injected["done"]: + injected["done"] = True + manager.set_llama_cpp_runtime(n_ctx=4096, n_gpu_layers=0, n_threads=99, chat_format="") + return real_apply(mutation, *args) + + monkeypatch.setattr(settings_module, "_apply", _apply_with_a_concurrent_runtime_change_in_the_window) + bus = SessionBus("settings-llama-cpp-runtime-race-test") + register_settings(bus, manager) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppNCtx", [16384])) + + assert injected["done"], "the concurrent write never ran - the test no longer exercises the window" + assert manager.get_llama_cpp_n_ctx() == 16384 + # Must NOT be reverted - the concurrently-set n_threads must survive. + assert manager.get_llama_cpp_n_threads() == 99 + + +def test_pick_llama_cpp_chat_model_file_stages_path_when_a_file_is_picked(manager, monkeypatch): + async def _fake_pick_file(file_types=(), directory=""): + return "C:/models/chat.gguf" + + monkeypatch.setattr(native_dialogs, "pick_file", _fake_pick_file) + bus = SessionBus("settings-llama-cpp-pick-chat-file-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppChatModelFile", [])) + + assert recorder.messages[-1]["payload"]["llamaCppChatModelPath"] == "C:/models/chat.gguf" + # Staged only - never persisted until Save. + assert manager.get_llama_cpp_chat_model_path() == "" + + +def test_pick_llama_cpp_chat_model_file_does_nothing_when_the_dialog_is_cancelled(manager, monkeypatch): + async def _fake_pick_file(file_types=(), directory=""): + return None + + monkeypatch.setattr(native_dialogs, "pick_file", _fake_pick_file) + bus = SessionBus("settings-llama-cpp-pick-chat-file-cancel-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppChatModelFile", [])) + + assert recorder.messages == [] # no publish at all when nothing was picked + + +def test_pick_llama_cpp_title_model_file_stages_path(manager, monkeypatch): + async def _fake_pick_file(file_types=(), directory=""): + return "C:/models/title.gguf" + + monkeypatch.setattr(native_dialogs, "pick_file", _fake_pick_file) + bus = SessionBus("settings-llama-cpp-pick-title-file-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppTitleModelFile", [])) + + assert recorder.messages[-1]["payload"]["llamaCppTitleModelPath"] == "C:/models/title.gguf" + assert manager.get_llama_cpp_title_model_override_path() == "" + + +def test_set_llama_cpp_chat_model_path_stages_without_persisting(manager): + bus = SessionBus("settings-llama-cpp-set-chat-path-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", ["C:/models/scanned.gguf"])) + + assert recorder.messages[-1]["payload"]["llamaCppChatModelPath"] == "C:/models/scanned.gguf" + assert manager.get_llama_cpp_chat_model_path() == "" + + +def test_set_llama_cpp_title_model_path_stages_without_persisting(manager): + bus = SessionBus("settings-llama-cpp-set-title-path-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppTitleModelPath", ["C:/models/scanned-title.gguf"])) + + assert recorder.messages[-1]["payload"]["llamaCppTitleModelPath"] == "C:/models/scanned-title.gguf" + assert manager.get_llama_cpp_title_model_override_path() == "" + + +def test_scan_llama_cpp_system_persists_results_and_reports_done(manager, monkeypatch): + monkeypatch.setattr( + api_provider, + "scan_local_llama_cpp_models", + lambda scan_path: {"models": ["C:/models/a.gguf"], "scan_mode": "system", "scan_path": "", "locations": ["C:/models"], "truncated": False}, + ) + bus = SessionBus("settings-llama-cpp-scan-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + + assert manager.get_llama_cpp_scanned_models() == ["C:/models/a.gguf"] + payload = recorder.messages[-1]["payload"] + assert payload["llamaCppScanStatus"] == "done" + assert payload["llamaCppNotice"] == "" + + +def test_scan_llama_cpp_system_reports_truncated_scans(manager, monkeypatch): + monkeypatch.setattr( + api_provider, + "scan_local_llama_cpp_models", + lambda scan_path: {"models": [], "scan_mode": "system", "scan_path": "", "locations": [], "truncated": True}, + ) + bus = SessionBus("settings-llama-cpp-scan-truncated-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + + payload = recorder.messages[-1]["payload"] + assert payload["llamaCppScanStatus"] == "done" + assert "stopped early" in payload["llamaCppNotice"] + + +def test_scan_llama_cpp_system_reports_error_on_a_genuine_exception(manager, monkeypatch): + def _boom(scan_path): + raise RuntimeError("Scan folder does not exist: /nope") + + monkeypatch.setattr(api_provider, "scan_local_llama_cpp_models", _boom) + bus = SessionBus("settings-llama-cpp-scan-error-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + + payload = recorder.messages[-1]["payload"] + assert payload["llamaCppScanStatus"] == "error" + assert "Scan folder does not exist" in payload["llamaCppNotice"] + assert manager.get_llama_cpp_scanned_models() == [] + + +def test_scan_llama_cpp_system_is_a_no_op_while_already_running(manager, monkeypatch): + calls = [] + monkeypatch.setattr( + api_provider, "scan_local_llama_cpp_models", lambda scan_path: calls.append(1) or {"models": []} + ) + bus = SessionBus("settings-llama-cpp-scan-no-op-test") + register_settings(bus, manager) + + async def _run(): + first = asyncio.create_task(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + await asyncio.sleep(0) + await bus.dispatch_intent("app-settings", "scanLlamaCppSystem", []) + await first + + asyncio.run(_run()) + assert len(calls) == 1 + + +def test_scan_llama_cpp_system_persist_failure_reports_error_and_does_not_strand_the_running_gate(manager, monkeypatch): + monkeypatch.setattr( + api_provider, + "scan_local_llama_cpp_models", + lambda scan_path: {"models": ["a.gguf"], "scan_mode": "system", "scan_path": "", "locations": []}, + ) + + def _boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(SettingsManager, "set_llama_cpp_model_scan_cache", _boom) + bus = SessionBus("settings-llama-cpp-scan-persist-failure-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + + payload = recorder.messages[-1]["payload"] + assert payload["llamaCppScanStatus"] == "error" + assert "disk full" in payload["llamaCppNotice"] + + calls = [] + monkeypatch.setattr(SettingsManager, "set_llama_cpp_model_scan_cache", lambda *a, **k: calls.append(1)) + asyncio.run(bus.dispatch_intent("app-settings", "scanLlamaCppSystem", [])) + assert calls == [1] + + +def test_pick_llama_cpp_scan_folder_scans_the_picked_folder(manager, monkeypatch): + async def _fake_pick_folder(directory=""): + return "C:/models/gguf" + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr( + api_provider, + "scan_local_llama_cpp_models", + lambda scan_path: {"models": ["a.gguf"], "scan_mode": "folder", "scan_path": scan_path, "locations": [scan_path]}, + ) + bus = SessionBus("settings-llama-cpp-pick-scan-folder-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", [])) + + assert manager.get_llama_cpp_scanned_models() == ["a.gguf"] + assert recorder.messages[-1]["payload"]["llamaCppScanStatus"] == "done" + + +def test_pick_llama_cpp_scan_folder_is_a_no_op_when_cancelled(manager, monkeypatch): + async def _fake_pick_folder(directory=""): + return None + + calls = [] + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr(api_provider, "scan_local_llama_cpp_models", lambda scan_path: calls.append(1) or {"models": []}) + bus = SessionBus("settings-llama-cpp-pick-scan-folder-cancel-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", [])) + + assert calls == [] + assert recorder.messages[-1]["payload"]["llamaCppScanStatus"] == "idle" + + +def test_pick_llama_cpp_scan_folder_is_a_no_op_while_already_running(manager, monkeypatch): + picker_calls = [] + + async def _fake_pick_folder(directory=""): + picker_calls.append(1) + return "C:/models" + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + monkeypatch.setattr(api_provider, "scan_local_llama_cpp_models", lambda scan_path: {"models": []}) + bus = SessionBus("settings-llama-cpp-pick-scan-folder-no-op-test") + register_settings(bus, manager) + + async def _run(): + first = asyncio.create_task(bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", [])) + await asyncio.sleep(0) + await bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", []) + await first + + asyncio.run(_run()) + assert len(picker_calls) == 1 + + +def test_pick_llama_cpp_scan_folder_dialog_failure_reports_error_and_does_not_strand_the_running_gate(manager, monkeypatch): + # Same reentrancy-gate hazard fixed above for pick_ollama_scan_folder. + async def _boom(directory=""): + raise RuntimeError("native dialog backend crashed") + + monkeypatch.setattr(native_dialogs, "pick_folder", _boom) + bus = SessionBus("settings-llama-cpp-pick-scan-folder-dialog-failure-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", [])) + + payload = recorder.messages[-1]["payload"] + assert payload["llamaCppScanStatus"] == "error" + assert "native dialog backend crashed" in payload["llamaCppNotice"] + + calls = [] + + async def _fake_pick_folder(directory=""): + calls.append(1) + return None + + monkeypatch.setattr(native_dialogs, "pick_folder", _fake_pick_folder) + asyncio.run(bus.dispatch_intent("app-settings", "pickLlamaCppScanFolder", [])) + assert calls == [1] + + +def test_save_llama_cpp_settings_rejects_missing_chat_path(manager): + bus = SessionBus("settings-llama-cpp-save-missing-chat-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert recorder.messages[-1]["payload"]["llamaCppNotice"] == "Chat Model File cannot be empty." + assert manager.get_llama_cpp_chat_model_path() == "" + + +def test_save_llama_cpp_settings_rejects_a_chat_path_that_is_not_a_real_file(manager): + bus = SessionBus("settings-llama-cpp-save-nonexistent-chat-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", ["C:/does/not/exist.gguf"])) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert recorder.messages[-1]["payload"]["llamaCppNotice"] == "Chat model file was not found: C:/does/not/exist.gguf" + assert manager.get_llama_cpp_chat_model_path() == "" + + +def test_save_llama_cpp_settings_rejects_a_chat_path_with_the_wrong_extension(manager, tmp_path): + not_gguf = tmp_path / "chat.txt" + not_gguf.write_text("not a real model") + bus = SessionBus("settings-llama-cpp-save-wrong-ext-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(not_gguf)])) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert recorder.messages[-1]["payload"]["llamaCppNotice"] == "Chat Model File must point to a .gguf file." + assert manager.get_llama_cpp_chat_model_path() == "" + + +def test_save_llama_cpp_settings_rejects_an_invalid_title_path_even_with_a_valid_chat_path(manager, tmp_path): + chat_gguf = tmp_path / "chat.gguf" + chat_gguf.write_text("fake gguf bytes") + bus = SessionBus("settings-llama-cpp-save-invalid-title-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(chat_gguf)])) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppTitleModelPath", ["C:/does/not/exist-title.gguf"])) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert ( + recorder.messages[-1]["payload"]["llamaCppNotice"] + == "Chat naming model file was not found: C:/does/not/exist-title.gguf" + ) + assert manager.get_llama_cpp_chat_model_path() == "" # nothing persisted, not even the valid chat path + + +def test_save_llama_cpp_settings_persists_on_success_with_an_optional_blank_title(manager, tmp_path, monkeypatch): + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + chat_gguf = tmp_path / "chat.gguf" + chat_gguf.write_text("fake gguf bytes") + bus = SessionBus("settings-llama-cpp-save-success-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(chat_gguf)])) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert manager.get_llama_cpp_chat_model_path() == str(chat_gguf) + assert manager.get_llama_cpp_title_model_override_path() == "" + assert recorder.messages[-1]["payload"]["llamaCppNotice"] == "" + + +def test_save_llama_cpp_settings_reapplies_live_only_when_llama_cpp_is_the_active_provider(manager, tmp_path, monkeypatch): + chat_gguf = tmp_path / "chat.gguf" + chat_gguf.write_text("fake gguf bytes") + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + bus = SessionBus("settings-llama-cpp-save-reapply-gate-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(chat_gguf)])) + + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + assert calls == [] + assert manager.get_llama_cpp_chat_model_path() == str(chat_gguf) # still persists even when not live-active + + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(chat_gguf)])) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + assert len(calls) == 1 + assert calls[0][0] == config.LOCAL_PROVIDER_LLAMACPP + assert calls[0][1]["chat_model_path"] == str(chat_gguf) + + +def test_save_llama_cpp_settings_aborts_without_persisting_when_the_live_reapply_fails(manager, tmp_path, monkeypatch): + # The most important defect this shape has to catch: a bad live-init + # call (e.g. an invalid GGUF file that fails to load) must not + # persist the new paths at all - Save should be all-or-nothing. + chat_gguf = tmp_path / "chat.gguf" + chat_gguf.write_text("fake gguf bytes") + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + + def _boom(*a, **k): + raise RuntimeError("failed to load model") + + monkeypatch.setattr(api_provider, "initialize_local_provider", _boom) + bus = SessionBus("settings-llama-cpp-save-reapply-failure-test") + register_settings(bus, manager) + asyncio.run(bus.dispatch_intent("app-settings", "setLlamaCppChatModelPath", [str(chat_gguf)])) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "saveLlamaCppSettings", [])) + + assert "Invalid Llama.cpp configuration" in recorder.messages[-1]["payload"]["llamaCppNotice"] + assert manager.get_llama_cpp_chat_model_path() == "" # NOT persisted - the whole save aborted diff --git a/graphlink_app/graphlink_app_settings_payload.py b/graphlink_app/graphlink_app_settings_payload.py index 3dd6733..59ba02a 100644 --- a/graphlink_app/graphlink_app_settings_payload.py +++ b/graphlink_app/graphlink_app_settings_payload.py @@ -1,15 +1,13 @@ -"""The SPA settings topic's wire contract (Qt-removal plan R2.5d, extended R7.4a, R7.4b). +"""The SPA settings topic's wire contract (Qt-removal plan R2.5d, extended R7.4a-c). Was deliberately a SUBSET of graphlink_settings_payload.py::SettingsStatePayload (General + Integrations fields only) - Ollama/Llama.cpp/API-provider pages weren't implemented yet (see backend/settings.py's module docstring for -why). R7.4a added the API-provider fields for real; R7.4b now adds the -Ollama fields for real too. Llama.cpp remains deferred (R7.4c) so its -fields would still be dead weight here - the same "only what the SPA -actually needs" rationale as every other R2.3-R2.5 app-* payload. -Registered as its own codegen artifact (topic "app-settings") so the -generated validator doesn't collide with the legacy island's own -settings-state.ts. +why). R7.4a added the API-provider fields for real, R7.4b added the Ollama +fields, and R7.4c now adds the Llama.cpp fields too - this closes every +field R2.5d originally deferred. Registered as its own codegen artifact +(topic "app-settings") so the generated validator doesn't collide with the +legacy island's own settings-state.ts. """ from __future__ import annotations @@ -62,4 +60,16 @@ class AppSettingsStatePayload: ollamaScanStatus: str ollamaPullStatus: str ollamaNotice: str + # R7.4c: Llama.cpp page. + llamaCppReasoningMode: str + llamaCppChatModelPath: str + llamaCppTitleModelPath: str + llamaCppChatFormat: str + llamaCppNCtx: int + llamaCppNGpuLayers: int + llamaCppNThreads: int + llamaCppScannedModels: list[str] + llamaCppScanSummary: str + llamaCppScanStatus: str + llamaCppNotice: str minCompatibleSchemaVersion: int | None = None diff --git a/web_ui/src/app/chrome/SettingsDialog.test.tsx b/web_ui/src/app/chrome/SettingsDialog.test.tsx index 4de4288..1fa9df7 100644 --- a/web_ui/src/app/chrome/SettingsDialog.test.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.test.tsx @@ -36,10 +36,21 @@ const snapshot = { ollamaCurrentModel: "", ollamaModelAssignments: {}, ollamaScannedModels: [], - ollamaScanSummary: "No saved scan yet. Run a system scan to build the local model list.", + ollamaScanSummary: "No saved scan yet. Run a system scan or choose a folder to build the local model list.", ollamaScanStatus: "idle", ollamaPullStatus: "idle", ollamaNotice: "", + llamaCppReasoningMode: "Thinking", + llamaCppChatModelPath: "", + llamaCppTitleModelPath: "", + llamaCppChatFormat: "", + llamaCppNCtx: 4096, + llamaCppNGpuLayers: 0, + llamaCppNThreads: 0, + llamaCppScannedModels: [], + llamaCppScanSummary: "No saved GGUF scan yet. Run a system scan or choose a folder to build the local model list.", + llamaCppScanStatus: "idle", + llamaCppNotice: "", }; function makeTransport() { @@ -110,6 +121,16 @@ async function goToOllama( act(() => push({ ...snapshot, ...overrides, activeSection: "ollama (local)" })); } +async function goToLlamaCpp( + user: ReturnType, + push: (payload: Record) => void, + overrides: Record = {}, +) { + await user.click(screen.getByText("open settings")); + await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" })); + act(() => push({ ...snapshot, ...overrides, activeSection: "llama.cpp (local)" })); +} + describe("SettingsDialog", () => { it("navigating sections fires setActiveSection with the clicked section's key", async () => { const { user, intents } = setup(); @@ -128,13 +149,12 @@ describe("SettingsDialog", () => { expect(screen.getByText("API Provider")).toBeInTheDocument(); }); - it("Llama.cpp still renders the deferred placeholder", async () => { + it("Llama.cpp page renders for the real (not deferred-placeholder) section", async () => { const { user, push } = setup(); - await user.click(screen.getByText("open settings")); - await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" })); - act(() => push({ ...snapshot, activeSection: "llama.cpp (local)" })); + await goToLlamaCpp(user, push); - expect(screen.getByText("Llama.cpp (Local) configuration lands in R7.4c.")).toBeInTheDocument(); + expect(screen.queryByText(/lands in R7\.4c/)).toBeNull(); + expect(screen.getByText("Reasoning Mode")).toBeInTheDocument(); }); it("Ollama page renders for the real (not deferred-placeholder) section", async () => { @@ -180,13 +200,6 @@ describe("SettingsDialog", () => { expect(intents).toContainEqual(["app-settings", "scanOllamaSystem", []]); }); - it("Scan Folder... is disabled - deferred to R7.4c's native picker", async () => { - const { user, push } = setup(); - await goToOllama(user, push); - - expect(screen.getByText("Scan Folder...")).toBeDisabled(); - }); - it("a per-task select defaults to auto and switching to inherit fires setOllamaModelAssignment immediately", async () => { const { user, push, intents } = setup(); await goToOllama(user, push); @@ -415,4 +428,187 @@ describe("SettingsDialog", () => { expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toHaveValue("https://api.openai.com/v1"); expect(screen.getByLabelText("Chat, Explain, Takeaways (main model)")).toHaveValue(""); }); + + it("clicking a reasoning mode radio fires setLlamaCppReasoningMode", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + await user.click(screen.getByLabelText("Quick Mode (No CoT)")); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppReasoningMode", ["Quick"]]); + }); + + it("shows 'No model selected' when no chat model path is set", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppChatModelPath: "" }); + + expect(screen.getByText("No model selected")).toBeInTheDocument(); + }); + + it("shows the current active GGUF's basename, not its full path", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppChatModelPath: "C:\\models\\chat.gguf" }); + + expect(screen.getByText("chat.gguf")).toBeInTheDocument(); + }); + + it("System Scan fires scanLlamaCppSystem and disables while running", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push, { llamaCppScanStatus: "running" }); + + expect(screen.getByText("Scanning...")).toBeDisabled(); + + act(() => push({ ...snapshot, activeSection: "llama.cpp (local)", llamaCppScanStatus: "idle" })); + await user.click(screen.getByText("System Scan")); + + expect(intents).toContainEqual(["app-settings", "scanLlamaCppSystem", []]); + }); + + it("Scan Folder... fires pickLlamaCppScanFolder (no longer a deferred placeholder)", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + await user.click(screen.getByText("Scan Folder...")); + + expect(intents).toContainEqual(["app-settings", "pickLlamaCppScanFolder", []]); + }); + + it("Scan Folder... is disabled while a scan is running", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppScanStatus: "running" }); + + expect(screen.getByText("Scan Folder...")).toBeDisabled(); + }); + + it("the shared scanned-models datalist populates from llamaCppScannedModels", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppScannedModels: ["C:/models/a.gguf", "C:/models/b.gguf"] }); + + const datalist = document.getElementById("settings-llama-cpp-scanned-models"); + expect(datalist?.querySelectorAll("option")).toHaveLength(2); + }); + + it("the Scanned Chat Model select only appears once models are scanned, and selecting one fires setLlamaCppChatModelPath", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + expect(screen.queryByLabelText("Scanned Chat Model")).toBeNull(); + + await goToLlamaCpp(user, push, { llamaCppScannedModels: ["C:/models/a.gguf"] }); + await user.selectOptions(screen.getByLabelText("Scanned Chat Model"), "C:/models/a.gguf"); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppChatModelPath", ["C:/models/a.gguf"]]); + }); + + it("the Scanned Chat Model select shows the blank placeholder when the configured path isn't in the scanned list", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { + llamaCppScannedModels: ["C:/models/a.gguf", "C:/models/b.gguf"], + llamaCppChatModelPath: "C:/models/not-in-the-scanned-list.gguf", + }); + + expect(screen.getByLabelText("Scanned Chat Model")).toHaveValue(""); + }); + + it("Chat Model File shows 'No file selected' by default and Browse fires pickLlamaCppChatModelFile", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + expect(screen.getByText("No file selected")).toBeInTheDocument(); + const browseButtons = screen.getAllByRole("button", { name: "Browse..." }); + await user.click(browseButtons[0]); + + expect(intents).toContainEqual(["app-settings", "pickLlamaCppChatModelFile", []]); + }); + + it("Chat Model File shows the staged path once one is set", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppChatModelPath: "C:/models/chat.gguf" }); + + expect(screen.getByText("C:/models/chat.gguf")).toBeInTheDocument(); + }); + + it("Chat Naming File shows a reuse fallback by default and its own Browse fires pickLlamaCppTitleModelFile", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + expect(screen.getByText("Reusing the main chat model")).toBeInTheDocument(); + const browseButtons = screen.getAllByRole("button", { name: "Browse..." }); + await user.click(browseButtons[1]); + + expect(intents).toContainEqual(["app-settings", "pickLlamaCppTitleModelFile", []]); + }); + + it("Chat Format Override fires setLlamaCppChatFormat", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + await user.type(screen.getByLabelText("Chat Format Override"), "x"); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppChatFormat", ["x"]]); + }); + + it("Context Window fires setLlamaCppNCtx with the parsed number", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + const field = screen.getByLabelText("Context Window"); + await user.clear(field); + await user.type(field, "8192"); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppNCtx", [8192]]); + }); + + it("GPU Layers fires setLlamaCppNGpuLayers with the parsed number", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + const field = screen.getByLabelText("GPU Layers"); + await user.clear(field); + await user.type(field, "20"); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppNGpuLayers", [20]]); + }); + + it("CPU Threads fires setLlamaCppNThreads with the parsed number", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + const field = screen.getByLabelText("CPU Threads"); + await user.clear(field); + await user.type(field, "4"); + + expect(intents).toContainEqual(["app-settings", "setLlamaCppNThreads", [4]]); + }); + + it("an llamaCppNotice renders as an inline error", async () => { + const { user, push } = setup(); + await goToLlamaCpp(user, push, { llamaCppNotice: "Chat Model File cannot be empty." }); + + expect(screen.getByText("Chat Model File cannot be empty.")).toBeInTheDocument(); + }); + + it("Save Settings fires saveLlamaCppSettings", async () => { + const { user, push, intents } = setup(); + await goToLlamaCpp(user, push); + + await user.click(screen.getByText("Save Settings")); + + expect(intents).toContainEqual(["app-settings", "saveLlamaCppSettings", []]); + }); + + it("Ollama's own Scan Folder... fires pickOllamaScanFolder (retroactively un-deferred by R7.4c)", async () => { + const { user, push, intents } = setup(); + await goToOllama(user, push); + + await user.click(screen.getByText("Scan Folder...")); + + expect(intents).toContainEqual(["app-settings", "pickOllamaScanFolder", []]); + }); + + it("Ollama's Scan Folder... disables while a scan is running", async () => { + const { user, push } = setup(); + await goToOllama(user, push, { ollamaScanStatus: "running" }); + + expect(screen.getByText("Scan Folder...")).toBeDisabled(); + }); }); diff --git a/web_ui/src/app/chrome/SettingsDialog.tsx b/web_ui/src/app/chrome/SettingsDialog.tsx index 89493ef..cee9339 100644 --- a/web_ui/src/app/chrome/SettingsDialog.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.tsx @@ -5,22 +5,17 @@ import type { AppSettingsState } from "../../lib/bridge-core/generated/app-setti import { Dialog } from "../overlays/overlays"; /** - * The settings dialog (Qt-removal plan R2.5d, extended R7.4a, R7.4b) - - * settings island's SPA successor. General + Integrations + API-provider + - * Ollama pages are real. Llama.cpp remains deferred (R7.4c) - it needs a - * native GGUF file-picker capability that doesn't exist yet in - * graphlink_desktop.py - rendered here as a disabled placeholder with an - * explicit label rather than faking it, the same explicit-defer discipline - * as the app bar's disabled Save/provider-select. The Ollama page's own - * "Scan Folder..." button is ALSO deferred for the same reason, narrower - * than the whole page - see backend/settings.py's module docstring. + * The settings dialog (Qt-removal plan R2.5d, extended R7.4a-c) - settings + * island's SPA successor. Every page R2.5d originally deferred is real now: + * General, Integrations, API-provider, Ollama, and (R7.4c) Llama.cpp. The + * Ollama page's own "Scan Folder..." button, deferred alongside Llama.cpp + * pending a native folder-picker capability, is real too - see + * backend/native_dialogs.py's docstring for the mechanism. */ const SECTIONS = ["General", "Ollama (Local)", "Llama.cpp (Local)", "API Endpoint", "Integrations"] as const; type Section = (typeof SECTIONS)[number]; -const DEFERRED_SECTIONS = new Set
(["Llama.cpp (Local)"]); - // Mirrors graphlink_task_config.py's TASK_* constants - the same 5-slot set // backend/settings.py's _OLLAMA_TASK_KEYS uses (task_image_gen is absent: // Ollama has no image-generation path). @@ -33,6 +28,15 @@ const OLLAMA_TASK_LABELS: Record<(typeof OLLAMA_TASKS)[number], string> = { task_web_summarize: "Web Content Summarization Model", }; const OLLAMA_MODELS_DATALIST_ID = "settings-ollama-scanned-models"; +const LLAMA_CPP_MODELS_DATALIST_ID = "settings-llama-cpp-scanned-models"; + +// Llama.cpp has no per-task assignment concept like Ollama's OLLAMA_TASKS - +// just one global chat model path plus an optional title/naming override +// (api_provider.py's _get_llama_cpp_model_path: chart/web-validate/web- +// summarize all silently fall back to the chat model path). +function basename(path: string): string { + return path.split(/[\\/]/).pop() || path; +} const THEME_OPTIONS = [ { value: "dark", label: "Dark" }, @@ -103,6 +107,17 @@ const initialState: AppSettingsState = { ollamaScanStatus: "idle", ollamaPullStatus: "idle", ollamaNotice: "", + llamaCppReasoningMode: "Thinking", + llamaCppChatModelPath: "", + llamaCppTitleModelPath: "", + llamaCppChatFormat: "", + llamaCppNCtx: 4096, + llamaCppNGpuLayers: 0, + llamaCppNThreads: 0, + llamaCppScannedModels: [], + llamaCppScanSummary: "", + llamaCppScanStatus: "idle", + llamaCppNotice: "", }; function sectionKey(section: Section): string { @@ -539,10 +554,12 @@ function OllamaPage({ state, transport }: { state: AppSettingsState; transport: > {state.ollamaScanStatus === "running" ? "Scanning..." : "System Scan"} - {/* "Scan Folder..." is deliberately deferred - see this file's module - docstring - it needs the same native folder-picker capability - R7.4c builds for Llama.cpp's GGUF file browse. */} - @@ -597,13 +614,262 @@ function OllamaPage({ state, transport }: { state: AppSettingsState; transport: ); } -function DeferredPage({ section }: { section: Section }) { +// Llama.cpp's runtime tunables (chat format, n_ctx, n_gpu_layers, n_threads) +// persist on every change - no separate Save step, unlike the model-path +// fields below. A plain value={state.llamaCpp*} binding (no local buffer) +// breaks multi-character typing: the fake test transport (and, over a real +// WS round trip, ordinary network latency) never echoes a change back +// before the NEXT keystroke fires, so the input keeps resetting to the +// stale last-confirmed value between keystrokes instead of accumulating +// what the user is typing. A small local draft, reset only when the +// confirmed value changes to something the draft doesn't already reflect +// (the same pattern OllamaTaskField/ApiProviderPage already use), fixes it. +// +// Adversarial-review finding, deliberately NOT further chased: this reset +// compares the incoming value only against the last value WE'VE SEEN, not +// against every edit we've SENT - so a same-session echo of an EARLIER +// keystroke landing after a NEWER one would still stomp the newer draft. +// A fully correct fix needs echo-cancellation (a pending-edit counter) or a +// backend-tracked version stamp; both add real state-machine complexity +// for a narrow window that requires sustained fast typing during a slow +// round trip on a loopback WS connection. This is the same class of +// already-accepted risk OllamaTaskField's own explicit-model-ID input has +// (no draft buffer at all there) - not a new regression, just not fully +// closed either. +function LlamaCppNumberField({ + label, + value, + min, + max, + step, + placeholder, + onCommit, +}: { + label: string; + value: number; + min?: number; + max?: number; + step?: number; + placeholder?: string; + onCommit: (n: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + const [lastValue, setLastValue] = useState(value); + + if (value !== lastValue) { + setLastValue(value); + setDraft(String(value)); + } + + return ( + + ); +} + +function LlamaCppPage({ state, transport }: { state: AppSettingsState; transport: WsTransport }) { + const [draftChatFormat, setDraftChatFormat] = useState(state.llamaCppChatFormat); + const [lastChatFormat, setLastChatFormat] = useState(state.llamaCppChatFormat); + + if (state.llamaCppChatFormat !== lastChatFormat) { + setLastChatFormat(state.llamaCppChatFormat); + setDraftChatFormat(state.llamaCppChatFormat); + } + + // Chat/title model path fields are STAGED server-side (backend/settings.py's + // llama_staged_chat_path/llama_staged_title_path) - Browse/select updates + // the draft immediately (visible via state), but nothing persists until + // Save Settings, matching the API-provider page's own draft-then-commit + // shape rather than Ollama's per-field immediate persist. + const scannedChatValue = state.llamaCppScannedModels.includes(state.llamaCppChatModelPath) + ? state.llamaCppChatModelPath + : ""; + const scannedTitleValue = state.llamaCppScannedModels.includes(state.llamaCppTitleModelPath) + ? state.llamaCppTitleModelPath + : ""; + return ( -
-

{section} configuration lands in R7.4c.

-

- Needs a native GGUF file picker that doesn't exist yet in graphlink_desktop.py. +

+
+ Reasoning Mode + + +
+ +

+ Current Active GGUF: {state.llamaCppChatModelPath ? basename(state.llamaCppChatModelPath) : "No model selected"}

+ +
+ + +
+

{state.llamaCppScanSummary}

+ + + {state.llamaCppScannedModels.map((path) => ( + + + {state.llamaCppScannedModels.length > 0 && ( + + )} + +
+ Chat Model File + {state.llamaCppChatModelPath || "No file selected"} + +
+ + {state.llamaCppScannedModels.length > 0 && ( + + )} + +
+ Chat Naming File (optional) + {state.llamaCppTitleModelPath || "Reusing the main chat model"} + +
+ + + + transport.intent("app-settings", "setLlamaCppNCtx", [n])} + /> + transport.intent("app-settings", "setLlamaCppNGpuLayers", [n])} + /> + transport.intent("app-settings", "setLlamaCppNThreads", [n])} + /> + + {state.llamaCppNotice && ( +

+ {state.llamaCppNotice} +

+ )} + +
+ +
); } @@ -646,8 +912,8 @@ export function SettingsDialog({ transport }: { transport: WsTransport }) { ) : activeSection === "Ollama (Local)" ? ( - ) : DEFERRED_SECTIONS.has(activeSection) ? ( - + ) : activeSection === "Llama.cpp (Local)" ? ( + ) : ( activeSection )} diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json index f3b4b66..b24af7b 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json @@ -81,6 +81,42 @@ "githubTokenConfigured": { "type": "boolean" }, + "llamaCppChatFormat": { + "type": "string" + }, + "llamaCppChatModelPath": { + "type": "string" + }, + "llamaCppNCtx": { + "type": "integer" + }, + "llamaCppNGpuLayers": { + "type": "integer" + }, + "llamaCppNThreads": { + "type": "integer" + }, + "llamaCppNotice": { + "type": "string" + }, + "llamaCppReasoningMode": { + "type": "string" + }, + "llamaCppScanStatus": { + "type": "string" + }, + "llamaCppScanSummary": { + "type": "string" + }, + "llamaCppScannedModels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "llamaCppTitleModelPath": { + "type": "string" + }, "minCompatibleSchemaVersion": { "type": "integer" }, @@ -162,7 +198,18 @@ "ollamaScanSummary", "ollamaScanStatus", "ollamaPullStatus", - "ollamaNotice" + "ollamaNotice", + "llamaCppReasoningMode", + "llamaCppChatModelPath", + "llamaCppTitleModelPath", + "llamaCppChatFormat", + "llamaCppNCtx", + "llamaCppNGpuLayers", + "llamaCppNThreads", + "llamaCppScannedModels", + "llamaCppScanSummary", + "llamaCppScanStatus", + "llamaCppNotice" ], "title": "AppSettingsState", "type": "object" diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts index ed03be1..ddc0ef5 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts @@ -37,6 +37,17 @@ export interface AppSettingsState { ollamaScanStatus: string; ollamaPullStatus: string; ollamaNotice: string; + llamaCppReasoningMode: string; + llamaCppChatModelPath: string; + llamaCppTitleModelPath: string; + llamaCppChatFormat: string; + llamaCppNCtx: number; + llamaCppNGpuLayers: number; + llamaCppNThreads: number; + llamaCppScannedModels: string[]; + llamaCppScanSummary: string; + llamaCppScanStatus: string; + llamaCppNotice: string; minCompatibleSchemaVersion?: number | null; } @@ -225,6 +236,62 @@ function checkAppSettingsState(value: unknown, path: string, errors: string[]): if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaNotice: missing required field`); else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaNotice` + ": expected string"); } } + { + const fieldValue = value["llamaCppReasoningMode"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppReasoningMode: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppReasoningMode` + ": expected string"); } + } + { + const fieldValue = value["llamaCppChatModelPath"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppChatModelPath: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppChatModelPath` + ": expected string"); } + } + { + const fieldValue = value["llamaCppTitleModelPath"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppTitleModelPath: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppTitleModelPath` + ": expected string"); } + } + { + const fieldValue = value["llamaCppChatFormat"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppChatFormat: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppChatFormat` + ": expected string"); } + } + { + const fieldValue = value["llamaCppNCtx"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNCtx: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNCtx` + ": expected number"); } + } + { + const fieldValue = value["llamaCppNGpuLayers"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNGpuLayers: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNGpuLayers` + ": expected number"); } + } + { + const fieldValue = value["llamaCppNThreads"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNThreads: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNThreads` + ": expected number"); } + } + { + const fieldValue = value["llamaCppScannedModels"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScannedModels: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.llamaCppScannedModels` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.llamaCppScannedModels` + `[${i}]` + ": expected string"); }); } + } + { + const fieldValue = value["llamaCppScanSummary"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScanSummary: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppScanSummary` + ": expected string"); } + } + { + const fieldValue = value["llamaCppScanStatus"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScanStatus: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppScanStatus` + ": expected string"); } + } + { + const fieldValue = value["llamaCppNotice"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNotice: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppNotice` + ": expected string"); } + } { const fieldValue = value["minCompatibleSchemaVersion"]; if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); }