mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
feat(cloud): enforce plan policies and refine workspace interface
This commit is contained in:
@@ -238,3 +238,7 @@ are not rewritten. Embedding and rerank models are not eligible.
|
||||
### Reset a bot session context
|
||||
|
||||
Use `reset_session_context(bot_id, session_id)` only when a user asks to start a session afresh. It requires `resource.manage`, preserves monitoring records, and refuses sessions with active tasks. It clears conversation runner state and excludes earlier transcript entries from subsequent model context. Files and long-term memory are not removed.
|
||||
|
||||
## Cloud plan platform restrictions
|
||||
|
||||
Bot creation and enabling use the same Workspace entitlement policy as HTTP. A disabled platform is rejected even through MCP or plugin calls; do not retry using an Omni or legacy alias to bypass the restriction. The Workspace-scoped HTTP adapter catalog omits disabled platforms.
|
||||
|
||||
@@ -296,6 +296,8 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
if isinstance(e, CodexProviderError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, EntitlementUnavailableError):
|
||||
return self.http_status(403, 'entitlement_unavailable', str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, WorkspaceNotFoundError):
|
||||
|
||||
@@ -8,6 +8,7 @@ import quart
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import RequestContext
|
||||
from langbot.pkg.cloud.plan_policy import workspace_entitlement, platform_feature_names, require_workspace_platform
|
||||
from langbot.pkg.core.errors import TaskCapacityError
|
||||
from langbot.pkg.utils import httpclient, importutil
|
||||
|
||||
@@ -238,11 +239,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
@self.route('', methods=['GET'])
|
||||
async def _() -> str:
|
||||
return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()})
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid)
|
||||
adapters = self.ap.platform_mgr.get_available_adapters_info()
|
||||
if snapshot is not None:
|
||||
adapters = [a for a in adapters if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))]
|
||||
return self.success(data={'adapters': adapters})
|
||||
|
||||
@self.route('/<adapter_name>', methods=['GET'])
|
||||
async def _(adapter_name: str) -> str:
|
||||
async def _(adapter_name: str, request_context: RequestContext) -> str:
|
||||
await require_workspace_platform(self.ap, request_context.workspace_uuid, adapter_name)
|
||||
adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name)
|
||||
|
||||
if adapter_info is None:
|
||||
|
||||
@@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
from ....platform.adapter_names import canonical_adapter_name
|
||||
from ....cloud.plan_policy import require_workspace_platform
|
||||
from ....agent.runner.errors import RunnerNotFoundError
|
||||
|
||||
|
||||
@@ -696,6 +697,7 @@ class BotService:
|
||||
|
||||
# TODO: 检查配置信息格式
|
||||
bot_data = await self._prepare_bot_data(context, bot_data, include_uuid=True)
|
||||
await require_workspace_platform(self.ap, workspace_uuid, bot_data['adapter'])
|
||||
bot_data['uuid'] = str(uuid.uuid4())
|
||||
bot_data['workspace_uuid'] = workspace_uuid
|
||||
bot_data.setdefault('event_bindings', [])
|
||||
@@ -718,6 +720,9 @@ class BotService:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
update_data = await self._prepare_bot_data(context, bot_data, include_uuid=False)
|
||||
update_data.pop('workspace_uuid', None)
|
||||
if 'adapter' in update_data or update_data.get('enable'):
|
||||
current = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
await require_workspace_platform(self.ap, workspace_uuid, update_data.get('adapter', current['adapter']))
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
|
||||
@@ -260,6 +260,9 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
A dict mapping table name to the number of deleted rows.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
from ....cloud.plan_policy import workspace_history_retention_days
|
||||
|
||||
retention_days = await workspace_history_retention_days(self.ap, workspace_uuid, retention_days)
|
||||
if retention_days < 1:
|
||||
raise ValueError('retention_days must be >= 1')
|
||||
if batch_size < 1:
|
||||
|
||||
@@ -347,7 +347,7 @@ class LangBotMCPServer:
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create a bot. `bot_data` is a JSON object matching the LangBot '
|
||||
'Create a bot. Cloud Workspace platform restrictions also apply to this tool. `bot_data` is a JSON object matching the LangBot '
|
||||
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
|
||||
'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array '
|
||||
'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. '
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Workspace plan policies evaluated from trusted entitlement projections."""
|
||||
|
||||
from .entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
|
||||
from ..platform.adapter_names import canonical_adapter_name
|
||||
|
||||
|
||||
def platform_feature_names(adapter: str) -> set[str]:
|
||||
canonical = canonical_adapter_name(adapter)
|
||||
names = {adapter, canonical}
|
||||
if adapter.lower() == "line":
|
||||
names.update({"line", "LINE"})
|
||||
if canonical.endswith('-omni'):
|
||||
names.add(canonical[:-5])
|
||||
names.add(canonical[:-5] + '-eba')
|
||||
return {'platform.' + name for name in names}
|
||||
|
||||
|
||||
async def workspace_entitlement(ap, workspace_uuid: str):
|
||||
if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud':
|
||||
return None
|
||||
resolver = getattr(ap, 'entitlement_resolver', None)
|
||||
if resolver is None:
|
||||
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
|
||||
return await resolver.resolve(workspace_uuid)
|
||||
|
||||
|
||||
async def require_workspace_platform(ap, workspace_uuid: str, adapter: str) -> None:
|
||||
snapshot = await workspace_entitlement(ap, workspace_uuid)
|
||||
if snapshot is None:
|
||||
return
|
||||
for feature in platform_feature_names(adapter):
|
||||
if snapshot.features.get(feature) is False:
|
||||
raise EntitlementFeatureUnavailableError(feature, entitlement_revision=snapshot.entitlement_revision)
|
||||
|
||||
|
||||
async def workspace_history_retention_days(ap, workspace_uuid: str, fallback: int = 30) -> int:
|
||||
snapshot = await workspace_entitlement(ap, workspace_uuid)
|
||||
if snapshot is None:
|
||||
return fallback
|
||||
# Old signed snapshots inherit the existing 30-day policy. Never interpret
|
||||
# missing entitlement data as an unlimited retention policy.
|
||||
return max(1, snapshot.limits.get('chat_history.retention_days', 30))
|
||||
@@ -427,7 +427,7 @@ class Application:
|
||||
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
monitoring_enabled = auto_cleanup_cfg.get('enabled', True)
|
||||
monitoring_enabled = auto_cleanup_cfg.get('enabled', True) or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud'
|
||||
retention_days = self._get_positive_int_config(
|
||||
auto_cleanup_cfg.get('retention_days', 30),
|
||||
default=30,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..cloud.plan_policy import require_workspace_platform
|
||||
|
||||
from ..i18n import language_scope
|
||||
from ..i18n.workspace import get_workspace_language
|
||||
|
||||
@@ -117,6 +119,7 @@ class RuntimeBot:
|
||||
async def assert_execution_active(self) -> None:
|
||||
"""Fail closed when this long-lived adapter belongs to a stale placement."""
|
||||
|
||||
await require_workspace_platform(self.ap, self.workspace_uuid, self.bot_entity.adapter)
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
self.workspace_uuid,
|
||||
expected_generation=self.placement_generation,
|
||||
@@ -919,6 +922,7 @@ class RuntimeBot:
|
||||
from ..telemetry import trace as trace_mod
|
||||
from ..telemetry.execution import ingress
|
||||
|
||||
await self.assert_execution_active()
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
execution_id = self._platform_event_execution_id(event)
|
||||
if getattr(event, 'adapter_name', None) == 'wecomcs-omni':
|
||||
@@ -2316,6 +2320,7 @@ class PlatformManager:
|
||||
bot_entity = persistence_bot.Bot(**bot_entity)
|
||||
|
||||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
|
||||
await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot_entity.adapter)
|
||||
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
|
||||
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
|
||||
if not _binding_validated:
|
||||
@@ -2405,6 +2410,7 @@ class PlatformManager:
|
||||
)
|
||||
if bot is None or bot.placement_generation != execution_context.placement_generation:
|
||||
return None
|
||||
await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot.bot_entity.adapter)
|
||||
return bot
|
||||
|
||||
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
|
||||
|
||||
@@ -76,3 +76,21 @@ def test_execution_context_preserves_entitlement_revision():
|
||||
)
|
||||
|
||||
assert ExecutionContext.from_request(request).entitlement_revision == 11
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_entitlement_denial_is_403_not_internal_error():
|
||||
from langbot.pkg.api.http.controller.group import AuthType
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
|
||||
router = _router(SimpleNamespace(mode='cloud'))
|
||||
router.path = '/policy'
|
||||
router.name = 'policy'
|
||||
|
||||
@router.route('', methods=['GET'], auth_type=AuthType.NONE)
|
||||
async def denied():
|
||||
raise EntitlementFeatureUnavailableError('platform.telegram', entitlement_revision=2)
|
||||
|
||||
response = await router.quart_app.test_client().get('/policy')
|
||||
assert response.status_code == 403
|
||||
assert (await response.get_json())['code'] == 'entitlement_unavailable'
|
||||
|
||||
@@ -423,3 +423,21 @@ async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
|
||||
assert remaining == 3
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_cloud_cleanup_uses_plan_retention_and_isolates_workspaces(service):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
service.ap.deployment = SimpleNamespace(mode='cloud')
|
||||
service.ap.entitlement_resolver = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(limits={'chat_history.retention_days': 7})))
|
||||
old = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=10)
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringMessage).values(
|
||||
id=f'policy-{workspace}', workspace_uuid=workspace, timestamp=old,
|
||||
bot_id='bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline',
|
||||
message_content='history', session_id='session', status='success', level='info',
|
||||
))
|
||||
deleted = await service.cleanup_expired_records(_context(WORKSPACE_A), retention_days=90)
|
||||
assert deleted['monitoring_messages'] == 1
|
||||
rows = await service.ap.persistence_mgr.execute_async(sqlalchemy.select(MonitoringMessage.workspace_uuid))
|
||||
assert rows.scalars().all() == [WORKSPACE_B]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
|
||||
from langbot.pkg.cloud.plan_policy import require_workspace_platform, workspace_history_retention_days
|
||||
|
||||
|
||||
def cloud_app():
|
||||
snapshots = {
|
||||
'restricted': SimpleNamespace(features={'platform.telegram': False}, limits={'chat_history.retention_days': 7}, entitlement_revision=2),
|
||||
'open': SimpleNamespace(features={}, limits={'chat_history.retention_days': 90}, entitlement_revision=3),
|
||||
}
|
||||
return SimpleNamespace(deployment=SimpleNamespace(mode='cloud'), entitlement_resolver=SimpleNamespace(resolve=AsyncMock(side_effect=lambda ws: snapshots[ws])))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_policy_is_scoped_and_covers_aliases():
|
||||
ap = cloud_app()
|
||||
for adapter in ['telegram', 'telegram-omni', 'telegram-eba']:
|
||||
with pytest.raises(EntitlementFeatureUnavailableError):
|
||||
await require_workspace_platform(ap, 'restricted', adapter)
|
||||
await require_workspace_platform(ap, 'open', adapter)
|
||||
await require_workspace_platform(ap, 'restricted', 'discord')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_uses_workspace_entitlement():
|
||||
ap = cloud_app()
|
||||
assert await workspace_history_retention_days(ap, 'restricted', 100) == 7
|
||||
assert await workspace_history_retention_days(ap, 'open', 100) == 90
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_and_unavailable_projection():
|
||||
ap = SimpleNamespace(deployment=SimpleNamespace(mode='local'))
|
||||
await require_workspace_platform(ap, 'any', 'telegram')
|
||||
assert await workspace_history_retention_days(ap, 'any', 60) == 60
|
||||
ap.deployment.mode = 'cloud'
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await require_workspace_platform(ap, 'any', 'telegram')
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await workspace_history_retention_days(ap, 'any')
|
||||
Generated
+1519
-2512
File diff suppressed because it is too large
Load Diff
@@ -195,3 +195,28 @@
|
||||
body:has([data-tour-modal='true']) [data-tour-modal='false'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reserve native scrollbar space before content overflows. Extending the
|
||||
utilities also covers responsive variants, nested panels and popup menus.
|
||||
Horizontal-only scrollers and custom overlay scroll areas stay unchanged. */
|
||||
@utility overflow-auto {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-y-auto {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-y-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
textarea {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,11 +225,21 @@ export default function WorkspaceSettingsPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelToolbar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
<PanelToolbar className="gap-4 py-4">
|
||||
<div className="min-w-0 flex-1 basis-56 space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
{workspaceInfo && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[11px] font-normal"
|
||||
>
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
isCloudProjection
|
||||
@@ -238,18 +248,27 @@ export default function WorkspaceSettingsPanel({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{workspaceInfo && (
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isCloudProjection && workspaceInfo && (
|
||||
<Button asChild size="sm">
|
||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('workspace.currentPlan')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold uppercase">
|
||||
{workspaceInfo.plan_name || t('workspace.planUnavailable')}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="shrink-0">
|
||||
<a
|
||||
href={cloudPortalURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Building2, Check, Settings } from 'lucide-react';
|
||||
import { Building2, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -56,7 +56,10 @@ export default function WorkspaceSwitcher({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.workspace.uuid}
|
||||
className="min-h-11 gap-2 px-2 py-1.5"
|
||||
className={cn(
|
||||
'min-h-11 gap-2 px-2 py-1.5',
|
||||
selected && 'bg-muted',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!selected)
|
||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||
@@ -71,7 +74,6 @@ export default function WorkspaceSwitcher({
|
||||
{entry.plan_name || t('workspace.planUnavailable')}
|
||||
</span>
|
||||
)}
|
||||
{selected && <Check className="size-4" />}
|
||||
{selected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -2567,7 +2567,7 @@ const enUS = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2618,7 +2618,7 @@ const esES = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2581,7 +2581,7 @@ const jaJP = {
|
||||
ossSingletonDescription:
|
||||
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
|
||||
cloudManagedDescription:
|
||||
'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
|
||||
'このワークスペースは LangBot Cloud でホストされています。',
|
||||
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
|
||||
members: 'メンバー',
|
||||
you: 'あなた',
|
||||
|
||||
@@ -2590,7 +2590,7 @@ const ruRU = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2511,7 +2511,7 @@ const thTH = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2549,7 +2549,7 @@ const viVN = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2407,7 +2407,7 @@ const zhHans = {
|
||||
ossSingletonDescription:
|
||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||
cloudManagedDescription:
|
||||
'此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
|
||||
'此工作区托管于 LangBot Cloud。',
|
||||
loadFailed: '加载工作区信息失败',
|
||||
members: '成员',
|
||||
you: '你',
|
||||
|
||||
@@ -2405,7 +2405,7 @@ const zhHant = {
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
Reference in New Issue
Block a user