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 <[email protected]>
This commit is contained in:
RockChinQ
2026-10-21 16:26:43 +00:00
co-authored by multica-agent
parent 0d365c98d8
commit d45b0a3957
20 changed files with 747 additions and 7 deletions
@@ -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
),
+117
View File
@@ -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,
}
+33
View File
@@ -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: ''