mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e95ba0da |
+103
-2045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
|||||||
|
"""Capability settings domain logic for Web, media, network, and API features."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
|
||||||
|
|
||||||
|
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||||
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
|
||||||
|
from nanobot.audio.transcription import resolve_transcription_config
|
||||||
|
from nanobot.audio.transcription_registry import (
|
||||||
|
resolve_transcription_provider,
|
||||||
|
transcription_provider_names,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.optional_features import (
|
||||||
|
OptionalFeatureError,
|
||||||
|
extra_installed,
|
||||||
|
optional_dependency_groups,
|
||||||
|
)
|
||||||
|
from nanobot.providers.image_generation import (
|
||||||
|
get_image_gen_provider,
|
||||||
|
image_gen_provider_names,
|
||||||
|
)
|
||||||
|
from nanobot.providers.registry import find_by_name
|
||||||
|
from nanobot.security.network import is_loopback_host
|
||||||
|
from nanobot.webui.settings_contracts import (
|
||||||
|
QueryParams,
|
||||||
|
SettingsRequest,
|
||||||
|
SettingsRouteResult,
|
||||||
|
WebUISettingsError,
|
||||||
|
parse_bool,
|
||||||
|
query_first,
|
||||||
|
query_first_alias,
|
||||||
|
)
|
||||||
|
from nanobot.webui.settings_models import (
|
||||||
|
OAuthStatusReader,
|
||||||
|
mask_secret_hint,
|
||||||
|
provider_configured_for_settings,
|
||||||
|
)
|
||||||
|
from nanobot.webui.workspaces import (
|
||||||
|
read_webui_default_access_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
SettingsOperation = Callable[..., dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilitySettingsOperations:
|
||||||
|
update_web_search: SettingsOperation
|
||||||
|
update_api: SettingsOperation
|
||||||
|
update_image: SettingsOperation
|
||||||
|
update_transcription: SettingsOperation
|
||||||
|
update_network: SettingsOperation
|
||||||
|
nanobot_features_action: SettingsOperation
|
||||||
|
api_runtime: Callable[[], ApiRuntime]
|
||||||
|
reload_image: Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilitySettingsPayload(TypedDict):
|
||||||
|
web_search: dict[str, Any]
|
||||||
|
web: dict[str, Any]
|
||||||
|
api: dict[str, Any]
|
||||||
|
observability: dict[str, Any]
|
||||||
|
image_generation: dict[str, Any]
|
||||||
|
transcription: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||||
|
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||||
|
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||||
|
}
|
||||||
|
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||||
|
"1:1",
|
||||||
|
"3:4",
|
||||||
|
"9:16",
|
||||||
|
"4:3",
|
||||||
|
"16:9",
|
||||||
|
"3:2",
|
||||||
|
"2:3",
|
||||||
|
"21:9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_generation_provider_rows(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for name in image_gen_provider_names():
|
||||||
|
image_provider = get_image_gen_provider(name)
|
||||||
|
spec = find_by_name(name)
|
||||||
|
provider_config = getattr(config.providers, name, None)
|
||||||
|
configured = (
|
||||||
|
provider_configured_for_settings(spec, provider_config, oauth_status)
|
||||||
|
if spec is not None and provider_config is not None
|
||||||
|
else bool(getattr(provider_config, "api_key", None))
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": spec.label if spec is not None else name,
|
||||||
|
"configured": configured,
|
||||||
|
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
|
||||||
|
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_base": getattr(provider_config, "api_base", None),
|
||||||
|
"default_api_base": (
|
||||||
|
spec.default_api_base if spec and spec.default_api_base else None
|
||||||
|
),
|
||||||
|
"models": list(image_provider.model_options) if image_provider else [],
|
||||||
|
"default_model": (
|
||||||
|
image_provider.model_options[0]
|
||||||
|
if image_provider and image_provider.model_options
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for name in transcription_provider_names():
|
||||||
|
spec = find_by_name(name)
|
||||||
|
provider_config = getattr(config.providers, name, None)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": spec.label if spec is not None else name,
|
||||||
|
"configured": bool(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_base": getattr(provider_config, "api_base", None),
|
||||||
|
"default_api_base": (
|
||||||
|
spec.default_api_base if spec and spec.default_api_base else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def capability_settings_payload(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> CapabilitySettingsPayload:
|
||||||
|
search_config = config.tools.web.search
|
||||||
|
image_config = config.tools.image_generation
|
||||||
|
transcription = resolve_transcription_config(config)
|
||||||
|
search_provider = (
|
||||||
|
search_config.provider
|
||||||
|
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||||
|
else "duckduckgo"
|
||||||
|
)
|
||||||
|
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
|
||||||
|
selected_image_provider = next(
|
||||||
|
(
|
||||||
|
provider
|
||||||
|
for provider in image_providers
|
||||||
|
if provider["name"] == image_config.provider
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"web_search": {
|
||||||
|
"provider": search_provider,
|
||||||
|
"api_key_hint": mask_secret_hint(search_config.api_key),
|
||||||
|
"base_url": search_config.base_url or None,
|
||||||
|
"max_results": search_config.max_results,
|
||||||
|
"timeout": search_config.timeout,
|
||||||
|
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"enable": config.tools.web.enable,
|
||||||
|
"proxy": config.tools.web.proxy,
|
||||||
|
"user_agent": config.tools.web.user_agent,
|
||||||
|
"search": {
|
||||||
|
"max_results": search_config.max_results,
|
||||||
|
"timeout": search_config.timeout,
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"host": config.api.host,
|
||||||
|
"port": config.api.port,
|
||||||
|
"timeout": config.api.timeout,
|
||||||
|
"api_key_hint": mask_secret_hint(config.api.api_key),
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"provider": "langfuse",
|
||||||
|
"configured": bool(
|
||||||
|
os.environ.get("LANGFUSE_SECRET_KEY")
|
||||||
|
and os.environ.get("LANGFUSE_PUBLIC_KEY")
|
||||||
|
),
|
||||||
|
"base_url": os.environ.get("LANGFUSE_BASE_URL")
|
||||||
|
or "https://cloud.langfuse.com",
|
||||||
|
},
|
||||||
|
"image_generation": {
|
||||||
|
"enabled": image_config.enabled,
|
||||||
|
"provider": image_config.provider,
|
||||||
|
"provider_configured": bool(
|
||||||
|
selected_image_provider and selected_image_provider["configured"]
|
||||||
|
),
|
||||||
|
"model": image_config.model,
|
||||||
|
"default_aspect_ratio": image_config.default_aspect_ratio,
|
||||||
|
"default_image_size": image_config.default_image_size,
|
||||||
|
"max_images_per_turn": image_config.max_images_per_turn,
|
||||||
|
"save_dir": image_config.save_dir,
|
||||||
|
"providers": image_providers,
|
||||||
|
},
|
||||||
|
"transcription": {
|
||||||
|
"enabled": transcription.enabled,
|
||||||
|
"provider": transcription.provider,
|
||||||
|
"provider_configured": transcription.configured,
|
||||||
|
"model": transcription.model,
|
||||||
|
"language": transcription.language,
|
||||||
|
"max_duration_sec": transcription.max_duration_sec,
|
||||||
|
"max_upload_mb": transcription.max_upload_mb,
|
||||||
|
"providers": _transcription_provider_rows(config),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_network_safety_settings(
|
||||||
|
config: Config,
|
||||||
|
query: QueryParams,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
raw_allow = (
|
||||||
|
query_first_alias(
|
||||||
|
query,
|
||||||
|
"webui_allow_local_service_access",
|
||||||
|
"webuiAllowLocalServiceAccess",
|
||||||
|
)
|
||||||
|
or query_first_alias(
|
||||||
|
query,
|
||||||
|
"allow_local_preview_access",
|
||||||
|
"allowLocalPreviewAccess",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raw_default_access_mode = query_first_alias(
|
||||||
|
query,
|
||||||
|
"webui_default_access_mode",
|
||||||
|
"webuiDefaultAccessMode",
|
||||||
|
)
|
||||||
|
if raw_allow is None and raw_default_access_mode is None:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"webui_allow_local_service_access or webui_default_access_mode is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if raw_allow is not None:
|
||||||
|
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
|
if config.tools.webui_allow_local_service_access != allow_local:
|
||||||
|
config.tools.webui_allow_local_service_access = allow_local
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_access_mode: str | None = None
|
||||||
|
if raw_default_access_mode is not None:
|
||||||
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
|
if default_access_mode == "restricted":
|
||||||
|
default_access_mode = "default"
|
||||||
|
if default_access_mode not in {"default", "full"}:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"webui_default_access_mode must be default or full"
|
||||||
|
)
|
||||||
|
return changed, default_access_mode
|
||||||
|
|
||||||
|
|
||||||
|
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||||
|
provider_name = (query_first(query, "provider") or "").strip().lower()
|
||||||
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
|
if provider_option is None:
|
||||||
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
|
search_config = config.tools.web.search
|
||||||
|
web_config = config.tools.web
|
||||||
|
previous_provider = search_config.provider
|
||||||
|
changed = False
|
||||||
|
restart_required = False
|
||||||
|
|
||||||
|
def set_search_value(attr: str, value: object) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
if getattr(search_config, attr) != value:
|
||||||
|
setattr(search_config, attr, value)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
def set_fetch_value(attr: str, value: object) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
if getattr(web_config.fetch, attr) != value:
|
||||||
|
setattr(web_config.fetch, attr, value)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if search_config.provider != provider_name:
|
||||||
|
search_config.provider = provider_name
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
credential = provider_option["credential"]
|
||||||
|
if credential == "none":
|
||||||
|
set_search_value("api_key", "")
|
||||||
|
set_search_value("base_url", "")
|
||||||
|
elif credential == "base_url":
|
||||||
|
base_url = query_first_alias(query, "base_url", "baseUrl")
|
||||||
|
base_url = base_url.strip() if base_url is not None else None
|
||||||
|
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||||
|
base_url = search_config.base_url
|
||||||
|
if not base_url:
|
||||||
|
raise WebUISettingsError("base_url is required")
|
||||||
|
set_search_value("base_url", base_url)
|
||||||
|
set_search_value("api_key", "")
|
||||||
|
elif credential in {"api_key", "optional_api_key"}:
|
||||||
|
raw_api_key = query_first_alias(query, "api_key", "apiKey")
|
||||||
|
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||||
|
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||||
|
api_key = search_config.api_key
|
||||||
|
if credential == "api_key" and not api_key:
|
||||||
|
raise WebUISettingsError("api_key is required")
|
||||||
|
set_search_value("api_key", api_key or "")
|
||||||
|
set_search_value("base_url", "")
|
||||||
|
else:
|
||||||
|
raise WebUISettingsError("unknown web search credential type")
|
||||||
|
|
||||||
|
max_results = query_first_alias(query, "max_results", "maxResults")
|
||||||
|
if max_results is not None:
|
||||||
|
try:
|
||||||
|
parsed = int(max_results)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_results must be an integer") from None
|
||||||
|
if parsed < 1 or parsed > 10:
|
||||||
|
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||||
|
set_search_value("max_results", parsed)
|
||||||
|
|
||||||
|
timeout = query_first(query, "timeout")
|
||||||
|
if timeout is not None:
|
||||||
|
try:
|
||||||
|
parsed_timeout = int(timeout)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("timeout must be an integer") from None
|
||||||
|
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||||
|
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||||
|
set_search_value("timeout", parsed_timeout)
|
||||||
|
|
||||||
|
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||||
|
if use_jina_reader is not None:
|
||||||
|
previous_jina_reader = web_config.fetch.use_jina_reader
|
||||||
|
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
|
||||||
|
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
||||||
|
restart_required = True
|
||||||
|
return changed, restart_required
|
||||||
|
|
||||||
|
|
||||||
|
def update_api_settings(config: Config, query: QueryParams) -> None:
|
||||||
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
|
api = config.api
|
||||||
|
host = query_first(query, "host")
|
||||||
|
if host is not None:
|
||||||
|
host = host.strip()
|
||||||
|
if not host:
|
||||||
|
raise WebUISettingsError("host is required")
|
||||||
|
api.host = host
|
||||||
|
|
||||||
|
port = query_first(query, "port")
|
||||||
|
if port is not None:
|
||||||
|
try:
|
||||||
|
parsed_port = int(port)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("port must be an integer") from None
|
||||||
|
if parsed_port < 1 or parsed_port > 65535:
|
||||||
|
raise WebUISettingsError("port must be between 1 and 65535")
|
||||||
|
api.port = parsed_port
|
||||||
|
|
||||||
|
timeout = query_first(query, "timeout")
|
||||||
|
if timeout is not None:
|
||||||
|
try:
|
||||||
|
parsed_timeout = float(timeout)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("timeout must be a number") from None
|
||||||
|
if parsed_timeout < 1 or parsed_timeout > 3600:
|
||||||
|
raise WebUISettingsError("timeout must be between 1 and 3600")
|
||||||
|
api.timeout = parsed_timeout
|
||||||
|
|
||||||
|
api_key = query_first_alias(query, "api_key", "apiKey")
|
||||||
|
if api_key is not None:
|
||||||
|
api.api_key = api_key.strip()
|
||||||
|
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"an API key is required when the API is available on the network"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_image_generation_settings(
|
||||||
|
config: Config,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> bool:
|
||||||
|
image_config = config.tools.image_generation
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
provider_name = query_first(query, "provider")
|
||||||
|
if provider_name is not None:
|
||||||
|
provider_name = provider_name.strip().lower()
|
||||||
|
if not provider_name:
|
||||||
|
raise WebUISettingsError("image generation provider is required")
|
||||||
|
if get_image_gen_provider(provider_name) is None:
|
||||||
|
raise WebUISettingsError("unknown image generation provider")
|
||||||
|
if image_config.provider != provider_name:
|
||||||
|
image_config.provider = provider_name
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
enabled = query_first(query, "enabled")
|
||||||
|
if enabled is not None:
|
||||||
|
parsed_enabled = parse_bool(enabled, "enabled")
|
||||||
|
if image_config.enabled != parsed_enabled:
|
||||||
|
image_config.enabled = parsed_enabled
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
model = query_first(query, "model")
|
||||||
|
if model is not None:
|
||||||
|
model = model.strip()
|
||||||
|
if not model:
|
||||||
|
raise WebUISettingsError("image generation model is required")
|
||||||
|
if len(model) > 200:
|
||||||
|
raise WebUISettingsError("image generation model is too long")
|
||||||
|
if image_config.model != model:
|
||||||
|
image_config.model = model
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_aspect_ratio = query_first_alias(
|
||||||
|
query,
|
||||||
|
"default_aspect_ratio",
|
||||||
|
"defaultAspectRatio",
|
||||||
|
)
|
||||||
|
if default_aspect_ratio is not None:
|
||||||
|
default_aspect_ratio = default_aspect_ratio.strip()
|
||||||
|
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
||||||
|
raise WebUISettingsError("unsupported image generation aspect ratio")
|
||||||
|
if image_config.default_aspect_ratio != default_aspect_ratio:
|
||||||
|
image_config.default_aspect_ratio = default_aspect_ratio
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_image_size = query_first_alias(
|
||||||
|
query,
|
||||||
|
"default_image_size",
|
||||||
|
"defaultImageSize",
|
||||||
|
)
|
||||||
|
if default_image_size is not None:
|
||||||
|
default_image_size = default_image_size.strip()
|
||||||
|
if not default_image_size:
|
||||||
|
raise WebUISettingsError("default image size is required")
|
||||||
|
if len(default_image_size) > 32 or not all(
|
||||||
|
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
||||||
|
for char in default_image_size
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("unsupported image generation size")
|
||||||
|
if image_config.default_image_size != default_image_size:
|
||||||
|
image_config.default_image_size = default_image_size
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_images_per_turn = query_first_alias(
|
||||||
|
query,
|
||||||
|
"max_images_per_turn",
|
||||||
|
"maxImagesPerTurn",
|
||||||
|
)
|
||||||
|
if max_images_per_turn is not None:
|
||||||
|
try:
|
||||||
|
parsed_max = int(max_images_per_turn)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
||||||
|
if parsed_max < 1 or parsed_max > 8:
|
||||||
|
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
||||||
|
if image_config.max_images_per_turn != parsed_max:
|
||||||
|
image_config.max_images_per_turn = parsed_max
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if image_config.enabled:
|
||||||
|
selected_provider = next(
|
||||||
|
(
|
||||||
|
provider
|
||||||
|
for provider in _image_generation_provider_rows(
|
||||||
|
config,
|
||||||
|
oauth_status=oauth_status,
|
||||||
|
)
|
||||||
|
if provider["name"] == image_config.provider
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not selected_provider or not selected_provider["configured"]:
|
||||||
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
|
||||||
|
transcription = config.transcription
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
enabled = query_first(query, "enabled")
|
||||||
|
if enabled is not None:
|
||||||
|
parsed_enabled = parse_bool(enabled, "enabled")
|
||||||
|
if transcription.enabled != parsed_enabled:
|
||||||
|
transcription.enabled = parsed_enabled
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
provider = query_first(query, "provider")
|
||||||
|
if provider is not None:
|
||||||
|
provider = provider.strip().lower()
|
||||||
|
provider_spec = resolve_transcription_provider(provider)
|
||||||
|
if provider_spec is None:
|
||||||
|
raise WebUISettingsError("unknown transcription provider")
|
||||||
|
provider = provider_spec.name
|
||||||
|
if transcription.provider != provider:
|
||||||
|
transcription.provider = provider
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
model = query_first(query, "model")
|
||||||
|
if model is not None:
|
||||||
|
model = model.strip() or None
|
||||||
|
if model is not None and len(model) > 200:
|
||||||
|
raise WebUISettingsError("transcription model is too long")
|
||||||
|
if transcription.model != model:
|
||||||
|
transcription.model = model
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
language = query_first(query, "language")
|
||||||
|
if language is not None:
|
||||||
|
language = language.strip().lower() or None
|
||||||
|
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"transcription language must be 2-3 lowercase letters"
|
||||||
|
)
|
||||||
|
if transcription.language != language:
|
||||||
|
transcription.language = language
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
||||||
|
if max_duration_sec is not None:
|
||||||
|
try:
|
||||||
|
parsed_duration = int(max_duration_sec)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
||||||
|
if parsed_duration < 1 or parsed_duration > 600:
|
||||||
|
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
||||||
|
if transcription.max_duration_sec != parsed_duration:
|
||||||
|
transcription.max_duration_sec = parsed_duration
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
||||||
|
if max_upload_mb is not None:
|
||||||
|
try:
|
||||||
|
parsed_upload = int(max_upload_mb)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
||||||
|
if parsed_upload < 1 or parsed_upload > 100:
|
||||||
|
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
||||||
|
if transcription.max_upload_mb != parsed_upload:
|
||||||
|
transcription.max_upload_mb = parsed_upload
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def network_safety_payload(config: Config) -> dict[str, Any]:
|
||||||
|
"""Return the network-related fields embedded in the advanced DTO."""
|
||||||
|
return {
|
||||||
|
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
|
||||||
|
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
|
||||||
|
"webui_default_access_mode": read_webui_default_access_mode(),
|
||||||
|
"private_service_protection_enabled": True,
|
||||||
|
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def masked_api_secret(value: str) -> str | None:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
|
||||||
|
|
||||||
|
|
||||||
|
def api_runtime_message(message: str) -> str:
|
||||||
|
known = {
|
||||||
|
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
|
||||||
|
"api_stop_timeout": "API server did not stop in time.",
|
||||||
|
"api_state_stale": "API server state was stale; try starting it again.",
|
||||||
|
}
|
||||||
|
if message in known:
|
||||||
|
return known[message]
|
||||||
|
if message.startswith("api_"):
|
||||||
|
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
|
||||||
|
return message.replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def api_service_payload(
|
||||||
|
settings: WebUISettingsServices,
|
||||||
|
runtime: ApiRuntime,
|
||||||
|
*,
|
||||||
|
last_action: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = settings.config.load()
|
||||||
|
status = runtime.status()
|
||||||
|
extras = optional_dependency_groups()
|
||||||
|
connect_host = (
|
||||||
|
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"installed": extra_installed("api", extras.get("api")),
|
||||||
|
"running": status.running,
|
||||||
|
"managed": status.running,
|
||||||
|
"host": config.api.host,
|
||||||
|
"port": config.api.port,
|
||||||
|
"timeout": config.api.timeout,
|
||||||
|
"api_key_hint": masked_api_secret(config.api.api_key),
|
||||||
|
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
|
||||||
|
"command": "nanobot serve",
|
||||||
|
"log_path": str(status.log_path),
|
||||||
|
}
|
||||||
|
if last_action:
|
||||||
|
payload["last_action"] = last_action
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilitySettingsHandler:
|
||||||
|
"""Handle capability commands after transport authentication and decoding."""
|
||||||
|
|
||||||
|
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
if action == "api-status":
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(self.settings, operations.api_runtime())
|
||||||
|
)
|
||||||
|
if action == "api-start":
|
||||||
|
return await self._start_api(request, operations)
|
||||||
|
if action == "api-stop":
|
||||||
|
return await self._stop_api(operations)
|
||||||
|
|
||||||
|
mutation = {
|
||||||
|
"web-search-update": (
|
||||||
|
operations.update_web_search,
|
||||||
|
"browser",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"transcription-update": (
|
||||||
|
operations.update_transcription,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"network-update": (
|
||||||
|
operations.update_network,
|
||||||
|
"runtime",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"image-update": (
|
||||||
|
operations.update_image,
|
||||||
|
"image",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
}.get(action)
|
||||||
|
if mutation is None:
|
||||||
|
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||||
|
|
||||||
|
operation, section, apply_image_reload = mutation
|
||||||
|
try:
|
||||||
|
payload = self.settings.mutate(operation, request.query)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
if apply_image_reload:
|
||||||
|
payload, image_restart_cleared = await self.apply_image_runtime_change(
|
||||||
|
payload,
|
||||||
|
operations.reload_image,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_restart_cleared = False
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section=section,
|
||||||
|
clear_restart_section=("image" if image_restart_cleared else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply_image_runtime_change(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
reload_image: Callable[[], Awaitable[dict[str, Any]]],
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
"""Hot-apply image settings, preserving restart fallback on failure."""
|
||||||
|
if not payload.get("requires_restart"):
|
||||||
|
return payload, False
|
||||||
|
try:
|
||||||
|
result = await reload_image()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to hot-reload image generation settings")
|
||||||
|
return payload, False
|
||||||
|
|
||||||
|
applied = bool(result.get("ok")) and not result.get("requires_restart")
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = not applied
|
||||||
|
if not applied:
|
||||||
|
self.logger.warning(
|
||||||
|
"image generation settings were saved but require restart: {}",
|
||||||
|
result.get("message") or "hot reload failed",
|
||||||
|
)
|
||||||
|
return updated, applied
|
||||||
|
|
||||||
|
async def _start_api(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
api_key = (request.payload or {}).get("api_key")
|
||||||
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
400,
|
||||||
|
"API service API key must be a string",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.settings.mutate,
|
||||||
|
operations.nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
{"name": ["api"]},
|
||||||
|
allow_install=self._allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
self.settings.mutate(operations.update_api, request.query)
|
||||||
|
config = self.settings.config.load()
|
||||||
|
runtime = operations.api_runtime()
|
||||||
|
options = ApiStartOptions(
|
||||||
|
host=config.api.host,
|
||||||
|
port=config.api.port,
|
||||||
|
workspace=str(config.workspace_path),
|
||||||
|
config_path=str(self.settings.config.path),
|
||||||
|
)
|
||||||
|
current = runtime.status()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
runtime.restart if current.running else runtime.start_background,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
if not result.ok:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
api_runtime_message(result.message),
|
||||||
|
)
|
||||||
|
except (WebUISettingsError, OptionalFeatureError) as exc:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
getattr(exc, "status", 400),
|
||||||
|
getattr(exc, "message", str(exc)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to start managed API service")
|
||||||
|
return SettingsRouteResult.failure(500, str(exc))
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(
|
||||||
|
self.settings,
|
||||||
|
operations.api_runtime(),
|
||||||
|
last_action="started",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _stop_api(
|
||||||
|
self,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
runtime = operations.api_runtime()
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(runtime.stop)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to stop managed API service")
|
||||||
|
return SettingsRouteResult.failure(500, str(exc))
|
||||||
|
if not result.ok and result.message != "api_not_running":
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
api_runtime_message(result.message),
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(
|
||||||
|
self.settings,
|
||||||
|
operations.api_runtime(),
|
||||||
|
last_action="stopped",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||||
|
if request.local_browser:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load remote package install policy")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Stable request and error contracts shared by WebUI settings domains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SettingsRequest:
|
||||||
|
"""Transport-neutral input decoded by the settings route facade."""
|
||||||
|
|
||||||
|
query: QueryParams
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
local_browser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SettingsRouteResult:
|
||||||
|
"""Transport-neutral result returned by a settings domain handler."""
|
||||||
|
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
status: int = 200
|
||||||
|
error: str | None = None
|
||||||
|
decorate_restart: bool = False
|
||||||
|
restart_section: str | None = None
|
||||||
|
clear_restart_section: str | None = None
|
||||||
|
restart_payload_key: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success(
|
||||||
|
cls,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
decorate_restart: bool = False,
|
||||||
|
restart_section: str | None = None,
|
||||||
|
clear_restart_section: str | None = None,
|
||||||
|
restart_payload_key: str | None = None,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
return cls(
|
||||||
|
payload=payload,
|
||||||
|
decorate_restart=decorate_restart,
|
||||||
|
restart_section=restart_section,
|
||||||
|
clear_restart_section=clear_restart_section,
|
||||||
|
restart_payload_key=restart_payload_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failure(cls, status: int, error: str) -> SettingsRouteResult:
|
||||||
|
return cls(status=status, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
class WebUISettingsError(ValueError):
|
||||||
|
"""User-facing settings validation failure."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def query_first(query: QueryParams, key: str) -> str | None:
|
||||||
|
values = query.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
||||||
|
value = query_first(query, snake)
|
||||||
|
return query_first(query, camel) if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
||||||
|
return snake in query or camel in query
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool(value: str, field: str) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||||
|
raise WebUISettingsError(f"{field} must be boolean")
|
||||||
|
return normalized in {"1", "true", "yes"}
|
||||||
File diff suppressed because it is too large
Load Diff
+291
-979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,957 @@
|
|||||||
|
"""System and channel settings domain logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from nanobot.channels._setup import channel_setup_spec
|
||||||
|
from nanobot.channels.connect import ChannelConnectError
|
||||||
|
from nanobot.channels.contracts import (
|
||||||
|
RouteFieldType,
|
||||||
|
channel_instance_config,
|
||||||
|
channel_update_instance_config,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
|
||||||
|
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||||
|
from nanobot.webui.settings_capabilities import network_safety_payload
|
||||||
|
from nanobot.webui.settings_contracts import (
|
||||||
|
QueryParams,
|
||||||
|
SettingsRequest,
|
||||||
|
SettingsRouteResult,
|
||||||
|
WebUISettingsError,
|
||||||
|
query_first,
|
||||||
|
query_first_alias,
|
||||||
|
)
|
||||||
|
from nanobot.webui.token_usage import token_usage_payload
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
LoadChannelPlugin = Callable[[str], Any]
|
||||||
|
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
|
||||||
|
SettingsOperation = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemSettingsOperations:
|
||||||
|
cli_apps_payload: SettingsOperation
|
||||||
|
cli_apps_action: SettingsOperation
|
||||||
|
nanobot_features_payload: SettingsOperation
|
||||||
|
nanobot_features_action: SettingsOperation
|
||||||
|
nanobot_feature_instance_target: SettingsOperation
|
||||||
|
validate_channel_config: SettingsOperation
|
||||||
|
load_channel_plugin: LoadChannelPlugin
|
||||||
|
list_pending: ListPendingPairings
|
||||||
|
approve_code: SettingsOperation
|
||||||
|
deny_code: SettingsOperation
|
||||||
|
mcp_presets_action: SettingsOperation
|
||||||
|
reload_mcp: SettingsOperation
|
||||||
|
check_for_update: SettingsOperation
|
||||||
|
channel_feature_action: SettingsOperation | None = None
|
||||||
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsPayload(TypedDict):
|
||||||
|
runtime: dict[str, Any]
|
||||||
|
usage: dict[str, Any]
|
||||||
|
advanced: dict[str, Any]
|
||||||
|
version: dict[str, Any]
|
||||||
|
docs: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
|
||||||
|
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
|
||||||
|
_SKIP_FIELD = object()
|
||||||
|
|
||||||
|
|
||||||
|
def docs_version(version: str) -> str:
|
||||||
|
"""Map package versions to the matching public docs path."""
|
||||||
|
normalized = version.strip()
|
||||||
|
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
|
||||||
|
return normalized
|
||||||
|
return "latest"
|
||||||
|
|
||||||
|
|
||||||
|
def docs_payload(version: str) -> dict[str, Any]:
|
||||||
|
selected_version = docs_version(version)
|
||||||
|
base_url = f"https://nanobot.wiki/docs/{selected_version}"
|
||||||
|
return {
|
||||||
|
"version": selected_version,
|
||||||
|
"base_url": base_url,
|
||||||
|
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
|
||||||
|
"latest_url": _DOCS_LATEST_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def system_settings_payload(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
config_path: Path,
|
||||||
|
version: str,
|
||||||
|
) -> SystemSettingsPayload:
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
exec_config = config.tools.exec
|
||||||
|
sandbox_status = workspace_sandbox_status(
|
||||||
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
|
workspace=config.workspace_path,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"runtime": {
|
||||||
|
"config_path": str(config_path.expanduser()),
|
||||||
|
"workspace_path": str(config.workspace_path),
|
||||||
|
"gateway_host": config.gateway.host,
|
||||||
|
"gateway_port": config.gateway.port,
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": config.gateway.heartbeat.enabled,
|
||||||
|
"interval_s": config.gateway.heartbeat.interval_s,
|
||||||
|
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||||
|
},
|
||||||
|
"dream": {
|
||||||
|
"schedule": defaults.dream.describe_schedule(),
|
||||||
|
},
|
||||||
|
"unified_session": defaults.unified_session,
|
||||||
|
},
|
||||||
|
"usage": token_usage_payload(timezone_name=defaults.timezone),
|
||||||
|
"advanced": {
|
||||||
|
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
||||||
|
"workspace_sandbox": sandbox_status.as_dict(),
|
||||||
|
**network_safety_payload(config),
|
||||||
|
"mcp_server_count": len(config.tools.mcp_servers),
|
||||||
|
"exec_enabled": exec_config.enable,
|
||||||
|
"exec_sandbox": exec_config.sandbox or None,
|
||||||
|
"exec_path_prepend_set": bool(exec_config.path_prepend),
|
||||||
|
"exec_path_append_set": bool(exec_config.path_append),
|
||||||
|
},
|
||||||
|
"version": {"current": version},
|
||||||
|
"docs": docs_payload(version),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def settings_usage_payload(config: Config) -> dict[str, Any]:
|
||||||
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
|
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
changed = False
|
||||||
|
restart_required = False
|
||||||
|
|
||||||
|
timezone = query_first(query, "timezone")
|
||||||
|
if timezone is not None:
|
||||||
|
timezone = timezone.strip()
|
||||||
|
if not timezone:
|
||||||
|
raise WebUISettingsError("timezone is required")
|
||||||
|
try:
|
||||||
|
ZoneInfo(timezone)
|
||||||
|
except Exception:
|
||||||
|
raise WebUISettingsError("invalid timezone") from None
|
||||||
|
timezone_changed = defaults.timezone != timezone
|
||||||
|
if timezone_changed or defaults.timezone_mode != "manual":
|
||||||
|
defaults.timezone = timezone
|
||||||
|
defaults.timezone_mode = "manual"
|
||||||
|
changed = True
|
||||||
|
restart_required = timezone_changed
|
||||||
|
|
||||||
|
tool_hint_max_length = query_first_alias(
|
||||||
|
query,
|
||||||
|
"tool_hint_max_length",
|
||||||
|
"toolHintMaxLength",
|
||||||
|
)
|
||||||
|
if tool_hint_max_length is not None:
|
||||||
|
try:
|
||||||
|
parsed = int(tool_hint_max_length)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"tool_hint_max_length must be an integer"
|
||||||
|
) from None
|
||||||
|
if parsed < 20 or parsed > 500:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"tool_hint_max_length must be between 20 and 500"
|
||||||
|
)
|
||||||
|
if defaults.tool_hint_max_length != parsed:
|
||||||
|
defaults.tool_hint_max_length = parsed
|
||||||
|
changed = True
|
||||||
|
restart_required = True
|
||||||
|
return changed, restart_required
|
||||||
|
|
||||||
|
|
||||||
|
def save_channel_config_values(
|
||||||
|
config: Config,
|
||||||
|
name: str,
|
||||||
|
raw_values: dict[str, Any],
|
||||||
|
instance_id: str = "default",
|
||||||
|
*,
|
||||||
|
load_channel_plugin: LoadChannelPlugin,
|
||||||
|
) -> list[str]:
|
||||||
|
if not name:
|
||||||
|
raise WebUISettingsError("missing channel name")
|
||||||
|
try:
|
||||||
|
plugin = load_channel_plugin(name)
|
||||||
|
except ImportError:
|
||||||
|
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
||||||
|
setup_spec = channel_setup_spec(name, plugin=plugin)
|
||||||
|
if setup_spec is None:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
f"channel '{name}' cannot be configured from WebUI",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
field_types = setup_spec.route_field_types
|
||||||
|
if not raw_values:
|
||||||
|
return []
|
||||||
|
|
||||||
|
section = getattr(config.channels, name, None)
|
||||||
|
channel_config = channel_instance_config(
|
||||||
|
plugin,
|
||||||
|
section,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
saved: list[str] = []
|
||||||
|
prefix = f"channels.{name}."
|
||||||
|
for raw_key, raw_value in raw_values.items():
|
||||||
|
if not raw_key:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"channel settings payload contains an invalid key"
|
||||||
|
)
|
||||||
|
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
|
||||||
|
value_type = field_types.get(field)
|
||||||
|
if value_type is None:
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
||||||
|
value = coerce_channel_value(raw_key, raw_value, value_type)
|
||||||
|
if value is _SKIP_FIELD:
|
||||||
|
continue
|
||||||
|
assign_channel_config_value(channel_config, field, value)
|
||||||
|
saved.append(raw_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_section = channel_update_instance_config(
|
||||||
|
plugin,
|
||||||
|
section,
|
||||||
|
channel_config,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
f"Invalid {name} configuration: {exc}",
|
||||||
|
status=400,
|
||||||
|
) from exc
|
||||||
|
setattr(config.channels, name, updated_section)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_channel_value(
|
||||||
|
raw_key: str,
|
||||||
|
raw_value: Any,
|
||||||
|
value_type: RouteFieldType,
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value_type, tuple):
|
||||||
|
kind = value_type[0]
|
||||||
|
allowed = value_type[1]
|
||||||
|
else:
|
||||||
|
kind = value_type
|
||||||
|
allowed = None
|
||||||
|
|
||||||
|
if kind in {"string", "secret"}:
|
||||||
|
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||||
|
if kind == "secret" and not value:
|
||||||
|
return _SKIP_FIELD
|
||||||
|
return value
|
||||||
|
|
||||||
|
if kind == "list":
|
||||||
|
if raw_value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||||
|
if isinstance(raw_value, list):
|
||||||
|
return [
|
||||||
|
str(item).strip()
|
||||||
|
for item in cast(list[Any], raw_value)
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
|
||||||
|
|
||||||
|
if kind == "int":
|
||||||
|
if raw_value in (None, ""):
|
||||||
|
return _SKIP_FIELD
|
||||||
|
try:
|
||||||
|
return int(raw_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
|
||||||
|
|
||||||
|
if kind == "bool":
|
||||||
|
if isinstance(raw_value, bool):
|
||||||
|
return raw_value
|
||||||
|
value = str(raw_value).strip().lower()
|
||||||
|
if value in {"true", "1", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if value in {"false", "0", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be true or false")
|
||||||
|
|
||||||
|
if kind == "enum":
|
||||||
|
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||||
|
if not value:
|
||||||
|
return _SKIP_FIELD
|
||||||
|
if allowed is None or value not in allowed:
|
||||||
|
options = ", ".join(sorted(allowed or ()))
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
|
||||||
|
|
||||||
|
|
||||||
|
def assign_channel_config_value(
|
||||||
|
channel_config: dict[str, Any],
|
||||||
|
field: str,
|
||||||
|
value: Any,
|
||||||
|
) -> None:
|
||||||
|
target = channel_config
|
||||||
|
parts = field.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
current: object = target.get(part)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
target[part] = current
|
||||||
|
target = cast(dict[str, Any], current)
|
||||||
|
target[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def pairing_payload(
|
||||||
|
list_pending: ListPendingPairings,
|
||||||
|
last_action: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current_time = time.time() if now is None else now
|
||||||
|
requests: list[dict[str, Any]] = []
|
||||||
|
for item in list_pending():
|
||||||
|
expires_at = float(item.get("expires_at", 0) or 0)
|
||||||
|
created_at = float(item.get("created_at", 0) or 0)
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"code": str(item.get("code", "")),
|
||||||
|
"channel": str(item.get("channel", "")),
|
||||||
|
"sender_id": str(item.get("sender_id", "")),
|
||||||
|
"created_at_ms": int(created_at * 1000) if created_at else None,
|
||||||
|
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
|
||||||
|
"expires_in_seconds": (
|
||||||
|
max(0, int(expires_at - current_time)) if expires_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {"requests": requests}
|
||||||
|
if last_action is not None:
|
||||||
|
payload["last_action"] = last_action
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsHandler:
|
||||||
|
"""Handle channel and system commands behind a transport-neutral request DTO."""
|
||||||
|
|
||||||
|
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.logger = logger
|
||||||
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
*,
|
||||||
|
channel_name: str | None = None,
|
||||||
|
connect_action: str | None = None,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
if action == "cli-list":
|
||||||
|
return await self._cli_apps(request, operations)
|
||||||
|
if action.startswith("cli-"):
|
||||||
|
return await self._cli_apps_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("cli-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "features-list":
|
||||||
|
return await self._features(operations)
|
||||||
|
if action in {"features-enable", "features-disable"}:
|
||||||
|
return await self._features_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("features-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "channel-validate":
|
||||||
|
return await self._channel_validate(request, operations)
|
||||||
|
if action == "channel-configure":
|
||||||
|
return await self._channel_configure(request, operations)
|
||||||
|
if action == "channel-connect" and channel_name and connect_action:
|
||||||
|
return await self._channel_connect(
|
||||||
|
request,
|
||||||
|
channel_name,
|
||||||
|
connect_action,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "pairing-list":
|
||||||
|
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
|
||||||
|
if action in {"pairing-approve", "pairing-deny"}:
|
||||||
|
return self._pairing_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("pairing-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "mcp-list":
|
||||||
|
return await self._mcp_presets(request, None, operations)
|
||||||
|
if action.startswith("mcp-"):
|
||||||
|
return await self._mcp_presets(
|
||||||
|
request,
|
||||||
|
action.removeprefix("mcp-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "version-check":
|
||||||
|
return await self._version_check(operations)
|
||||||
|
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||||
|
|
||||||
|
async def _cli_apps(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
payload = await operations.cli_apps_payload(
|
||||||
|
installed_only=installed_only,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
|
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
async def _cli_apps_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.cli_apps_action,
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
async def _features(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.nanobot_features_payload,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load nanobot features")
|
||||||
|
return SettingsRouteResult.failure(500, "failed to load nanobot features")
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
self._with_channel_runtime_status(payload, operations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _nanobot_features_payload(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return operations.nanobot_features_payload(config_path=self.settings.config.path)
|
||||||
|
|
||||||
|
def _nanobot_features_action(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
*,
|
||||||
|
allow_install: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.settings.mutate(
|
||||||
|
operations.nanobot_features_action,
|
||||||
|
action,
|
||||||
|
query,
|
||||||
|
allow_install=allow_install,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _features_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
operations,
|
||||||
|
allow_install=(
|
||||||
|
action != "enable"
|
||||||
|
or self.allow_feature_package_install(request)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception(
|
||||||
|
"nanobot feature action '{}' failed",
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
payload = await self._apply_feature_runtime_change(
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload = self._with_channel_runtime_status(payload, operations)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _with_channel_runtime_status(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if operations.channel_runtime_status is None:
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
return with_channel_runtime_status(
|
||||||
|
payload,
|
||||||
|
operations.channel_runtime_status(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load channel runtime status")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _apply_feature_runtime_change(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if operations.channel_feature_action is None:
|
||||||
|
return payload
|
||||||
|
name = (query_first(query, "name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
instance_id = operations.nanobot_feature_instance_target(query)
|
||||||
|
result = operations.channel_feature_action(action, name, instance_id)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
result = await result
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to apply channel '{}' without restart", name)
|
||||||
|
return self.feature_runtime_fallback(
|
||||||
|
payload,
|
||||||
|
message=(
|
||||||
|
f"{name} channel config was saved, but hot reload failed: {exc}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return payload
|
||||||
|
result = cast(dict[str, Any], result)
|
||||||
|
if not result.get("handled"):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = bool(result.get("requires_restart"))
|
||||||
|
message = result.get("message")
|
||||||
|
if isinstance(message, str) and message:
|
||||||
|
last_action = dict(updated.get("last_action") or {})
|
||||||
|
previous = last_action.get("message")
|
||||||
|
last_action["message"] = (
|
||||||
|
f"{previous}. {message}"
|
||||||
|
if isinstance(previous, str) and previous
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
last_action["hot_reload"] = not updated["requires_restart"]
|
||||||
|
if "ok" in result:
|
||||||
|
last_action["ok"] = bool(result["ok"])
|
||||||
|
updated["last_action"] = last_action
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def feature_runtime_fallback(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = True
|
||||||
|
last_action = dict(updated.get("last_action") or {})
|
||||||
|
previous = last_action.get("message")
|
||||||
|
last_action["message"] = (
|
||||||
|
f"{previous}. {message}"
|
||||||
|
if isinstance(previous, str) and previous
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
last_action["hot_reload"] = False
|
||||||
|
updated["last_action"] = last_action
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def _channel_configure(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
name = (query_first(request.query, "name") or "").strip()
|
||||||
|
instance_id = (
|
||||||
|
query_first(request.query, "instance_id") or "default"
|
||||||
|
).strip()
|
||||||
|
enable = (query_first(request.query, "enable") or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
saved = await asyncio.to_thread(
|
||||||
|
self._save_channel_config_values,
|
||||||
|
name,
|
||||||
|
self.parse_channel_values(request),
|
||||||
|
instance_id,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to save channel '{}' settings", name)
|
||||||
|
return SettingsRouteResult.failure(500, "failed to save channel settings")
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"saved": True,
|
||||||
|
"saved_keys": saved,
|
||||||
|
}
|
||||||
|
if not enable:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
feature_query = {"name": [name]}
|
||||||
|
if instance_id:
|
||||||
|
feature_query["instance_id"] = [instance_id]
|
||||||
|
try:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
feature_query,
|
||||||
|
operations,
|
||||||
|
allow_install=self.allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
exc.status,
|
||||||
|
f"Settings saved, but {exc.message}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception(
|
||||||
|
"failed to enable channel '{}' after settings save",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
f"Settings saved, but enabling {name} failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
features = await self._apply_feature_runtime_change(
|
||||||
|
"enable",
|
||||||
|
feature_query,
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _channel_validate(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
name = (query_first(request.query, "name") or "").strip()
|
||||||
|
instance_id = (
|
||||||
|
query_first(request.query, "instance_id") or "default"
|
||||||
|
).strip()
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.validate_channel_config,
|
||||||
|
name,
|
||||||
|
self.parse_channel_values(request),
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to validate channel '{}' settings", name)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
"failed to validate channel settings",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
|
||||||
|
if request.payload is None or "values" not in request.payload:
|
||||||
|
return {}
|
||||||
|
values = request.payload.get("values")
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"channel settings payload must be a JSON object"
|
||||||
|
)
|
||||||
|
return cast(dict[str, Any], values)
|
||||||
|
|
||||||
|
def _save_channel_config_values(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
raw_values: dict[str, Any],
|
||||||
|
instance_id: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> list[str]:
|
||||||
|
return self.settings.config.update(
|
||||||
|
lambda config: save_channel_config_values(
|
||||||
|
config,
|
||||||
|
name,
|
||||||
|
raw_values,
|
||||||
|
instance_id,
|
||||||
|
load_channel_plugin=operations.load_channel_plugin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _channel_connect(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
channel_name: str,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
connector = self._channel_connectors.get(channel_name)
|
||||||
|
if connector is None:
|
||||||
|
plugin = operations.load_channel_plugin(channel_name)
|
||||||
|
connector = plugin.load_connector()
|
||||||
|
self._channel_connectors[channel_name] = connector
|
||||||
|
except ImportError:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
f"channel '{channel_name}' does not support connect",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await connector.handle(action, request.query)
|
||||||
|
except ChannelConnectError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(
|
||||||
|
"failed to run {} WebUI connect action for {}",
|
||||||
|
action,
|
||||||
|
channel_name,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
f"failed to {action} {channel_name} connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("status") != "succeeded":
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
payload = await self._with_channel_connect_success(
|
||||||
|
request,
|
||||||
|
channel_name,
|
||||||
|
payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _with_channel_connect_success(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
channel_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target = {"name": [channel_name]}
|
||||||
|
if payload.get("instance_id"):
|
||||||
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
|
try:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
target,
|
||||||
|
operations,
|
||||||
|
allow_install=self.allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
features = self.feature_runtime_fallback(
|
||||||
|
self._nanobot_features_payload(operations),
|
||||||
|
message=(
|
||||||
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
|
f"{exc.message}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
features = await self._apply_feature_runtime_change(
|
||||||
|
"enable",
|
||||||
|
target,
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||||
|
if request.local_browser:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load remote package install policy")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _pairing_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
code = (query_first(request.query, "code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return SettingsRouteResult.failure(400, "Missing pairing code")
|
||||||
|
if action == "approve":
|
||||||
|
result = operations.approve_code(code)
|
||||||
|
if result is None:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
"Pairing code not found or expired",
|
||||||
|
)
|
||||||
|
channel, sender_id = result
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
pairing_payload(
|
||||||
|
operations.list_pending,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"action": "approve",
|
||||||
|
"message": f"Approved {sender_id} for {channel}",
|
||||||
|
"channel": channel,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not operations.deny_code(code):
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
"Pairing code not found or expired",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
pairing_payload(
|
||||||
|
operations.list_pending,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"action": "deny",
|
||||||
|
"message": f"Denied pairing code {code}",
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _mcp_presets(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str | None,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await operations.mcp_presets_action(
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
reload_mcp=operations.reload_mcp,
|
||||||
|
config=self.settings.config,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception(
|
||||||
|
"MCP preset action '{}' failed",
|
||||||
|
action or "list",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=action is not None,
|
||||||
|
restart_section="runtime" if action is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _version_check(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
update_info = await asyncio.to_thread(operations.check_for_update)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("version check failed")
|
||||||
|
return SettingsRouteResult.failure(500, "version check failed")
|
||||||
|
return SettingsRouteResult.success({"updateAvailable": update_info})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_capabilities import (
|
||||||
|
capability_settings_payload,
|
||||||
|
update_api_settings,
|
||||||
|
update_image_generation_settings,
|
||||||
|
update_network_safety_settings,
|
||||||
|
update_transcription_settings,
|
||||||
|
update_web_search_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||||
|
return {"configured": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_domain_updates_representative_settings() -> None:
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "sk-test"
|
||||||
|
|
||||||
|
web_changed, web_restart = update_web_search_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"provider": ["duckduckgo"],
|
||||||
|
"max_results": ["7"],
|
||||||
|
"use_jina_reader": ["false"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
update_api_settings(
|
||||||
|
config,
|
||||||
|
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
|
||||||
|
)
|
||||||
|
image_changed = update_image_generation_settings(
|
||||||
|
config,
|
||||||
|
{"enabled": ["true"], "provider": ["openrouter"]},
|
||||||
|
oauth_status=_oauth_status,
|
||||||
|
)
|
||||||
|
transcription_changed = update_transcription_settings(
|
||||||
|
config,
|
||||||
|
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
|
||||||
|
)
|
||||||
|
network_changed, access_mode = update_network_safety_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"webui_allow_local_service_access": ["false"],
|
||||||
|
"webui_default_access_mode": ["restricted"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = capability_settings_payload(config, oauth_status=_oauth_status)
|
||||||
|
|
||||||
|
assert (web_changed, web_restart) == (True, True)
|
||||||
|
assert image_changed is True
|
||||||
|
assert transcription_changed is True
|
||||||
|
assert (network_changed, access_mode) == (True, "default")
|
||||||
|
assert payload["web_search"]["max_results"] == 7
|
||||||
|
assert payload["api"]["host"] == "127.0.0.2"
|
||||||
|
assert payload["api"]["port"] == 8900
|
||||||
|
assert payload["image_generation"]["enabled"] is True
|
||||||
|
assert payload["transcription"]["provider"] == "openrouter"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_models import (
|
||||||
|
model_settings_payload,
|
||||||
|
update_agent_model_settings,
|
||||||
|
update_provider_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"account": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"login_supported": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_domain_owns_dto_and_config_updates() -> None:
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "sk-before"
|
||||||
|
|
||||||
|
agent_changed = update_agent_model_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"model": ["openai/gpt-5.4"],
|
||||||
|
"provider": ["openrouter"],
|
||||||
|
"context_window_tokens": ["200000"],
|
||||||
|
},
|
||||||
|
oauth_status=_oauth_status,
|
||||||
|
)
|
||||||
|
provider_changed, restart_required = update_provider_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"provider": ["openrouter"],
|
||||||
|
"api_key": ["sk-after"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = model_settings_payload(config, oauth_status=_oauth_status)
|
||||||
|
|
||||||
|
assert agent_changed is True
|
||||||
|
assert provider_changed is True
|
||||||
|
assert restart_required is False
|
||||||
|
assert config.agents.defaults.model == "openai/gpt-5.4"
|
||||||
|
assert config.agents.defaults.provider == "openrouter"
|
||||||
|
assert config.agents.defaults.context_window_tokens == 200_000
|
||||||
|
assert config.providers.openrouter.api_key == "sk-after"
|
||||||
|
assert set(payload) == {
|
||||||
|
"agent",
|
||||||
|
"model_presets",
|
||||||
|
"model_call_order",
|
||||||
|
"model_call_order_editable",
|
||||||
|
"providers",
|
||||||
|
}
|
||||||
|
assert payload["agent"]["model"] == "openai/gpt-5.4"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_system import (
|
||||||
|
coerce_channel_value,
|
||||||
|
system_settings_payload,
|
||||||
|
update_agent_system_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
changed, restart_required = update_agent_system_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"timezone": ["Asia/Shanghai"],
|
||||||
|
"tool_hint_max_length": ["120"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = system_settings_payload(
|
||||||
|
config,
|
||||||
|
config_path=tmp_path / "config.json",
|
||||||
|
version="0.3.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert restart_required is True
|
||||||
|
assert config.agents.defaults.timezone == "Asia/Shanghai"
|
||||||
|
assert config.agents.defaults.timezone_mode == "manual"
|
||||||
|
assert config.agents.defaults.tool_hint_max_length == 120
|
||||||
|
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
|
||||||
|
assert payload["version"] == {"current": "0.3.0"}
|
||||||
|
assert payload["docs"]["version"] == "0.3.0"
|
||||||
|
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_domain_validates_channel_field_values() -> None:
|
||||||
|
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
|
||||||
|
"alice",
|
||||||
|
"bob",
|
||||||
|
]
|
||||||
|
assert coerce_channel_value("enabled", "yes", "bool") is True
|
||||||
|
assert coerce_channel_value("port", "8765", "int") == 8765
|
||||||
Reference in New Issue
Block a user