feat(cloud): complete secure invitation experience

This commit is contained in:
dadachann
2026-07-26 13:54:42 +08:00
parent ff068564ab
commit e90a1546de
18 changed files with 214 additions and 46 deletions
@@ -75,6 +75,8 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
try:
@@ -293,7 +295,7 @@ class UserRouterGroup(group.RouterGroup):
# Login is selected per account in a multi-user instance. A public
# bootstrap endpoint must never project one user's authentication
# methods onto every other user or disclose that user's state.
'password_login_enabled': True,
'password_login_enabled': getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud',
'space_login_enabled': True,
}
)
@@ -329,6 +329,12 @@ class InvitationsRouterGroup(group.RouterGroup):
return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
registration = data.get('registration')
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
return self.http_status(
401,
'account_exists_login_required',
'Login with your LangBot Account to accept this invitation',
)
if not isinstance(registration, dict):
return self.http_status(
401,
+6
View File
@@ -212,6 +212,12 @@ class Application:
name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
if self.workspace_collaboration_service is not None:
self.task_mgr.create_task(
self.workspace_collaboration_service.run_expired_invitation_cleanup(),
name='workspace-invitation-cleanup',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
await self.plugin_connector.initialize_plugins()
@@ -552,6 +552,47 @@ class WorkspaceCollaborationService:
return await self._run(operation, session=session)
async def cleanup_expired_invitations(
self,
*,
retention: datetime.timedelta = datetime.timedelta(0),
) -> int:
"""Delete expired invitation records without crossing Cloud tenant scopes."""
cutoff = self._utcnow() - retention
async def cleanup_session(active_session: AsyncSession, workspace_uuid: str | None = None) -> int:
statement = sqlalchemy.delete(WorkspaceInvitation).where(
WorkspaceInvitation.status.in_((InvitationStatus.PENDING.value, InvitationStatus.EXPIRED.value)),
WorkspaceInvitation.expires_at <= cutoff,
)
if workspace_uuid is not None:
statement = statement.where(WorkspaceInvitation.workspace_uuid == workspace_uuid)
result = await active_session.execute(statement)
return int(result.rowcount or 0)
if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
list_bindings = getattr(self.workspace_service, 'list_active_execution_bindings', None)
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
if not callable(list_bindings) or not callable(tenant_uow):
raise RuntimeError('Cloud invitation cleanup requires tenant units of work')
deleted = 0
for binding in await list_bindings():
async with tenant_uow(binding.workspace_uuid) as uow:
deleted += await cleanup_session(uow.session, binding.workspace_uuid)
return deleted
return await self._run(cleanup_session, session=None)
async def run_expired_invitation_cleanup(self, *, interval_seconds: float = 3600) -> None:
"""Periodically remove expired records, waiting first so expiry inspection wins."""
while True:
await asyncio.sleep(interval_seconds)
try:
await self.cleanup_expired_invitations()
except asyncio.CancelledError:
raise
except Exception:
self.ap.logger.exception('Expired Workspace invitation cleanup failed')
async def update_member_role(
self,
workspace_uuid: str,
@@ -234,22 +234,51 @@ class InvitationDeliveryService:
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
f'You were invited to join {workspace_name} on LangBot.\n\n'
f'Open this secure invitation link to continue:\n{invitation_link}\n'
'You have been invited to LangBot Cloud\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
'it was sent to. If you were not expecting it, you can safely ignore this email.\n'
)
@staticmethod
def _html(workspace_name: str, invitation_link: str) -> str:
escaped_workspace = workspace_name.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
escaped_link = (
invitation_link.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
)
return (
'<p>You were invited to join '
f'<strong>{escaped_workspace}</strong> on LangBot.</p>'
f'<p><a href="{escaped_link}">Accept the invitation</a></p>'
f'<p>{escaped_link}</p>'
)
import html
escaped_workspace = html.escape(workspace_name, quote=True)
escaped_link = html.escape(invitation_link, quote=True)
return f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Join {escaped_workspace} on LangBot Cloud</title>
</head>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
</td></tr></table>
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
@staticmethod
def _number(value: typing.Any, default: float) -> float:
@@ -86,3 +86,20 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
'https://env.langbot.example/invitations/accept#token=lbi_secret'
)
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
assert 'LangBot Cloud' in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
assert 'Accept invitation' in html
assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' in html
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import datetime
import uuid
from contextlib import asynccontextmanager
from types import SimpleNamespace
@@ -19,6 +20,7 @@ from langbot.pkg.entity.persistence.workspace import (
)
from langbot.pkg.workspace.collaboration import (
InvitationEmailMismatchError,
InvitationExpiredError,
InvitationRoleError,
InvitationUsedError,
LastOwnerError,
@@ -159,6 +161,23 @@ async def test_invitation_rejects_owner_role_and_email_mismatch(collaboration_co
await service.accept_invitation(created.token, wrong_account.uuid)
async def test_expired_invitation_is_inspectable_before_periodic_cleanup_deletes_it(collaboration_context):
service, _, session_factory, _, workspace, owner_membership = collaboration_context
created = await service.create_invitation(
workspace.uuid, owner_membership, 'expired@example.com', 'viewer'
)
async with session_factory.begin() as session:
invitation = await session.get(WorkspaceInvitation, created.invitation.uuid)
invitation.expires_at = service._utcnow() - datetime.timedelta(minutes=1)
with pytest.raises(InvitationExpiredError):
await service.inspect_invitation(created.token)
assert await service.cleanup_expired_invitations(retention=datetime.timedelta(0)) == 1
async with session_factory() as session:
assert await session.get(WorkspaceInvitation, created.invitation.uuid) is None
async def test_last_owner_cannot_be_demoted(collaboration_context):
service, _, session_factory, _, workspace, owner_membership = collaboration_context
created = await service.create_invitation(
+5
View File
@@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import {
beginAuthenticatedSession,
bootstrapWorkspaceSession,
getPendingInvitationToken,
} from '@/app/infra/http';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -91,6 +92,10 @@ function SpaceOAuthCallbackContent() {
}
beginAuthenticatedSession(response.token, response.user);
if (getPendingInvitationToken()) {
navigate('/invitations/accept', { replace: true });
return;
}
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid,
});
+33 -2
View File
@@ -92,6 +92,7 @@ export default function AcceptInvitationPage() {
const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(false);
useEffect(() => {
const handleHashChange = () => setInvitationHash(window.location.hash);
@@ -108,6 +109,9 @@ export default function AcceptInvitationPage() {
'error',
);
setToken(invitationToken);
backendClient.getAccountInfo().then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
}).catch(() => setPasswordRegistrationEnabled(false));
if (!invitationToken) {
setErrorMessage(t('workspace.invitationMissing'));
setStatus('error');
@@ -210,6 +214,15 @@ export default function AcceptInvitationPage() {
});
}
function switchAccount() {
clearUserInfo();
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
}
navigate('/login?invitation=1&auto=space', { replace: true });
}
function returnToLogin() {
clearUserInfo();
if (typeof window !== 'undefined') {
@@ -221,6 +234,11 @@ export default function AcceptInvitationPage() {
const hasLoginToken =
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
const currentEmail =
typeof window !== 'undefined' ? localStorage.getItem('userEmail') : null;
const currentAccountMatches =
currentEmail?.trim().toLocaleLowerCase() ===
view?.invitation.normalized_email.toLocaleLowerCase();
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
@@ -285,7 +303,7 @@ export default function AcceptInvitationPage() {
</div>
)}
{hasLoginToken ? (
{hasLoginToken && currentAccountMatches ? (
<Button
className="w-full"
disabled={status === 'submitting'}
@@ -296,7 +314,16 @@ export default function AcceptInvitationPage() {
)}
{t('workspace.acceptAsCurrentAccount')}
</Button>
) : (
) : hasLoginToken ? (
<div className="space-y-3">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
{t('workspace.invitationEmailMismatch')}
</div>
<Button className="w-full" onClick={switchAccount}>
{t('workspace.switchAccount')}
</Button>
</div>
) : passwordRegistrationEnabled ? (
<>
<div className="space-y-2">
<label
@@ -370,6 +397,10 @@ export default function AcceptInvitationPage() {
{t('workspace.alreadyHaveAccount')}
</Button>
</>
) : (
<Button className="w-full" onClick={() => navigate('/login?invitation=1&auto=space')}>
{t('common.loginWithSpace')}
</Button>
)}
</div>
)}
+24 -15
View File
@@ -44,6 +44,7 @@ export default function Register() {
const navigate = useNavigate();
const { t } = useTranslation();
const [spaceLoading, setSpaceLoading] = useState(false);
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(true);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
@@ -55,6 +56,10 @@ export default function Register() {
useEffect(() => {
getIsInitialized();
httpClient
.getAccountInfo()
.then((info) => setPasswordRegistrationEnabled(info.password_login_enabled !== false))
.catch(() => setPasswordRegistrationEnabled(true));
}, []);
function getIsInitialized() {
@@ -159,20 +164,22 @@ export default function Register() {
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
{passwordRegistrationEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
{/* Local Account Registration */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Local Account Registration */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
@@ -223,8 +230,10 @@ export default function Register() {
>
{t('register.registerWithPassword')}
</Button>
</form>
</Form>
</form>
</Form>
</>
)}
<p className="text-xs text-center text-muted-foreground">
{t('common.agreementNotice')}{' '}
+3 -2
View File
@@ -80,7 +80,7 @@ const enUS = {
loading: 'Loading...',
fieldRequired: 'This field is required',
or: 'or',
loginWithSpace: 'Login with Space',
loginWithSpace: 'Login with LangBot Account',
spaceLoginRecommended:
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account',
@@ -277,7 +277,7 @@ const enUS = {
searchProviders: 'Search providers...',
langbotModelsDescription: 'Cloud models powered by LangBot Space',
credits: 'Credits',
loginWithSpace: 'Login with Space',
loginWithSpace: 'Login with LangBot Account',
loginToUseModels: 'Login with Space to use cloud models',
noModels: 'No models configured',
langbotModels: 'LangBot Models',
@@ -1339,6 +1339,7 @@ const enUS = {
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
confirmPassword: 'Confirm password',
+2 -2
View File
@@ -83,7 +83,7 @@ const esES = {
loading: 'Cargando...',
fieldRequired: 'Este campo es obligatorio',
or: 'o',
loginWithSpace: 'Iniciar sesión con Space',
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginRecommended:
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local',
@@ -286,7 +286,7 @@ const esES = {
searchProviders: 'Buscar proveedores...',
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
credits: 'Créditos',
loginWithSpace: 'Iniciar sesión con Space',
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
noModels: 'No hay modelos configurados',
langbotModels: 'Modelos LangBot',
+3 -2
View File
@@ -81,7 +81,7 @@ const jaJP = {
loading: '読み込み中...',
fieldRequired: 'この項目は必須です',
or: 'または',
loginWithSpace: 'Space でログイン',
loginWithSpace: 'LangBot アカウントでログイン',
spaceLoginRecommended:
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン',
@@ -282,7 +282,7 @@ const jaJP = {
searchProviders: 'プロバイダーを検索...',
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
credits: 'クレジット',
loginWithSpace: 'Space でログイン',
loginWithSpace: 'LangBot アカウントでログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
noModels: 'モデルがありません',
langbotModels: 'LangBot モデル',
@@ -1340,6 +1340,7 @@ const jaJP = {
existingAccountLoginRequired:
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
acceptAsCurrentAccount: '現在のアカウントで承認',
switchAccount: 'アカウントを切り替える',
registerAndAccept: 'アカウントを作成して承認',
alreadyHaveAccount: 'アカウントを持っています',
confirmPassword: 'パスワードを確認',
+2 -2
View File
@@ -80,7 +80,7 @@ const ruRU = {
loading: 'Загрузка...',
fieldRequired: 'Это поле обязательно для заполнения',
or: 'или',
loginWithSpace: 'Войти через Space',
loginWithSpace: 'Войти с аккаунтом LangBot',
spaceLoginRecommended:
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью',
@@ -284,7 +284,7 @@ const ruRU = {
searchProviders: 'Поиск провайдеров...',
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
credits: 'Кредиты',
loginWithSpace: 'Войти через Space',
loginWithSpace: 'Войти с аккаунтом LangBot',
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
noModels: 'Модели не настроены',
langbotModels: 'Модели LangBot',
+2 -2
View File
@@ -80,7 +80,7 @@ const thTH = {
loading: 'กำลังโหลด...',
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
or: 'หรือ',
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginRecommended:
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
@@ -273,7 +273,7 @@ const thTH = {
searchProviders: 'ค้นหาผู้ให้บริการ...',
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
credits: 'เครดิต',
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
langbotModels: 'โมเดล LangBot',
+2 -2
View File
@@ -81,7 +81,7 @@ const viVN = {
loading: 'Đang tải...',
fieldRequired: 'Trường này là bắt buộc',
or: 'hoặc',
loginWithSpace: 'Đăng nhập với Space',
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginRecommended:
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
loginLocal: 'Đăng nhập với tài khoản cục bộ',
@@ -281,7 +281,7 @@ const viVN = {
searchProviders: 'Tìm kiếm nhà cung cấp...',
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
credits: 'Tín dụng',
loginWithSpace: 'Đăng nhập với Space',
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây',
noModels: 'Chưa cấu hình mô hình nào',
langbotModels: 'Mô hình LangBot',
+3 -2
View File
@@ -79,7 +79,7 @@ const zhHans = {
loading: '加载中...',
fieldRequired: '此字段为必填项',
or: '或',
loginWithSpace: '通过 Space 登录',
loginWithSpace: '使用 LangBot 账号登录',
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录',
@@ -265,7 +265,7 @@ const zhHans = {
searchProviders: '搜索供应商...',
langbotModelsDescription: 'LangBot Space 提供的云端模型',
credits: '积分',
loginWithSpace: '通过 Space 登录',
loginWithSpace: '使用 LangBot 账号登录',
loginToUseModels: '通过 Space 登录以使用云端模型',
noModels: '暂无模型',
langbotModels: 'LangBot 模型',
@@ -1270,6 +1270,7 @@ const zhHans = {
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
acceptAsCurrentAccount: '使用当前账户接受',
switchAccount: '切换账号',
registerAndAccept: '创建账户并接受',
alreadyHaveAccount: '我已有账户',
confirmPassword: '确认密码',
+2 -2
View File
@@ -79,7 +79,7 @@ const zhHant = {
loading: '載入中...',
fieldRequired: '此欄位為必填',
or: '或',
loginWithSpace: '透過 Space 登入',
loginWithSpace: '使用 LangBot 帳號登入',
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入',
@@ -263,7 +263,7 @@ const zhHant = {
searchProviders: '搜尋供應商...',
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
credits: '積分',
loginWithSpace: '使用 Space 登入',
loginWithSpace: '使用 LangBot 帳號登入',
loginToUseModels: '使用 Space 登入以使用雲端模型',
noModels: '暫無模型',
langbotModels: 'LangBot 模型',