From 4c8696605541dbf22a7ad3c2e4cd06a9a7c977fd Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 12:12:05 -0700 Subject: [PATCH] Offer online play before local setup and require experimental model acknowledgment --- README.md | 18 ++++- acknowledge_local_model.py | 30 ++++++++ docs/local-launch-and-consent.md | 53 +++++++++++++ install_neverendingquest_windows.bat | 49 ++++++++---- launch_game.bat | 32 ++++++++ model_config.py | 53 ++++++++++++- utils/openai_client.py | 5 +- web/frontend/e2e/ember-providers.spec.ts | 14 ++++ web/frontend/e2e/launch_contract_test.py | 49 ++++++++++++ web/frontend/e2e/provider_contract_test.py | 75 ++++++++++++++++++- .../settings/LocalProviderPanel.tsx | 13 +++- .../components/settings/SettingsMenu.test.tsx | 27 +++++++ .../components/settings/localModelConsent.ts | 18 +++++ web/frontend/src/contract/events.ts | 8 +- web/templates/game_interface.html | 40 +++++++--- web/web_interface.py | 37 ++++++--- 16 files changed, 469 insertions(+), 52 deletions(-) create mode 100644 acknowledge_local_model.py create mode 100644 docs/local-launch-and-consent.md create mode 100644 launch_game.bat create mode 100644 web/frontend/e2e/launch_contract_test.py create mode 100644 web/frontend/src/components/settings/localModelConsent.ts diff --git a/README.md b/README.md index f216288d..c94cbfc0 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,22 @@ environment activated: - **React player (default):** `python run_web.py` - **Legacy player (explicit opt-in):** `python run_web.py --ui legacy` -For Windows installer users, run `launch_game.bat` for React, or -`launch_game.bat --ui legacy` for legacy. +**Want to play without a local installation?** [Explore NeverEndingQuest online](https://eternaltavern.com/neverendingquest/). +Hosted alpha access is limited; the website describes current availability. + +On Windows, `launch_game.bat` now ships with the repository. Double-click it to +choose online play or local play. The Windows installer offers the same choice +before checking or installing dependencies. For an installed local game, explicit +arguments skip the choice: `launch_game.bat --ui react` or `launch_game.bat --ui legacy`. +Automated launches can set `NEQ_LOCAL_ONLY=1` or keep using `python run_web.py`. + +Local/custom models are experimental. Settings requires acknowledgment that +capability and safeguards vary, outputs may be inappropriate or unreliable, and +game rules may be misunderstood. Existing local configurations remain selected, +but local calls wait for acknowledgment. For headless setup, review the warning +with `python acknowledge_local_model.py`; automation can explicitly accept the +displayed version with `python acknowledge_local_model.py --accept local-model-alpha-1`. +Only the disclaimer version and acceptance time are added to local settings. Both interfaces open in your browser. React is selected automatically; there is no interface-selection prompt and no automatic fallback to legacy. If React needs diff --git a/acknowledge_local_model.py b/acknowledge_local_model.py new file mode 100644 index 00000000..b810bbc4 --- /dev/null +++ b/acknowledge_local_model.py @@ -0,0 +1,30 @@ +"""Review/record local-model consent without starting a game or making AI calls.""" +import argparse +import sys + + +def main(): + import model_config + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--accept", metavar="VERSION", help="Explicit acknowledgment for automated setup") + args = parser.parse_args() + print(model_config.LOCAL_MODEL_DISCLAIMER) + print("Disclaimer version:", model_config.LOCAL_MODEL_CONSENT_VERSION) + if args.accept is None: + if not sys.stdin.isatty(): + print("No acknowledgment recorded. Review the disclaimer, then use --accept VERSION.") + return 1 + if input("Type ACCEPT to acknowledge, or press Enter to cancel: ").strip() != "ACCEPT": + print("No acknowledgment recorded.") + return 1 + try: + model_config.acknowledge_local_model(args.accept or model_config.LOCAL_MODEL_CONSENT_VERSION) + except ValueError as exc: + print(str(exc)) + return 1 + print("Acknowledgment saved locally. Your selected provider has not been changed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/local-launch-and-consent.md b/docs/local-launch-and-consent.md new file mode 100644 index 00000000..784efb60 --- /dev/null +++ b/docs/local-launch-and-consent.md @@ -0,0 +1,53 @@ +# Online choice and experimental local models + +`launch_game.bat` ships with the repository. With no arguments it offers online +play, local play, or exit. The online choice opens the public product page and +does not check Python/Git/Node, install dependencies, or create settings. The +standalone Windows installer presents the same choice before installation. +Hosted alpha access is limited; this release does not advertise open BYOK access. + +Selecting local play uses the installation's `venv\Scripts\python.exe`. If that +environment is absent, the launcher directs the user to the installer/manual +setup. An installer inside a checkout uses that checkout rather than nesting +another clone, and keeps the tracked launcher instead of generating an older one. +React remains the local default; explicit arguments, including `--ui legacy`, +are forwarded. Any arguments or `NEQ_LOCAL_ONLY=1` skip the online/local menu. +`python run_web.py` and headless commands retain their noninteractive behavior. + +## Acknowledgment + +React and legacy settings show the experimental warning and request explicit +confirmation before selecting a local provider or first saving/testing its +endpoint. The backend checks the current disclaimer version before persistence +or probes. The local client factory also checks it before constructing a client, +so a saved pre-upgrade local configuration cannot bypass acknowledgment. + +Existing local selection stays visible and does not fall back to paid OpenAI. +An unacknowledged local call fails with instructions to use Settings or +`python acknowledge_local_model.py`. This CLI displays the full warning and +requires `ACCEPT` interactively. It never reads consent from redirected input. +For deliberate automation, after reviewing the warning use: + +``` +python acknowledge_local_model.py --accept local-model-alpha-1 +``` + +This records only `local_model_consent.version` and an integer UTC acceptance +timestamp in the installation's ignored `user_settings.json`. It does not change +the selected provider, collect prompts, or make an AI call. An older version does +not count after a disclaimer update. Consent is installation-wide, like the +existing local provider configuration, not a separate per-player account system. +Someone who owns and edits this open-source installation can alter its local +settings; this is an acknowledgment mechanism, not tamper-proof attestation. + +The hosted edition still excludes local/custom endpoints. Consent never grants +permission to use an arbitrary URL from a hosted world. + +## Checks + +Python contract tests exercise actual handler bodies, Socket.IO event decorators, +temporary settings and synthetic/local provider stubs. Windows tests execute +online/exit branches without a browser/install and verify argument forwarding +and exit codes through a temporary venv. Frontend tests exercise decline/accept, +save/probe gating and prior settings behavior; browser checks verify selection +and reload behavior. No paid provider requests are used in this validation. diff --git a/install_neverendingquest_windows.bat b/install_neverendingquest_windows.bat index 8f0ffc71..81eb04e4 100644 --- a/install_neverendingquest_windows.bat +++ b/install_neverendingquest_windows.bat @@ -4,8 +4,24 @@ REM NeverEndingQuest - Windows Installation Script with Virtual Environment REM Automated installer for non-technical users REM ============================================================================ -SETLOCAL EnableDelayedExpansion - +SETLOCAL EnableDelayedExpansion + +REM This choice must precede Python/Git/Node checks and any installation. +if "%NEQ_LOCAL_ONLY%"=="1" goto LOCAL_SETUP +echo. +echo Play online without installing NeverEndingQuest on this computer. +echo Hosted alpha access is limited; see the website for current availability. +echo O: Explore online play L: Continue with local installation X: Exit +choice /C OLX /N /M "Choose O, L, or X: " +if errorlevel 3 exit /b 0 +if errorlevel 2 goto LOCAL_SETUP +if errorlevel 1 ( + start "" "https://eternaltavern.com/neverendingquest/" + exit /b 0 +) +exit /b 1 + +:LOCAL_SETUP echo. echo ======================================== echo NeverEndingQuest Installation @@ -61,7 +77,12 @@ git --version echo [OK] Git found! echo. -REM Step 3: Clone repository +REM Step 3: Clone repository +REM A downloaded checkout already contains the game; do not clone inside it. +if exist "%~dp0run_web.py" ( + cd /d "%~dp0" + goto REPOSITORY_READY +) echo Step 3: Cloning repository... echo Installing to: %CD% echo. @@ -86,9 +107,10 @@ if exist "NeverEndingQuest" ( echo [OK] Repository cloned successfully! ) -cd NeverEndingQuest - -REM Step 4: Create virtual environment +cd NeverEndingQuest + +:REPOSITORY_READY +REM Step 4: Create virtual environment echo. echo Step 4: Creating Python virtual environment... if not exist "venv" ( @@ -201,14 +223,13 @@ REM Step 7: Create desktop shortcut and launch script echo. echo Step 7: Creating launch scripts... -REM Create launch_game.bat in the repo folder -echo @echo off > launch_game.bat -echo cd /d "%%~dp0" >> launch_game.bat -echo call venv\Scripts\activate.bat >> launch_game.bat -echo python run_web.py %%* >> launch_game.bat -echo pause >> launch_game.bat - -echo [OK] Created launch_game.bat +REM launch_game.bat ships with the checkout and includes the online/local choice. +REM Do not replace it with a generated launcher that loses that choice. +if not exist launch_game.bat ( + echo [ERROR] This checkout is missing launch_game.bat. Download the current release. + exit /b 1 +) +echo [OK] Using the repository's launch_game.bat REM Create desktop shortcut set SCRIPT_DIR=%CD% diff --git a/launch_game.bat b/launch_game.bat new file mode 100644 index 00000000..44233e16 --- /dev/null +++ b/launch_game.bat @@ -0,0 +1,32 @@ +@echo off +setlocal +cd /d "%~dp0" +REM Arguments or NEQ_LOCAL_ONLY=1 preserve scripted local startup without a menu. +if not "%~1"=="" goto LOCAL +if "%NEQ_LOCAL_ONLY%"=="1" goto LOCAL +echo. +echo NeverEndingQuest - choose how to play +echo O: Explore online play - no local installation required +echo Hosted alpha access is limited. See the website for current availability. +echo L: Continue with local play on this computer +echo X: Exit +choice /C OLX /N /M "Choose O, L, or X: " +if errorlevel 3 exit /b 0 +if errorlevel 2 goto LOCAL +if errorlevel 1 ( + start "" "https://eternaltavern.com/neverendingquest/" + exit /b 0 +) +exit /b 1 + +:LOCAL +if not exist "venv\Scripts\python.exe" goto SETUP_REQUIRED +"venv\Scripts\python.exe" run_web.py %* +exit /b %errorlevel% + +:SETUP_REQUIRED +echo Local setup has not been completed in this folder. +echo Run install_neverendingquest_windows.bat and choose local setup. +echo For manual or automated installations, use your Python environment: +echo python run_web.py +exit /b 1 diff --git a/model_config.py b/model_config.py index 8ef02533..3053bade 100644 --- a/model_config.py +++ b/model_config.py @@ -1138,6 +1138,12 @@ def _convert_prop(prop): def set_provider(provider_name): + if provider_name == "lmstudio": + require_local_model_consent() + _apply_provider(provider_name) + + +def _apply_provider(provider_name): """Switch all model variables to the specified provider's models. Updates both model_config globals AND config module globals (since @@ -1306,6 +1312,8 @@ def _forget_credential(name): def persist_provider(provider_name): """Save provider choice to disk so it survives restarts.""" + if provider_name == "lmstudio": + require_local_model_consent() settings = _load_user_settings() settings["model_provider"] = provider_name _save_user_settings(settings) @@ -1322,12 +1330,54 @@ def load_persisted_provider(): settings = _load_user_settings() provider = settings.get("model_provider", "openai") if provider in PROVIDER_MODELS: - set_provider(provider) + # Keep an existing local selection visible for acknowledgment. Never + # silently fall back to a paid cloud provider. The client factory blocks + # local calls until consent is current, including headless startup. + _apply_provider(provider) DEFAULT_LOCAL_BASE_URL = "http://localhost:1234/v1" DEFAULT_LOCAL_API_KEY = "not-needed" +LOCAL_MODEL_CONSENT_VERSION = "local-model-alpha-1" +LOCAL_MODEL_DISCLAIMER = ( + "Local models vary widely in capability and safeguards. They may produce " + "inappropriate or unreliable content, misunderstand game rules, or behave " + "unpredictably. This integration is experimental and still in development. " + "I understand these limitations and want to enable a local model." +) + + +def local_model_consent_current(): + consent = _load_user_settings().get("local_model_consent") + return (isinstance(consent, dict) + and consent.get("version") == LOCAL_MODEL_CONSENT_VERSION + and type(consent.get("accepted_at")) is int + and consent["accepted_at"] > 0) + + +def require_local_model_consent(): + if not local_model_consent_current(): + raise ValueError("Local models are experimental. Accept the disclaimer in Settings " + "before using them, or run python acknowledge_local_model.py for headless setup.") + + +def acknowledge_local_model(version): + if version != LOCAL_MODEL_CONSENT_VERSION: + raise ValueError("Please review and accept the current local-model disclaimer.") + import time + settings = _load_user_settings() + settings["local_model_consent"] = {"version": version, "accepted_at": int(time.time())} + _save_user_settings(settings) + + +def accept_local_model_request(data): + """Explicit versioned acknowledgment, or an already accepted installation.""" + version = data.get("local_model_consent_version") + if version is not None: + acknowledge_local_model(version) + require_local_model_consent() + def get_local_endpoint(): """Return the Local/Custom endpoint config without exposing stored secrets. @@ -1352,6 +1402,7 @@ def persist_local_endpoint(base_url="", api_key=None, model=""): it. base_url/model are always written (blank base_url falls back to the default; blank model means keep each callsite's own model). """ + require_local_model_consent() s = _migrate_plaintext_secrets(_load_user_settings()) s["local_base_url"] = (base_url or "").strip() s["local_model"] = (model or "").strip() diff --git a/utils/openai_client.py b/utils/openai_client.py index e8eb91df..0f7de135 100644 --- a/utils/openai_client.py +++ b/utils/openai_client.py @@ -32,8 +32,9 @@ def get_openai_client(provider=None): # remote host). Endpoint is read live from user_settings.json so a web-UI # change applies on the next request with no restart. Defaults preserve # the original LM Studio localhost:1234 behavior. (Issue #120) - import model_config - ep = model_config.get_local_endpoint() + import model_config + model_config.require_local_model_consent() + ep = model_config.get_local_endpoint() return OpenAI( base_url=ep["base_url"], api_key=ep["api_key"] or "not-needed" diff --git a/web/frontend/e2e/ember-providers.spec.ts b/web/frontend/e2e/ember-providers.spec.ts index b46c11b6..e775b4e6 100644 --- a/web/frontend/e2e/ember-providers.spec.ts +++ b/web/frontend/e2e/ember-providers.spec.ts @@ -4,6 +4,7 @@ import { test, expect } from '@playwright/test' test.describe.configure({ mode: 'serial' }) let lateResultObserved: Promise test.beforeEach(async ({ request, page }) => { + page.on('dialog', dialog => dialog.accept()) lateResultObserved = new Promise(resolve => page.on('websocket', socket => socket.on('framereceived', frame => { if (String(frame.payload).includes('Delayed closed-panel result.')) resolve() }))) @@ -14,6 +15,19 @@ test.beforeEach(async ({ request, page }) => { await expect(page.getByLabel('Provider', { exact: true })).toBeEnabled() }) +test('declining the local disclaimer leaves the confirmed provider unchanged', async ({ page }) => { + page.removeAllListeners('dialog') + page.once('dialog', async dialog => { + expect(dialog.message()).toContain('inappropriate or unreliable') + await dialog.dismiss() + }) + await page.getByLabel('Provider', { exact: true }).selectOption('lmstudio') + await expect(page.getByLabel('Provider', { exact: true })).toHaveValue('legacy') + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect(page.getByLabel('Provider', { exact: true })).toHaveValue('legacy') +}) + test('each provider is confirmed and survives browser reload', async ({ page }) => { for (const provider of ['openai', 'gemini', 'lmstudio', 'legacy']) { await page.getByLabel('Provider', { exact: true }).selectOption(provider) diff --git a/web/frontend/e2e/launch_contract_test.py b/web/frontend/e2e/launch_contract_test.py new file mode 100644 index 00000000..e903207f --- /dev/null +++ b/web/frontend/e2e/launch_contract_test.py @@ -0,0 +1,49 @@ +"""Run Windows launch branches in a disposable folder, with no installs/browser.""" +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +REPO = Path(__file__).resolve().parents[3] +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows entry points") + + +@pytest.mark.parametrize("script", ["launch_game.bat", "install_neverendingquest_windows.bat"]) +@pytest.mark.parametrize("selection, expected", [("O", "ONLINE_SELECTED"), ("X", None)]) +def test_online_or_exit_never_reaches_local_setup(tmp_path, script, selection, expected): + text = (REPO / script).read_text() + # Replace only the external browser launch with an observable sentinel. + # Everything after the local label is a tripwire: not even Python is needed. + text = text.replace('start "" "https://eternaltavern.com/neverendingquest/"', 'echo ONLINE_SELECTED') + label = ':LOCAL_SETUP\n' if script.startswith('install') else ':LOCAL\n' + text = text.split(label)[0] + label + 'echo LOCAL_TRIPWIRE\nexit /b 99\n' + target = tmp_path / script + target.write_text(text) + env = dict(os.environ) + env.pop('NEQ_LOCAL_ONLY', None) + result = subprocess.run(['cmd', '/d', '/c', str(target)], input=selection, text=True, + capture_output=True, env=env, timeout=10) + assert result.returncode == 0, result.stdout + result.stderr + assert 'LOCAL_TRIPWIRE' not in result.stdout + assert ('ONLINE_SELECTED' in result.stdout) == bool(expected) + + +def test_explicit_local_arguments_are_forwarded_and_exit_status_preserved(tmp_path): + # Use a genuine Python venv executable; the only program it runs is a stub. + subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(tmp_path / 'venv')], check=True) + (tmp_path / 'launch_game.bat').write_text((REPO / 'launch_game.bat').read_text()) + (tmp_path / 'run_web.py').write_text('import sys\nprint(repr(sys.argv[1:]))\nraise SystemExit(7)\n') + result = subprocess.run(['cmd', '/d', '/c', str(tmp_path / 'launch_game.bat'), '--ui', 'legacy'], + text=True, capture_output=True, timeout=10) + assert result.returncode == 7 + assert "['--ui', 'legacy']" in result.stdout + assert 'choose how to play' not in result.stdout + + +def test_installer_uses_checkout_launcher_and_local_checkout_label(): + text = (REPO / 'install_neverendingquest_windows.bat').read_text() + assert ':REPOSITORY_READY\n' in text + assert 'echo @echo off > launch_game.bat' not in text + assert text.index('choice /C OLX') < text.index('python --version') diff --git a/web/frontend/e2e/provider_contract_test.py b/web/frontend/e2e/provider_contract_test.py index 93613218..eda0876d 100644 --- a/web/frontend/e2e/provider_contract_test.py +++ b/web/frontend/e2e/provider_contract_test.py @@ -19,6 +19,71 @@ REPO = Path(__file__).resolve().parents[3] +@pytest.mark.parametrize("version", [None, "old-version", True]) +def test_local_activation_requires_current_explicit_consent(provider_runtime, version): + rt = provider_runtime + payload = {"provider": "lmstudio"} + if version is not None: + payload["local_model_consent_version"] = version + rt.handlers["handle_set_provider"](payload) + assert rt.events[-1][0] == "error" + assert rt.module.get_provider() == "openai" + assert not rt.module.local_model_consent_current() + with pytest.raises(ValueError, match="disclaimer"): + rt.module.set_provider("lmstudio") + + +def test_existing_local_installation_stays_local_but_calls_wait_for_consent(provider_runtime, monkeypatch): + rt = provider_runtime + rt.module._save_user_settings({"model_provider": "lmstudio"}) + module = rt.reload() + assert module.get_provider() == "lmstudio" + with pytest.raises(ValueError, match="disclaimer"): + module.require_local_model_consent() + import utils.openai_client as factory + monkeypatch.setattr(factory, "OpenAI", lambda **kwargs: pytest.fail("Unacknowledged provider was contacted")) + with pytest.raises(ValueError, match="disclaimer"): + factory.get_openai_client("lmstudio") + module.acknowledge_local_model(module.LOCAL_MODEL_CONSENT_VERSION) + assert rt.reload().local_model_consent_current() + recorded = json.loads((rt.root / "user_settings.json").read_text()) + assert set(recorded["local_model_consent"]) == {"version", "accepted_at"} + assert type(recorded["local_model_consent"]["accepted_at"]) is int + + +def test_local_save_and_probe_cannot_bypass_consent(provider_runtime): + rt = provider_runtime + rt.handlers["OpenAI"] = lambda **kwargs: pytest.fail("Probe bypassed consent") + payload = {"base_url": "http://fixture.invalid/v1", "api_key": "synthetic", "model": "test"} + rt.handlers["handle_set_local_endpoint"](payload) + assert rt.events[-1][0] == "error" and not rt.secrets + rt.handlers["handle_test_local_endpoint"](payload) + assert rt.events[-1][1]["ok"] is False + with pytest.raises(ValueError, match="disclaimer"): + rt.module.persist_local_endpoint(**payload) + + +def test_old_consent_version_requires_new_acknowledgment(provider_runtime): + rt = provider_runtime + rt.module._save_user_settings({"model_provider": "lmstudio", "local_model_consent": {"version": "old", "accepted_at": 1}}) + assert not rt.reload().local_model_consent_current() + rt.handlers["handle_set_provider"]({"provider": "lmstudio", "local_model_consent_version": rt.module.LOCAL_MODEL_CONSENT_VERSION}) + assert rt.events[-1] == ("provider_changed", {"provider": "lmstudio"}) + + +def test_headless_acknowledgment_is_explicit_and_never_reads_from_a_pipe(provider_runtime, monkeypatch): + import acknowledge_local_model + rt = provider_runtime + monkeypatch.setattr(sys, "argv", ["acknowledge_local_model.py"]) + monkeypatch.setattr(sys, "stdin", types.SimpleNamespace(isatty=lambda: False)) + assert acknowledge_local_model.main() == 1 + assert not rt.module.local_model_consent_current() + monkeypatch.setattr(sys, "argv", ["acknowledge_local_model.py", "--accept", rt.module.LOCAL_MODEL_CONSENT_VERSION]) + assert acknowledge_local_model.main() == 0 + assert rt.module.local_model_consent_current() + assert rt.module.get_provider() == "openai" + + @pytest.fixture def provider_runtime(tmp_path, monkeypatch): # Import-time settings/key migration must never see the developer's profile. @@ -70,7 +135,7 @@ def reload_config(): @pytest.mark.parametrize("provider", ["legacy", "openai", "gemini", "lmstudio"]) def test_provider_round_trip_survives_fresh_module_import(provider_runtime, provider): rt = provider_runtime - rt.handlers["handle_set_provider"]({"provider": provider}) + rt.handlers["handle_set_provider"]({"provider": provider, "local_model_consent_version": rt.module.LOCAL_MODEL_CONSENT_VERSION}) assert rt.events[-1] == ("provider_changed", {"provider": provider}) assert rt.module.get_provider() == provider assert rt.reload().get_provider() == provider @@ -100,6 +165,7 @@ def test_malformed_provider_does_not_change_live_or_persisted_selection(provider def test_endpoint_preserves_blank_key_without_echoing_secret(provider_runtime): rt = provider_runtime + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) synthetic = "fixture-only-local-key" rt.handlers["handle_set_local_endpoint"]({"base_url": "http://127.0.0.1:9999/v1", "model": "test-model", "api_key": synthetic}) rt.handlers["handle_set_local_endpoint"]({"base_url": "http://127.0.0.1:9998/v1", "model": "next-model", "api_key": ""}) @@ -125,6 +191,7 @@ def test_key_set_and_blank_submit_report_status_only(provider_runtime, provider) def test_probe_uses_posted_values_and_reports_model_mismatch(provider_runtime): rt = provider_runtime + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) calls = [] def client(**kwargs): @@ -141,6 +208,7 @@ def client(**kwargs): def test_probe_rejects_empty_url_without_network(provider_runtime): rt = provider_runtime + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) rt.handlers["handle_test_local_endpoint"]({"base_url": ""}) assert rt.events[-1] == ("local_endpoint_test_result", {"ok": False, "detail": "Base URL is required."}) @@ -221,6 +289,7 @@ def client(**kwargs): ]) def test_real_sdk_probe_success_and_fallback(provider_runtime, local_provider_stub, mode, model, detail): rt, stub = provider_runtime, local_provider_stub + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) stub.mode = mode before = rt.module.get_local_endpoint() rt.handlers["handle_test_local_endpoint"]({"base_url": stub.url, "model": model, "api_key": "fixture-only-key"}) @@ -239,6 +308,7 @@ def test_real_sdk_probe_success_and_fallback(provider_runtime, local_provider_st @pytest.mark.parametrize("model", ["", "fixture-model"]) def test_real_sdk_authentication_failure_is_not_success(provider_runtime, local_provider_stub, model): rt, stub = provider_runtime, local_provider_stub + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) stub.mode = "auth" rt.handlers["handle_test_local_endpoint"]({"base_url": stub.url, "model": model, "api_key": "fixture-only-key"}) assert rt.events[-1][1]["ok"] is False @@ -253,6 +323,7 @@ def test_real_sdk_transport_failure_can_be_retried(provider_runtime, failure): from openai import OpenAI rt = provider_runtime + rt.module.acknowledge_local_model(rt.module.LOCAL_MODEL_CONSENT_VERSION) failing = True def transport(request): @@ -358,7 +429,7 @@ def test_production_event_decorators_route_through_flask_socketio(provider_runti client = socketio.test_client(app) try: for provider in ("legacy", "openai", "gemini", "lmstudio"): - client.emit("set_model_provider", {"provider": provider}) + client.emit("set_model_provider", {"provider": provider, "local_model_consent_version": rt.module.LOCAL_MODEL_CONSENT_VERSION}) packet = client.get_received()[-1] assert packet["name"] == "provider_changed" assert packet["args"] == [{"provider": provider}] diff --git a/web/frontend/src/components/settings/LocalProviderPanel.tsx b/web/frontend/src/components/settings/LocalProviderPanel.tsx index 7cc51781..761c4958 100644 --- a/web/frontend/src/components/settings/LocalProviderPanel.tsx +++ b/web/frontend/src/components/settings/LocalProviderPanel.tsx @@ -1,3 +1,4 @@ +import { LOCAL_CONSENT_VERSION, useLocalModelConsent } from './localModelConsent' import { useEffect, useRef, useState } from 'react' import { emitC } from '../../services/socket' import { useDialogs } from '../../stores' @@ -5,6 +6,7 @@ import type { ClientEvents } from '../../contract/events' type ProviderValue = ClientEvents['set_model_provider']['provider'] + const PROVIDER_OPTIONS: Array<{ value: ProviderValue; label: string }> = [ { value: 'legacy', label: 'Legacy (GPT-4.1) - Stable baseline' }, { value: 'openai', label: 'OpenAI (GPT-5.x) - Next-gen, tested per task' }, @@ -38,6 +40,7 @@ function isProviderValue(value: string): value is ProviderValue { function LocalProviderPanelBody() { const settings = useDialogs((s) => s.settings) + const confirmLocalModel = useLocalModelConsent() // Sync all provider state from the server when the panel mounts. useEffect(() => { @@ -66,8 +69,9 @@ function LocalProviderPanelBody() { const changeProvider = (value: string) => { if (!isProviderValue(value)) return + if (value === 'lmstudio' && !confirmLocalModel()) return setPendingProvider(value) - emitC('set_model_provider', { provider: value }) + emitC('set_model_provider', { provider: value, ...(value === 'lmstudio' ? { local_model_consent_version: LOCAL_CONSENT_VERSION } : {}) }) } // ---- local endpoint form (blank api_key keeps the stored key) ---- @@ -83,7 +87,8 @@ function LocalProviderPanelBody() { }, [settings.localEndpoint]) const saveLocalEndpoint = () => { - emitC('set_local_endpoint', { base_url: baseUrl, model, api_key: localApiKey }) + if (!confirmLocalModel()) return + emitC('set_local_endpoint', { base_url: baseUrl, model, api_key: localApiKey, local_model_consent_version: LOCAL_CONSENT_VERSION }) setLocalApiKey('') // never keep the secret in the DOM; blank keeps the stored key } @@ -109,6 +114,7 @@ function LocalProviderPanelBody() { }, [settings.endpointTest]) const runEndpointTest = () => { + if (!confirmLocalModel()) return if (!baseUrl.trim()) { setTestStatus({ text: 'Please enter a Server URL first.', tone: 'fail' }) return @@ -116,7 +122,7 @@ function LocalProviderPanelBody() { setTesting(true) awaitingTest.current = true setTestStatus({ text: 'Testing connection...', tone: 'pending' }) - emitC('test_local_endpoint', { base_url: baseUrl, model, api_key: localApiKey }) + emitC('test_local_endpoint', { base_url: baseUrl, model, api_key: localApiKey, local_model_consent_version: LOCAL_CONSENT_VERSION }) } // ---- API keys (blank submit keeps the stored key server-side) ---- @@ -165,6 +171,7 @@ function LocalProviderPanelBody() { {provider === 'lmstudio' && (
Local / Custom Server
+

Experimental: model capability and safeguards vary. Local models may produce inappropriate or unreliable content and break game rules. This integration is still in development.

Point at any OpenAI-compatible server (LM Studio, Ollama, vLLM, OpenRouter, or a remote host). Leave blank to use the default local server at localhost:1234. diff --git a/web/frontend/src/components/settings/SettingsMenu.test.tsx b/web/frontend/src/components/settings/SettingsMenu.test.tsx index 3dfdc853..33c76018 100644 --- a/web/frontend/src/components/settings/SettingsMenu.test.tsx +++ b/web/frontend/src/components/settings/SettingsMenu.test.tsx @@ -19,6 +19,7 @@ beforeEach(() => { useSettings.setState(initialSettings, true) useSession.setState(initialSession, true) vi.clearAllMocks() + vi.stubGlobal('confirm', vi.fn(() => true)) }) afterEach(() => { @@ -28,6 +29,32 @@ afterEach(() => { }) describe('provider and voice settings behavior', () => { + it('does not activate a local provider when its disclaimer is declined', () => { + vi.stubGlobal('confirm', vi.fn(() => false)) + useDialogs.getState().setProvider({ provider: 'openai' }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'lmstudio' } }) + expect(emitC).not.toHaveBeenCalledWith('set_model_provider', expect.anything()) + expect((screen.getByLabelText('Provider') as HTMLSelectElement).value).toBe('openai') + }) + it('sends explicit versioned acknowledgment when local selection is accepted', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'lmstudio' } }) + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('inappropriate or unreliable')) + expect(emitC).toHaveBeenCalledWith('set_model_provider', { provider: 'lmstudio', local_model_consent_version: 'local-model-alpha-1' }) + }) + it('blocks local saving and probing on an old installation until acknowledged', () => { + vi.stubGlobal('confirm', vi.fn(() => false)) + useDialogs.getState().setProvider({ provider: 'lmstudio' }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })) + expect(emitC).not.toHaveBeenCalledWith('set_local_endpoint', expect.anything()) + expect(emitC).not.toHaveBeenCalledWith('test_local_endpoint', expect.anything()) + }) it('times out an unanswered endpoint probe and accepts a successful retry', () => { vi.useFakeTimers() useDialogs.getState().setProvider({ provider: 'lmstudio' }) diff --git a/web/frontend/src/components/settings/localModelConsent.ts b/web/frontend/src/components/settings/localModelConsent.ts new file mode 100644 index 00000000..f63d8aae --- /dev/null +++ b/web/frontend/src/components/settings/localModelConsent.ts @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { useDialogs } from '../../stores' + +export const LOCAL_CONSENT_VERSION = 'local-model-alpha-1' +const LOCAL_DISCLAIMER = 'Local models vary widely in capability and safeguards. They may produce inappropriate or unreliable content, misunderstand game rules, or behave unpredictably. This integration is experimental and still in development. I understand these limitations and want to enable a local model.' + + +export function useLocalModelConsent() { + const settings = useDialogs((s) => s.settings) + const [localAcknowledged, setLocalAcknowledged] = useState(false) + const confirmLocalModel = () => { + const accepted = localAcknowledged || (settings.localEndpoint?.consent_version === LOCAL_CONSENT_VERSION && settings.localEndpoint.consent_accepted) + if (!accepted && !window.confirm(LOCAL_DISCLAIMER)) return false + setLocalAcknowledged(true) + return true + } + return confirmLocalModel +} diff --git a/web/frontend/src/contract/events.ts b/web/frontend/src/contract/events.ts index 1d3ac4ee..f40886bd 100644 --- a/web/frontend/src/contract/events.ts +++ b/web/frontend/src/contract/events.ts @@ -85,14 +85,14 @@ export interface ClientEvents { request_module_list: undefined; // --- local-edition operator settings (hidden when VITE_EDITION=hosted) --- get_model_provider: undefined; - set_model_provider: { provider: 'legacy' | 'openai' | 'gemini' | 'lmstudio' }; + set_model_provider: { provider: 'legacy' | 'openai' | 'gemini' | 'lmstudio'; local_model_consent_version?: string }; get_local_endpoint: undefined; - set_local_endpoint: { base_url: string; api_key?: string; model: string }; + set_local_endpoint: { base_url: string; api_key?: string; model: string; local_model_consent_version?: string }; get_openai_key: undefined; set_openai_key: { api_key: string }; get_gemini_key: undefined; set_gemini_key: { api_key: string }; - test_local_endpoint: { base_url: string; api_key?: string; model?: string }; + test_local_endpoint: { base_url: string; api_key?: string; model?: string; local_model_consent_version?: string }; // --- operator/toolkit scope: NOT bound in the player app (toolkit page owns these) --- start_build: { module_name: string; narrative: string; num_areas: number; locations_per_area: number; per_area_locations?: number[] }; cancel_build: undefined; @@ -197,7 +197,7 @@ export interface ServerEvents { map_data_response: { data: MapDataPayload | null; error?: string; request_id?: string; revision?: number; server_instance_id?: string }; exit_acknowledged: { message: string }; provider_changed: { provider: string }; - local_endpoint_changed: { base_url: string; model: string; has_key: boolean }; + local_endpoint_changed: { base_url: string; model: string; has_key: boolean; consent_version?: string; consent_accepted?: boolean }; openai_key_status: { has_key: boolean }; gemini_key_status: { has_key: boolean }; local_endpoint_test_result: { ok: boolean; detail: string }; diff --git a/web/templates/game_interface.html b/web/templates/game_interface.html index 5f816eb7..15d9fdd7 100644 --- a/web/templates/game_interface.html +++ b/web/templates/game_interface.html @@ -4480,7 +4480,8 @@

NeverEndingQuest