Merge master into deploy/prod for Cloud rollout

This commit is contained in:
dadachann
2026-08-24 03:18:22 +00:00
18 changed files with 377 additions and 75 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \ && rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \ && python -m pip install --no-cache-dir uv \
&& uv sync \ && uv sync --extra seekdb \
&& apt-get purge -y --auto-remove curl git gnupg \ && apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv && touch /.dockerenv
+18 -1
View File
@@ -10,6 +10,19 @@ uvx langbot
This will automatically download and run the latest version of LangBot. This will automatically download and run the latest version of LangBot.
SeekDB support is optional and is not installed by the command above. If you
want to use the SeekDB vector database or the built-in SeekDB embedding model,
run LangBot with the `seekdb` extra:
```bash
uvx --from 'langbot[seekdb]@latest' langbot
```
The extra includes native dependencies whose supported operating systems may
be narrower than LangBot's. In particular, the current Apple Silicon wheels
require macOS 15 or later. The default Chroma backend does not have this
requirement.
## Install with pip/uv ## Install with pip/uv
You can also install LangBot as a regular Python package: You can also install LangBot as a regular Python package:
@@ -20,6 +33,10 @@ pip install langbot
# Using uv # Using uv
uv pip install langbot uv pip install langbot
# Include optional SeekDB support
pip install 'langbot[seekdb]'
# or: uv pip install 'langbot[seekdb]'
``` ```
Then run it: Then run it:
@@ -101,7 +118,7 @@ uvx langbot
## System Requirements ## System Requirements
- Python 3.10.1 or higher - Python 3.11 or higher (lower than Python 4)
- Operating System: Linux, macOS, or Windows - Operating System: Linux, macOS, or Windows
## Differences from Source Installation ## Differences from Source Installation
+34 -43
View File
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation ## Installation
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`. SeekDB is an optional LangBot feature. A normal LangBot installation uses
Chroma by default and does not install `pyseekdb` or its native bindings.
If you need to install it manually: Choose the command that matches how you run LangBot:
```bash ```bash
pip install pyseekdb # PyPI / uvx
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
``` ```
## ⚠️ Platform Compatibility ## ⚠️ Platform Compatibility
@@ -30,31 +38,36 @@ pip install pyseekdb
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` | | Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | | macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | | macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode. **Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
force-install or retag a wheel built for a newer macOS release: the bundled
binaries also declare macOS 15 as their minimum deployment target.
### Server Mode (Docker) ### Server Mode (Docker)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Full Docker support | | Linux | ✅ Supported | Full Docker support |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) | | macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested | | Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection) ### Server Mode (Remote Connection)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server | | Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration. Remote server mode does not use embedded storage at runtime. However, whether
the Python client can be installed still depends on `pyseekdb`'s package
metadata for the current platform.
## Configuration ## Configuration
@@ -170,22 +183,23 @@ Key methods:
### Import Error ### Import Error
If you see: `ImportError: pyseekdb is not installed` If you see: `SeekDB support is not installed`
Solution: Solution:
```bash ```bash
pip install pyseekdb uv sync --extra seekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
``` ```
### Embedded Mode Error on macOS/Windows ### Embedded Mode Is Unavailable on the Current Platform
**Error**: **Error**:
``` ```
RuntimeError: Embedded Client is not available because pylibseekdb is not available. RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
``` ```
**Cause**: `pylibseekdb` is only available on Linux platforms. **Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
CPU architecture, Python version, and macOS deployment target.
**Solution**: Use server mode instead: **Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM 1. Deploy SeekDB on a Linux server or VM
@@ -208,29 +222,6 @@ vdb:
use: chroma # or qdrant use: chroma # or qdrant
``` ```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode) ### Connection Error (Server Mode)
If SeekDB server is not reachable, check: If SeekDB server is not reachable, check:
+5 -1
View File
@@ -70,7 +70,6 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.5.5", "langbot-plugin==0.5.5",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
@@ -108,6 +107,11 @@ classifiers = [
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
] ]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls] [project.urls]
Homepage = "https://langbot.app" Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app" Documentation = "https://docs.langbot.app"
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode: if cloud_mode:
capabilities['password_login_enabled'] = False capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities}) return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try: try:
import pyseekdb import pyseekdb
except ImportError: except ImportError:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb') raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self._embedding_function = pyseekdb.get_default_embedding_function() self._embedding_function = pyseekdb.get_default_embedding_function()
+4 -1
View File
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE: if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb') raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self.ap = ap self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb'] config = self.ap.instance_config.data['vdb']['seekdb']
+5
View File
@@ -181,6 +181,11 @@ vdb:
host: localhost host: localhost
port: 6333 port: 6333
api_key: '' api_key: ''
# SeekDB is optional. Native/package installs need the `seekdb` extra:
# `uv sync --extra seekdb` (source) or
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
# The official Docker image already includes it.
# Embedded-mode platform support depends on the native pylibseekdb wheels.
seekdb: seekdb:
mode: embedded # 'embedded' or 'server' mode: embedded # 'embedded' or 'server'
# Embedded mode options: # Embedded mode options:
+23
View File
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'authenticated_invitation_acceptance_enabled': False, 'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': True, 'password_login_enabled': True,
'space_login_enabled': False, 'space_login_enabled': False,
} }
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'authenticated_invitation_acceptance_enabled': True, 'authenticated_invitation_acceptance_enabled': True,
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
}
@pytest.mark.asyncio
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
self, quart_test_client, fake_api_app
):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_login_capabilities = AsyncMock(
return_value={'password_login_enabled': False, 'space_login_enabled': True}
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': False, 'password_login_enabled': False,
'space_login_enabled': True, 'space_login_enabled': True,
} }
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application.space_service.get_credits.assert_awaited_once_with('owner@example.com') application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
@pytest.mark.asyncio
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
application, client = space_oauth_api
application.user_service.get_workspace_owner = AsyncMock(
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
)
application.space_service.get_credits = AsyncMock()
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
'credits': None,
'owner_space_bound': False,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -0,0 +1,15 @@
from __future__ import annotations
import tomllib
from pathlib import Path
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project_root = Path(__file__).resolve().parents[2]
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
@@ -0,0 +1,34 @@
from __future__ import annotations
import importlib
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.vector.vdbs.seekdb'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_module = importlib.import_module(module_name)
assert seekdb_module.SEEKDB_AVAILABLE is False
with pytest.raises(ImportError, match=_INSTALL_HINT):
seekdb_module.SeekDBVectorDatabase(MagicMock())
@pytest.mark.asyncio
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_embedding_module = importlib.import_module(module_name)
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
with pytest.raises(ImportError, match=_INSTALL_HINT):
await requester.initialize()
Generated
+9 -4
View File
@@ -9,10 +9,10 @@ resolution-markers = [
"python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
] ]
@@ -2063,7 +2063,6 @@ dependencies = [
{ name = "pymilvus" }, { name = "pymilvus" },
{ name = "pynacl" }, { name = "pynacl" },
{ name = "pypdf2" }, { name = "pypdf2" },
{ name = "pyseekdb" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "python-socks" }, { name = "python-socks" },
@@ -2089,6 +2088,11 @@ dependencies = [
{ name = "websockets" }, { name = "websockets" },
] ]
[package.optional-dependencies]
seekdb = [
{ name = "pyseekdb" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "moto" }, { name = "moto" },
@@ -2153,7 +2157,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" }, { name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" }, { name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" }, { name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", specifier = "==1.1.0.post3" }, { name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" }, { name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" }, { name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" }, { name = "python-socks", specifier = ">=2.7.1" },
@@ -2178,6 +2182,7 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" }, { name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" }, { name = "websockets", specifier = ">=15.0.1" },
] ]
provides-extras = ["seekdb"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
+1
View File
@@ -1181,6 +1181,7 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{ public getAccountInfo(): Promise<{
initialized: boolean; initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean; authenticated_invitation_acceptance_enabled?: boolean;
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean; password_login_enabled?: boolean;
space_login_enabled?: boolean; space_login_enabled?: boolean;
}> { }> {
+28 -22
View File
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
useState(false);
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
useState(false); useState(false);
const [ const [
authenticatedInvitationAcceptanceEnabled, authenticatedInvitationAcceptanceEnabled,
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
backendClient backendClient
.getAccountInfo() .getAccountInfo()
.then((info) => { .then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false); setInvitationRegistrationEnabled(
info.invitation_registration_enabled ??
info.password_login_enabled !== false,
);
setAuthenticatedInvitationAcceptanceEnabled( setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true, info.authenticated_invitation_acceptance_enabled === true,
); );
}) })
.catch(() => setPasswordRegistrationEnabled(false)); .catch(() => setInvitationRegistrationEnabled(false))
.finally(() => setInvitationCapabilitiesLoaded(true));
if (!invitationToken) { if (!invitationToken) {
setErrorMessage(t('workspace.invitationMissing')); setErrorMessage(t('workspace.invitationMissing'));
setStatus('error'); setStatus('error');
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
</div> </div>
)} )}
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( {!invitationCapabilitiesLoaded ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin" />
</div>
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
<Button <Button
className="w-full" className="w-full"
disabled={status === 'submitting'} disabled={status === 'submitting'}
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
{t('workspace.logoutAndReturn')} {t('workspace.logoutAndReturn')}
</Button> </Button>
</div> </div>
) : passwordRegistrationEnabled ? ( ) : invitationRegistrationEnabled ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label <label
@@ -376,15 +386,19 @@ export default function AcceptInvitationPage() {
> >
{t('workspace.confirmPassword')} {t('workspace.confirmPassword')}
</label> </label>
<Input <div className="relative">
id="invite-password-confirm" <Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
type="password" <Input
value={confirmPassword} id="invite-password-confirm"
onChange={(event) => type="password"
setConfirmPassword(event.target.value) value={confirmPassword}
} onChange={(event) =>
autoComplete="new-password" setConfirmPassword(event.target.value)
/> }
className="pl-10"
autoComplete="new-password"
/>
</div>
</div> </div>
<Button <Button
className="w-full" className="w-full"
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
)} )}
{t('workspace.registerAndAccept')} {t('workspace.registerAndAccept')}
</Button> </Button>
<Button
variant="ghost"
className="w-full"
disabled={status === 'submitting'}
onClick={() => navigate('/login?invitation=1')}
>
{t('workspace.alreadyHaveAccount')}
</Button>
</> </>
) : ( ) : (
<Button <Button
+2
View File
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
if (path === '/api/v1/user/account-info') { if (path === '/api/v1/user/account-info') {
return fulfillJson(route, { return fulfillJson(route, {
initialized: true, initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: true, password_login_enabled: true,
space_login_enabled: false, space_login_enabled: false,
}); });
+107 -1
View File
@@ -93,7 +93,7 @@ test('login preserves an explicit invitation email mismatch error', async ({
}); });
await page.goto('/invitations/accept#token=mismatch-invitation'); await page.goto('/invitations/accept#token=mismatch-invitation');
await page.getByRole('button', { name: 'I already have an account' }).click(); await page.goto('/login?invitation=1');
await page.getByPlaceholder('Enter email address').fill('other@example.com'); await page.getByPlaceholder('Enter email address').fill('other@example.com');
await page.getByPlaceholder('Enter password').fill('password'); await page.getByPlaceholder('Enter password').fill('password');
await page.getByRole('button', { name: 'Login with password' }).click(); await page.getByRole('button', { name: 'Login with password' }).click();
@@ -107,6 +107,111 @@ test('login preserves an explicit invitation email mismatch error', async ({
await expect(page.getByText('Login successful')).toHaveCount(0); await expect(page.getByText('Login successful')).toHaveCount(0);
}); });
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
page,
}) => {
let registration: { email?: string; password?: string } | undefined;
await installLangBotApiMocks(page, { authenticated: false });
await page.route('**/api/v1/user/account-info', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: false,
space_login_enabled: true,
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/inspect', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
invitation: {
uuid: 'oss-local-registration',
workspace_uuid: 'workspace-playwright',
normalized_email: 'invited@example.com',
role: 'viewer',
status: 'pending',
},
workspace: {
uuid: 'workspace-playwright',
name: 'Playwright Workspace',
},
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/accept', async (route) => {
const body = JSON.parse(route.request().postData() || '{}') as {
registration?: { email?: string; password?: string };
};
registration = body.registration;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
login_required: true,
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/invitations/accept#token=oss-local-registration');
await expect(page.getByText('Playwright Workspace')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await expect(page.locator('#invite-email')).toHaveValue(
'invited@example.com',
);
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
await expect(page.locator('#invite-password')).toBeVisible();
await expect(page.locator('#invite-password-confirm')).toBeVisible();
for (const inputId of ['invite-password', 'invite-password-confirm']) {
const input = page.locator(`#${inputId}`);
const field = input.locator('xpath=..');
await expect(field).toHaveClass(/relative/);
await expect(field.locator('svg')).toBeVisible();
await expect(input).toHaveClass(/pl-10/);
}
await expect(
page.getByRole('button', { name: 'Create account and accept' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'I already have an account' }),
).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await page.locator('#invite-password').fill('invite-password-123');
await page.locator('#invite-password-confirm').fill('invite-password-123');
await page.getByRole('button', { name: 'Create account and accept' }).click();
await expect(page).toHaveURL(/\/login\?invitation=1$/);
expect(registration).toEqual({
email: 'invited@example.com',
password: 'invite-password-123',
});
});
test('an authenticated OSS invitation requires logout before registration', async ({ test('an authenticated OSS invitation requires logout before registration', async ({
page, page,
}) => { }) => {
@@ -185,6 +290,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
data: { data: {
initialized: true, initialized: true,
authenticated_invitation_acceptance_enabled: true, authenticated_invitation_acceptance_enabled: true,
invitation_registration_enabled: false,
password_login_enabled: false, password_login_enabled: false,
space_login_enabled: true, space_login_enabled: true,
}, },
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/user/space-credits', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
credits: null,
owner_space_bound: false,
is_workspace_owner: true,
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
providers: [
{
uuid: 'langbot-models-provider',
name: 'LangBot Models',
requester: 'space-chat-completions',
base_url: '',
api_keys: [],
},
],
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/requesters**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
}),
);
await page.route('**/api/v1/provider/models/**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
}),
);
await page.goto('/home?action=showModelSettings');
await expect(
page.getByRole('button', {
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
}),
).toBeVisible();
});