mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-24 11:07:14 +00:00
Merge master into deploy/prod for Cloud rollout
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -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,6 +386,8 @@ export default function AcceptInvitationPage() {
|
|||||||
>
|
>
|
||||||
{t('workspace.confirmPassword')}
|
{t('workspace.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="invite-password-confirm"
|
id="invite-password-confirm"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -383,9 +395,11 @@ export default function AcceptInvitationPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setConfirmPassword(event.target.value)
|
setConfirmPassword(event.target.value)
|
||||||
}
|
}
|
||||||
|
className="pl-10"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user