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
+237 -28
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import subprocess
import sys
from contextlib import suppress
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
@@ -13,7 +14,19 @@ from loguru import logger
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from nanobot.channels._feishu_instances import (
DEFAULT_INSTANCE_ID,
feishu_instance_specs,
set_feishu_instance_enabled,
)
from nanobot.channels._setup import (
channel_field_value,
channel_setup_spec,
channel_value_present,
stringify_channel_value,
)
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.loader import merge_missing_defaults
from nanobot.config.schema import Config
@@ -35,6 +48,8 @@ class InstallResult:
_INSTALL_TIMEOUT_SECONDS = 300
_LOG_OUTPUT_LIMIT = 4000
_HIDDEN_OPTIONAL_FEATURES = {"documents", "langsmith", "pdf"}
_BUNDLED_FEATURE_ALIASES = {"documents", "pdf"}
def load_pyproject(path: Path) -> dict[str, Any]:
@@ -78,9 +93,13 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
return {
name: list(values)
for name, values in deps.items()
if name != "dev" and isinstance(values, list)
if name != "dev" and name not in _HIDDEN_OPTIONAL_FEATURES and isinstance(values, list)
}
return optional_dependency_groups_from_metadata()
return {
name: values
for name, values in optional_dependency_groups_from_metadata().items()
if name not in _HIDDEN_OPTIONAL_FEATURES
}
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
@@ -260,16 +279,6 @@ def write_config_data(path: Path, data: dict[str, Any]) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
merged = dict(defaults)
for key, value in existing.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_missing_defaults(value, merged[key])
else:
merged[key] = value
return merged
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -282,6 +291,21 @@ def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[s
write_config_data(config_path, data)
def enable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True)
write_config_data(config_path, data)
def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -293,8 +317,27 @@ def disable_channel_config(config_path: Path, channel_name: str) -> None:
write_config_data(config_path, data)
def disable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False)
write_config_data(config_path, data)
def channel_enabled(config: Config, name: str) -> bool:
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if section is None:
return default_enabled
@@ -303,6 +346,97 @@ def channel_enabled(config: Config, name: str) -> bool:
return bool(getattr(section, "enabled", default_enabled))
def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], list[str]]:
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
return {}, []
spec = channel_setup_spec(name)
if spec is None:
return {}, []
values: dict[str, str] = {}
configured_fields: list[str] = []
for field in spec.snapshot_fields:
value = channel_field_value(section, field)
if not channel_value_present(value):
continue
key = f"channels.{name}.{field}"
configured_fields.append(key)
if field in spec.secrets:
continue
values[key] = stringify_channel_value(value)
return values, configured_fields
def _channel_has_required_setup(section: Any, name: str) -> bool:
spec = channel_setup_spec(name)
return bool(spec and spec.is_configured(section))
def _local_login_state_present(section: Any, name: str) -> bool:
"""Return whether a QR-login channel has reusable local account state."""
from nanobot.config.loader import get_config_path
if name == "weixin":
configured_dir = channel_field_value(section, "stateDir")
state_dir = (
Path(str(configured_dir)).expanduser()
if configured_dir
else get_config_path().parent / "weixin"
)
try:
payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return False
return bool(str(payload.get("token") or "").strip())
if name == "whatsapp":
configured_path = channel_field_value(section, "databasePath")
database_path = (
Path(str(configured_path)).expanduser()
if configured_path
else get_config_path().parent / "whatsapp-auth" / "neonize.db"
)
try:
return database_path.is_file() and database_path.stat().st_size > 0
except OSError:
return False
return False
def _feishu_instance_display_name(config: dict[str, Any]) -> str:
display_name = str(config.get("displayName") or "").strip()
if display_name:
return display_name
local_name = str(config.get("name") or "").strip()
return local_name or "nanobot"
def channel_configured(config: Config, name: str) -> bool:
"""Return whether a channel has enough saved setup to be enabled directly."""
section = getattr(config.channels, name, None)
if name in {"weixin", "whatsapp"} and _local_login_state_present(section, name):
return True
if section is None:
return False
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return any(
_channel_has_required_setup(instance.config, "feishu")
for instance in feishu_instance_specs(section, FeishuChannel.default_config())
)
spec = channel_setup_spec(name)
if not spec or not spec.required:
return channel_enabled(config, name)
return _channel_has_required_setup(section, name)
def optional_features_payload(
*,
config: Config | None = None,
@@ -311,7 +445,14 @@ def optional_features_payload(
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config
config_provided = config is not None
config = config or load_config()
if not config_provided:
with suppress(Exception):
from nanobot.channels.feishu import refresh_saved_feishu_identities
if refresh_saved_feishu_identities(config):
config = load_config()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
@@ -321,21 +462,53 @@ def optional_features_payload(
is_channel = name in builtin_channels or name in plugin_channels
installed = extra_installed(name, extras[name]) if name in extras else True
enabled = channel_enabled(config, name) if is_channel else installed
configured = channel_configured(config, name) if is_channel else installed
ready = bool(enabled and installed)
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
features.append(
{
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": is_channel or name in extras,
}
)
feature = {
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"configured": configured,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": _feature_requires_restart(name, is_channel=is_channel),
}
if is_channel:
config_values, configured_fields = _channel_config_snapshot(
getattr(config.channels, name, None),
name,
)
if config_values:
feature["config_values"] = config_values
if configured_fields:
feature["configured_fields"] = configured_fields
if name == "feishu" and is_channel:
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(
getattr(config.channels, "feishu", None),
FeishuChannel.default_config(),
)
feature["instances"] = [
{
"id": spec.instance_id,
"name": spec.config.get("name") or "nanobot",
"display_name": _feishu_instance_display_name(spec.config),
"avatar_url": spec.config.get("avatarUrl") or "",
"domain": spec.config.get("domain") or "feishu",
"enabled": bool(spec.config.get("enabled", False)),
"configured": _channel_has_required_setup(spec.config, "feishu"),
"app_id": spec.config.get("appId") or spec.config.get("app_id") or "",
"group_policy": spec.config.get("groupPolicy") or "mention",
"allow_from": list(spec.config.get("allowFrom") or []),
}
for spec in specs
]
features.append(feature)
payload = {
"features": features,
@@ -351,6 +524,7 @@ def enable_optional_feature(
*,
config_path: Path | None = None,
allow_install: bool = True,
instance_id: str = DEFAULT_INSTANCE_ID,
runner: Any = run_install_command,
) -> dict[str, Any]:
from nanobot.channels.registry import (
@@ -360,6 +534,20 @@ def enable_optional_feature(
)
from nanobot.config.loader import get_config_path
# The old extra never powered a runtime integration. Keep the CLI spelling
# as a compatibility alias while directing users to the supported tracer.
if name == "langsmith":
name = "langfuse"
if name in _BUNDLED_FEATURE_ALIASES:
payload = optional_features_payload(
last_action={
"ok": True,
"message": f"Feature '{name}' is included with nanobot",
"enabled": True,
}
)
payload["requires_restart"] = False
return payload
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
@@ -394,7 +582,10 @@ def enable_optional_feature(
f"Channel '{name}' is not importable after enable: {exc}",
status=500,
) from exc
enable_channel_config(config_path, name, channel_cls.default_config())
if name == "feishu":
enable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
enable_channel_config(config_path, name, channel_cls.default_config())
message = f"Enabled channel '{name}'"
elif name in plugin_channels:
enable_channel_config(config_path, name, plugin_channels[name].default_config())
@@ -403,14 +594,26 @@ def enable_optional_feature(
message = f"Enabled feature '{name}'"
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras)
payload["requires_restart"] = _feature_requires_restart(
name,
is_channel=name in builtin_channels or name in plugin_channels,
)
return payload
def _feature_requires_restart(name: str, *, is_channel: bool) -> bool:
"""Return whether an installed feature needs the running engine rebuilt."""
if is_channel:
return True
# These libraries are imported lazily or used by a newly spawned service.
return name not in {"api", "documents", "pdf", "olostep"}
def disable_optional_feature(
name: str,
*,
config_path: Path | None = None,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import get_config_path
@@ -426,7 +629,13 @@ def disable_optional_feature(
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name not in known_channels:
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
disable_channel_config(config_path, name)
if name == "feishu":
from nanobot.channels.registry import load_channel_class
channel_cls = load_channel_class(name)
disable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
disable_channel_config(config_path, name)
payload = optional_features_payload(
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
)