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: ''
+107
View File
@@ -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('-', '_')]
+10 -2
View File
@@ -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 <Outlet />.
// route and renders the matched child route via <Outlet />. 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 <Outlet />;
return (
<>
<CloudBanner />
<Outlet />
</>
);
}
+16
View File
@@ -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<string, string>;
}
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;
+1
View File
@@ -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: '',
+8 -2
View File
@@ -1088,7 +1088,10 @@ export default function WizardPage() {
if (isLoading) {
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<div
style={{ top: 'var(--cloud-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex items-center justify-center"
>
<LoadingSpinner text={t('wizard.loading')} />
</div>
);
@@ -1102,7 +1105,10 @@ export default function WizardPage() {
];
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<div
style={{ top: 'var(--cloud-banner-height, 0px)' }}
className="fixed inset-x-0 bottom-0 z-50 bg-background flex flex-col"
>
{/* Top bar: Skip button */}
<div className="shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 border-b">
<div className="flex items-center gap-2">
+224
View File
@@ -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<string, string> = {
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<string, typeof Info> = {
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<HTMLDivElement>(null);
const [banner, setBanner] = useState<ApiRespCloudBanner | null>(null);
const [dismissedSignature, setDismissedSignature] = useState<string | null>(
() =>
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 (
<Alert
ref={bannerRef}
className={`relative z-40 rounded-none border-x-0 border-t-0 py-2 ${TONE_CLASSES[tone]}`}
>
<Icon />
<AlertDescription className="text-sm">
<div className="flex w-full items-start gap-3">
<span className="flex-1">
{customCopy ? (
customCopy
) : (
<>
{t('cloud_banner.message')}{' '}
{banner.show_links && (
<>
<a
href={banner.dedicated_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline hover:opacity-80"
>
{t('cloud_banner.dedicated_link')}
</a>{' '}
{t('cloud_banner.or')}{' '}
<a
href={banner.repository_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline hover:opacity-80"
>
{t('cloud_banner.self_hosted_link')}
</a>
</>
)}
{t('cloud_banner.period')}
</>
)}
</span>
{banner.dismissible && (
<button
type="button"
onClick={dismiss}
className="-mr-2 -mt-1 shrink-0 p-1 opacity-80 hover:opacity-100"
aria-label={t('cloud_banner.dismiss')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</AlertDescription>
</Alert>
);
}
+5 -3
View File
@@ -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({
<div
data-slot="sidebar-container"
style={{
top: 0,
height: '100svh',
top: 'var(--cloud-banner-height, 0px)',
height: 'calc(100svh - var(--cloud-banner-height, 0px))',
}}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
+9
View File
@@ -3764,5 +3764,14 @@ const enUS = {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
},
cloud_banner: {
message:
'You are using LangBot Cloud, the managed service. For dedicated capacity and higher quotas, consider',
dedicated_link: 'LangBot Cloud (dedicated environment)',
or: 'or',
self_hosted_link: 'the self-hosted open-source version',
period: '.',
dismiss: 'Dismiss',
},
};
export default enUS;
+9
View File
@@ -3832,5 +3832,14 @@ const esES = {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
cloud_banner: {
message:
'Estás usando LangBot Cloud, el servicio gestionado. Para obtener recursos dedicados y cuotas mayores, considera usar',
dedicated_link: 'LangBot Cloud (entorno dedicado)',
or: 'o',
self_hosted_link: 'la versión de código abierto autoalojada',
period: '.',
dismiss: 'Cerrar',
},
};
export default esES;
+9
View File
@@ -3775,5 +3775,14 @@ const jaJP = {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
cloud_banner: {
message:
'現在は LangBot Cloud のマネージド環境です。専用リソースと高い利用上限が必要な場合は、',
dedicated_link: 'LangBot Cloud(専用環境)',
or: 'または',
self_hosted_link: 'セルフホスト版',
period: 'をご検討ください。',
dismiss: '閉じる',
},
};
export default jaJP;
+9
View File
@@ -3790,5 +3790,14 @@ const ruRU = {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
cloud_banner: {
message:
'Вы используете LangBot Cloud — управляемый сервис. Для выделенных ресурсов и повышенных лимитов рекомендуем',
dedicated_link: 'LangBot Cloud (выделенная среда)',
or: 'или',
self_hosted_link: 'версию с открытым исходным кодом на своём сервере',
period: '.',
dismiss: 'Закрыть',
},
};
export default ruRU;
+9
View File
@@ -3691,5 +3691,14 @@ const thTH = {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
cloud_banner: {
message:
'คุณกำลังใช้งาน LangBot Cloud ซึ่งเป็นบริการแบบมีการจัดการ หากต้องการทรัพยากรเฉพาะและโควตาที่สูงขึ้น โปรดพิจารณาใช้',
dedicated_link: 'LangBot Cloud (สภาพแวดล้อมเฉพาะ)',
or: 'หรือ',
self_hosted_link: 'เวอร์ชันโอเพนซอร์สที่โฮสต์เอง',
period: '',
dismiss: 'ปิด',
},
};
export default thTH;
+9
View File
@@ -3745,5 +3745,14 @@ const viVN = {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
cloud_banner: {
message:
'Bạn đang sử dụng LangBot Cloud, dịch vụ được quản lý. Nếu cần tài nguyên riêng và hạn mức cao hơn, hãy cân nhắc',
dedicated_link: 'LangBot Cloud (môi trường riêng)',
or: 'hoặc',
self_hosted_link: 'phiên bản mã nguồn mở tự lưu trữ',
period: '.',
dismiss: 'Đóng',
},
};
export default viVN;
+8
View File
@@ -3547,5 +3547,13 @@ const zhHans = {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
},
cloud_banner: {
message: '当前为 LangBot Cloud 云服务环境。如需独享资源与更高配额,可选择',
dedicated_link: '独立环境版 LangBot Cloud',
or: '或',
self_hosted_link: '自部署开源版本',
period: '。',
dismiss: '关闭横幅',
},
};
export default zhHans;
+9
View File
@@ -3547,5 +3547,14 @@ const zhHant = {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
cloud_banner: {
message:
'目前為 LangBot Cloud 雲端服務環境。如需專屬資源與更高配額,可選擇',
dedicated_link: '獨立環境版 LangBot Cloud',
or: '或',
self_hosted_link: '自行部署的開源版本',
period: '。',
dismiss: '關閉橫幅',
},
};
export default zhHant;
+47
View File
@@ -0,0 +1,47 @@
import type { ApiRespCloudBanner } from '@/app/infra/entities/api';
/**
* Map an i18next locale (`zh-Hans`) to the config key form (`zh_hans`).
*
* `config.yaml` exposes per-language copy through the `CLOUD__...` environment
* override namespace, which only splits on `__`, so languages are stored with
* underscores on the backend.
*/
export function cloudBannerLanguageKey(language: string): string {
return language
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Resolve the copy for one language: the configured override for that
* language, then the configured English copy, then `null` so the caller falls
* back to the built-in translated message with its inline links.
*/
export function resolveCloudBannerCopy(
banner: Pick<ApiRespCloudBanner, 'content'>,
language: string,
): string | null {
const content = banner?.content || {};
for (const key of [cloudBannerLanguageKey(language), 'en_us']) {
const value = content[key];
if (typeof value === 'string' && value.trim() !== '') {
return value.trim();
}
}
return null;
}
/**
* Identity of the notice currently on screen. A dismissal is bound to this
* value so publishing different copy brings the banner back, while a reload
* of the same notice stays dismissed.
*/
export function cloudBannerSignature(
banner: Pick<ApiRespCloudBanner, 'level'>,
message: string,
): string {
return `${banner.level}:${message}`;
}
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
function loadTs(relativePath) {
const source = readFileSync(new URL(relativePath, import.meta.url), 'utf8');
const m = { exports: {} };
// Locale catalogs import their own sub-catalogs; those are irrelevant to the
// keys asserted here, so every relative import resolves to an empty module.
const requireShim = () => ({});
new Function(
'module',
'exports',
'require',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(m, m.exports, requireShim);
return m.exports;
}
const { cloudBannerLanguageKey, resolveCloudBannerCopy, cloudBannerSignature } =
loadTs('../../src/lib/cloud-banner.ts');
test('language keys follow the config.yaml underscore form', () => {
assert.equal(cloudBannerLanguageKey('zh-Hans'), 'zh_hans');
assert.equal(cloudBannerLanguageKey('zh-Hant'), 'zh_hant');
assert.equal(cloudBannerLanguageKey('en-US'), 'en_us');
assert.equal(cloudBannerLanguageKey(' EN_us '), 'en_us');
assert.equal(cloudBannerLanguageKey('ja-JP'), 'ja_jp');
});
test('configured copy wins over the built-in text for that language', () => {
const banner = {
content: { zh_hans: '服务维护中', en_us: 'Maintenance in progress' },
};
assert.equal(resolveCloudBannerCopy(banner, 'zh-Hans'), '服务维护中');
assert.equal(
resolveCloudBannerCopy(banner, 'zh-Hant'),
'Maintenance in progress',
);
});
test('missing or blank entries fall back to the translated default', () => {
assert.equal(
resolveCloudBannerCopy({ content: { en_us: ' ' } }, 'zh-Hans'),
null,
);
assert.equal(resolveCloudBannerCopy({ content: {} }, 'en-US'), null);
assert.equal(resolveCloudBannerCopy({ content: null }, 'en-US'), null);
});
test('a dismissal is bound to the notice that was dismissed', () => {
const first = cloudBannerSignature({ level: 'info' }, 'alpha');
const second = cloudBannerSignature({ level: 'maintenance' }, 'alpha');
assert.notEqual(first, second);
assert.equal(first, cloudBannerSignature({ level: 'info' }, 'alpha'));
});
test('every locale carries the cloud_banner copy', () => {
const locales = [
'en-US',
'zh-Hans',
'zh-Hant',
'ja-JP',
'th-TH',
'vi-VN',
'es-ES',
'ru-RU',
];
const expectedKeys = [
'message',
'dedicated_link',
'or',
'self_hosted_link',
'period',
'dismiss',
];
for (const locale of locales) {
const translations = loadTs(`../../src/i18n/locales/${locale}.ts`).default;
const banner = translations.cloud_banner;
assert.ok(banner, `${locale} is missing cloud_banner`);
assert.deepEqual(Object.keys(banner).sort(), [...expectedKeys].sort());
for (const key of expectedKeys) {
assert.equal(
typeof banner[key],
'string',
`${locale}.cloud_banner.${key} must be a string`,
);
}
assert.notEqual(banner.message.trim(), '', `${locale} message is empty`);
// The message is followed by inline links, so a trailing period would
// render as a stray full stop before them.
assert.ok(
!banner.message.trim().endsWith('.') &&
!banner.message.trim().endsWith('。'),
`${locale} message must not end the sentence before its links`,
);
}
});