From d45b0a39578111682c8c1f25e3b122bbef80a3ff Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Wed, 21 Oct 2026 16:26:43 +0000 Subject: [PATCH] feat(cloud): add configurable Cloud-only WebUI notice banner Cloud deployments need a way to tell readers what is happening to the instance they are using (maintenance windows, service notices) without shipping a release per notice. Add a cloud.banner config block (switch, tone, dismissal, link targets and per-language copy) and expose the resolved banner on the unauthenticated /api/v1/system/info payload so the WebUI can render it before login. The banner resolves to null on OSS deployments, so a self-hosted copy of config.yaml never shows managed-service copy. Copy is plain text; only absolute http(s) link targets are rendered. The WebUI renders it once from the root layout, offsets the fixed app shell and the wizard through --cloud-banner-height, and keeps a dismissal bound to the notice it dismissed so new copy reappears. Co-authored-by: multica-agent --- .../pkg/api/http/controller/groups/system.py | 4 + src/langbot/pkg/cloud/banner.py | 117 +++++++++ src/langbot/templates/config.yaml | 33 +++ tests/unit_tests/cloud/test_banner.py | 107 +++++++++ web/src/app/RootLayout.tsx | 12 +- web/src/app/infra/entities/api/index.ts | 16 ++ web/src/app/infra/http/index.ts | 1 + web/src/app/wizard/page.tsx | 10 +- web/src/components/CloudBanner.tsx | 224 ++++++++++++++++++ web/src/components/ui/sidebar.tsx | 8 +- web/src/i18n/locales/en-US.ts | 9 + web/src/i18n/locales/es-ES.ts | 9 + web/src/i18n/locales/ja-JP.ts | 9 + web/src/i18n/locales/ru-RU.ts | 9 + web/src/i18n/locales/th-TH.ts | 9 + web/src/i18n/locales/vi-VN.ts | 9 + web/src/i18n/locales/zh-Hans.ts | 8 + web/src/i18n/locales/zh-Hant.ts | 9 + web/src/lib/cloud-banner.ts | 47 ++++ web/tests/unit/cloud-banner.test.mjs | 104 ++++++++ 20 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 src/langbot/pkg/cloud/banner.py create mode 100644 tests/unit_tests/cloud/test_banner.py create mode 100644 web/src/components/CloudBanner.tsx create mode 100644 web/src/lib/cloud-banner.ts create mode 100644 web/tests/unit/cloud-banner.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 56ca4f791..3f95ee7e4 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -5,6 +5,7 @@ import sqlalchemy from .. import group from .....utils import constants +from .....cloud.banner import resolve_cloud_banner from .....entity.persistence.metadata import WorkspaceMetadata from ...authz import Permission from ...context import PrincipalType, RequestContext @@ -219,6 +220,9 @@ class SystemRouterGroup(group.RouterGroup): 'edition': constants.edition, 'beta': is_beta, 'deployment_mode': 'cloud' if is_cloud else 'oss', + # Cloud-only instance notice. None on OSS deployments so a + # self-hosted copy of config.yaml can never surface it. + 'cloud_banner': resolve_cloud_banner(self.ap), 'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get( 'enable_marketplace', True ), diff --git a/src/langbot/pkg/cloud/banner.py b/src/langbot/pkg/cloud/banner.py new file mode 100644 index 000000000..a6a2834e3 --- /dev/null +++ b/src/langbot/pkg/cloud/banner.py @@ -0,0 +1,117 @@ +"""Cloud-only WebUI notice banner resolved from the instance configuration. + +The banner is a deployment statement, not a Workspace or tenant resource: it +describes the instance the browser is currently talking to, so it is exposed +through the unauthenticated ``/api/v1/system/info`` payload and only ever +resolved on Cloud deployments. OSS deployments never render it even when the +configuration block is present, which keeps self-hosters from inheriting copy +that only makes sense for the managed service. +""" + +from __future__ import annotations + +import re +from typing import Any + +SUPPORTED_LEVELS = ('info', 'warning', 'maintenance') + +DEFAULT_LEVEL = 'info' +DEFAULT_DEDICATED_URL = 'https://space.langbot.app/cloud?environment=dedicated' +DEFAULT_REPOSITORY_URL = 'https://github.com/langbot-app/LangBot' + +# Copy is rendered as text, never as markup, so only the length is bounded. +MAX_CONTENT_CHARS = 600 +MAX_URL_CHARS = 500 +MAX_LANGUAGES = 8 + +_LANGUAGE_KEY_RE = re.compile(r'[^a-z0-9]+') +_SAFE_URL_PREFIXES = ('https://', 'http://') + + +def normalize_language_key(value: str) -> str: + """Normalize a language tag to the ``zh_hans``/``en_us`` config key form. + + ``config.yaml`` cannot express dashes through the ``CLOUD__...`` environment + override (the override namespace only splits on ``__``), so both the file + and the environment expose languages with underscores. Browser locales and + i18next codes use dashes, hence the normalization here. + """ + + return _LANGUAGE_KEY_RE.sub('_', value.strip().lower()).strip('_') + + +def _bounded_text(raw: Any, limit: int) -> str: + if not isinstance(raw, str): + return '' + return raw.strip()[:limit] + + +def _bounded_url(raw: Any, fallback: str) -> str: + candidate = _bounded_text(raw, MAX_URL_CHARS) + if not candidate: + return fallback + if not candidate.lower().startswith(_SAFE_URL_PREFIXES): + # Only absolute web URLs are rendered as links; anything else (for + # example ``javascript:``) falls back to the canonical destination. + return fallback + return candidate + + +def _as_bool(raw: Any, fallback: bool) -> bool: + if isinstance(raw, bool): + return raw + if isinstance(raw, str): + normalized = raw.strip().lower() + if normalized in ('true', '1', 'yes', 'on'): + return True + if normalized in ('false', '0', 'no', 'off', ''): + return False + return fallback + + +def resolve_cloud_banner(ap) -> dict | None: + """Return the sanitized banner payload, or ``None`` when it is not shown. + + ``None`` means "render nothing": either the deployment is not Cloud, the + switch is off, or no content can be resolved for any language. + """ + + if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud': + return None + + config = getattr(getattr(ap, 'instance_config', None), 'data', None) or {} + if not isinstance(config, dict): + return None + section = config.get('cloud') + if not isinstance(section, dict): + return None + raw_banner = section.get('banner') + if not isinstance(raw_banner, dict): + return None + if not _as_bool(raw_banner.get('enabled', False), False): + return None + + level = _bounded_text(raw_banner.get('level'), 32).lower() or DEFAULT_LEVEL + if level not in SUPPORTED_LEVELS: + level = DEFAULT_LEVEL + + content: dict[str, str] = {} + raw_content = raw_banner.get('content') + if isinstance(raw_content, dict): + for language, text in list(raw_content.items())[:MAX_LANGUAGES]: + if not isinstance(language, str): + continue + key = normalize_language_key(language) + value = _bounded_text(text, MAX_CONTENT_CHARS) + if key and value: + content[key] = value + + return { + 'enabled': True, + 'level': level, + 'dismissible': _as_bool(raw_banner.get('dismissible', True), True), + 'show_links': _as_bool(raw_banner.get('show_links', True), True), + 'dedicated_url': _bounded_url(raw_banner.get('dedicated_url'), DEFAULT_DEDICATED_URL), + 'repository_url': _bounded_url(raw_banner.get('repository_url'), DEFAULT_REPOSITORY_URL), + 'content': content, + } diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 97650b8d8..4cc4ab711 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -77,6 +77,39 @@ cloud: # Signed control-plane envelope buffered by the closed adapter before # JSON/JWS verification (32 MiB; absolute maximum 64 MiB). max_response_bytes: 33554432 + # Instance notice banner shown at the top of the Cloud WebUI. It is only + # rendered when this instance runs as a Cloud deployment, so self-hosted + # copies of this file never surface it. Every field is overridable through + # the environment (for example CLOUD__BANNER__ENABLED, and + # CLOUD__BANNER__CONTENT__ZH_HANS for one language of the copy). + banner: + # Master switch. False keeps the banner out of the UI entirely. + enabled: false + # Visual tone: info, warning, or maintenance. + level: info + # Let the reader hide the banner in the current browser session. A + # change of copy brings it back. + dismissible: true + # Append the "dedicated environment / self-hosting" links after the + # message. Turn off for a plain announcement. + show_links: true + # Inline link targets. Absolute http(s) URLs only; anything else falls + # back to these same defaults. + dedicated_url: 'https://space.langbot.app/cloud?environment=dedicated' + repository_url: 'https://github.com/langbot-app/LangBot' + # Optional per-language copy. A non-empty entry replaces the built-in + # message for that language; empty entries fall back to the built-in + # text. Keys use underscores because the environment override + # namespace cannot carry dashes. Plain text only, never markup. + content: + zh_Hans: '' + zh_Hant: '' + en_US: '' + ja_JP: '' + th_TH: '' + vi_VN: '' + es_ES: '' + ru_RU: '' proxy: http: '' https: '' diff --git a/tests/unit_tests/cloud/test_banner.py b/tests/unit_tests/cloud/test_banner.py new file mode 100644 index 000000000..307158c78 --- /dev/null +++ b/tests/unit_tests/cloud/test_banner.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace + +import pytest + +from langbot.pkg.cloud.banner import resolve_cloud_banner + + +def cloud_app(config: dict | None = None, mode: str = 'cloud'): + return SimpleNamespace( + deployment=SimpleNamespace(mode=mode), + instance_config=SimpleNamespace(data=config or {}), + ) + + +def banner_config(**overrides) -> dict: + banner = { + 'enabled': True, + 'level': 'maintenance', + 'content': { + 'zh_Hans': ' 服务维护中 ', + 'en_US': 'Service maintenance in progress', + }, + } + banner.update(overrides) + return {'cloud': {'banner': banner}} + + +def test_cloud_banner_resolves_configured_copy(): + banner = resolve_cloud_banner(cloud_app(banner_config())) + + assert banner is not None + assert banner['level'] == 'maintenance' + assert banner['content'] == { + 'zh_hans': '服务维护中', + 'en_us': 'Service maintenance in progress', + } + # Defaults remain available for the inline links. + assert banner['show_links'] is True + assert banner['dismissible'] is True + assert banner['dedicated_url'] == 'https://space.langbot.app/cloud?environment=dedicated' + assert banner['repository_url'] == 'https://github.com/langbot-app/LangBot' + + +def test_cloud_banner_is_cloud_only(): + assert resolve_cloud_banner(cloud_app(banner_config(), mode='oss')) is None + + +def test_cloud_banner_needs_the_switch(): + assert resolve_cloud_banner(cloud_app(banner_config(enabled=False))) is None + assert resolve_cloud_banner(cloud_app({})) is None + assert resolve_cloud_banner(cloud_app({'cloud': {'banner': 'true'}})) is None + + +def test_cloud_banner_accepts_string_switch_from_environment(): + # ``CLOUD__BANNER__ENABLED=true`` on an instance whose config.yaml has no + # banner block lands as the string 'true' instead of a boolean. + banner = resolve_cloud_banner(cloud_app({'cloud': {'banner': {'enabled': 'true', 'content': {'en_US': 'x'}}}})) + + assert banner is not None + assert banner['content'] == {'en_us': 'x'} + + +def test_cloud_banner_normalizes_level_and_links(): + banner = resolve_cloud_banner( + cloud_app( + { + 'cloud': { + 'banner': { + 'enabled': True, + 'level': 'LOUD', + 'show_links': False, + 'dismissible': False, + 'dedicated_url': 'javascript:alert(1)', + 'repository_url': 'https://example.com/repo', + 'content': {'en_US': 'notice'}, + } + } + } + ) + ) + + assert banner is not None + assert banner['level'] == 'info' + assert banner['show_links'] is False + assert banner['dismissible'] is False + # A non-web URL never reaches the rendered link. + assert banner['dedicated_url'] == 'https://space.langbot.app/cloud?environment=dedicated' + assert banner['repository_url'] == 'https://example.com/repo' + + +def test_cloud_banner_drops_empty_copy(): + banner = resolve_cloud_banner( + cloud_app({'cloud': {'banner': {'enabled': True, 'content': {'zh_Hans': ' ', 'en_US': ''}}}}) + ) + + assert banner is not None + assert banner['content'] == {} + + +@pytest.mark.parametrize('language', ['zh-Hans', 'zh_hant', 'EN_us', 'ja-JP', 'ru-RU']) +def test_cloud_banner_language_keys_are_underscored(language: str): + banner = resolve_cloud_banner( + cloud_app({'cloud': {'banner': {'enabled': True, 'content': {language: 'copy'}}}}) + ) + + assert banner is not None + assert list(banner['content']) == [language.strip().lower().replace('-', '_')] diff --git a/web/src/app/RootLayout.tsx b/web/src/app/RootLayout.tsx index 444de4b4a..37adb0cb6 100644 --- a/web/src/app/RootLayout.tsx +++ b/web/src/app/RootLayout.tsx @@ -1,9 +1,17 @@ import { Outlet } from 'react-router-dom'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { CloudBanner } from '@/components/CloudBanner'; // Top-level route layout: drives the dynamic document title from the active -// route and renders the matched child route via . +// route and renders the matched child route via . The Cloud-only +// instance notice lives here so it is present on every route (login included); +// it publishes `--cloud-banner-height` for the fixed shell to offset itself. export default function RootLayout() { useDocumentTitle(); - return ; + return ( + <> + + + + ); } diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index a91ff09b0..3223ed7b3 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -562,12 +562,28 @@ export interface WizardProgress { selected_runner: string | null; } +export interface ApiRespCloudBanner { + enabled: boolean; + /** Visual tone requested by the deployment: info, warning, maintenance. */ + level: 'info' | 'warning' | 'maintenance'; + dismissible: boolean; + /** Append the dedicated-environment / self-hosting links after the copy. */ + show_links: boolean; + dedicated_url: string; + repository_url: string; + /** Language key (`zh_hans`, `en_us`, ...) -> operator-authored copy. */ + content: Record; +} + export interface ApiRespSystemInfo { debug: boolean; version: string; edition: string; beta: boolean; deployment_mode: 'cloud' | 'oss'; + /** Cloud-only instance notice from `cloud.banner` in config.yaml. Null for + * OSS deployments or when the banner is disabled. */ + cloud_banner?: ApiRespCloudBanner | null; /** Independent instance-level gate for local stdio MCP transports. */ mcp_stdio_enabled: boolean; cloud_service_url: string; diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index e7fcf4d03..758bac8df 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -27,6 +27,7 @@ export const systemInfo: ApiRespSystemInfo = { edition: 'community', beta: false, deployment_mode: 'oss', + cloud_banner: null, mcp_stdio_enabled: false, enable_marketplace: true, cloud_service_url: '', diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index c825c2111..fee5b7371 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -1088,7 +1088,10 @@ export default function WizardPage() { if (isLoading) { return ( -
+
); @@ -1102,7 +1105,10 @@ export default function WizardPage() { ]; return ( -
+
{/* Top bar: Skip button */}
diff --git a/web/src/components/CloudBanner.tsx b/web/src/components/CloudBanner.tsx new file mode 100644 index 000000000..119ea5dca --- /dev/null +++ b/web/src/components/CloudBanner.tsx @@ -0,0 +1,224 @@ +import { useTranslation } from 'react-i18next'; +import { AlertCircle, Info, Wrench, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { backendClient } from '@/app/infra/http'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import type { ApiRespCloudBanner } from '@/app/infra/entities/api'; +import { + cloudBannerSignature, + resolveCloudBannerCopy, +} from '@/lib/cloud-banner'; + +const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000]; +// The dismissal is bound to the copy that was dismissed, so publishing a new +// notice (or editing the current one) brings the banner back instead of +// leaving every returning reader permanently blind to it. +const DISMISSED_SIGNATURE_KEY = 'langbot_cloud_banner_dismissed'; + +const TONE_CLASSES: Record = { + info: 'bg-sky-50 dark:bg-sky-950/25 border-sky-200 dark:border-sky-900/50 text-sky-900 dark:text-sky-100 [&>svg]:text-sky-600 dark:[&>svg]:text-sky-400', + warning: + 'bg-amber-50 dark:bg-amber-950/25 border-amber-200 dark:border-amber-900/50 text-amber-900 dark:text-amber-100 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400', + maintenance: + 'bg-rose-50 dark:bg-rose-950/25 border-rose-200 dark:border-rose-900/50 text-rose-900 dark:text-rose-100 [&>svg]:text-rose-600 dark:[&>svg]:text-rose-400', +}; + +const TONE_ICONS: Record = { + info: Info, + warning: AlertCircle, + maintenance: Wrench, +}; + +const LOCALE_KEYS = [ + 'zh-Hans', + 'zh-Hant', + 'en-US', + 'ja-JP', + 'th-TH', + 'vi-VN', + 'es-ES', + 'ru-RU', +] as const; + +function currentLanguage(): string { + const candidates = [ + typeof document !== 'undefined' ? document.documentElement.lang : '', + typeof navigator !== 'undefined' ? navigator.language : '', + ...LOCALE_KEYS, + ]; + return ( + candidates.find( + (value) => typeof value === 'string' && value.trim() !== '', + ) || 'en-US' + ); +} + +/** + * Instance notice banner for Cloud deployments. + * + * Rendered once by the root layout so every route (login included) sees the + * same announcement. It publishes its own height as ``--cloud-banner-height`` + * so the fixed app shell starts below it rather than underneath it. + */ +export function CloudBanner() { + const { t, i18n } = useTranslation(); + const bannerRef = useRef(null); + const [banner, setBanner] = useState(null); + const [dismissedSignature, setDismissedSignature] = useState( + () => + typeof window === 'undefined' + ? null + : window.sessionStorage.getItem(DISMISSED_SIGNATURE_KEY), + ); + + useEffect(() => { + let cancelled = false; + let retryTimer: number | undefined; + + const probe = async (attempt = 0) => { + try { + const info = await backendClient.getSystemInfo(); + if (cancelled) { + return; + } + setBanner( + info.deployment_mode === 'cloud' && info.cloud_banner?.enabled + ? info.cloud_banner + : null, + ); + } catch { + if (cancelled) { + return; + } + // A cold start or a restarting backend must not hide an active notice + // for the rest of the session, so retry with backoff and keep the last + // known state in between. + if (attempt < RETRY_DELAYS_MS.length) { + retryTimer = window.setTimeout( + () => void probe(attempt + 1), + RETRY_DELAYS_MS[attempt], + ); + } + } + }; + + void probe(); + + const recheck = () => { + if (!cancelled) { + void probe(); + } + }; + window.addEventListener('focus', recheck); + + return () => { + cancelled = true; + clearTimeout(retryTimer); + window.removeEventListener('focus', recheck); + }; + }, []); + + const language = i18n?.language || currentLanguage(); + const customCopy = banner ? resolveCloudBannerCopy(banner, language) : null; + const message = customCopy ?? (banner ? t('cloud_banner.message') : ''); + const signature = banner ? cloudBannerSignature(banner, message) : ''; + const visible = + Boolean(banner && message) && signature !== dismissedSignature; + + // Publish the occupied height so the sidebar/shell can offset itself. + useEffect(() => { + const root = document.documentElement; + if (!visible) { + root.style.removeProperty('--cloud-banner-height'); + return; + } + const element = bannerRef.current; + if (!element) { + return; + } + const sync = () => { + root.style.setProperty( + '--cloud-banner-height', + `${element.getBoundingClientRect().height}px`, + ); + }; + sync(); + const observer = new ResizeObserver(sync); + observer.observe(element); + return () => { + observer.disconnect(); + root.style.removeProperty('--cloud-banner-height'); + }; + }, [visible, signature]); + + const dismiss = useCallback(() => { + setDismissedSignature(signature); + try { + window.sessionStorage.setItem(DISMISSED_SIGNATURE_KEY, signature); + } catch { + // Private-mode storage failures must not break the dismissal itself. + } + }, [signature]); + + if (!banner || !visible) { + return null; + } + + const tone = banner.level in TONE_CLASSES ? banner.level : 'info'; + const Icon = TONE_ICONS[tone]; + + return ( + + + +
+ + {customCopy ? ( + customCopy + ) : ( + <> + {t('cloud_banner.message')}{' '} + {banner.show_links && ( + <> + + {t('cloud_banner.dedicated_link')} + {' '} + {t('cloud_banner.or')}{' '} + + {t('cloud_banner.self_hosted_link')} + + + )} + {t('cloud_banner.period')} + + )} + + {banner.dismissible && ( + + )} +
+
+
+ ); +} diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index d142aaf03..4277ff65e 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -203,7 +203,9 @@ function SidebarProvider({ { '--sidebar-width': `${sidebarWidth}px`, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, - height: '100svh', + // A Cloud notice banner publishes its height so the shell starts + // below the announcement instead of underneath it. + height: 'calc(100svh - var(--cloud-banner-height, 0px))', ...style, } as React.CSSProperties } @@ -300,8 +302,8 @@ function Sidebar({