feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren
2026-05-30 23:45:26 +08:00
committed by GitHub
parent b2e43955e3
commit 3dcf511c84
65 changed files with 4526 additions and 1428 deletions
+15 -287
View File
@@ -27,7 +27,6 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError,
@@ -45,35 +44,15 @@ from nanobot.utils.media_decode import (
save_base64_data_url,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
runtime_capabilities,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.cli_apps_api import (
cli_apps_action,
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.settings_api import runtime_capabilities
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.media_api import (
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
)
from nanobot.webui.mcp_presets_api import (
mcp_presets_settings_action,
normalize_mcp_preset_mentions,
)
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
@@ -88,18 +67,6 @@ from nanobot.webui.workspaces import (
WebUIWorkspaceController,
)
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -318,34 +285,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
query = _parse_query(request.path)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
"""Return the first value for *key*, or None."""
values = query.get(key)
@@ -586,7 +525,16 @@ class WebSocketChannel(BaseChannel):
self._runtime_surface,
runtime_capabilities_overrides,
)
self._settings_restart_sections: set[str] = set()
self._settings_routes = WebUISettingsRouter(
bus=self.bus,
logger=self.logger,
check_api_token=self._check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
runtime_surface=self._runtime_surface,
runtime_capabilities=self._runtime_capabilities,
)
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one
@@ -808,59 +756,7 @@ class WebSocketChannel(BaseChannel):
request: WsRequest,
got: str,
) -> Response | None:
if got == "/api/settings":
return self._handle_settings(request)
if got == "/api/settings/update":
return self._handle_settings_update(request)
if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if got == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if got == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if got == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if got == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if got == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if got == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if got == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
return await self._settings_routes.dispatch(request, got)
def _dispatch_session_api_route(
self,
@@ -1019,38 +915,6 @@ class WebSocketChannel(BaseChannel):
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
)
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self._with_settings_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _with_settings_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section)
sections = sorted(self._settings_restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -1083,142 +947,6 @@ class WebSocketChannel(BaseChannel):
return _http_error(500, "failed to write sidebar state")
return _http_json_response(state)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_agent_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = create_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_provider_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_web_search_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_image_generation_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_network_safety_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return _http_error(500, "failed to load CLI Apps")
return _http_json_response(payload)
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = await asyncio.to_thread(cli_apps_action, action, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return _http_error(status, message)
return _http_json_response(payload)
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = await mcp_presets_settings_action(
action,
_parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return _http_error(status, message)
if action is None:
return _http_json_response(payload)
return _http_json_response(
self._with_settings_restart_state(payload, section="runtime")
)
# -- Session replay, transcript, and signed media ----------------------
@staticmethod
+9 -1
View File
@@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
if api_key:
client_kw["api_key"] = api_key
if api_base:
client_kw["base_url"] = api_base
client_kw["base_url"] = self._normalize_base_url(api_base)
if extra_headers:
client_kw["default_headers"] = extra_headers
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
client_kw["max_retries"] = 0
self._client = AsyncAnthropic(**client_kw)
@staticmethod
def _normalize_base_url(api_base: str) -> str:
"""Anthropic SDK appends /v1 to request paths internally."""
normalized = api_base.rstrip("/")
if normalized.endswith("/v1"):
return normalized[: -len("/v1")]
return normalized
@classmethod
def _handle_error(cls, e: Exception) -> LLMResponse:
response = getattr(e, "response", None)
+15 -7
View File
@@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
find_legal_message_start,
image_placeholder_text,
safe_filename,
strip_think,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
@@ -76,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
return _text_preview(content)
def _metadata_title(metadata: Any) -> str:
if not isinstance(metadata, dict):
return ""
title = metadata.get("title")
if not isinstance(title, str):
return ""
if metadata.get("title_user_edited") is True:
return title
return strip_think(title)
@dataclass
class Session:
"""A conversation session."""
@@ -642,7 +654,7 @@ class SessionManager:
if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1)
metadata = data.get("metadata", {})
title = metadata.get("title") if isinstance(metadata, dict) else None
title = _metadata_title(metadata)
preview = ""
fallback_preview = ""
scanned_records = 0
@@ -673,7 +685,7 @@ class SessionManager:
"key": key,
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "",
"title": title,
"preview": preview,
"path": str(path)
})
@@ -684,11 +696,7 @@ class SessionManager:
"key": repaired.key,
"created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(),
"title": (
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"title": _metadata_title(repaired.metadata),
"preview": next(
(
text
+12 -2
View File
@@ -19,7 +19,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
WEBUI_SESSION_METADATA_KEY = "webui"
@@ -48,6 +48,7 @@ def clean_generated_title(raw: str | None) -> str:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = strip_think(text)
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
@@ -65,6 +66,9 @@ def _title_inputs(session: Session) -> tuple[str, str]:
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
content = strip_think(content)
if not content:
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
@@ -89,7 +93,13 @@ async def maybe_generate_webui_title(
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
cleaned_current_title = clean_generated_title(current_title)
if cleaned_current_title:
if cleaned_current_title != current_title:
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
sessions.save(session)
return False
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
user_text, assistant_text = _title_inputs(session)
if not user_text:
+9
View File
@@ -50,10 +50,17 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"image/jpeg",
"image/webp",
"image/gif",
"image/svg+xml",
"video/mp4",
"video/webm",
"video/quicktime",
})
_SVG_MEDIA_HEADERS: tuple[tuple[str, str], ...] = (
(
"Content-Security-Policy",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
),
)
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
@@ -203,6 +210,8 @@ def serve_signed_media(
("Cache-Control", "private, max-age=31536000, immutable"),
("X-Content-Type-Options", "nosniff"),
]
if mime == "image/svg+xml":
common_headers.extend(_SVG_MEDIA_HEADERS)
try:
size = candidate.stat().st_size
except OSError:
+248
View File
@@ -6,12 +6,15 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations
import os
import re
import time
from contextlib import suppress
from typing import Any, Literal
from zoneinfo import ZoneInfo
import httpx
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.image_generation import (
@@ -87,6 +90,47 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
}
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
"anthropic",
"azure_openai",
"bedrock",
"github_copilot",
"openai_codex",
}
_MODEL_LIST_CATALOG_PROVIDERS = {
"aihubmix",
"byteplus",
"byteplus_coding_plan",
"huggingface",
"novita",
"openrouter",
"siliconflow",
"volcengine",
"volcengine_coding_plan",
}
_MODEL_LIST_OFFICIAL_PROVIDERS = {
"ant_ling",
"dashscope",
"deepseek",
"gemini",
"groq",
"longcat",
"minimax",
"minimax_anthropic",
"mistral",
"moonshot",
"nvidia",
"openai",
"qianfan",
"skywork",
"stepfun",
"xiaomi_mimo",
"zhipu",
}
class WebUISettingsError(ValueError):
@@ -180,6 +224,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}"
def _resolve_env_placeholders(value: str | None) -> str | None:
if not value:
return None
missing = False
def replace(match: re.Match[str]) -> str:
nonlocal missing
env_value = os.environ.get(match.group(1))
if env_value is None:
missing = True
return ""
return env_value
resolved = _ENV_REF_RE.sub(replace, value).strip()
if missing and not resolved:
return None
return resolved or None
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
@@ -251,6 +314,191 @@ def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
)
def _model_catalog_kind(spec: Any) -> str:
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
return "catalog"
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
return "official"
if spec.is_local:
return "local"
if spec.is_direct:
return "custom"
if spec.is_gateway:
return "catalog"
return "official"
def _model_id_from_row(row: Any) -> str | None:
if isinstance(row, str):
return row.strip() or None
if not isinstance(row, dict):
return None
for key in ("id", "name", "model"):
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _model_context_window(row: Any) -> int | None:
if not isinstance(row, dict):
return None
for key in (
"context_window",
"context_length",
"max_context_length",
"max_model_len",
"max_input_tokens",
):
value = row.get(key)
if isinstance(value, int) and value > 0:
return value
if isinstance(value, float) and value > 0:
return int(value)
return None
def _model_row_payload(row: Any) -> dict[str, Any] | None:
model_id = _model_id_from_row(row)
if not model_id:
return None
label: str | None = None
owned_by: str | None = None
if isinstance(row, dict):
raw_label = row.get("display_name") or row.get("label") or row.get("name")
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
label = raw_label.strip()
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
if isinstance(raw_owner, str) and raw_owner.strip():
owned_by = raw_owner.strip()
return {
"id": model_id,
"label": label,
"owned_by": owned_by,
"context_window": _model_context_window(row),
}
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
raw_rows = body.get("data") if isinstance(body, dict) else body
if not isinstance(raw_rows, list):
return []
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for raw_row in raw_rows:
row = _model_row_payload(raw_row)
if row is None or row["id"] in seen:
continue
seen.add(row["id"])
rows.append(row)
return rows
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"""Fetch an OpenAI-compatible provider's model list for Settings.
The result is advisory only: users can always type a custom model id. This
helper deliberately avoids mutating config so probing model lists never
changes runtime behavior.
"""
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None:
raise WebUISettingsError("unknown provider")
base_payload: dict[str, Any] = {
"provider": spec.name,
"label": spec.label,
"catalog_kind": _model_catalog_kind(spec),
"models": [],
"model_count": 0,
"message": None,
"fetched_at": time.time(),
}
if (
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
and spec.name != "minimax_anthropic"
) or spec.is_oauth:
return {
**base_payload,
"status": "unsupported",
"catalog_kind": "unsupported",
"message": "Model list is not available for this provider. Type a model ID manually.",
}
config = load_config()
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
raise WebUISettingsError("unknown provider")
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base:
api_base = "https://api.openai.com/v1"
if not api_base:
return {
**base_payload,
"status": "missing_api_base",
"message": "Configure an API base URL to load models.",
}
api_key = _resolve_env_placeholders(provider_config.api_key)
if _provider_requires_api_key(spec) and not api_key:
return {
**base_payload,
"status": "not_configured",
"message": "Configure this provider before loading models.",
}
headers = {"Accept": "application/json"}
if api_key:
if spec.name == "minimax_anthropic":
headers["X-Api-Key"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
models_url = f"{api_base.rstrip('/')}/models"
if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"):
models_url = f"{api_base.rstrip('/')}/v1/models"
try:
response = httpx.get(
models_url,
headers=headers,
timeout=10.0,
follow_redirects=False,
)
response.raise_for_status()
rows = _extract_model_rows(response.json())
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status in {401, 403}:
return {
**base_payload,
"status": "not_configured",
"message": "The provider rejected the configured credential.",
}
return {
**base_payload,
"status": "error",
"message": f"Model list request failed with HTTP {status}.",
}
except (httpx.HTTPError, ValueError) as exc:
return {
**base_payload,
"status": "error",
"message": f"Could not load models: {exc}",
}
return {
**base_payload,
"status": "available",
"models": rows,
"model_count": len(rows),
}
def _parse_bool(value: str, field: str) -> bool:
normalized = value.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
+329
View File
@@ -0,0 +1,329 @@
"""HTTP route adapter for WebUI Settings APIs.
Keep WebUI Settings route handlers here, not in ``channels/websocket.py``.
The websocket channel owns transport concerns; this module owns WebUI Settings
request mapping and response shaping.
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from typing import Any
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.bus.queue import MessageBus
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
provider_models_payload,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
"/api/settings/mcp-presets/import": "import",
"/api/settings/mcp-presets/import-cursor": "import-cursor",
"/api/settings/mcp-presets/tools": "tools",
}
class WebUISettingsRouter:
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
def __init__(
self,
*,
bus: MessageBus,
logger: Any,
check_api_token: Callable[[WsRequest], bool],
parse_query: Callable[[str], QueryParams],
json_response: Callable[[dict[str, Any]], Response],
error_response: Callable[[int, str | None], Response],
runtime_surface: str,
runtime_capabilities: dict[str, Any],
) -> None:
self.bus = bus
self.logger = logger
self._check_api_token = check_api_token
self._parse_query = parse_query
self._json_response = json_response
self._error_response = error_response
self._runtime_surface = runtime_surface
self._runtime_capabilities = runtime_capabilities
self._restart_sections: set[str] = set()
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/update":
return self._handle_settings_update(request)
if path == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if path == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if path == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if path == "/api/settings/provider-models":
return await self._handle_settings_provider_models(request)
if path == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if path == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if path == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if path == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if path == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if path == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if path == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if path == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
def _query(self, request: WsRequest) -> QueryParams:
return self._parse_query(request.path)
def _authorized(self, request: WsRequest) -> bool:
return self._check_api_token(request)
def _unauthorized(self) -> Response:
return self._error_response(401, "Unauthorized")
def _with_restart_state(
self,
payload: dict[str, Any],
*,
section: str | None = None,
) -> dict[str, Any]:
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._restart_sections.add(section)
sections = sorted(self._restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _handle_settings(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(
self._with_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_agent_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = create_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_provider_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to load provider model list")
return self._error_response(500, "failed to load provider model list")
return self._json_response(payload)
async def _handle_settings_provider_oauth(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_web_search_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_image_generation_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = update_network_safety_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return self._error_response(500, "failed to load CLI Apps")
return self._json_response(payload)
async def _handle_settings_cli_apps_action(
self,
request: WsRequest,
action: str,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return self._error_response(status, message)
return self._json_response(payload)
async def _handle_settings_mcp_presets(
self,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await mcp_presets_settings_action(
action,
self._parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("MCP preset action '{}' failed", action or "list")
return self._error_response(status, message)
if action is None:
return self._json_response(payload)
return self._json_response(self._with_restart_state(payload, section="runtime"))
+7 -1
View File
@@ -27,6 +27,7 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".jpeg",
".webp",
".gif",
".svg",
})
_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({
".mp4",
@@ -87,7 +88,12 @@ def rewrite_local_markdown_images(
def _media_kind_from_name(name: str) -> str:
return "video" if Path(name).suffix.lower() in _INLINE_MARKDOWN_VIDEO_EXTS else "image"
ext = Path(name).suffix.lower()
if ext in _INLINE_MARKDOWN_IMAGE_EXTS:
return "image"
if ext in _INLINE_MARKDOWN_VIDEO_EXTS:
return "video"
return "file"
def webui_transcript_path(session_key: str) -> Path: