feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+153
View File
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
create_model_configuration,
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3") == "0.2.3"
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
assert _docs_version("0.2.3.dev0") == "latest"
assert _docs_version("0.2.3+editable") == "latest"
def test_settings_payload_includes_versioned_docs(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.settings_api.__version__", "0.2.3")
payload = settings_payload()
assert payload["docs"] == {
"version": "0.2.3",
"base_url": "https://nanobot.wiki/docs/0.2.3",
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
"latest_url": "https://nanobot.wiki/docs/latest",
}
def test_settings_payload_includes_relocated_capabilities(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.api.port = 9910
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
payload = settings_payload()
assert payload["api"]["port"] == 9910
assert payload["api"]["api_key_hint"] is None
assert payload["observability"]["provider"] == "langfuse"
assert payload["observability"]["configured"] is True
def test_update_api_settings_requires_key_for_network_access(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({
"host": ["0.0.0.0"],
"port": ["9900"],
"api_key": ["secret-token"],
})
saved = load_config(config_path)
assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900
assert saved.api.api_key == "secret-token"
assert payload["api"]["api_key_hint"]
def test_update_api_settings_requires_key_for_specific_network_interface(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
def test_update_api_settings_allows_alternate_loopback_without_key(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
assert load_config(config_path).api.host == "127.0.0.2"
def _dynamic_provider_config(
*,
api_base: str = DYNAMIC_PROVIDER_API_BASE,
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
assert [(row["name"], row["label"]) for row in opencode_rows] == [
("opencode", "OpenCode Zen"),
("opencode_go", "OpenCode Go"),
]
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": {
"defaults": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
}
},
})
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
assert len(zen_rows) == 1
assert zen_rows[0]["name"] == "opencode_zen"
assert zen_rows[0]["configured"] is True
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin"
assert payload["model_count"] == 7
assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex",
"context_window": 372000,
}
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1044,6 +1196,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("skywork")) == "official"
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
def test_create_model_configuration_accepts_configured_oauth_provider(