mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
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:
co-authored by
multica-agent
parent
0d365c98d8
commit
d45b0a3957
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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('-', '_')]
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user