diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 1f663a121..0e799def8 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -34,9 +34,18 @@ from nanobot.webui.workspaces import ( write_webui_default_access_mode, ) +from nanobot import __version__ + QueryParams = dict[str, list[str]] RuntimeSurface = Literal["browser", "native"] + +def _version_payload() -> dict[str, Any]: + """Return version info for the settings payload.""" + return { + "current": __version__, + } + _RUNTIME_CAPABILITIES = { "can_restart_engine": False, "can_pick_folder": False, @@ -805,6 +814,7 @@ def settings_payload( "exec_path_append_set": bool(exec_config.path_append), }, "requires_restart": requires_restart, + "version": _version_payload(), } return decorate_settings_payload( payload, diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index b8dbb4b73..017652331 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -36,6 +36,7 @@ from nanobot.webui.settings_api import ( update_transcription_settings, update_web_search_settings, ) +from nanobot.webui.version_check import check_for_update QueryParams = dict[str, list[str]] @@ -117,6 +118,8 @@ class WebUISettingsRouter: return await self._handle_settings_cli_apps_action(request, "test") if path == "/api/settings/mcp-presets": return await self._handle_settings_mcp_presets(request) + if path == "/api/settings/version-check": + return await self._handle_settings_version_check(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) @@ -347,3 +350,15 @@ class WebUISettingsRouter: if action is None: return self._json_response(payload) return self._json_response(self._with_restart_state(payload, section="runtime")) + + async def _handle_settings_version_check(self, request: WsRequest) -> Response: + if not self._authorized(request): + return self._unauthorized() + try: + update_info = await asyncio.to_thread(check_for_update) + except Exception: + self.logger.exception("version check failed") + return self._error_response(500, "version check failed") + return self._json_response({ + "updateAvailable": update_info, + }) diff --git a/nanobot/webui/version_check.py b/nanobot/webui/version_check.py new file mode 100644 index 000000000..6db45c630 --- /dev/null +++ b/nanobot/webui/version_check.py @@ -0,0 +1,51 @@ +"""On-demand version checker for nanobot-ai releases. + +Checks PyPI for newer versions when explicitly requested (no background polling). +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +import httpx + +from nanobot import __version__ + +logger = logging.getLogger(__name__) + +_PYPI_URL = "https://pypi.org/pypi/nanobot-ai/json" +_CACHE_TTL_S = 300 # 5 minutes cache to avoid hammering PyPI + +_cache: tuple[float, str | None] = (0.0, None) + + +def check_for_update() -> dict[str, Any] | None: + """Check PyPI for a newer version. Returns update info dict or None if up-to-date. + + Uses a short cache to avoid repeated requests within the TTL window. + This is a blocking call — invoke from a thread or background task. + """ + global _cache + now = time.monotonic() + cached_at, cached_val = _cache + if now - cached_at < _CACHE_TTL_S and cached_val is not None: + latest = cached_val + else: + try: + resp = httpx.get(_PYPI_URL, timeout=5.0, follow_redirects=True) + resp.raise_for_status() + latest = resp.json().get("info", {}).get("version") + except Exception: + logger.debug("PyPI version check failed", exc_info=True) + return None + _cache = (now, latest) + + if not latest or latest == __version__: + return None + return { + "currentVersion": __version__, + "latestVersion": latest, + "pypiUrl": "https://pypi.org/project/nanobot-ai/", + } diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 0a6ebcf5a..b1ea148d5 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { Activity, + ArrowUpCircle, Bot, Brain, Check, @@ -22,6 +23,7 @@ import { Database, Eye, EyeOff, + ExternalLink, Gem, Globe2, Grid3X3, @@ -75,6 +77,7 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { + checkVersion, createModelConfiguration, fetchSettings, fetchSettingsUsage, @@ -1852,6 +1855,104 @@ function OverviewSettings({ /> + +
+ {tx("settings.sections.about", "About")} + + + +
+ + ); +} + +function VersionCheckRow({ currentVersion }: { currentVersion?: string }) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const { token } = useClient(); + const [checking, setChecking] = useState(false); + const [result, setResult] = useState< + | { type: "up-to-date" } + | { type: "update"; latestVersion: string; pypiUrl?: string } + | { type: "error"; message: string } + | null + >(null); + + const handleCheck = async () => { + setChecking(true); + setResult(null); + try { + const res = await checkVersion(token); + if (res.updateAvailable) { + setResult({ + type: "update", + latestVersion: res.updateAvailable.latestVersion, + pypiUrl: res.updateAvailable.pypiUrl, + }); + } else { + setResult({ type: "up-to-date" }); + } + } catch (err) { + setResult({ type: "error", message: (err as Error).message }); + } finally { + setChecking(false); + } + }; + + return ( +
+
+
+ {tx("settings.about.version", "Version")} +
+
+ {currentVersion ? `v${currentVersion}` : "nanobot"} +
+
+
+ + {result?.type === "up-to-date" ? ( + + + {tx("settings.about.upToDate", "You're up to date")} + + ) : null} + {result?.type === "update" ? ( + + + {tx("settings.about.updateAvailable", "Update available")}{result.latestVersion && ` v${result.latestVersion}`} + {result.pypiUrl ? ( + + PyPI + + + ) : null} + + ) : null} + {result?.type === "error" ? ( + {result.message} + ) : null} +
); } diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 1342a102b..39b48c907 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -229,6 +229,26 @@ export async function fetchSettingsUsage( ); } +export interface VersionCheckResult { + updateAvailable: { + currentVersion: string; + latestVersion: string; + pypiUrl?: string; + } | null; +} + +export async function checkVersion( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/settings/version-check`, + token, + undefined, + 10_000, + ); +} + export async function fetchWorkspaces( token: string, base: string = "", diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index c9dc4164d..8687c369e 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -485,6 +485,9 @@ export interface SettingsPayload { }; requires_restart: boolean; restart_required_sections?: Array<"runtime" | "browser" | "image">; + version?: { + current: string; + }; } export interface AppPackageRef {