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
+486 -4
View File
@@ -72,6 +72,34 @@ class _FakeTelegram(BaseChannel):
pass
class _FakeFeishu(BaseChannel):
name = "feishu"
display_name = "Feishu"
@classmethod
def default_config(cls) -> dict:
return {
"instanceId": "default",
"name": "nanobot",
"enabled": False,
"appId": "",
"appSecret": "",
"domain": "feishu",
"groupPolicy": "mention",
"topicIsolation": True,
"allowFrom": [],
}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
def _make_entry_point(name: str, cls: type):
"""Create a mock entry point that returns *cls* on load()."""
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
@@ -136,6 +164,49 @@ def test_channels_config_extract_document_text_accepts_camel_alias():
assert cfg.extract_document_text is False
def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr(
"nanobot.channels.registry.discover_enabled",
lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu}
if "feishu" in enabled
else {},
)
cfg = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"enabled": True,
"appId": "cli_product",
"appSecret": "secret",
},
{
"id": "off",
"enabled": False,
"appId": "cli_off",
"appSecret": "secret",
},
]
}
}
})
manager = ChannelManager(cfg, MessageBus())
assert set(manager.channels) == {"feishu", "feishu.product"}
assert manager.channels["feishu"].name == "feishu"
assert manager.channels["feishu.product"].name == "feishu.product"
# ---------------------------------------------------------------------------
# discover_plugins
# ---------------------------------------------------------------------------
@@ -201,6 +272,17 @@ def test_discover_all_includes_builtins():
assert name in discover_channel_names()
def test_discover_channel_names_excludes_internal_helpers():
from nanobot.channels.registry import discover_channel_names
names = discover_channel_names()
assert "_feishu_ws" not in names
assert "_setup" not in names
assert "setup" not in names
assert "_feishu_instances" not in names
def test_discover_all_includes_external_plugin():
from nanobot.channels.registry import discover_all
@@ -891,6 +973,25 @@ def test_enable_optional_feature_skips_install_when_dependency_present(
assert not config_path.exists()
def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatch, tmp_path):
from nanobot.optional_features import enable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"documents": ["pypdf>=5.0.0,<6.0.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
payload = enable_optional_feature("documents", config_path=config_path)
assert payload["requires_restart"] is False
assert payload["last_action"]["message"] == "Feature 'documents' is included with nanobot"
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
from nanobot.optional_features import (
InstallResult,
@@ -1009,6 +1110,370 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc
assert payload["enabled_count"] == 1
def test_optional_features_payload_reflects_saved_channel_config(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"discord": {
"enabled": False,
"token": "discord-secret-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["name"] == "discord"
assert discord["enabled"] is False
assert discord["configured"] is True
assert discord["config_values"] == {
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
assert discord["configured_fields"] == [
"channels.discord.token",
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
]
assert "discord-secret-token" not in json.dumps(payload)
def test_optional_features_payload_marks_enabled_channel_missing_credentials(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({"channels": {"discord": {"enabled": True}}})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["enabled"] is True
assert discord["configured"] is False
assert "config_values" not in discord
assert "configured_fields" not in discord
def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, monkeypatch):
from nanobot.optional_features import optional_features_payload
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "saved-weixin-token"}),
encoding="utf-8",
)
config = Config.model_validate({
"channels": {
"weixin": {
"enabled": True,
"stateDir": str(state_dir),
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
weixin = payload["features"][0]
assert weixin["enabled"] is True
assert weixin["configured"] is True
def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, monkeypatch):
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "legacy-weixin-token"}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=Config())
weixin = payload["features"][0]
assert weixin["enabled"] is False
assert weixin["configured"] is True
@pytest.mark.parametrize("device_id", ["", "DEVICE-ID"])
def test_optional_features_payload_requires_matrix_device_id_for_token_login(
monkeypatch,
device_id,
):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"matrix": {
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
"deviceId": device_id,
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
assert payload["features"][0]["configured"] is bool(device_id)
def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"enabled": False,
"appId": "cli_test",
"appSecret": "secret",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is False
assert feishu["configured"] is True
assert feishu["ready"] is False
assert payload["enabled_count"] == 0
def test_optional_features_payload_lists_feishu_instances(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"name": "nanobot",
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/bot.png",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"name": "Product bot",
"enabled": False,
"appId": "cli_product",
"appSecret": "secret",
},
]
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is True
assert feishu["configured"] is True
assert payload["enabled_count"] == 1
assert feishu["instances"] == [
{
"id": "default",
"name": "nanobot",
"display_name": "Voraflare Bot",
"avatar_url": "https://example.com/bot.png",
"domain": "feishu",
"enabled": True,
"configured": True,
"app_id": "cli_default",
"group_policy": "mention",
"allow_from": [],
},
{
"id": "product",
"name": "Product bot",
"display_name": "Product bot",
"avatar_url": "",
"domain": "feishu",
"enabled": False,
"configured": True,
"app_id": "cli_product",
"group_policy": "mention",
"allow_from": [],
},
]
def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Xubin Ren的智能助手",
"avatarUrl": "https://example.com/assistant.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "Xubin Ren的智能助手"
assert instance["avatar_url"] == "https://example.com/assistant.png"
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["displayName"] == "Xubin Ren的智能助手"
assert saved["avatarUrl"] == "https://example.com/assistant.png"
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result(
monkeypatch,
tmp_path,
):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {})
monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z")
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "nanobot"
assert instance["avatar_url"] == ""
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
assert "displayName" not in saved
assert "avatarUrl" not in saved
def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"enabled": True,
"appId": "cli_legacy",
"appSecret": "legacy-secret",
"groupPolicy": "mention",
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda *_args: {
"displayName": "Legacy assistant",
"avatarUrl": "https://example.com/legacy.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant"
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_legacy"
assert saved["appSecret"] == "legacy-secret"
assert saved["displayName"] == "Legacy assistant"
assert saved["avatarUrl"] == "https://example.com/legacy.png"
assert "instances" not in saved
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
from nanobot import optional_features
@@ -1066,6 +1531,8 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch):
def test_optional_dependency_metadata_for_enable():
from nanobot import optional_features
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["optional-dependencies"]
required = data["project"]["dependencies"]
@@ -1077,25 +1544,32 @@ def test_optional_dependency_metadata_for_enable():
"dingtalk-stream",
"lark-oapi",
"msgpack",
"openpyxl",
"pypdf",
"python-telegram-bot",
"python-docx",
"python-pptx",
"python-socketio",
"qq-botpy",
"slack-sdk",
"slackify-markdown",
):
assert not any(dep.startswith(dep_name) for dep in required)
for dependency in (
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
):
assert dependency in required
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
assert deps["documents"] == [
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
assert deps["mochat"] == [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
@@ -1107,6 +1581,10 @@ def test_optional_dependency_metadata_for_enable():
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
visible = optional_features.optional_dependency_groups()
assert "documents" not in visible
assert "pdf" not in visible
assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"])
assert any(
dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep
@@ -1986,6 +2464,7 @@ async def test_stop_all_cancels_dispatcher_and_stops_channels():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
# Create a real cancelled task
async def dummy_task():
@@ -2063,6 +2542,7 @@ async def test_stop_all_handles_channel_exception():
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"stopfailing": _StopFailingChannel(fake_config, mgr.bus)}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Should not raise even if channel.stop() raises
@@ -2101,6 +2581,7 @@ async def test_stop_all_handles_channel_stop_cancelled_task():
"stopcancelled": _StopCancelledChannel(fake_config, mgr.bus),
"next": next_channel,
}
mgr._channel_tasks = {}
mgr._dispatch_task = None
await mgr.stop_all()
@@ -2142,6 +2623,7 @@ async def test_start_all_creates_dispatch_task():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Cancel immediately after start to avoid running forever