From 20710df9cb96b8c447d862d2177ba395db1dff9b Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 11 Aug 2026 16:57:16 +0800 Subject: [PATCH 01/42] fix(cloud): scope QR login requests to workspace (#2414) Co-authored-by: Chan --- .../qrcode-login/QrCodeLoginDialog.tsx | 47 +++++++++--- web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/es-ES.ts | 3 + web/src/i18n/locales/ja-JP.ts | 3 + web/src/i18n/locales/ru-RU.ts | 3 + web/src/i18n/locales/th-TH.ts | 3 + web/src/i18n/locales/vi-VN.ts | 3 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + .../unit/qr-code-login-workspace.test.mjs | 73 +++++++++++++++++++ 10 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 web/tests/unit/qr-code-login-workspace.test.mjs diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 5865dbb4a..439b99f9d 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -15,6 +15,7 @@ import { XCircle, } from 'lucide-react'; import QRCode from 'qrcode'; +import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; export type QrLoginPlatform = | 'feishu' @@ -55,12 +56,12 @@ const PLATFORM_CONFIGS: Record = { }, weixin: { titleKey: 'weixin.scanLogin', - connectingKey: 'feishu.connecting', + connectingKey: 'weixin.connecting', scanQRCodeKey: 'weixin.scanQRCode', - waitingKey: 'feishu.waitingForScan', + waitingKey: 'weixin.waitingForScan', successKey: 'weixin.loginSuccess', failedKey: 'weixin.loginFailed', - retryKey: 'feishu.retry', + retryKey: 'weixin.retry', apiBase: '/api/v1/platform/adapters/weixin/login', extractSuccess: (data) => ({ token: data.token, @@ -146,6 +147,8 @@ export default function QrCodeLoginDialog({ const checkExpiredRef = useRef | null>(null); const abortRef = useRef(null); const sessionIdRef = useRef(null); + const sessionWorkspaceUuidRef = useRef(null); + const sessionApiBaseRef = useRef(''); const baseUrlRef = useRef(''); const cleanedRef = useRef(false); @@ -180,18 +183,23 @@ export default function QrCodeLoginDialog({ } if (sessionIdRef.current) { const token = localStorage.getItem('token'); - const baseUrl = - import.meta.env.VITE_API_BASE_URL || window.location.origin; + const workspaceUuid = sessionWorkspaceUuidRef.current; fetch( - `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, + `${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; } + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; }, []); const startLogin = useCallback(async () => { @@ -204,6 +212,7 @@ export default function QrCodeLoginDialog({ setSuccessMeta(''); const token = localStorage.getItem('token'); + const workspaceUuid = getActiveWorkspaceUuid(); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; baseUrlRef.current = baseUrl; const cfg = platformConfigRef.current; @@ -214,7 +223,10 @@ export default function QrCodeLoginDialog({ const res = await fetch(`${baseUrl}${cfg.apiBase}`, { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, signal: controller.signal, }); @@ -225,6 +237,8 @@ export default function QrCodeLoginDialog({ const { session_id, qr_data_url, qr_url, expire_at } = json.data; sessionIdRef.current = session_id; + sessionWorkspaceUuidRef.current = workspaceUuid; + sessionApiBaseRef.current = cfg.apiBase; if (qr_data_url) { setQrDataUrl(qr_data_url); @@ -270,11 +284,19 @@ export default function QrCodeLoginDialog({ `${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`, { method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid + ? { 'X-Workspace-Id': workspaceUuid } + : {}), + }, keepalive: true, }, ).catch(() => {}); sessionIdRef.current = null; + sessionWorkspaceUuidRef.current = null; + sessionApiBaseRef.current = ''; + baseUrlRef.current = ''; } setState('expired'); } @@ -286,7 +308,12 @@ export default function QrCodeLoginDialog({ try { const pollRes = await fetch( `${baseUrl}${cfg.apiBase}/status/${session_id}`, - { headers: { Authorization: `Bearer ${token}` } }, + { + headers: { + Authorization: `Bearer ${token}`, + ...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}), + }, + }, ); if (!pollRes.ok) return; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6603cf560..c60ad295f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1921,6 +1921,9 @@ const enUS = { 'Scan the QR code below with WeChat to authorize and automatically fill in the token', loginSuccess: 'Login successful! Token has been filled in', loginFailed: 'Login failed', + connecting: 'Connecting to WeChat service...', + waitingForScan: 'Waiting for scan', + retry: 'Retry', }, dingtalk: { createApp: 'One-Click Create DingTalk App', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1efa34173..43fc33a9b 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1747,6 +1747,9 @@ const esES = { loginSuccess: '¡Inicio de sesión correcto! El token se ha rellenado automáticamente', loginFailed: 'Error al iniciar sesión', + connecting: 'Conectando con el servicio de WeChat...', + waitingForScan: 'Esperando escaneo', + retry: 'Reintentar', }, dingtalk: { createApp: 'Crear aplicación de DingTalk con un clic', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7285086c4..026ddd3c4 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1837,6 +1837,9 @@ const jaJP = { scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力', loginSuccess: 'ログイン成功!トークンが自動入力されました', loginFailed: 'ログイン失敗', + connecting: 'WeChatサービスに接続中...', + waitingForScan: 'スキャン待ち', + retry: '再試行', }, dingtalk: { createApp: 'ワンクリックでDingTalkアプリ作成', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index b167c81a1..975360fb8 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1717,6 +1717,9 @@ const ruRU = { 'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен', loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически', loginFailed: 'Не удалось выполнить вход', + connecting: 'Подключение к сервису WeChat...', + waitingForScan: 'Ожидание сканирования', + retry: 'Повторить', }, dingtalk: { createApp: 'Создать приложение DingTalk в один клик', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c1afcb48a..d19e2fef1 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1680,6 +1680,9 @@ const thTH = { 'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ', loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว', loginFailed: 'เข้าสู่ระบบไม่สำเร็จ', + connecting: 'กำลังเชื่อมต่อบริการ WeChat...', + waitingForScan: 'กำลังรอการสแกน', + retry: 'ลองอีกครั้ง', }, dingtalk: { createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 1a4af1319..02bab4c7a 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1708,6 +1708,9 @@ const viVN = { 'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token', loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động', loginFailed: 'Đăng nhập thất bại', + connecting: 'Đang kết nối tới dịch vụ WeChat...', + waitingForScan: 'Đang chờ quét mã', + retry: 'Thử lại', }, dingtalk: { createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bbece7f35..3aba0fcfa 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1834,6 +1834,9 @@ const zhHans = { scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌', loginSuccess: '登录成功!令牌已自动填入', loginFailed: '登录失败', + connecting: '正在连接微信服务...', + waitingForScan: '等待扫码中', + retry: '重试', }, dingtalk: { createApp: '一键创建钉钉应用', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 707907a32..91ecdde61 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1657,6 +1657,9 @@ const zhHant = { scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌', loginSuccess: '登入成功!令牌已自動填入', loginFailed: '登入失敗', + connecting: '正在連接微信服務...', + waitingForScan: '等待掃碼中', + retry: '重試', }, dingtalk: { createApp: '一鍵建立釘釘應用', diff --git a/web/tests/unit/qr-code-login-workspace.test.mjs b/web/tests/unit/qr-code-login-workspace.test.mjs new file mode 100644 index 000000000..5e7819373 --- /dev/null +++ b/web/tests/unit/qr-code-login-workspace.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const dialogPath = path.join( + root, + 'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx', +); +const localeDir = path.join(root, 'src/i18n/locales'); + +const dialogSource = fs.readFileSync(dialogPath, 'utf8'); + +test('QR credential exchanges preserve the active Workspace scope', () => { + assert.match(dialogSource, /getActiveWorkspaceUuid/); + assert.match( + dialogSource, + /sessionWorkspaceUuidRef\.current = workspaceUuid/, + ); + assert.match( + dialogSource, + /const workspaceUuid = sessionWorkspaceUuidRef\.current/, + ); + assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/); + assert.match( + dialogSource, + /`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/, + ); + assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/); + + const workspaceHeaderUses = dialogSource.match( + /'X-Workspace-Id': workspaceUuid/g, + ); + assert.equal( + workspaceHeaderUses?.length, + 4, + 'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope', + ); +}); + +test('WeChat QR login never reuses Feishu progress copy', () => { + const weixinConfig = dialogSource.match( + /weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/, + )?.[0]; + assert.ok(weixinConfig, 'WeChat platform config is missing'); + assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/); + assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/); + assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/); + assert.doesNotMatch(weixinConfig, /feishu\./); + + for (const locale of [ + 'en-US.ts', + 'es-ES.ts', + 'ja-JP.ts', + 'ru-RU.ts', + 'th-TH.ts', + 'vi-VN.ts', + 'zh-Hans.ts', + 'zh-Hant.ts', + ]) { + const source = fs.readFileSync(path.join(localeDir, locale), 'utf8'); + const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0]; + assert.ok(block, `${locale} is missing the WeChat locale block`); + for (const key of ['connecting', 'waitingForScan', 'retry']) { + assert.match( + block, + new RegExp(`\\b${key}:`), + `${locale} is missing weixin.${key}`, + ); + } + } +}); From bbc912d0ef700f83900c2797cafdae5d16140e30 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 11 Aug 2026 21:35:03 +0800 Subject: [PATCH 02/42] fix(runtime): restore standalone runtime compatibility --- docs/multi-tenant/implementation-decisions.md | 8 ++++---- skills/skills/langbot-deploy/SKILL.md | 9 +++++---- .../pkg/api/http/controller/groups/plugins.py | 11 +++++++++- src/langbot/pkg/box/connector.py | 14 +++++++------ src/langbot/pkg/plugin/handler.py | 7 ++++++- src/langbot/templates/config.yaml | 5 +++-- .../integration/api/test_plugins_security.py | 13 +++++++++++- tests/unit_tests/box/test_box_connector.py | 11 +++++++++- .../unit_tests/plugin/test_handler_tenancy.py | 20 +++++++++++++++++++ 9 files changed, 78 insertions(+), 20 deletions(-) diff --git a/docs/multi-tenant/implementation-decisions.md b/docs/multi-tenant/implementation-decisions.md index a74c13f32..bdbe93fbf 100644 --- a/docs/multi-tenant/implementation-decisions.md +++ b/docs/multi-tenant/implementation-decisions.md @@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch - Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy. - Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary. -### Runtime control transports authenticate before protocol dispatch +### Runtime control transports support opt-in shared-secret authentication -- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate. -- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse. -- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket. +- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate. +- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse. +- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value. ### Dashboard WebSocket sessions are tenant runtime objects diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md index 9115b3cd4..e03182e01 100644 --- a/skills/skills/langbot-deploy/SKILL.md +++ b/skills/skills/langbot-deploy/SKILL.md @@ -27,10 +27,11 @@ The `all` / `box` profile starts three services: - `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to spawn sandbox containers, so the **Box root host path and in-container path must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`). - Its RPC and managed-process relay require a shared - `LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both - the LangBot and Box containers. Generate it once with `openssl rand -hex 32`; - never put it in `box.runtime.endpoint` or commit it to config. + OSS allows its RPC and managed-process relay to run without a token when both + sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set + the same value of at least 32 non-whitespace characters in both the LangBot + and Box containers. Generate it once with `openssl rand -hex 32`; never put + it in `box.runtime.endpoint` or commit it to config. A Compose deployment may optionally set `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 069fcb027..77d710693 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -398,7 +398,16 @@ class PluginsRouterGroup(group.RouterGroup): # Get debug URL from config plugin_config = self.ap.instance_config.data.get('plugin', {}) - debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401') + debug_url = plugin_config.get( + 'display_plugin_debug_url', + 'ws://localhost:5401/plugin/debug/ws', + ) + parsed_debug_url = urlparse(debug_url) + if parsed_debug_url.scheme in {'http', 'https'}: + debug_url = parsed_debug_url._replace( + scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws', + path=parsed_debug_url.path or '/plugin/debug/ws', + ).geturl() return self.success( data={ diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 2ef990d0c..df3a0d7fa 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -367,6 +367,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): def _ensure_control_token(self, *, allow_generate: bool) -> str: if not self._control_token and allow_generate: self._control_token = secrets.token_urlsafe(48) + if not self._control_token: + return '' try: self._control_token = validate_control_token(self._control_token) except ValueError as exc: @@ -376,19 +378,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): return self._control_token def get_control_headers(self) -> dict[str, str]: - """Headers for the instance-authenticated RPC control handshake.""" + """Return instance-scoped RPC headers and the optional shared secret.""" self._ensure_control_token(allow_generate=False) - return { - BOX_CONTROL_TOKEN_HEADER: self._control_token, - BOX_INSTANCE_HEADER: self._trusted_instance_uuid, - } + headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid} + if self._control_token: + headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token + return headers def get_relay_headers( self, action_context: ActionContext, ) -> dict[str, str]: - """Return authenticated, placement-scoped relay handshake headers.""" + """Return instance- and placement-scoped relay handshake headers.""" context = ActionContext.model_validate(action_context).without_installation() if context.instance_uuid != self._trusted_instance_uuid: diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index d642b10ea..e7029f625 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1962,11 +1962,16 @@ class RuntimeConnectionHandler(handler.Handler): async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]: """Get debug information including debug key and WS URL""" + action_context = ActionContext( + instance_uuid=execution_context.instance_uuid, + workspace_uuid=execution_context.workspace_uuid, + placement_generation=execution_context.placement_generation, + ) result = await self.call_action( LangBotToRuntimeAction.GET_DEBUG_INFO, {}, timeout=10, - action_context=execution_context, + action_context=action_context, ) return result diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index a9d25086f..c21c9b320 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -328,8 +328,9 @@ box: enabled: true backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND. runtime: - # External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in - # both LangBot and Box. Keep the shared secret out of this config file. + # LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket + # runtimes. To protect an exposed endpoint, set the same strong secret + # in both LangBot and Box. Keep it out of this config file. endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime. limits: max_sessions: 64 diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index b42c25356..5f79f716a 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -235,13 +235,24 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api assert operator_denied.status_code == 403 assert allowed.status_code == 200 assert (await allowed.get_json())['data'] == { - 'debug_url': 'http://localhost:5401', + 'debug_url': 'ws://localhost:5401/plugin/debug/ws', 'plugin_debug_key': 'runtime-debug-secret', 'expires_at': '2026-08-04T12:00:00Z', } application.plugin_connector.get_debug_info.assert_awaited_once() +@pytest.mark.asyncio +async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api): + application, client, _ = plugin_security_api + application.instance_config.data['plugin'].pop('display_plugin_debug_url') + + response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token')) + + assert response.status_code == 200 + assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws' + + @pytest.mark.asyncio async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api): application, client, _ = plugin_security_api diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py index 367b2e362..4d769f244 100644 --- a/tests/unit_tests/box/test_box_connector.py +++ b/tests/unit_tests/box/test_box_connector.py @@ -306,10 +306,19 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance( ) -def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch): +def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset( + monkeypatch: pytest.MonkeyPatch, +): monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False) connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) + assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'} + + +def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short') + connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) + with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV): connector.get_control_headers() diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py index 43856b9cf..371123eb4 100644 --- a/tests/unit_tests/plugin/test_handler_tenancy.py +++ b/tests/unit_tests/plugin/test_handler_tenancy.py @@ -444,3 +444,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context(): 'runtime_id': 'runtime-a', } assert request.get('context') is None + + +@pytest.mark.asyncio +async def test_get_debug_info_converts_execution_context_to_sdk_action_context(): + runtime_handler, _app, _installation_context = make_handler() + runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'}) + execution_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=7, + ) + + result = await runtime_handler.get_debug_info(execution_context) + + assert result == {'plugin_debug_key': 'debug-key'} + assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=7, + ) From 9b81a0b60616c2a90ab982f3763e7b372d4a49c8 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 11 Aug 2026 21:35:03 +0800 Subject: [PATCH 03/42] chore(deps): pin langbot-plugin 0.5.1 --- pyproject.toml | 2 +- uv.lock | 124 ++++++++++++++++++++++++------------------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 125f4c58b..b49f66200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6", + "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 7866686ed..78787c1a4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1018,7 +1018,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1051,34 +1051,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime" }, + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft" }, + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufile = [ - { name = "nvidia-cufile" }, + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti" }, + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand" }, + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver" }, + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx" }, + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -2125,7 +2125,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2191,8 +2191,8 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.0" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6#9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" } +version = "0.5.1" +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -3238,7 +3238,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3277,7 +3277,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3289,7 +3289,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3319,9 +3319,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3333,7 +3333,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -5163,10 +5163,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5213,7 +5213,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5284,14 +5284,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "torch", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "transformers", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5664,21 +5664,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.14'" }, + { name = "fsspec", marker = "python_full_version >= '3.14'" }, + { name = "jinja2", marker = "python_full_version >= '3.14'" }, + { name = "networkx", marker = "python_full_version >= '3.14'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.14'" }, + { name = "sympy", marker = "python_full_version >= '3.14'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5720,15 +5720,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "packaging", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "regex", marker = "python_full_version >= '3.14'" }, + { name = "safetensors", marker = "python_full_version >= '3.14'" }, + { name = "tokenizers", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "typer", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -5989,9 +5989,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "protobuf" }, - { name = "sniffio" }, + { name = "anyio", marker = "sys_platform != 'win32'" }, + { name = "protobuf", marker = "sys_platform != 'win32'" }, + { name = "sniffio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From 96a535827fdac125561b735c58f4b40475a1cbbd Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 11 Aug 2026 23:34:36 +0800 Subject: [PATCH 04/42] fix(i18n): add missing reasoning labels --- web/src/i18n/locales/es-ES.ts | 13 +++++++++++++ web/src/i18n/locales/ru-RU.ts | 13 +++++++++++++ web/src/i18n/locales/th-TH.ts | 13 +++++++++++++ web/src/i18n/locales/vi-VN.ts | 13 +++++++++++++ web/src/i18n/locales/zh-Hant.ts | 13 +++++++++++++ 5 files changed, 65 insertions(+) diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 43fc33a9b..53a232311 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -220,6 +220,19 @@ const esES = { selectModelAbilities: 'Seleccionar capacidades del modelo', visionAbility: 'Capacidad de visión', functionCallAbility: 'Llamada a funciones', + reasoningAbility: 'Razonamiento', + reasoningLevel: 'Nivel de razonamiento', + reasoningLevels: { + providerDefault: 'Predeterminado del proveedor', + disabled: 'Desactivado', + enabled: 'Activado', + minimal: 'Mínimo', + low: 'Bajo', + medium: 'Medio', + high: 'Alto', + xhigh: 'Extra alto', + max: 'Máximo', + }, contextLength: 'Ventana de contexto', contextLengthPlaceholder: 'Desconocido', contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 975360fb8..01db76f2d 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -217,6 +217,19 @@ const ruRU = { selectModelAbilities: 'Выберите возможности модели', visionAbility: 'Распознавание изображений', functionCallAbility: 'Вызов функций', + reasoningAbility: 'Рассуждение', + reasoningLevel: 'Уровень рассуждений', + reasoningLevels: { + providerDefault: 'По умолчанию провайдера', + disabled: 'Выключено', + enabled: 'Включено', + minimal: 'Минимальный', + low: 'Низкий', + medium: 'Средний', + high: 'Высокий', + xhigh: 'Очень высокий', + max: 'Максимальный', + }, contextLength: 'Контекстное окно', contextLengthPlaceholder: 'Неизвестно', contextLengthInvalid: diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index d19e2fef1..de50c6a80 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -213,6 +213,19 @@ const thTH = { selectModelAbilities: 'เลือกความสามารถของโมเดล', visionAbility: 'ความสามารถด้านภาพ', functionCallAbility: 'การเรียกฟังก์ชัน', + reasoningAbility: 'ความสามารถในการให้เหตุผล', + reasoningLevel: 'ระดับการให้เหตุผล', + reasoningLevels: { + providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ', + disabled: 'ปิด', + enabled: 'เปิด', + minimal: 'ต่ำสุด', + low: 'ต่ำ', + medium: 'ปานกลาง', + high: 'สูง', + xhigh: 'สูงมาก', + max: 'สูงสุด', + }, contextLength: 'หน้าต่างบริบท', contextLengthPlaceholder: 'ไม่ทราบ', contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 02bab4c7a..82c820972 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -217,6 +217,19 @@ const viVN = { selectModelAbilities: 'Chọn khả năng mô hình', visionAbility: 'Khả năng thị giác', functionCallAbility: 'Gọi hàm', + reasoningAbility: 'Khả năng suy luận', + reasoningLevel: 'Mức độ suy luận', + reasoningLevels: { + providerDefault: 'Mặc định của nhà cung cấp', + disabled: 'Tắt', + enabled: 'Bật', + minimal: 'Tối thiểu', + low: 'Thấp', + medium: 'Trung bình', + high: 'Cao', + xhigh: 'Rất cao', + max: 'Tối đa', + }, contextLength: 'Cửa sổ ngữ cảnh', contextLengthPlaceholder: 'Không rõ', contextLengthInvalid: 'Cửa sổ ngữ cảnh phải là số nguyên dương', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 91ecdde61..81c3f06ad 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -205,6 +205,19 @@ const zhHant = { selectModelAbilities: '選擇模型能力', visionAbility: '視覺能力', functionCallAbility: '函數呼叫', + reasoningAbility: '思考能力', + reasoningLevel: '思考等級', + reasoningLevels: { + providerDefault: '供應商預設', + disabled: '關閉', + enabled: '開啟', + minimal: '最低', + low: '低', + medium: '中', + high: '高', + xhigh: '極高', + max: '最大', + }, contextLength: '上下文視窗', contextLengthPlaceholder: '未知', contextLengthInvalid: '上下文視窗必須是正整數', From be8478e94063b8315a982d88894088b7db8778a2 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 15:53:38 +0800 Subject: [PATCH 05/42] [verified] docs(skills): add Space model selection tool (#2415) Co-authored-by: Chan --- skills/skills/langbot-space-ops/SKILL.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md index 196205a70..c360d4e3f 100644 --- a/skills/skills/langbot-space-ops/SKILL.md +++ b/skills/skills/langbot-space-ops/SKILL.md @@ -6,7 +6,8 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv # LangBot Space MCP Operations LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI -agents can browse and search the marketplaces (plugins, MCP servers, skills). +agents can browse and search the marketplaces (plugins, MCP servers, skills) and +rank live models for automated setup. ## Endpoint @@ -46,10 +47,12 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`. | `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace | | `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace | | `list_skills` / `search_skills` / `get_skill` | Skill marketplace | +| `select_models` | Live best-first model list for setup wizards; optional `category` filter | `list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes `author` + `name`. The tool surface mirrors the REST endpoints under -`/api/v1/marketplace/*` and is read/browse only. +`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`. +All tools are read-only. ## How to use @@ -58,12 +61,16 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`. 3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items, then `get_*` for details (e.g. to obtain author/name for installation in LangBot itself). +4. For automatic local-agent setup, call `select_models` (optionally with + `category`) and choose the first compatible item. Ordering is latest probe + state (available, unprobed, unavailable), then Space recommendation. Each + item includes `availability.up`, `last_probed_at`, latency, and HTTP status. ## Implementation & maintenance (for Space developers) - Server: `internal/controller/mcp/server.go` (official Go MCP SDK `github.com/modelcontextprotocol/go-sdk`). Tools call the service layer - (`PluginService`, `MCPService`, `SkillService`) directly. + (`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly. - Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`. - Auth: PAT via `AccountService.ValidatePersonalAccessToken`. - Docs: `docs/MCP_SERVER.md`. From c3299fd1a654de4b6e540bf6ac65f5db46d2fe27 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 16:56:25 +0800 Subject: [PATCH 06/42] fix(wizard): remove redundant Space CTA (#2416) Co-authored-by: Chan --- web/src/app/wizard/page.tsx | 47 --------------------------------- web/src/i18n/locales/en-US.ts | 6 ----- web/src/i18n/locales/es-ES.ts | 6 ----- web/src/i18n/locales/ja-JP.ts | 6 ----- web/src/i18n/locales/ru-RU.ts | 6 ----- web/src/i18n/locales/th-TH.ts | 6 ----- web/src/i18n/locales/vi-VN.ts | 6 ----- web/src/i18n/locales/zh-Hans.ts | 5 ---- web/src/i18n/locales/zh-Hant.ts | 5 ---- 9 files changed, 93 deletions(-) diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index eb533f118..262f91dd6 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -16,7 +16,6 @@ import { import { httpClient } from '@/app/infra/http/HttpClient'; import { - userInfo, systemInfo, bootstrapWorkspaceSession, initializeSystemInfo, @@ -490,24 +489,6 @@ export default function WizardPage() { t, ]); - // ---- Space auth redirect ---- - - const handleSpaceAuth = useCallback(async () => { - try { - const callbackUrl = `${window.location.origin}/auth/space/callback`; - const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl); - window.location.href = resp.authorize_url; - } catch (err) { - console.error('Failed to get space authorize URL', err); - toast.error(t('wizard.spaceAuthError')); - } - }, [t]); - - // ---- Check if local account ---- - // Re-evaluated after remote data fetch (when userInfo is populated) - const isLocalAccount = - !isLoading && (!userInfo || userInfo.account_type === 'local'); - // ---- Skip handler ---- const [showSkipConfirm, setShowSkipConfirm] = useState(false); const [isSkipping, setIsSkipping] = useState(false); @@ -662,8 +643,6 @@ export default function WizardPage() { runnerOptions={runnerOptions} selected={selectedRunner} onSelect={handleSelectRunner} - isLocalAccount={isLocalAccount} - onSpaceAuth={handleSpaceAuth} runnerConfigItems={selectedRunnerConfigItems} runnerConfigValues={runnerConfig} onRunnerConfigChange={setRunnerConfig} @@ -1006,8 +985,6 @@ function StepAIEngine({ runnerOptions, selected, onSelect, - isLocalAccount, - onSpaceAuth, runnerConfigItems, runnerConfigValues, onRunnerConfigChange, @@ -1015,8 +992,6 @@ function StepAIEngine({ runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; selected: string | null; onSelect: (name: string) => void; - isLocalAccount: boolean; - onSpaceAuth: () => void; runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigValues: Record; onRunnerConfigChange: (v: Record) => void; @@ -1127,28 +1102,6 @@ function StepAIEngine({ ); })} - - {/* Space promotion banner */} - {selected === 'local-agent' && isLocalAccount && ( -
-
-
- -

- {t('wizard.spaceBanner.message')} -

- -
-
-
- )} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c60ad295f..d5f02607c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1807,7 +1807,6 @@ const enUS = { botCreateSuccess: 'Bot created successfully!', botSaveSuccess: 'Bot configuration saved and enabled!', createError: 'Failed to create resources', - spaceAuthError: 'Failed to initiate Space authorization', skipSaveError: 'Failed to save skip status. Please try again.', completeSaveError: 'Failed to save completion status. Please try again.', step: { @@ -1836,11 +1835,6 @@ const enUS = { description: "Choose the AI engine that will power your bot's intelligence.", }, - spaceBanner: { - message: - 'Connect to LangBot Space for free trial model credits and zero-config instant setup!', - action: 'Authorize with Space', - }, config: { botInfo: 'Bot Information', botNamePlaceholder: 'Enter bot name', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 53a232311..382b90236 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1672,7 +1672,6 @@ const esES = { botCreateSuccess: '¡Bot creado correctamente!', botSaveSuccess: '¡Configuración del Bot guardada y activada!', createError: 'Error al crear los recursos', - spaceAuthError: 'Error al iniciar la autorización de Space', skipSaveError: 'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.', completeSaveError: @@ -1705,11 +1704,6 @@ const esES = { description: 'Elige el motor de IA que impulsará la inteligencia de tu Bot.', }, - spaceBanner: { - message: - '¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!', - action: 'Autorizar con Space', - }, config: { botInfo: 'Información del Bot', botNamePlaceholder: 'Introduce el nombre del Bot', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 026ddd3c4..34773cabf 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1722,7 +1722,6 @@ const jaJP = { botCreateSuccess: 'ボットが正常に作成されました!', botSaveSuccess: 'ボット設定が保存され、有効になりました!', createError: 'リソースの作成に失敗しました', - spaceAuthError: 'Space 認証の開始に失敗しました', skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。', completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。', step: { @@ -1753,11 +1752,6 @@ const jaJP = { description: 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', }, - spaceBanner: { - message: - 'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!', - action: 'Spaceで認証', - }, config: { botInfo: 'ボット情報', botNamePlaceholder: 'ボット名を入力', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 01db76f2d..ce5bd2a4b 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1645,7 +1645,6 @@ const ruRU = { botCreateSuccess: 'Бот успешно создан!', botSaveSuccess: 'Конфигурация бота сохранена и включена!', createError: 'Не удалось создать ресурсы', - spaceAuthError: 'Не удалось инициировать авторизацию через Space', skipSaveError: 'Не удалось сохранить статус пропуска. Повторите попытку.', completeSaveError: 'Не удалось сохранить статус завершения. Повторите попытку.', @@ -1675,11 +1674,6 @@ const ruRU = { description: 'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.', }, - spaceBanner: { - message: - 'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!', - action: 'Авторизация через Space', - }, config: { botInfo: 'Информация о боте', botNamePlaceholder: 'Введите имя бота', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index de50c6a80..614cb9a86 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1612,7 +1612,6 @@ const thTH = { botCreateSuccess: 'สร้าง Bot สำเร็จ!', botSaveSuccess: 'บันทึกและเปิดใช้งาน Bot สำเร็จ!', createError: 'ไม่สามารถสร้างทรัพยากรได้', - spaceAuthError: 'ไม่สามารถเริ่มต้นการยืนยันสิทธิ์ Space ได้', skipSaveError: 'ไม่สามารถบันทึกสถานะการข้ามได้ กรุณาลองใหม่', completeSaveError: 'ไม่สามารถบันทึกสถานะการเสร็จสิ้นได้ กรุณาลองใหม่', step: { @@ -1640,11 +1639,6 @@ const thTH = { title: 'เลือกเครื่องมือ AI', description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot', }, - spaceBanner: { - message: - 'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!', - action: 'ยืนยันสิทธิ์กับ Space', - }, config: { botInfo: 'ข้อมูล Bot', botNamePlaceholder: 'กรอกชื่อ Bot', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 82c820972..4113946e4 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1638,7 +1638,6 @@ const viVN = { botCreateSuccess: 'Tạo Bot thành công!', botSaveSuccess: 'Cấu hình Bot đã lưu và bật!', createError: 'Tạo tài nguyên thất bại', - spaceAuthError: 'Khởi tạo ủy quyền Space thất bại', skipSaveError: 'Lưu trạng thái bỏ qua thất bại. Vui lòng thử lại.', completeSaveError: 'Lưu trạng thái hoàn tất thất bại. Vui lòng thử lại.', step: { @@ -1666,11 +1665,6 @@ const viVN = { title: 'Chọn công cụ AI', description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.', }, - spaceBanner: { - message: - 'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!', - action: 'Ủy quyền với Space', - }, config: { botInfo: 'Thông tin Bot', botNamePlaceholder: 'Nhập tên Bot', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 3aba0fcfa..a9da25999 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1730,7 +1730,6 @@ const zhHans = { botCreateSuccess: '机器人创建成功!', botSaveSuccess: '机器人配置已保存并启用!', createError: '创建资源失败', - spaceAuthError: '无法发起 Space 授权', skipSaveError: '保存跳过状态失败,请重试。', completeSaveError: '保存完成状态失败,请重试。', step: { @@ -1756,10 +1755,6 @@ const zhHans = { title: '选择 AI 引擎', description: '选择驱动机器人智能的 AI 引擎。', }, - spaceBanner: { - message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!', - action: '前往授权登录', - }, config: { botInfo: '机器人信息', botNamePlaceholder: '请输入机器人名称', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 81c3f06ad..10042bca5 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1566,7 +1566,6 @@ const zhHant = { botCreateSuccess: '機器人建立成功!', botSaveSuccess: '機器人配置已儲存並啟用!', createError: '建立資源失敗', - spaceAuthError: '無法發起 Space 授權', skipSaveError: '儲存跳過狀態失敗,請重試。', completeSaveError: '儲存完成狀態失敗,請重試。', step: { @@ -1592,10 +1591,6 @@ const zhHant = { title: '選擇 AI 引擎', description: '選擇驅動機器人智慧的 AI 引擎。', }, - spaceBanner: { - message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!', - action: '前往授權登入', - }, config: { botInfo: '機器人資訊', botNamePlaceholder: '請輸入機器人名稱', From 338ee733cb9fbfc32b4cae7a62557878bec27561 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 18:06:34 +0800 Subject: [PATCH 07/42] fix(deploy): preserve recovered Cloud adapter pin (#2417) Co-authored-by: Chan --- .github/workflows/deploy-prod.yml | 2 +- tests/unit_tests/cloud/test_deploy_prod_config.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/cloud/test_deploy_prod_config.py diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index c8500c4e2..1b0bdd0f5 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -15,7 +15,7 @@ concurrency: env: CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core - SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b + SPACE_REF: 1c31172dee7aa912ce807d899018de27ff29054f jobs: build-and-deploy: diff --git a/tests/unit_tests/cloud/test_deploy_prod_config.py b/tests/unit_tests/cloud/test_deploy_prod_config.py new file mode 100644 index 000000000..c48e17056 --- /dev/null +++ b/tests/unit_tests/cloud/test_deploy_prod_config.py @@ -0,0 +1,11 @@ +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_RECOVERY_SPACE_REF = '1c31172dee7aa912ce807d899018de27ff29054f' + + +def test_production_build_pins_recovered_cloud_adapter_revision(): + workflow = (_REPO_ROOT / '.github' / 'workflows' / 'deploy-prod.yml').read_text(encoding='utf-8') + + assert f'SPACE_REF: {_RECOVERY_SPACE_REF}' in workflow From 97428310b9c99bdc5bdd544b76e88ca55c331170 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 18:42:23 +0800 Subject: [PATCH 08/42] fix(runtime): require Cloud control secrets (#2418) Co-authored-by: Chan --- src/langbot/pkg/box/connector.py | 4 ++++ src/langbot/pkg/plugin/connector.py | 5 +++++ tests/unit_tests/box/test_box_connector.py | 11 ++++++++++- tests/unit_tests/plugin/test_connector_ping.py | 11 ++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index df3a0d7fa..e10e8b9ac 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -368,6 +368,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): if not self._control_token and allow_generate: self._control_token = secrets.token_urlsafe(48) if not self._control_token: + if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + raise BoxRuntimeUnavailableError( + f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime' + ) return '' try: self._control_token = validate_control_token(self._control_token) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index f2dbff418..0279ab285 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -264,6 +264,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if not self._control_token and allow_generate: self._control_token = secrets.token_urlsafe(48) if not self._control_token: + if self.runtime_profile == 'shared': + raise PluginRuntimeNotConnectedError( + f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret ' + 'for a Cloud Plugin Runtime' + ) return {} try: self._control_token = validate_runtime_secret( diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py index 4d769f244..0949801c6 100644 --- a/tests/unit_tests/box/test_box_connector.py +++ b/tests/unit_tests/box/test_box_connector.py @@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector _CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes' -def make_app(logger: Mock, runtime_endpoint: str = ''): +def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False): return SimpleNamespace( logger=logger, workspace_service=SimpleNamespace(instance_uuid='instance-a'), @@ -42,6 +42,7 @@ def make_app(logger: Mock, runtime_endpoint: str = ''): } } ), + deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'), ) @@ -315,6 +316,14 @@ def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'} +def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False) + connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True)) + + with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV): + connector.get_control_headers() + + def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short') connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py index 3b3aee016..d44b1ca2b 100644 --- a/tests/unit_tests/plugin/test_connector_ping.py +++ b/tests/unit_tests/plugin/test_connector_ping.py @@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import ( ) -def make_connector() -> PluginRuntimeConnector: +def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector: app = SimpleNamespace( logger=Mock(), instance_config=SimpleNamespace( @@ -34,6 +34,7 @@ def make_connector() -> PluginRuntimeConnector: 'space': {'url': ''}, } ), + deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'), ) return PluginRuntimeConnector(app, AsyncMock()) @@ -332,6 +333,14 @@ def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeyp assert connector._control_headers(allow_generate=False) == {} +def test_cloud_runtime_rejects_missing_control_secret(monkeypatch): + monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False) + connector = make_connector(cloud=True) + + with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV): + connector._control_headers(allow_generate=False) + + def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch): monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False) connector = make_connector() From 8b14d2ab8e8c594d23015c05efcdf862fdd9519b Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 19:31:23 +0800 Subject: [PATCH 09/42] fix(runtime): allow shared plugin reconcile to finish (#2419) Co-authored-by: Chan --- src/langbot/pkg/plugin/handler.py | 2 +- tests/unit_tests/plugin/test_handler.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index e7029f625..4805f2668 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1579,7 +1579,7 @@ class RuntimeConnectionHandler(handler.Handler): return await self.call_action( LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS, request.model_dump(), - timeout=120, + timeout=300, ) async def apply_plugin_installation( diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index c0339d3c4..952d59134 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -9,8 +9,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, Mock import pytest -from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction -from langbot_plugin.entities.io.context import ActionContext, InstallationBinding +from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction +from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState def make_handler(app): @@ -67,6 +67,20 @@ def make_handler(app): return runtime_handler +@pytest.mark.asyncio +async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(): + app = SimpleNamespace() + runtime_handler = make_handler(app) + runtime_handler.call_action = AsyncMock(return_value={}) + binding = next(iter(runtime_handler._installation_bindings.values()))[0] + desired = PluginInstallationDesiredState(binding=binding, enabled=True) + + await runtime_handler.reconcile_plugin_installations((desired,)) + + assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS + assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300 + + class TestHandlerQueryVariables: """Tests for handler query variable logic.""" From c67a5035321516226b3937e3c0fe8b8c3a53bade Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 12 Aug 2026 19:49:56 +0800 Subject: [PATCH 10/42] feat(health): expose plugin runtime readiness (#2420) Co-authored-by: Chan --- src/langbot/pkg/core/app.py | 4 ++++ tests/unit_tests/core/test_app_shutdown.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 3f4a17329..4b82b3efc 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -249,6 +249,10 @@ class Application: {}, ) ), + 'plugin_runtime_connected': bool( + self.plugin_connector is not None + and getattr(self.plugin_connector, '_runtime_available', lambda: False)() + ), } mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None) runtime_stats.update( diff --git a/tests/unit_tests/core/test_app_shutdown.py b/tests/unit_tests/core/test_app_shutdown.py index e284f5000..ad6dcfad6 100644 --- a/tests/unit_tests/core/test_app_shutdown.py +++ b/tests/unit_tests/core/test_app_shutdown.py @@ -87,7 +87,10 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None: app.platform_mgr = SimpleNamespace(_bots_by_key={}) app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={}) app.rag_mgr = SimpleNamespace(knowledge_bases={}) - app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()}) + app.plugin_connector = SimpleNamespace( + _known_desired_states={'installation': object()}, + _runtime_available=lambda: True, + ) app.persistence_mgr = SimpleNamespace( get_resource_stats=lambda: { 'configured_capacity': 20, @@ -140,3 +143,4 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None: } assert stats['models']['providers'] == 1 assert stats['runtimes']['plugin_installations'] == 1 + assert stats['runtimes']['plugin_runtime_connected'] is True From 45ed6efceb3a2f2838d72472d6a9b110ea9d9e39 Mon Sep 17 00:00:00 2001 From: Chan Date: Thu, 13 Aug 2026 08:14:40 +0000 Subject: [PATCH 11/42] chore: remove private Cloud deployment configuration --- .github/workflows/deploy-prod.yml | 59 ----------- deploy/prod/deploy.sh | 97 ------------------ deploy/prod/docker-compose.yml | 162 ------------------------------ 3 files changed, 318 deletions(-) delete mode 100644 .github/workflows/deploy-prod.yml delete mode 100755 deploy/prod/deploy.sh delete mode 100644 deploy/prod/docker-compose.yml diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml deleted file mode 100644 index 1b0bdd0f5..000000000 --- a/.github/workflows/deploy-prod.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Build and deploy production - -on: - push: - branches: [deploy/prod] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: langbot-production - cancel-in-progress: false - -env: - CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot - CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core - SPACE_REF: 1c31172dee7aa912ce807d899018de27ff29054f - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build exact Core image - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ${{ env.CORE_IMAGE }}:prod-${{ github.sha }} - ${{ env.CORE_IMAGE }}:deploy-prod - cache-from: type=gha,scope=core-prod - cache-to: type=gha,mode=max,scope=core-prod - - name: Checkout production Cloud adapter - uses: actions/checkout@v4 - with: - repository: langbot-app/langbot-space - ref: ${{ env.SPACE_REF }} - token: ${{ secrets.CLA_PAT }} - path: .space - - name: Build exact Cloud Core image - uses: docker/build-push-action@v6 - with: - context: .space - file: .space/Dockerfile.cloud - push: true - build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }} - tags: | - ${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }} - ${{ env.CLOUD_IMAGE }}:deploy-prod - cache-from: type=gha,scope=cloud-core-prod - cache-to: type=gha,mode=max,scope=cloud-core-prod diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh deleted file mode 100755 index dc51f89a5..000000000 --- a/deploy/prod/deploy.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -cd /opt/langbot-cloud-prod -TAG=${1:?usage: deploy.sh prod-<40-char-sha>} -[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; } -[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; } - -rendered_compose=$(docker compose config) -grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || { - echo 'Cloud control-plane URL must be https://space.langbot.app' >&2 - exit 4 -} -grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || { - echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2 - exit 5 -} -grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || { - echo 'Cloud telemetry ingest token must be configured' >&2 - exit 6 -} - -update_env() { - local key=$1 value=$2 - python3 - "$key" "$value" <<'PY' -from pathlib import Path -import os -import sys - -path = Path('.env') -key, value = sys.argv[1:] -lines = path.read_text().splitlines() -updated = False -for index, line in enumerate(lines): - if line.startswith(f'{key}='): - lines[index] = f'{key}={value}' - updated = True - break -if not updated: - lines.append(f'{key}={value}') -temporary = Path('.env.tmp') -temporary.write_text('\n'.join(lines) + '\n') -os.chmod(temporary, 0o600) -temporary.replace(path) -PY -} -update_env LANGBOT_IMAGE_TAG "$TAG" -set -a -. ./.env -set +a -: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}" - -for attempt in 1 2 3 4 5; do - if docker compose pull postgres redis migrate plugin-runtime core; then - break - fi - if [ "$attempt" -eq 5 ]; then - echo "docker compose pull failed after $attempt attempts" >&2 - exit 1 - fi - delay=$((attempt * 10)) - echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2 - sleep "$delay" -done -docker compose up -d postgres redis -for _ in $(seq 1 60); do - if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi - sleep 2 -done -docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null - -docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \ - -v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL' -SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password') -WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec -ALTER ROLE langbot_runtime PASSWORD :'runtime_password'; -GRANT CONNECT ON DATABASE langbot TO langbot_runtime; -REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime; -REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime; -REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime; -ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime; -ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime; -GRANT USAGE ON SCHEMA public TO langbot_runtime; -SQL - -docker compose --profile tools run --rm migrate - -docker compose up -d --remove-orphans plugin-runtime core -for _ in $(seq 1 90); do - if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then - docker compose ps - exit 0 - fi - sleep 2 -done -docker compose logs --tail=200 core plugin-runtime >&2 -exit 1 diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml deleted file mode 100644 index db224b4f0..000000000 --- a/deploy/prod/docker-compose.yml +++ /dev/null @@ -1,162 +0,0 @@ -services: - postgres: - image: pgvector/pgvector:pg17 - container_name: langbot-cloud-postgres - restart: unless-stopped - environment: - POSTGRES_DB: langbot - POSTGRES_USER: langbot_operator - POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"] - interval: 5s - timeout: 5s - retries: 30 - networks: [internal] - - redis: - image: redis:7.4-alpine - container_name: langbot-cloud-redis - restart: unless-stopped - command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"] - volumes: - - redis-data:/data - healthcheck: - test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] - interval: 5s - timeout: 5s - retries: 20 - environment: - REDIS_PASSWORD: ${REDIS_PASSWORD} - networks: [internal] - - migrate: - image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} - profiles: [tools] - command: [uv, run, langbot, migrate, --cloud] - environment: &core-env - TZ: Asia/Shanghai - SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID} - SYSTEM__EDITION: cloud - SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY} - SYSTEM__JWT__SECRET: ${JWT_SECRET} - SYSTEM__LIMITATION__MAX_BOTS: "2" - SYSTEM__LIMITATION__MAX_PIPELINES: "3" - SYSTEM__LIMITATION__MAX_EXTENSIONS: "3" - SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2" - API__WEBHOOK_PREFIX: https://cloud.langbot.app - API__WEBUI_URL: https://cloud.langbot.app - WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app - DATABASE__USE: postgresql - DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot - DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN - LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot - VDB__USE: pgvector - VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true" - VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536" - PLUGIN__ENABLE: "true" - PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws - PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws - PLUGIN__WORKER__MAX_CPUS: "0.25" - PLUGIN__WORKER__MAX_MEMORY_MB: "256" - PLUGIN__WORKER__MAX_PIDS: "128" - PLUGIN__WORKER__MAX_WORKERS: "16" - PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0" - PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096" - PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true" - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} - # Cloud v2 currently grants no managed Box capability. Keep the shared - # runtime deployed but disable Core integration until a hard-quota-capable - # backend can satisfy the fail-closed Cloud readiness contract. - BOX__ENABLED: "false" - BOX__BACKEND: nsjail - BOX__RUNTIME__ENDPOINT: ws://box:5410 - BOX__ADMISSION__REQUIRED: "true" - BOX__ADMISSION__LOGICAL_SESSION_ID: global - BOX__ADMISSION__REQUIRED_BACKEND: nsjail - BOX__ADMISSION__MAX_SESSIONS: "1" - BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0" - BOX__ADMISSION__CPUS: "0.25" - BOX__ADMISSION__MEMORY_MB: "256" - BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256" - BOX__LOCAL__HOST_ROOT: /app/data/box - BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box - BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box - LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} - MCP__STDIO__ENABLED: "false" - LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app - LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} - LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN} - LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY} - LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID} - SPACE__URL: https://space.langbot.app - depends_on: - postgres: {condition: service_healthy} - networks: [internal] - - plugin-runtime: - image: rockchin/langbot:${LANGBOT_IMAGE_TAG} - container_name: langbot-cloud-plugin-runtime - restart: unless-stopped - command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt] - environment: - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN} - volumes: - - plugin-data:/app/data - - /sys/fs/cgroup:/sys/fs/cgroup:rw - cgroup: host - privileged: true - expose: ["5400"] - networks: [internal] - - box: - image: rockchin/langbot:${LANGBOT_IMAGE_TAG} - container_name: langbot-cloud-box - restart: unless-stopped - command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"] - environment: - LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN} - LANGBOT_BOX_ROOT: /app/data/box - volumes: - - box-data:/app/data/box - - /sys/fs/cgroup:/sys/fs/cgroup:rw - cgroup: host - privileged: true - expose: ["5410"] - networks: [internal] - - core: - image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG} - container_name: langbot-cloud-core - restart: unless-stopped - environment: *core-env - volumes: - - core-data:/app/data - - box-data:/app/data/box - depends_on: - postgres: {condition: service_healthy} - redis: {condition: service_healthy} - plugin-runtime: {condition: service_started} - box: {condition: service_started} - expose: ["5300"] - healthcheck: - test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ] - interval: 10s - timeout: 5s - retries: 30 - start_period: 30s - networks: [internal, shared-network] - -networks: - internal: - shared-network: - external: true - -volumes: - postgres-data: - redis-data: - plugin-data: - box-data: - core-data: From c18fc9dfe33cd60d5fc511842885aee5a1f7895b Mon Sep 17 00:00:00 2001 From: Chan Date: Thu, 13 Aug 2026 09:27:50 +0000 Subject: [PATCH 12/42] chore(runtime): pin plugin SDK 0.5.2 --- pyproject.toml | 2 +- uv.lock | 124 ++++++++++++++++++++++++------------------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b49f66200..b108cab86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3", + "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@a77c21be50d46a49acb503d2a2fec016478964ad", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 78787c1a4..99a02d482 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1018,7 +1018,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1051,34 +1051,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -2125,7 +2125,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a77c21be50d46a49acb503d2a2fec016478964ad" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2191,8 +2191,8 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.1" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" } +version = "0.5.2" +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a77c21be50d46a49acb503d2a2fec016478964ad#a77c21be50d46a49acb503d2a2fec016478964ad" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -3238,7 +3238,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3277,7 +3277,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3289,7 +3289,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3319,9 +3319,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3333,7 +3333,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -5163,10 +5163,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5213,7 +5213,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5284,14 +5284,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "torch", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "transformers", marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5664,21 +5664,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "filelock", marker = "python_full_version >= '3.14'" }, - { name = "fsspec", marker = "python_full_version >= '3.14'" }, - { name = "jinja2", marker = "python_full_version >= '3.14'" }, - { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.14'" }, - { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5720,15 +5720,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pyyaml", marker = "python_full_version >= '3.14'" }, - { name = "regex", marker = "python_full_version >= '3.14'" }, - { name = "safetensors", marker = "python_full_version >= '3.14'" }, - { name = "tokenizers", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "typer", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -5989,9 +5989,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'win32'" }, - { name = "protobuf", marker = "sys_platform != 'win32'" }, - { name = "sniffio", marker = "sys_platform != 'win32'" }, + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From f7914a8900f482f9fe9271cf83a28b779bbbf344 Mon Sep 17 00:00:00 2001 From: Chan Date: Thu, 13 Aug 2026 09:31:45 +0000 Subject: [PATCH 13/42] test: remove obsolete private deploy workflow assertion --- tests/unit_tests/cloud/test_deploy_prod_config.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 tests/unit_tests/cloud/test_deploy_prod_config.py diff --git a/tests/unit_tests/cloud/test_deploy_prod_config.py b/tests/unit_tests/cloud/test_deploy_prod_config.py deleted file mode 100644 index c48e17056..000000000 --- a/tests/unit_tests/cloud/test_deploy_prod_config.py +++ /dev/null @@ -1,11 +0,0 @@ -from pathlib import Path - - -_REPO_ROOT = Path(__file__).resolve().parents[3] -_RECOVERY_SPACE_REF = '1c31172dee7aa912ce807d899018de27ff29054f' - - -def test_production_build_pins_recovered_cloud_adapter_revision(): - workflow = (_REPO_ROOT / '.github' / 'workflows' / 'deploy-prod.yml').read_text(encoding='utf-8') - - assert f'SPACE_REF: {_RECOVERY_SPACE_REF}' in workflow From 536534865eed0db443d23d61b1d8989dfd74e634 Mon Sep 17 00:00:00 2001 From: Chan Date: Thu, 13 Aug 2026 10:39:51 +0000 Subject: [PATCH 14/42] fix: pin plugin runtime SDK 0.5.3 --- pyproject.toml | 2 +- uv.lock | 122 ++++++++++++++++++++++++------------------------- 2 files changed, 62 insertions(+), 62 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b108cab86..600e8b92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@a77c21be50d46a49acb503d2a2fec016478964ad", + "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 99a02d482..6afff2c9b 100644 --- a/uv.lock +++ b/uv.lock @@ -1018,7 +1018,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1051,34 +1051,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime" }, + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft" }, + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufile = [ - { name = "nvidia-cufile" }, + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti" }, + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand" }, + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver" }, + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx" }, + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -2125,7 +2125,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a77c21be50d46a49acb503d2a2fec016478964ad" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2191,8 +2191,8 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.2" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=a77c21be50d46a49acb503d2a2fec016478964ad#a77c21be50d46a49acb503d2a2fec016478964ad" } +version = "0.5.3" +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19#555a58e5db3de28e977b08dd4cd116b332848a19" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -3238,7 +3238,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3277,7 +3277,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3289,7 +3289,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3319,9 +3319,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3333,7 +3333,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -5163,10 +5163,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5213,7 +5213,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5284,14 +5284,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "torch", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "transformers", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5664,21 +5664,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.14'" }, + { name = "fsspec", marker = "python_full_version >= '3.14'" }, + { name = "jinja2", marker = "python_full_version >= '3.14'" }, + { name = "networkx", marker = "python_full_version >= '3.14'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.14'" }, + { name = "sympy", marker = "python_full_version >= '3.14'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5720,15 +5720,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "packaging", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "regex", marker = "python_full_version >= '3.14'" }, + { name = "safetensors", marker = "python_full_version >= '3.14'" }, + { name = "tokenizers", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "typer", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -5989,9 +5989,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "protobuf" }, - { name = "sniffio" }, + { name = "anyio", marker = "sys_platform != 'win32'" }, + { name = "protobuf", marker = "sys_platform != 'win32'" }, + { name = "sniffio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From 5084f2391de1dd3baf162babd4de31693bc75dea Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 13 Aug 2026 23:18:01 +0800 Subject: [PATCH 15/42] fix(auth): support dynamic OSS callbacks and LangBot Account copy (#2428) * fix(auth): support dynamic OSS callbacks and LangBot Account copy * style(web): format LangBot Account copy --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 25 +++---- src/langbot/pkg/api/http/service/user.py | 8 +-- src/langbot/pkg/entity/errors/account.py | 2 +- .../integration/api/test_user_space_oauth.py | 66 ++++++++++++------- .../api/service/test_user_service.py | 4 +- web/src/i18n/locales/en-US.ts | 47 ++++++------- web/src/i18n/locales/es-ES.ts | 48 +++++++------- web/src/i18n/locales/ja-JP.ts | 47 ++++++------- web/src/i18n/locales/ru-RU.ts | 47 ++++++------- web/src/i18n/locales/th-TH.ts | 49 +++++++------- web/src/i18n/locales/vi-VN.ts | 47 ++++++------- web/src/i18n/locales/zh-Hans.ts | 47 ++++++------- web/src/i18n/locales/zh-Hant.ts | 46 ++++++------- .../unit/langbot-account-terminology.test.mjs | 47 +++++++++++++ 14 files changed, 298 insertions(+), 232 deletions(-) create mode 100644 web/tests/unit/langbot-account-terminology.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 6cfd5f8a7..932de0823 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -15,11 +15,9 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): @staticmethod - def _origin(value: str) -> tuple[str, str, int | None] | None: - parsed = urlsplit(value) - if parsed.scheme not in {'http', 'https'} or not parsed.hostname: - return None - return parsed.scheme, parsed.hostname.casefold(), parsed.port + def _is_loopback_host(hostname: str) -> bool: + normalized = hostname.casefold().rstrip('.') + return normalized in {'localhost', '127.0.0.1', '::1'} def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str: parsed = urlsplit(redirect_uri) @@ -38,17 +36,12 @@ class UserRouterGroup(group.RouterGroup): if query != {'mode': ['bind']}: raise ValueError('Invalid Space binding redirect_uri') elif query: - raise ValueError('Invalid Space login redirect_uri') + raise ValueError('Invalid LangBot Account login redirect_uri') - redirect_origin = self._origin(redirect_uri) - api_config = self.ap.instance_config.data.get('api', {}) - trusted_origins = { - self._origin(str(api_config.get(config_key, '') or '').strip()) - for config_key in ('webui_url', 'webhook_prefix') - } - trusted_origins.discard(None) - if redirect_origin not in trusted_origins: - raise ValueError('Untrusted redirect_uri origin') + # OSS instances can live behind arbitrary domains and gateway ports. + # Accept any HTTPS callback, plus HTTP only for local development. + if parsed.scheme == 'http' and not self._is_loopback_host(parsed.hostname): + raise ValueError('Insecure redirect_uri origin') return redirect_uri async def initialize(self) -> None: @@ -416,7 +409,7 @@ class UserRouterGroup(group.RouterGroup): 'Bind the LangBot Account with the same email as this local Account', ) except ValueError: - return self.http_status(400, -1, 'Space account binding failed') + return self.http_status(400, -1, 'LangBot Account binding failed') except Exception: raise diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 28c23b32e..5e3ca2082 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -114,7 +114,7 @@ class UserService: if purpose == 'login' and account_uuid is not None: raise ValueError('Login state cannot be bound to an Account') if purpose != 'login' and launch_workspace_uuid is not None: - raise ValueError('Launch Workspace state is only valid for Space login') + raise ValueError('Launch Workspace state is only valid for LangBot Account login') if ttl_seconds <= 0: raise ValueError('OAuth state lifetime must be positive') @@ -327,7 +327,7 @@ class UserService: normalized_email = normalize_email(user_email) if self._uses_control_plane_directory(): raise ControlPlaneDirectoryRequiredError( - 'Cloud invitation registration must use a Space account to preserve control-plane identity' + 'Cloud invitation registration must use a LangBot Account to preserve control-plane identity' ) invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token) if invitation.normalized_email != normalized_email: @@ -394,7 +394,7 @@ class UserService: # Check if this user has a local password set if not user_obj.password: - raise ValueError('请使用 Space 账户登录') + raise ValueError('请使用 LangBot 账号登录') await self._verify_password(user_obj.password, password) @@ -825,7 +825,7 @@ class UserService: # Check if this Space account is already bound to another user existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email): - raise ValueError('This Space account is already bound to another user') + raise ValueError('This LangBot Account is already bound to another user') # Update local account to Space account normalized_email = normalize_email(user_email) diff --git a/src/langbot/pkg/entity/errors/account.py b/src/langbot/pkg/entity/errors/account.py index a2d0f1e85..d6565713d 100644 --- a/src/langbot/pkg/entity/errors/account.py +++ b/src/langbot/pkg/entity/errors/account.py @@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError): code = 'space_account_binding_required' def __str__(self) -> str: - return 'This local Account must bind Space from Account settings before Space login' + return 'This local account must bind a LangBot Account from Account settings before LangBot Account login' diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 6fc1dfe07..42ae4b066 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -165,34 +165,50 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau @pytest.mark.asyncio -async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api): +async def test_redirect_allows_dynamic_https_origin_and_loopback_http(space_oauth_api): _, client = space_oauth_api - wrong_origin = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'https://evil.example/auth/space/callback'}, - headers={'Origin': 'http://localhost'}, - ) - wrong_path = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'http://localhost/arbitrary'}, - headers={'Origin': 'http://localhost'}, - ) - forged_origin = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'https://evil.example/auth/space/callback'}, - headers={'Origin': 'https://evil.example'}, - ) - forged_host = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'https://evil.example/auth/space/callback'}, - headers={'Host': 'evil.example'}, - ) + responses = [ + await client.get( + '/api/v1/user/space/authorize-url', + query_string={'redirect_uri': redirect_uri}, + headers={'Origin': 'https://irrelevant.example'}, + ) + for redirect_uri in ( + 'https://langbot.example/auth/space/callback', + 'https://gateway.example:8443/auth/space/callback', + 'https://192.0.2.10/auth/space/callback', + 'http://localhost:5300/auth/space/callback', + 'http://127.0.0.1:5300/auth/space/callback', + 'http://[::1]:5300/auth/space/callback', + ) + ] - assert (await wrong_origin.get_json())['code'] == 1 - assert (await wrong_path.get_json())['code'] == 1 - assert (await forged_origin.get_json())['code'] == 1 - assert (await forged_host.get_json())['code'] == 1 + assert all(response.status_code == 200 for response in responses) + payloads = [await response.get_json() for response in responses] + assert all(payload['code'] == 0 for payload in payloads) + + +@pytest.mark.asyncio +async def test_redirect_rejects_insecure_remote_origin_and_invalid_callback_shape(space_oauth_api): + _, client = space_oauth_api + + responses = [ + await client.get( + '/api/v1/user/space/authorize-url', + query_string={'redirect_uri': redirect_uri}, + ) + for redirect_uri in ( + 'http://langbot.example/auth/space/callback', + 'https://langbot.example/arbitrary', + 'https://langbot.example/auth/space/callback?next=https://evil.example', + 'https://user@langbot.example/auth/space/callback', + 'https://langbot.example/auth/space/callback#fragment', + ) + ] + + payloads = [await response.get_json() for response in responses] + assert all(payload['code'] == 1 for payload in payloads) @pytest.mark.asyncio diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index a3479725e..12b440b32 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -377,7 +377,7 @@ class TestUserServiceAuthenticate: service = UserService(ap) # Execute & Verify - with pytest.raises(ValueError, match='请使用 Space 账户登录'): + with pytest.raises(ValueError, match='请使用 LangBot 账号登录'): await service.authenticate('space@example.com', 'password') @@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser: ) service = UserService(ap) - with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'): + with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'): await service.register_invited_account('invite-token', 'member@example.com', 'password') async def test_create_or_update_new_space_user_first_init(self): diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index d5f02607c..258e7f21e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -85,18 +85,18 @@ const enUS = { 'Recommended: Use official stable model APIs and cloud services', loginLocal: 'Login with local account', loginWithPassword: 'Login with password', - spaceLoginTitle: 'Login with Space', + spaceLoginTitle: 'Login with LangBot Account', spaceLoginDescription: 'Scan the QR code or visit the link below to authorize', spaceLoginUserCode: 'Your code', spaceLoginExpires: 'Code expires in {{seconds}} seconds', spaceLoginWaiting: 'Waiting for authorization...', spaceLoginSuccess: 'Authorization successful', - spaceLoginFailed: 'Space login failed', + spaceLoginFailed: 'LangBot Account login failed', spaceLoginExpired: 'Authorization code expired, please try again', spaceLoginCancel: 'Cancel', spaceLoginVisitLink: 'Visit link', - spaceLoginProcessing: 'Logging in with Space', + spaceLoginProcessing: 'Logging in with LangBot Account', spaceLoginProcessingDescription: 'Please wait while we complete your login...', spaceLoginSuccessDescription: 'Redirecting to LangBot...', @@ -105,7 +105,7 @@ const enUS = { backToLogin: 'Back to Login', backToHome: 'Back to Home', spaceAccountCannotChangePassword: - 'Space accounts cannot change password here', + 'LangBot Accounts cannot change password here', theme: 'Theme', changePassword: 'Change Password', currentPassword: 'Current Password', @@ -254,8 +254,9 @@ const enUS = { llmModels: 'LLM Models', localProvider: 'Local', localProviderDescription: 'Models configured and managed locally', - spaceProviderDescription: 'Models synced from your Space account', - spaceDisabledForLocalAccount: 'Login with Space to use cloud models', + spaceProviderDescription: 'Models synced from your LangBot Account', + spaceDisabledForLocalAccount: + 'Login with LangBot Account to use cloud models', syncModels: 'Sync', syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated', syncError: 'Sync failed: ', @@ -291,15 +292,15 @@ const enUS = { langbotModelsDescription: 'Cloud models powered by LangBot Space', credits: 'Credits', loginWithSpace: 'Login with LangBot Account', - loginToUseModels: 'Login with Space to use cloud models', + loginToUseModels: 'Login with LangBot Account to use cloud models', ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", noModels: 'No models configured', langbotModels: 'LangBot Models', spaceTrialTooltip: - 'Free trial credits available! Login with Space to access cloud models with zero configuration.', + 'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.', unlockModels: 'Login to use', editProvider: 'Edit Provider', addProvider: 'Add Provider', @@ -1218,13 +1219,13 @@ const enUS = { adminAccountNote: 'The account you use here will be set as the administrator account', register: 'Register', - initWithSpace: 'Initialize with Space', + initWithSpace: 'Initialize with LangBot Account', spaceRecommended: 'Recommended: Use official stable model APIs and cloud services', spaceInfoTip1: 'Space provides unified account authentication services without uploading any of your sensitive information.', spaceInfoTip2: - 'Logging in with a Space account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.', + 'Logging in with a LangBot Account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.', spaceInfoTip3: 'Your login method does not affect other features. You can configure and use models from other sources at any time.', registerLocal: 'Register local account', @@ -1281,32 +1282,32 @@ const enUS = { passwordNotSet: 'Not Set', passwordSetDescription: 'Password is set, you can login with email and password', - spaceStatus: 'Space Account', + spaceStatus: 'LangBot Account', spaceBound: 'Bound', spaceNotBound: 'Not Bound', spaceBoundDescription: - 'Space account bound, official model APIs and cloud services available', - bindSpace: 'Bind Space Account', + 'LangBot Account bound, official model APIs and cloud services available', + bindSpace: 'Bind LangBot Account', bindSpaceDescription: 'Bind to use official model APIs and cloud services', bindSpaceButton: 'Bind', bindSpaceConfirmTitle: 'Confirm Binding', bindSpaceConfirmDescription: - 'You are about to bind your local instance to a Space account', + 'You are about to bind your local instance to a LangBot Account', bindSpaceWarning: - 'After binding, your login email will be changed from {{localEmail}} to the Space account email.', - bindSpaceSuccess: 'Space account bound successfully', - bindSpaceFailed: 'Failed to bind Space account', + 'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.', + bindSpaceSuccess: 'LangBot Account bound successfully', + bindSpaceFailed: 'Failed to bind LangBot Account', bindSpaceInvalidState: 'Invalid bind request. Please try again from account settings.', setPasswordHint: 'Set a password to login with email and password', spaceEmailMismatch: - 'The Space login email does not match the local account email.', + 'The LangBot Account login email does not match the local account email.', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, workspace: { title: 'Workspace', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 382b90236..cef4f0f74 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -88,19 +88,19 @@ const esES = { 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', loginLocal: 'Iniciar sesión con cuenta local', loginWithPassword: 'Iniciar sesión con contraseña', - spaceLoginTitle: 'Iniciar sesión con Space', + spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot', spaceLoginDescription: 'Escanea el código QR o visita el enlace para autorizar', spaceLoginUserCode: 'Tu código', spaceLoginExpires: 'El código expira en {{seconds}} segundos', spaceLoginWaiting: 'Esperando autorización...', spaceLoginSuccess: 'Autorización exitosa', - spaceLoginFailed: 'Error de inicio de sesión con Space', + spaceLoginFailed: 'Error de inicio de sesión con una cuenta de LangBot', spaceLoginExpired: 'El código de autorización ha expirado, por favor inténtalo de nuevo', spaceLoginCancel: 'Cancelar', spaceLoginVisitLink: 'Visitar enlace', - spaceLoginProcessing: 'Iniciando sesión con Space', + spaceLoginProcessing: 'Iniciando sesión con una cuenta de LangBot', spaceLoginProcessingDescription: 'Por favor espera mientras completamos tu inicio de sesión...', spaceLoginSuccessDescription: 'Redirigiendo a LangBot...', @@ -109,7 +109,7 @@ const esES = { backToLogin: 'Volver al inicio de sesión', backToHome: 'Volver al inicio', spaceAccountCannotChangePassword: - 'Las cuentas de Space no pueden cambiar la contraseña aquí', + 'Las cuentas de LangBot no pueden cambiar la contraseña aquí', theme: 'Tema', changePassword: 'Cambiar contraseña', currentPassword: 'Contraseña actual', @@ -261,9 +261,10 @@ const esES = { llmModels: 'Modelos LLM', localProvider: 'Local', localProviderDescription: 'Modelos configurados y gestionados localmente', - spaceProviderDescription: 'Modelos sincronizados desde tu cuenta de Space', + spaceProviderDescription: + 'Modelos sincronizados desde tu cuenta de LangBot', spaceDisabledForLocalAccount: - 'Inicia sesión con Space para usar modelos en la nube', + 'Inicia sesión con una cuenta de LangBot para usar modelos en la nube', syncModels: 'Sincronizar', syncSuccess: 'Sincronización completa: {{created}} creados, {{updated}} actualizados', @@ -302,11 +303,12 @@ const esES = { langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space', credits: 'Créditos', loginWithSpace: 'Iniciar sesión con una cuenta de LangBot', - loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube', + loginToUseModels: + 'Inicia sesión con una cuenta de LangBot para usar modelos en la nube', noModels: 'No hay modelos configurados', langbotModels: 'Modelos LangBot', spaceTrialTooltip: - '¡Créditos de prueba gratuitos disponibles! Inicia sesión con Space para acceder a modelos en la nube sin configuración.', + '¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.', unlockModels: 'Inicia sesión para usar', editProvider: 'Editar proveedor', addProvider: 'Añadir proveedor', @@ -341,9 +343,9 @@ const esES = { }, ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { title: 'Bots', @@ -1251,13 +1253,13 @@ const esES = { adminAccountNote: 'La cuenta que uses aquí se establecerá como cuenta de administrador', register: 'Registrarse', - initWithSpace: 'Inicializar con Space', + initWithSpace: 'Inicializar con una cuenta de LangBot', spaceRecommended: 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', spaceInfoTip1: 'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.', spaceInfoTip2: - 'Iniciar sesión con una cuenta de Space te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.', + 'Iniciar sesión con una cuenta de LangBot te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.', spaceInfoTip3: 'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.', registerLocal: 'Registrar cuenta local', @@ -1315,35 +1317,35 @@ const esES = { passwordNotSet: 'No establecida', passwordSetDescription: 'La contraseña está establecida, puedes iniciar sesión con correo y contraseña', - spaceStatus: 'Cuenta de Space', + spaceStatus: 'Cuenta de LangBot', spaceBound: 'Vinculada', spaceNotBound: 'No vinculada', spaceBoundDescription: - 'Cuenta de Space vinculada, API de modelos oficiales y servicios en la nube disponibles', - bindSpace: 'Vincular cuenta de Space', + 'Cuenta de LangBot vinculada, API de modelos oficiales y servicios en la nube disponibles', + bindSpace: 'Vincular cuenta de LangBot', bindSpaceDescription: 'Vincular para usar API de modelos oficiales y servicios en la nube', bindSpaceButton: 'Vincular', bindSpaceConfirmTitle: 'Confirmar vinculación', bindSpaceConfirmDescription: - 'Estás a punto de vincular tu instancia local a una cuenta de Space', + 'Estás a punto de vincular tu instancia local a una cuenta de LangBot', bindSpaceWarning: - 'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de Space.', - bindSpaceSuccess: 'Cuenta de Space vinculada correctamente', - bindSpaceFailed: 'Error al vincular la cuenta de Space', + 'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.', + bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente', + bindSpaceFailed: 'Error al vincular la cuenta de LangBot', bindSpaceInvalidState: 'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.', setPasswordHint: 'Establece una contraseña para iniciar sesión con correo y contraseña', spaceEmailMismatch: - 'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local', + 'El correo de la cuenta de LangBot no coincide con el correo de la cuenta local', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, monitoring: { title: 'Panel de control', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 34773cabf..e23b6e101 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -86,19 +86,19 @@ const jaJP = { 'おすすめ:公式の安定したモデル API とクラウドサービスを利用', loginLocal: 'ローカルアカウントでログイン', loginWithPassword: 'パスワードでログイン', - spaceLoginTitle: 'Space でログイン', + spaceLoginTitle: 'LangBot アカウントでログイン', spaceLoginDescription: 'QRコードをスキャンするか、下のリンクにアクセスして認証してください', spaceLoginUserCode: '認証コード', spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります', spaceLoginWaiting: '認証を待っています...', spaceLoginSuccess: '認証に成功しました', - spaceLoginFailed: 'Space ログインに失敗しました', + spaceLoginFailed: 'LangBot アカウントログインに失敗しました', spaceLoginExpired: '認証コードの有効期限が切れました。もう一度お試しください', spaceLoginCancel: 'キャンセル', spaceLoginVisitLink: 'リンクにアクセス', - spaceLoginProcessing: 'Space でログイン中', + spaceLoginProcessing: 'LangBot アカウントでログイン中', spaceLoginProcessingDescription: 'ログインを完了しています。しばらくお待ちください...', spaceLoginSuccessDescription: 'LangBot にリダイレクト中...', @@ -107,7 +107,7 @@ const jaJP = { backToLogin: 'ログインに戻る', backToHome: 'ホームに戻る', spaceAccountCannotChangePassword: - 'Space アカウントはここでパスワードを変更できません', + 'LangBot アカウントはここでパスワードを変更できません', theme: 'テーマ', changePassword: 'パスワードを変更', currentPassword: '現在のパスワード', @@ -257,8 +257,9 @@ const jaJP = { llmModels: 'LLM モデル', localProvider: 'ローカル', localProviderDescription: 'ローカルで設定・管理されているモデル', - spaceProviderDescription: 'Space アカウントから同期されたモデル', - spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用', + spaceProviderDescription: 'LangBot アカウントから同期されたモデル', + spaceDisabledForLocalAccount: + 'LangBot アカウントでログインしてクラウドモデルを使用', syncModels: '同期', syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新', syncError: '同期に失敗しました:', @@ -296,15 +297,15 @@ const jaJP = { langbotModelsDescription: 'LangBot Space が提供するクラウドモデル', credits: 'クレジット', loginWithSpace: 'LangBot アカウントでログイン', - loginToUseModels: 'Space でログインしてクラウドモデルを使用', + loginToUseModels: 'LangBot アカウントでログインしてクラウドモデルを使用', ownerMustBindSpace: - 'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。', + 'LangBot モデルを使うにはワークスペース所有者が LangBot アカウントを連携する必要があります。', usesOwnerSpaceBilling: - 'ワークスペース所有者の Space 課金とクレジットを使用します。', + 'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。', noModels: 'モデルがありません', langbotModels: 'LangBot モデル', spaceTrialTooltip: - '無料トライアルクレジットが利用可能!Space でログインして、設定不要でクラウドモデルを使用できます。', + '無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。', unlockModels: 'ログインして使用', editProvider: 'プロバイダーを編集', addProvider: 'プロバイダーを追加', @@ -1223,13 +1224,13 @@ const jaJP = { adminAccountNote: 'ここで初期化されたアカウントは管理者アカウントとして使用されます', register: '登録', - initWithSpace: 'Space で初期化', + initWithSpace: 'LangBot アカウントで初期化', spaceRecommended: 'おすすめ:公式の安定したモデル API とクラウドサービスを利用', spaceInfoTip1: 'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。', spaceInfoTip2: - 'Space アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。', + 'LangBot アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。', spaceInfoTip3: 'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。', registerLocal: 'ローカルアカウントを登録', @@ -1286,33 +1287,33 @@ const jaJP = { passwordNotSet: '未設定', passwordSetDescription: 'パスワードが設定されています。メールとパスワードでログインできます', - spaceStatus: 'Space アカウント', + spaceStatus: 'LangBot アカウント', spaceBound: '連携済み', spaceNotBound: '未連携', spaceBoundDescription: - 'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能', - bindSpace: 'Space アカウントを連携', + 'LangBot アカウントと連携済み、公式モデル API とクラウドサービスが利用可能', + bindSpace: 'LangBot アカウントを連携', bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用', bindSpaceButton: '連携', bindSpaceConfirmTitle: '連携を確認', bindSpaceConfirmDescription: - 'ローカルインスタンスを Space アカウントに連携しようとしています', + 'ローカルインスタンスを LangBot アカウントに連携しようとしています', bindSpaceWarning: - '連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。', - bindSpaceSuccess: 'Space アカウントの連携に成功しました', - bindSpaceFailed: 'Space アカウントの連携に失敗しました', + '連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。', + bindSpaceSuccess: 'LangBot アカウントの連携に成功しました', + bindSpaceFailed: 'LangBot アカウントの連携に失敗しました', bindSpaceInvalidState: '無効な連携リクエストです。アカウント設定から再度お試しください。', setPasswordHint: 'パスワードを設定するとメールとパスワードでログインできます', spaceEmailMismatch: - 'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません', + 'LangBot アカウントのメールアドレスがローカルアカウントのメールアドレスと一致しません', space_account_not_registeredTitle: 'アカウントが登録されていません', space_account_not_registered: - 'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。', - space_account_binding_requiredTitle: 'Space の連携が必要です', + 'この LangBot アカウントのメールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。', + space_account_binding_requiredTitle: 'LangBot アカウントの連携が必要です', space_account_binding_required: - 'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。', + 'LangBot アカウントログインを使用する前に、アカウント設定でこのローカルアカウントを LangBot アカウントに連携してください。', }, workspace: { title: 'ワークスペース', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index ce5bd2a4b..955a8933c 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -85,18 +85,18 @@ const ruRU = { 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', loginLocal: 'Войти с локальной учётной записью', loginWithPassword: 'Войти с паролем', - spaceLoginTitle: 'Войти через Space', + spaceLoginTitle: 'Войти с аккаунтом LangBot', spaceLoginDescription: 'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации', spaceLoginUserCode: 'Ваш код', spaceLoginExpires: 'Код истекает через {{seconds}} секунд', spaceLoginWaiting: 'Ожидание авторизации...', spaceLoginSuccess: 'Авторизация успешна', - spaceLoginFailed: 'Ошибка входа через Space', + spaceLoginFailed: 'Ошибка входа с аккаунтом LangBot', spaceLoginExpired: 'Код авторизации истёк, попробуйте снова', spaceLoginCancel: 'Отмена', spaceLoginVisitLink: 'Перейти по ссылке', - spaceLoginProcessing: 'Вход через Space', + spaceLoginProcessing: 'Вход с аккаунтом LangBot', spaceLoginProcessingDescription: 'Пожалуйста, подождите, пока мы завершим вход...', spaceLoginSuccessDescription: 'Перенаправление в LangBot...', @@ -105,7 +105,7 @@ const ruRU = { backToLogin: 'Вернуться к входу', backToHome: 'На главную', spaceAccountCannotChangePassword: - 'Для аккаунтов Space невозможно изменить пароль здесь', + 'Для аккаунтов LangBot невозможно изменить пароль здесь', theme: 'Тема', changePassword: 'Изменить пароль', currentPassword: 'Текущий пароль', @@ -259,9 +259,9 @@ const ruRU = { localProvider: 'Локальный', localProviderDescription: 'Модели, настроенные и управляемые локально', spaceProviderDescription: - 'Модели, синхронизированные из вашего аккаунта Space', + 'Модели, синхронизированные из вашего аккаунта LangBot', spaceDisabledForLocalAccount: - 'Войдите через Space, чтобы использовать облачные модели', + 'Войдите с аккаунтом LangBot, чтобы использовать облачные модели', syncModels: 'Синхронизировать', syncSuccess: 'Синхронизация завершена: {{created}} создано, {{updated}} обновлено', @@ -300,11 +300,12 @@ const ruRU = { langbotModelsDescription: 'Облачные модели на базе LangBot Space', credits: 'Кредиты', loginWithSpace: 'Войти с аккаунтом LangBot', - loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели', + loginToUseModels: + 'Войдите с аккаунтом LangBot, чтобы использовать облачные модели', noModels: 'Модели не настроены', langbotModels: 'Модели LangBot', spaceTrialTooltip: - 'Доступны бесплатные пробные кредиты! Войдите через Space, чтобы получить доступ к облачным моделям без настройки.', + 'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.', unlockModels: 'Войдите для использования', editProvider: 'Редактировать провайдера', addProvider: 'Добавить провайдера', @@ -340,9 +341,9 @@ const ruRU = { }, ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { title: 'Боты', @@ -1229,13 +1230,13 @@ const ruRU = { adminAccountNote: 'Указанная учётная запись будет настроена как администратор', register: 'Регистрация', - initWithSpace: 'Инициализация через Space', + initWithSpace: 'Инициализация с аккаунтом LangBot', spaceRecommended: 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', spaceInfoTip1: 'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.', spaceInfoTip2: - 'Вход через Space даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.', + 'Вход с аккаунтом LangBot даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.', spaceInfoTip3: 'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.', registerLocal: 'Зарегистрировать локальную учётную запись', @@ -1291,34 +1292,34 @@ const ruRU = { passwordNotSet: 'Не установлен', passwordSetDescription: 'Пароль установлен, вы можете входить с email и паролем', - spaceStatus: 'Аккаунт Space', + spaceStatus: 'Аккаунт LangBot', spaceBound: 'Привязан', spaceNotBound: 'Не привязан', spaceBoundDescription: - 'Аккаунт Space привязан, доступны официальные API моделей и облачные сервисы', - bindSpace: 'Привязать аккаунт Space', + 'Аккаунт LangBot привязан, доступны официальные API моделей и облачные сервисы', + bindSpace: 'Привязать аккаунт LangBot', bindSpaceDescription: 'Привяжите для использования официальных API моделей и облачных сервисов', bindSpaceButton: 'Привязать', bindSpaceConfirmTitle: 'Подтверждение привязки', bindSpaceConfirmDescription: - 'Вы собираетесь привязать локальный экземпляр к аккаунту Space', + 'Вы собираетесь привязать локальный экземпляр к аккаунту LangBot', bindSpaceWarning: - 'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта Space.', - bindSpaceSuccess: 'Аккаунт Space успешно привязан', - bindSpaceFailed: 'Не удалось привязать аккаунт Space', + 'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.', + bindSpaceSuccess: 'Аккаунт LangBot успешно привязан', + bindSpaceFailed: 'Не удалось привязать аккаунт LangBot', bindSpaceInvalidState: 'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.', setPasswordHint: 'Установите пароль для входа с email и паролем', spaceEmailMismatch: - 'Email входа через Space не совпадает с email локальной учётной записи', + 'Email входа с аккаунтом LangBot не совпадает с email локальной учётной записи', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, monitoring: { title: 'Мониторинг', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 614cb9a86..9476c2d1a 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -85,18 +85,18 @@ const thTH = { 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น', loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน', - spaceLoginTitle: 'เข้าสู่ระบบด้วย Space', + spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot', spaceLoginDescription: 'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์', spaceLoginUserCode: 'รหัสของคุณ', spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที', spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...', spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ', - spaceLoginFailed: 'เข้าสู่ระบบ Space ล้มเหลว', + spaceLoginFailed: 'เข้าสู่ระบบด้วยบัญชี LangBot ล้มเหลว', spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่', spaceLoginCancel: 'ยกเลิก', spaceLoginVisitLink: 'เข้าชมลิงก์', - spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วย Space', + spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วยบัญชี LangBot', spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...', spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...', spaceLoginError: 'เข้าสู่ระบบล้มเหลว', @@ -104,7 +104,7 @@ const thTH = { backToLogin: 'กลับไปหน้าเข้าสู่ระบบ', backToHome: 'กลับไปหน้าแรก', spaceAccountCannotChangePassword: - 'บัญชี Space ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่', + 'บัญชี LangBot ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่', theme: 'ธีม', changePassword: 'เปลี่ยนรหัสผ่าน', currentPassword: 'รหัสผ่านปัจจุบัน', @@ -252,8 +252,9 @@ const thTH = { llmModels: 'โมเดล LLM', localProvider: 'ท้องถิ่น', localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง', - spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี Space ของคุณ', - spaceDisabledForLocalAccount: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์', + spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี LangBot ของคุณ', + spaceDisabledForLocalAccount: + 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์', syncModels: 'ซิงค์', syncSuccess: 'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ', @@ -289,11 +290,11 @@ const thTH = { langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space', credits: 'เครดิต', loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot', - loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์', + loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์', noModels: 'ยังไม่มีโมเดลที่กำหนดค่า', langbotModels: 'โมเดล LangBot', spaceTrialTooltip: - 'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วย Space เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า', + 'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า', unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน', editProvider: 'แก้ไขผู้ให้บริการ', addProvider: 'เพิ่มผู้ให้บริการ', @@ -327,9 +328,9 @@ const thTH = { }, ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { title: 'บอท', @@ -1202,13 +1203,13 @@ const thTH = { description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot', adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ', register: 'ลงทะเบียน', - initWithSpace: 'เริ่มต้นด้วย Space', + initWithSpace: 'เริ่มต้นด้วยบัญชี LangBot', spaceRecommended: 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', spaceInfoTip1: 'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ', spaceInfoTip2: - 'การเข้าสู่ระบบด้วยบัญชี Space ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว', + 'การเข้าสู่ระบบด้วยบัญชี LangBot ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว', spaceInfoTip3: 'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา', registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น', @@ -1263,30 +1264,32 @@ const thTH = { passwordNotSet: 'ยังไม่ได้ตั้งค่า', passwordSetDescription: 'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', - spaceStatus: 'บัญชี Space', + spaceStatus: 'บัญชี LangBot', spaceBound: 'ผูกแล้ว', spaceNotBound: 'ยังไม่ผูก', spaceBoundDescription: - 'ผูกบัญชี Space แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้', - bindSpace: 'ผูกบัญชี Space', + 'ผูกบัญชี LangBot แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้', + bindSpace: 'ผูกบัญชี LangBot', bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์', bindSpaceButton: 'ผูก', bindSpaceConfirmTitle: 'ยืนยันการผูก', - bindSpaceConfirmDescription: 'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี Space', + bindSpaceConfirmDescription: + 'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี LangBot', bindSpaceWarning: - 'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี Space', - bindSpaceSuccess: 'ผูกบัญชี Space สำเร็จ', - bindSpaceFailed: 'ผูกบัญชี Space ล้มเหลว', + 'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot', + bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ', + bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว', bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', - spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น', + spaceEmailMismatch: + 'อีเมลเข้าสู่ระบบด้วยบัญชี LangBot ไม่ตรงกับอีเมลบัญชีท้องถิ่น', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, monitoring: { title: 'แดชบอร์ด', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 4113946e4..865908ad2 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -86,18 +86,18 @@ const viVN = { '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ộ', loginWithPassword: 'Đăng nhập bằng mật khẩu', - spaceLoginTitle: 'Đăng nhập với Space', + spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot', spaceLoginDescription: 'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền', spaceLoginUserCode: 'Mã của bạn', spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây', spaceLoginWaiting: 'Đang chờ ủy quyền...', spaceLoginSuccess: 'Ủy quyền thành công', - spaceLoginFailed: 'Đăng nhập Space thất bại', + spaceLoginFailed: 'Đăng nhập bằng tài khoản LangBot thất bại', spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại', spaceLoginCancel: 'Hủy', spaceLoginVisitLink: 'Truy cập liên kết', - spaceLoginProcessing: 'Đang đăng nhập với Space', + spaceLoginProcessing: 'Đang đăng nhập bằng tài khoản LangBot', spaceLoginProcessingDescription: 'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...', spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...', @@ -106,7 +106,7 @@ const viVN = { backToLogin: 'Quay lại đăng nhập', backToHome: 'Quay lại trang chủ', spaceAccountCannotChangePassword: - 'Tài khoản Space không thể đổi mật khẩu tại đây', + 'Tài khoản LangBot không thể đổi mật khẩu tại đây', theme: 'Giao diện', changePassword: 'Đổi mật khẩu', currentPassword: 'Mật khẩu hiện tại', @@ -258,9 +258,9 @@ const viVN = { localProvider: 'Cục bộ', localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ', spaceProviderDescription: - 'Các mô hình được đồng bộ từ tài khoản Space của bạn', + 'Các mô hình được đồng bộ từ tài khoản LangBot của bạn', spaceDisabledForLocalAccount: - 'Đăng nhập với Space để sử dụng mô hình đám mây', + 'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây', syncModels: 'Đồng bộ', syncSuccess: 'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật', @@ -297,11 +297,12 @@ const viVN = { langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space', credits: 'Tín dụng', 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', + loginToUseModels: + 'Đăng nhập bằng tài khoản LangBot để 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', spaceTrialTooltip: - 'Có tín dụng dùng thử miễn phí! Đăng nhập với Space để truy cập mô hình đám mây không cần cấu hình.', + 'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.', unlockModels: 'Đăng nhập để sử dụng', editProvider: 'Chỉnh sửa nhà cung cấp', addProvider: 'Thêm nhà cung cấp', @@ -336,9 +337,9 @@ const viVN = { }, ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { title: 'Bot', @@ -1222,13 +1223,13 @@ const viVN = { adminAccountNote: 'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên', register: 'Đăng ký', - initWithSpace: 'Khởi tạo với Space', + initWithSpace: 'Khởi tạo bằng tài khoản LangBot', spaceRecommended: 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây', spaceInfoTip1: 'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.', spaceInfoTip2: - 'Đăng nhập bằng tài khoản Space cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.', + 'Đăng nhập bằng tài khoản LangBot cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.', spaceInfoTip3: 'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.', registerLocal: 'Đăng ký tài khoản cục bộ', @@ -1285,34 +1286,34 @@ const viVN = { passwordNotSet: 'Chưa đặt', passwordSetDescription: 'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu', - spaceStatus: 'Tài khoản Space', + spaceStatus: 'Tài khoản LangBot', spaceBound: 'Đã liên kết', spaceNotBound: 'Chưa liên kết', spaceBoundDescription: - 'Tài khoản Space đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây', - bindSpace: 'Liên kết tài khoản Space', + 'Tài khoản LangBot đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây', + bindSpace: 'Liên kết tài khoản LangBot', bindSpaceDescription: 'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây', bindSpaceButton: 'Liên kết', bindSpaceConfirmTitle: 'Xác nhận liên kết', bindSpaceConfirmDescription: - 'Bạn sắp liên kết phiên bản cục bộ với tài khoản Space', + 'Bạn sắp liên kết phiên bản cục bộ với tài khoản LangBot', bindSpaceWarning: - 'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản Space.', - bindSpaceSuccess: 'Liên kết tài khoản Space thành công', - bindSpaceFailed: 'Liên kết tài khoản Space thất bại', + 'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.', + bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công', + bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại', bindSpaceInvalidState: 'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.', setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu', spaceEmailMismatch: - 'Email đăng nhập Space không khớp với email tài khoản cục bộ', + 'Email tài khoản LangBot không khớp với email tài khoản cục bộ', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, monitoring: { title: 'Bảng điều khiển', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a9da25999..45011e943 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -83,24 +83,24 @@ const zhHans = { spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', loginLocal: '使用本地账号登录', loginWithPassword: '通过密码登录', - spaceLoginTitle: '通过 Space 登录', + spaceLoginTitle: '通过 LangBot 账号登录', spaceLoginDescription: '扫描二维码或访问下方链接进行授权', spaceLoginUserCode: '您的验证码', spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期', spaceLoginWaiting: '等待授权中...', spaceLoginSuccess: '授权成功', - spaceLoginFailed: 'Space 登录失败', + spaceLoginFailed: 'LangBot 账号登录失败', spaceLoginExpired: '验证码已过期,请重试', spaceLoginCancel: '取消', spaceLoginVisitLink: '访问链接', - spaceLoginProcessing: '正在通过 Space 登录', + spaceLoginProcessing: '正在通过 LangBot 账号登录', spaceLoginProcessingDescription: '请稍候,正在完成登录...', spaceLoginSuccessDescription: '正在跳转到 LangBot...', spaceLoginError: '登录失败', spaceLoginNoCode: '缺少授权码', backToLogin: '返回登录', backToHome: '返回首页', - spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码', + spaceAccountCannotChangePassword: 'LangBot 账号无法在此修改密码', theme: '主题', changePassword: '修改密码', currentPassword: '当前密码', @@ -243,8 +243,8 @@ const zhHans = { llmModels: '对话模型', localProvider: '本地', localProviderDescription: '在本地配置和管理的模型', - spaceProviderDescription: '从您的 Space 账户同步的模型', - spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型', + spaceProviderDescription: '从您的 LangBot 账号同步的模型', + spaceDisabledForLocalAccount: '使用 LangBot 账号登录以使用云端模型', syncModels: '同步', syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个', syncError: '同步失败:', @@ -279,13 +279,14 @@ const zhHans = { langbotModelsDescription: 'LangBot Space 提供的云端模型', credits: '积分', loginWithSpace: '使用 LangBot 账号登录', - loginToUseModels: '通过 Space 登录以使用云端模型', - ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。', - usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。', + loginToUseModels: '通过 LangBot 账号登录以使用云端模型', + ownerMustBindSpace: + '工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。', + usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。', noModels: '暂无模型', langbotModels: 'LangBot 模型', spaceTrialTooltip: - '免费试用积分已就绪!通过 Space 登录即可零配置使用云端模型。', + '免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。', unlockModels: '登录以使用', editProvider: '编辑供应商', addProvider: '添加供应商', @@ -1161,11 +1162,11 @@ const zhHans = { description: '这是您首次启动 LangBot', adminAccountNote: '您在此处初始化使用的账号将作为管理员账号', register: '注册', - initWithSpace: '通过 Space 初始化', + initWithSpace: '通过 LangBot 账号初始化', spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', spaceInfoTip1: 'Space 提供统一的账户鉴权服务,不会上传您的任何敏感信息。', spaceInfoTip2: - '使用 Space 账户登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。', + '使用 LangBot 账号登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。', spaceInfoTip3: '登录方式不会影响其他功能,您在任何情况下都可以配置使用其他来源的模型。', registerLocal: '注册本地账号', @@ -1219,28 +1220,28 @@ const zhHans = { passwordSet: '已设置', passwordNotSet: '未设置', passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录', - spaceStatus: 'Space 账户', + spaceStatus: 'LangBot 账号', spaceBound: '已绑定', spaceNotBound: '未绑定', - spaceBoundDescription: '已绑定 Space 账户,可使用官方模型 API 和云服务', - bindSpace: '绑定 Space 账户', + spaceBoundDescription: '已绑定 LangBot 账号,可使用官方模型 API 和云服务', + bindSpace: '绑定 LangBot 账号', bindSpaceDescription: '绑定后可使用官方模型 API 和云服务', bindSpaceButton: '绑定', bindSpaceConfirmTitle: '确认绑定', - bindSpaceConfirmDescription: '您即将把本地实例绑定到 Space 账户', + bindSpaceConfirmDescription: '您即将把本地实例绑定到 LangBot 账号', bindSpaceWarning: - '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 Space 账户的邮箱。', - bindSpaceSuccess: 'Space 账户绑定成功', - bindSpaceFailed: '绑定 Space 账户失败', + '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。', + bindSpaceSuccess: 'LangBot 账号绑定成功', + bindSpaceFailed: '绑定 LangBot 账号失败', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', setPasswordHint: '设置密码后可使用邮箱密码登录', - spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配', + spaceEmailMismatch: 'LangBot 账号邮箱与本实例账号邮箱不匹配', space_account_not_registeredTitle: '账户尚未注册', space_account_not_registered: - '此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。', - space_account_binding_requiredTitle: '需要绑定 Space', + '此 LangBot 账号邮箱尚无本地账户,请联系工作区所有者获取邀请。', + space_account_binding_requiredTitle: '需要绑定 LangBot 账号', space_account_binding_required: - '此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。', + '此本地账户必须先在账户设置中绑定 LangBot 账号,才能使用 LangBot 账号登录。', }, workspace: { title: '工作区', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 10042bca5..d1ab15273 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -83,24 +83,24 @@ const zhHant = { spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', loginLocal: '使用本地帳號登入', loginWithPassword: '透過密碼登入', - spaceLoginTitle: '透過 Space 登入', + spaceLoginTitle: '透過 LangBot 帳號登入', spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權', spaceLoginUserCode: '您的驗證碼', spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期', spaceLoginWaiting: '等待授權中...', spaceLoginSuccess: '授權成功', - spaceLoginFailed: 'Space 登入失敗', + spaceLoginFailed: 'LangBot 帳號登入失敗', spaceLoginExpired: '驗證碼已過期,請重試', spaceLoginCancel: '取消', spaceLoginVisitLink: '訪問連結', - spaceLoginProcessing: '正在透過 Space 登入', + spaceLoginProcessing: '正在透過 LangBot 帳號登入', spaceLoginProcessingDescription: '請稍候,正在完成登入...', spaceLoginSuccessDescription: '正在跳轉到 LangBot...', spaceLoginError: '登入失敗', spaceLoginNoCode: '缺少授權碼', backToLogin: '返回登入', backToHome: '返回首頁', - spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼', + spaceAccountCannotChangePassword: 'LangBot 帳號無法在此修改密碼', theme: '主題', changePassword: '修改密碼', currentPassword: '當前密碼', @@ -244,8 +244,8 @@ const zhHant = { llmModels: '對話模型', localProvider: '本地', localProviderDescription: '在本地設定和管理的模型', - spaceProviderDescription: '從您的 Space 帳戶同步的模型', - spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型', + spaceProviderDescription: '從您的 LangBot 帳號同步的模型', + spaceDisabledForLocalAccount: '使用 LangBot 帳號登入以使用雲端模型', syncModels: '同步', syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個', syncError: '同步失敗:', @@ -279,11 +279,11 @@ const zhHant = { langbotModelsDescription: '由 LangBot Space 提供的雲端模型', credits: '積分', loginWithSpace: '使用 LangBot 帳號登入', - loginToUseModels: '使用 Space 登入以使用雲端模型', + loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型', noModels: '暫無模型', langbotModels: 'LangBot 模型', spaceTrialTooltip: - '免費試用積分已就緒!使用 Space 登入即可零設定使用雲端模型。', + '免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。', unlockModels: '登入以使用', editProvider: '編輯供應商', addProvider: '新增供應商', @@ -317,9 +317,9 @@ const zhHant = { }, ownerMustBindSpace: - 'The Workspace owner must connect Space for LangBot Models.', + 'The Workspace owner must connect a LangBot Account for LangBot Models.', usesOwnerSpaceBilling: - "Uses the Workspace owner's Space billing and credits.", + "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { title: '機器人', @@ -1163,11 +1163,11 @@ const zhHant = { description: '這是您首次啟動 LangBot', adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號', register: '註冊', - initWithSpace: '透過 Space 初始化', + initWithSpace: '透過 LangBot 帳號初始化', spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務,不會上傳您的任何敏感資訊。', spaceInfoTip2: - '使用 Space 帳戶登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。', + '使用 LangBot 帳號登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。', spaceInfoTip3: '登入方式不會影響其他功能,您在任何情況下都可以配置使用其他來源的模型。', registerLocal: '註冊本地帳號', @@ -1221,29 +1221,29 @@ const zhHant = { passwordSet: '已設定', passwordNotSet: '未設定', passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入', - spaceStatus: 'Space 帳戶', + spaceStatus: 'LangBot 帳號', spaceBound: '已綁定', spaceNotBound: '未綁定', - spaceBoundDescription: '已綁定 Space 帳戶,可使用官方模型 API 和雲服務', - bindSpace: '綁定 Space 帳戶', + spaceBoundDescription: '已綁定 LangBot 帳號,可使用官方模型 API 和雲服務', + bindSpace: '綁定 LangBot 帳號', bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務', bindSpaceButton: '綁定', bindSpaceConfirmTitle: '確認綁定', - bindSpaceConfirmDescription: '您即將把本地實例綁定到 Space 帳戶', + bindSpaceConfirmDescription: '您即將把本地實例綁定到 LangBot 帳號', bindSpaceWarning: - '綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 Space 帳戶的電子郵件。', - bindSpaceSuccess: 'Space 帳戶綁定成功', - bindSpaceFailed: '綁定 Space 帳戶失敗', + '綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。', + bindSpaceSuccess: 'LangBot 帳號綁定成功', + bindSpaceFailed: '綁定 LangBot 帳號失敗', bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', setPasswordHint: '設定密碼後可使用電子郵件密碼登入', - spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', + spaceEmailMismatch: 'LangBot 帳號電子郵件與本實例帳號電子郵件不匹配', space_account_not_registeredTitle: 'Account not registered', space_account_not_registered: - 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', - space_account_binding_requiredTitle: 'Space connection required', + 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'LangBot Account connection required', space_account_binding_required: - 'This local account must connect Space from Account settings before using Space login.', + 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.', }, monitoring: { title: '儀表盤', diff --git a/web/tests/unit/langbot-account-terminology.test.mjs b/web/tests/unit/langbot-account-terminology.test.mjs new file mode 100644 index 000000000..707505b5d --- /dev/null +++ b/web/tests/unit/langbot-account-terminology.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import test from 'node:test'; + +const localeDir = new URL('../../src/i18n/locales/', import.meta.url); +const localeFiles = readdirSync(localeDir).filter((name) => + name.endsWith('.ts'), +); + +const deprecatedAccountCopy = [ + /Initialize with Space/i, + /Login with Space/i, + /Logging in with Space/i, + /Space login/i, + /Space accounts?/i, + /Bind Space Account/i, + /Authorize with Space/i, + /通过 Space 登录/, + /使用 Space 登录/, + /Space 登录/, + /Space 账户/, + /Space 帳戶/, + /绑定 Space/, + /綁定 Space/, + /Space アカウント/, + /Space でログイン/, + /cuenta de Space/i, + /cuentas de Space/i, + /cuenta Space/i, + /tài khoản Space/i, + /บัญชี Space/, + /аккаунт(?:ов|а)? Space/i, + /аккаунт Space/i, +]; + +test('user-facing account authentication copy uses LangBot Account terminology', () => { + const violations = []; + + for (const file of localeFiles) { + const source = readFileSync(new URL(file, localeDir), 'utf8'); + for (const pattern of deprecatedAccountCopy) { + if (pattern.test(source)) violations.push(`${file}: ${pattern}`); + } + } + + assert.deepEqual(violations, []); +}); From 79773b669a31abaa02ad84482bb023c2022ab69b Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 13 Aug 2026 23:58:23 +0800 Subject: [PATCH 16/42] fix(auth): allow callbacks from literally any origin (#2430) * fix(auth): allow callbacks from any origin * chore: retrigger repository checks --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/api/http/controller/groups/user.py | 9 --------- tests/integration/api/test_user_space_oauth.py | 7 ++++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 932de0823..6b42d4ea3 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -14,11 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati @group.group_class('user', '/api/v1/user') class UserRouterGroup(group.RouterGroup): - @staticmethod - def _is_loopback_host(hostname: str) -> bool: - normalized = hostname.casefold().rstrip('.') - return normalized in {'localhost', '127.0.0.1', '::1'} - def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str: parsed = urlsplit(redirect_uri) if ( @@ -38,10 +33,6 @@ class UserRouterGroup(group.RouterGroup): elif query: raise ValueError('Invalid LangBot Account login redirect_uri') - # OSS instances can live behind arbitrary domains and gateway ports. - # Accept any HTTPS callback, plus HTTP only for local development. - if parsed.scheme == 'http' and not self._is_loopback_host(parsed.hostname): - raise ValueError('Insecure redirect_uri origin') return redirect_uri async def initialize(self) -> None: diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 42ae4b066..9d22e5b3c 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -165,7 +165,7 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau @pytest.mark.asyncio -async def test_redirect_allows_dynamic_https_origin_and_loopback_http(space_oauth_api): +async def test_redirect_allows_any_http_or_https_origin(space_oauth_api): _, client = space_oauth_api responses = [ @@ -181,6 +181,8 @@ async def test_redirect_allows_dynamic_https_origin_and_loopback_http(space_oaut 'http://localhost:5300/auth/space/callback', 'http://127.0.0.1:5300/auth/space/callback', 'http://[::1]:5300/auth/space/callback', + 'http://langbot.example/auth/space/callback', + 'http://192.0.2.10:5300/auth/space/callback', ) ] @@ -190,7 +192,7 @@ async def test_redirect_allows_dynamic_https_origin_and_loopback_http(space_oaut @pytest.mark.asyncio -async def test_redirect_rejects_insecure_remote_origin_and_invalid_callback_shape(space_oauth_api): +async def test_redirect_rejects_invalid_callback_shape(space_oauth_api): _, client = space_oauth_api responses = [ @@ -199,7 +201,6 @@ async def test_redirect_rejects_insecure_remote_origin_and_invalid_callback_shap query_string={'redirect_uri': redirect_uri}, ) for redirect_uri in ( - 'http://langbot.example/auth/space/callback', 'https://langbot.example/arbitrary', 'https://langbot.example/auth/space/callback?next=https://evil.example', 'https://user@langbot.example/auth/space/callback', From de28b3160c8a43d39482295921773e1da02fe27e Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 14 Aug 2026 00:29:58 +0800 Subject: [PATCH 17/42] docs(deploy): document optional runtime tokens (#2431) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- README.md | 10 ++++++++++ README_CN.md | 10 ++++++++++ README_ES.md | 2 ++ README_FR.md | 2 ++ README_JP.md | 2 ++ README_KO.md | 2 ++ README_RU.md | 2 ++ README_TW.md | 10 ++++++++++ README_VI.md | 2 ++ docker/docker-compose.yaml | 14 +++++++------- 10 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 84f02caf1..6452b029e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ cd LangBot/docker docker compose --profile all up -d ``` +> If the deployment is accessible from the Internet, protect the internal Runtime connections with strong tokens: +> +> ```bash +> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" +> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" +> docker compose --profile all up -d +> ``` +> +> Compose passes each value to both ends of its connection. If both ends leave a token unset, the open-source deployment permits a tokenless connection for backward compatibility. Store configured values in your deployment secret manager; do not commit them. + ### One-Click Cloud Deploy [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_CN.md b/README_CN.md index 2bf7d9e86..62d2444ed 100644 --- a/README_CN.md +++ b/README_CN.md @@ -83,6 +83,16 @@ cd LangBot/docker docker compose --profile all up -d ``` +> 如果部署环境可从公网访问,建议为内部 Runtime 连接设置强 Token: +> +> ```bash +> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" +> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" +> docker compose --profile all up -d +> ``` +> +> Compose 会把每个 Token 传给对应连接的两端。开源版在两端都未设置 Token 时仍允许无 Token 连接,以保持向后兼容。已配置的 Token 应保存在部署平台的 Secret 管理中,不要提交到仓库。 + ### 一键云部署 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) diff --git a/README_ES.md b/README_ES.md index eac5aaaca..bba25ec6d 100644 --- a/README_ES.md +++ b/README_ES.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> Para despliegues accesibles desde Internet, recomendamos configurar `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` y `LANGBOT_BOX_CONTROL_TOKEN` con secretos compartidos robustos (al menos 32 caracteres que no sean espacios). Genera cada valor con `openssl rand -hex 32`; Compose lo proporciona a ambos extremos de la conexión correspondiente. Si ambos extremos dejan el Token sin configurar, la edición de código abierto permite la conexión sin Token por compatibilidad. + ### Despliegue en la Nube con un Clic [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_FR.md b/README_FR.md index fe766d084..fe6cfee19 100644 --- a/README_FR.md +++ b/README_FR.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> Pour un déploiement accessible depuis Internet, nous recommandons de définir `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` et `LANGBOT_BOX_CONTROL_TOKEN` avec des secrets partagés robustes (au moins 32 caractères non blancs). Générez chaque valeur avec `openssl rand -hex 32` ; Compose la transmet aux deux extrémités de la connexion concernée. Si les deux extrémités ne définissent aucun Token, l’édition open source autorise la connexion sans Token pour assurer la compatibilité. + ### Déploiement Cloud en un Clic [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_JP.md b/README_JP.md index 1a9585635..8b4906a63 100644 --- a/README_JP.md +++ b/README_JP.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> インターネットからアクセス可能な環境では、`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` と `LANGBOT_BOX_CONTROL_TOKEN` に強力な共有シークレット(空白以外の文字を32文字以上)を設定することを推奨します。各値は `openssl rand -hex 32` で生成でき、Compose が対応する接続の両端に同じ値を渡します。両端とも Token を未設定にした場合、オープンソース版では互換性のため Token なしの接続を許可します。 + ### ワンクリッククラウドデプロイ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_KO.md b/README_KO.md index 0e72547e0..55a344934 100644 --- a/README_KO.md +++ b/README_KO.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> 인터넷에서 접근 가능한 배포 환경에서는 `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN`과 `LANGBOT_BOX_CONTROL_TOKEN`에 강력한 공유 시크릿(공백이 아닌 문자 32자 이상)을 설정하는 것을 권장합니다. 각 값은 `openssl rand -hex 32`로 생성할 수 있으며 Compose가 해당 연결의 양쪽에 동일한 값을 전달합니다. 양쪽 모두 Token을 설정하지 않으면 오픈 소스 버전은 호환성을 위해 Token 없는 연결을 허용합니다. + ### 원클릭 클라우드 배포 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_RU.md b/README_RU.md index 3a3ddeba0..078dba195 100644 --- a/README_RU.md +++ b/README_RU.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> Для развёртываний, доступных из Интернета, рекомендуем задать `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` и `LANGBOT_BOX_CONTROL_TOKEN` как надёжные общие секреты (не менее 32 непробельных символов). Каждое значение можно создать командой `openssl rand -hex 32`; Compose передаст его обеим сторонам соответствующего соединения. Если Token не задан с обеих сторон, версия с открытым исходным кодом разрешает соединение без Token для обратной совместимости. + ### Облачное развертывание одним кликом [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/README_TW.md b/README_TW.md index ed69b3168..fd5ae437a 100644 --- a/README_TW.md +++ b/README_TW.md @@ -84,6 +84,16 @@ cd LangBot/docker docker compose --profile all up -d ``` +> 如果部署環境可從公網存取,建議為內部 Runtime 連線設定強 Token: +> +> ```bash +> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" +> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" +> docker compose --profile all up -d +> ``` +> +> Compose 會將每個 Token 傳給對應連線的兩端。開源版在兩端都未設定 Token 時仍允許無 Token 連線,以保持向後相容。已設定的 Token 應儲存在部署平台的 Secret 管理中,不要提交至儲存庫。 + ### 一鍵雲端部署 [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) diff --git a/README_VI.md b/README_VI.md index e29963599..824edd524 100644 --- a/README_VI.md +++ b/README_VI.md @@ -82,6 +82,8 @@ cd LangBot/docker docker compose --profile all up -d ``` +> Với triển khai có thể truy cập từ Internet, bạn nên đặt `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` và `LANGBOT_BOX_CONTROL_TOKEN` thành các khóa bí mật dùng chung đủ mạnh (ít nhất 32 ký tự không phải khoảng trắng). Có thể tạo từng giá trị bằng `openssl rand -hex 32`; Compose sẽ truyền cùng giá trị tới hai đầu của kết nối tương ứng. Nếu cả hai đầu đều không đặt Token, bản mã nguồn mở vẫn cho phép kết nối không Token để duy trì khả năng tương thích. + ### Triển khai đám mây một cú nhấp [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cb9e18db0..c2c276ac5 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -47,11 +47,10 @@ services: restart: on-failure environment: - TZ=Asia/Shanghai - # Shared control-plane secret used to authenticate both the RPC socket - # and managed-process relay. Generate once (for example with - # ``openssl rand -hex 32``) and export it before enabling this profile. - # An empty value is accepted by Compose so Box can remain optional, but - # the Box runtime itself fails closed when the profile is started. + # Optional shared control-plane secret used to authenticate both the RPC + # socket and managed-process relay. Leave unset on both OSS services, or + # generate one with ``openssl rand -hex 32`` and set the same value on + # both ends. Strongly recommended when the deployment is Internet-accessible. - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} # Box has its own process-wide blocking-work budget. - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8} @@ -79,8 +78,9 @@ services: - TZ=Asia/Shanghai # Optional. Leave unset on both OSS services, or match plugin Runtime. - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-} - # Must match the value supplied to langbot_box. The token is sent only - # in WebSocket handshake headers, never in URLs or action payloads. + # When set, this must match langbot_box. If both ends leave it unset, + # OSS permits the connection without token authentication. The token is + # sent only in WebSocket handshake headers, never in URLs or payloads. - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} # Core process-wide blocking-work admission. These are native config # overrides and are persisted with the effective data/config.yaml. From 1f1a3aff55d828ac2ccc51b2f3ec6b0e78c0a56b Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 14 Aug 2026 01:06:31 +0800 Subject: [PATCH 18/42] fix(release): publish installable multi-arch artifacts (#2432) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Dockerfile | 2 +- README.md | 9 ---- README_CN.md | 9 ---- README_ES.md | 1 - README_FR.md | 1 - README_JP.md | 1 - README_KO.md | 1 - README_RU.md | 1 - README_TW.md | 9 ---- README_VI.md | 1 - pyproject.toml | 2 +- uv.lock | 124 +++++++++++++++++++++++++------------------------ 12 files changed, 66 insertions(+), 95 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb88c9151..c8569fb95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS node +FROM --platform=$BUILDPLATFORM node:22-alpine AS node WORKDIR /app diff --git a/README.md b/README.md index 6452b029e..f6eeb0b22 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> If the deployment is accessible from the Internet, protect the internal Runtime connections with strong tokens: -> -> ```bash -> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" -> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" -> docker compose --profile all up -d -> ``` -> -> Compose passes each value to both ends of its connection. If both ends leave a token unset, the open-source deployment permits a tokenless connection for backward compatibility. Store configured values in your deployment secret manager; do not commit them. ### One-Click Cloud Deploy diff --git a/README_CN.md b/README_CN.md index 62d2444ed..77b448f87 100644 --- a/README_CN.md +++ b/README_CN.md @@ -83,15 +83,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> 如果部署环境可从公网访问,建议为内部 Runtime 连接设置强 Token: -> -> ```bash -> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" -> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" -> docker compose --profile all up -d -> ``` -> -> Compose 会把每个 Token 传给对应连接的两端。开源版在两端都未设置 Token 时仍允许无 Token 连接,以保持向后兼容。已配置的 Token 应保存在部署平台的 Secret 管理中,不要提交到仓库。 ### 一键云部署 diff --git a/README_ES.md b/README_ES.md index bba25ec6d..2f7ec0ce1 100644 --- a/README_ES.md +++ b/README_ES.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> Para despliegues accesibles desde Internet, recomendamos configurar `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` y `LANGBOT_BOX_CONTROL_TOKEN` con secretos compartidos robustos (al menos 32 caracteres que no sean espacios). Genera cada valor con `openssl rand -hex 32`; Compose lo proporciona a ambos extremos de la conexión correspondiente. Si ambos extremos dejan el Token sin configurar, la edición de código abierto permite la conexión sin Token por compatibilidad. ### Despliegue en la Nube con un Clic diff --git a/README_FR.md b/README_FR.md index fe6cfee19..66d1f9bb6 100644 --- a/README_FR.md +++ b/README_FR.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> Pour un déploiement accessible depuis Internet, nous recommandons de définir `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` et `LANGBOT_BOX_CONTROL_TOKEN` avec des secrets partagés robustes (au moins 32 caractères non blancs). Générez chaque valeur avec `openssl rand -hex 32` ; Compose la transmet aux deux extrémités de la connexion concernée. Si les deux extrémités ne définissent aucun Token, l’édition open source autorise la connexion sans Token pour assurer la compatibilité. ### Déploiement Cloud en un Clic diff --git a/README_JP.md b/README_JP.md index 8b4906a63..efc6e23b2 100644 --- a/README_JP.md +++ b/README_JP.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> インターネットからアクセス可能な環境では、`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` と `LANGBOT_BOX_CONTROL_TOKEN` に強力な共有シークレット(空白以外の文字を32文字以上)を設定することを推奨します。各値は `openssl rand -hex 32` で生成でき、Compose が対応する接続の両端に同じ値を渡します。両端とも Token を未設定にした場合、オープンソース版では互換性のため Token なしの接続を許可します。 ### ワンクリッククラウドデプロイ diff --git a/README_KO.md b/README_KO.md index 55a344934..7e0284788 100644 --- a/README_KO.md +++ b/README_KO.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> 인터넷에서 접근 가능한 배포 환경에서는 `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN`과 `LANGBOT_BOX_CONTROL_TOKEN`에 강력한 공유 시크릿(공백이 아닌 문자 32자 이상)을 설정하는 것을 권장합니다. 각 값은 `openssl rand -hex 32`로 생성할 수 있으며 Compose가 해당 연결의 양쪽에 동일한 값을 전달합니다. 양쪽 모두 Token을 설정하지 않으면 오픈 소스 버전은 호환성을 위해 Token 없는 연결을 허용합니다. ### 원클릭 클라우드 배포 diff --git a/README_RU.md b/README_RU.md index 078dba195..f779c0c46 100644 --- a/README_RU.md +++ b/README_RU.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> Для развёртываний, доступных из Интернета, рекомендуем задать `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` и `LANGBOT_BOX_CONTROL_TOKEN` как надёжные общие секреты (не менее 32 непробельных символов). Каждое значение можно создать командой `openssl rand -hex 32`; Compose передаст его обеим сторонам соответствующего соединения. Если Token не задан с обеих сторон, версия с открытым исходным кодом разрешает соединение без Token для обратной совместимости. ### Облачное развертывание одним кликом diff --git a/README_TW.md b/README_TW.md index fd5ae437a..9abb32976 100644 --- a/README_TW.md +++ b/README_TW.md @@ -84,15 +84,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> 如果部署環境可從公網存取,建議為內部 Runtime 連線設定強 Token: -> -> ```bash -> export LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN="$(openssl rand -hex 32)" -> export LANGBOT_BOX_CONTROL_TOKEN="$(openssl rand -hex 32)" -> docker compose --profile all up -d -> ``` -> -> Compose 會將每個 Token 傳給對應連線的兩端。開源版在兩端都未設定 Token 時仍允許無 Token 連線,以保持向後相容。已設定的 Token 應儲存在部署平台的 Secret 管理中,不要提交至儲存庫。 ### 一鍵雲端部署 diff --git a/README_VI.md b/README_VI.md index 824edd524..ca9ef667d 100644 --- a/README_VI.md +++ b/README_VI.md @@ -82,7 +82,6 @@ cd LangBot/docker docker compose --profile all up -d ``` -> Với triển khai có thể truy cập từ Internet, bạn nên đặt `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` và `LANGBOT_BOX_CONTROL_TOKEN` thành các khóa bí mật dùng chung đủ mạnh (ít nhất 32 ký tự không phải khoảng trắng). Có thể tạo từng giá trị bằng `openssl rand -hex 32`; Compose sẽ truyền cùng giá trị tới hai đầu của kết nối tương ứng. Nếu cả hai đầu đều không đặt Token, bản mã nguồn mở vẫn cho phép kết nối không Token để duy trì khả năng tương thích. ### Triển khai đám mây một cú nhấp diff --git a/pyproject.toml b/pyproject.toml index 600e8b92f..c8ea0836a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19", + "langbot-plugin==0.5.3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 6afff2c9b..09f525a19 100644 --- a/uv.lock +++ b/uv.lock @@ -1018,7 +1018,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1051,34 +1051,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -2125,7 +2125,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19" }, + { name = "langbot-plugin", specifier = "==0.5.3" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2192,7 +2192,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.5.3" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19#555a58e5db3de28e977b08dd4cd116b332848a19" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -2212,6 +2212,10 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149, upload-time = "2026-08-13T10:17:45.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559, upload-time = "2026-08-13T10:17:44.385Z" }, +] [[package]] name = "langchain" @@ -3238,7 +3242,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3277,7 +3281,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3289,7 +3293,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3319,9 +3323,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3333,7 +3337,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -5163,10 +5167,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5213,7 +5217,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5284,14 +5288,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "torch", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "transformers", marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5664,21 +5668,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "filelock", marker = "python_full_version >= '3.14'" }, - { name = "fsspec", marker = "python_full_version >= '3.14'" }, - { name = "jinja2", marker = "python_full_version >= '3.14'" }, - { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.14'" }, - { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5720,15 +5724,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pyyaml", marker = "python_full_version >= '3.14'" }, - { name = "regex", marker = "python_full_version >= '3.14'" }, - { name = "safetensors", marker = "python_full_version >= '3.14'" }, - { name = "tokenizers", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "typer", marker = "python_full_version >= '3.14'" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -5989,9 +5993,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'win32'" }, - { name = "protobuf", marker = "sys_platform != 'win32'" }, - { name = "sniffio", marker = "sys_platform != 'win32'" }, + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From 0cc0e1b02d9767acdc664bf5244107b47fddb4c7 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:42:20 +0000 Subject: [PATCH 19/42] fix(migrations): retry backup reopen on bind mounts --- .../persistence/sqlite_migration_backup.py | 16 ++++++++-- .../test_sqlite_migration_backup.py | 31 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/persistence/sqlite_migration_backup.py b/src/langbot/pkg/persistence/sqlite_migration_backup.py index 5e1f7e683..9004b7fcc 100644 --- a/src/langbot/pkg/persistence/sqlite_migration_backup.py +++ b/src/langbot/pkg/persistence/sqlite_migration_backup.py @@ -12,6 +12,7 @@ import re import secrets import sqlite3 import tempfile +import time import typing from sqlalchemy.ext.asyncio import AsyncEngine @@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing. temporary_path.unlink(missing_ok=True) -def _fsync_file(path: pathlib.Path) -> None: - descriptor = os.open(path, os.O_RDONLY) +def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None: + """Sync a file, tolerating delayed visibility after replace on bind mounts.""" + + descriptor: int | None = None + for attempt in range(reopen_attempts): + try: + descriptor = os.open(path, os.O_RDONLY) + break + except FileNotFoundError: + if attempt + 1 >= reopen_attempts: + raise + time.sleep(0.05) + assert descriptor is not None try: os.fsync(descriptor) finally: diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py index dab50dd96..e808cb3fa 100644 --- a/tests/integration/persistence/test_sqlite_migration_backup.py +++ b/tests/integration/persistence/test_sqlite_migration_backup.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import os import pathlib import sqlite3 @@ -9,7 +10,7 @@ import pytest import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine -from langbot.pkg.persistence import alembic_runner +from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup from langbot.pkg.persistence.mgr import PersistenceManager from .resource_migration_support import create_legacy_resource_schema @@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision( assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() finally: await engine.dispose() + + +async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch): + database_path = tmp_path / 'legacy-bind-mount.db' + engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}') + real_open = os.open + transient_failures = 0 + + def transient_open(path, flags, *args, **kwargs): + nonlocal transient_failures + candidate = pathlib.Path(path) + if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0: + transient_failures += 1 + raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate)) + return real_open(path, flags, *args, **kwargs) + + try: + await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount') + await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs') + monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open) + + await _manager(engine)._run_alembic_migrations() + + assert transient_failures == 1 + assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() + assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2 + finally: + await engine.dispose() From 717bd4b8bfadbb508e68331611c11acd35385889 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:01:02 +0000 Subject: [PATCH 20/42] fix(commands): pass trusted workspace scope to plugins --- src/langbot/pkg/command/cmdmgr.py | 10 ++++++++- tests/unit_tests/command/test_cmdmgr.py | 27 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py index 7560f7c43..31f28dfdf 100644 --- a/src/langbot/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -3,6 +3,7 @@ from __future__ import annotations import typing import inspect +from ..api.http.context import ExecutionContext from ..core import app from . import operator from ..utils import importutil @@ -66,7 +67,14 @@ class CommandManager: require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None) if require_context is not None: - result = require_context(context) + result = require_context( + ExecutionContext( + instance_uuid=context.instance_uuid, + workspace_uuid=context.workspace_uuid, + placement_generation=context.placement_generation, + query_uuid=context.query_uuid, + ) + ) if inspect.isawaitable(result): await result diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index ade27cf48..afc251a13 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock from langbot.pkg.command import operator from langbot.pkg.command.cmdmgr import CommandManager +from langbot.pkg.api.http.context import ExecutionContext from tests.factories import FakeApp, command_query import langbot_plugin.api.entities.builtin.provider.session as provider_session @@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute: assert len(results) == 1 assert results[0].text == 'plugin response' + @pytest.mark.asyncio + async def test_execute_selects_workspace_with_trusted_context(self): + """Plugin command discovery receives the typed runtime scope.""" + + fake_app = FakeApp() + mgr = CommandManager(fake_app) + mgr.cmd_list = [] + fake_app.plugin_connector.require_workspace_context = AsyncMock() + fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) + + ctx = self._create_context(command='help') + ctx.instance_uuid = 'instance-a' + ctx.workspace_uuid = 'workspace-a' + ctx.placement_generation = 4 + ctx.query_uuid = 'query-a' + + async for _ in mgr._execute(ctx, mgr.cmd_list): + pass + + selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0] + assert isinstance(selected, ExecutionContext) + assert selected.instance_uuid == 'instance-a' + assert selected.workspace_uuid == 'workspace-a' + assert selected.placement_generation == 4 + assert selected.query_uuid == 'query-a' + @pytest.mark.asyncio async def test_execute_with_bound_plugins(self): """_execute passes bound_plugins to plugin connector.""" From b62cc9da451acd3d9e8fcbda82456478f7b975a5 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:15:57 +0000 Subject: [PATCH 21/42] fix(migrations): preserve legacy workspace ownership --- src/langbot/pkg/persistence/mgr.py | 13 +++- .../persistence/test_workspace_migration.py | 73 +++++++++++++++---- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index e9062c066..b29d6b44e 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -177,7 +177,6 @@ class PersistenceManager: await self._validate_cloud_runtime() return - self._enable_sqlite_foreign_keys() if self.mode == PersistenceMode.RELEASE_MIGRATION: async with self._release_migration_lock(): await self._initialize_managed_schema() @@ -185,6 +184,7 @@ class PersistenceManager: return await self._initialize_managed_schema() + await self._enable_sqlite_foreign_keys_after_migration() if self.mode == PersistenceMode.OSS_COMPAT: await self.write_space_model_providers() @@ -373,6 +373,17 @@ class PersistenceManager: sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope) self._oss_tenant_scope_listener_installed = True + async def _enable_sqlite_foreign_keys_after_migration(self) -> None: + """Enable SQLite FK enforcement only after table-rebuilding migrations.""" + engine = self.get_db_engine() + if engine.dialect.name != 'sqlite': + return + await engine.dispose() + self._enable_sqlite_foreign_keys() + # Dispose again so every runtime connection is opened through the new + # listener instead of reusing a pre-migration pooled connection. + await engine.dispose() + def _enable_sqlite_foreign_keys(self) -> None: """Enable SQLite FK enforcement for every pooled runtime connection.""" engine = self.get_db_engine() diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 70c46f89a..2ebb0f1e8 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): ) async with engine.begin() as conn: await conn.run_sync(schema.create_all) - await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}) await conn.execute( - sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"), + sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id} + ) + await conn.execute( + sa.text( + "INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')" + ), {'uuid': old_workspace_uuid, 'instance': instance_id}, ) await conn.execute( - sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"), + sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'), {'uuid': old_workspace_uuid}, ) await run_alembic_stamp(engine, '0016_support_admin_sessions') @@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): await run_alembic_upgrade(engine, 'head') async with engine.connect() as conn: - assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid - assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid await engine.dispose() @@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade await engine.dispose() +async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys( + tmp_path, + monkeypatch, +): + database_path = tmp_path / 'startup-foreign-keys.db' + engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}') + try: + await _create_legacy_schema(engine) + await run_alembic_stamp(engine, '0008_mcp_resource_prefs') + finally: + await engine.dispose() + + monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test') + application = type('Application', (), {})() + application.logger = logging.getLogger('workspace-startup-foreign-keys-test') + application.instance_config = type( + 'InstanceConfig', + (), + {'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}}, + )() + manager = PersistenceManager(application) + + await manager.initialize() + try: + async with manager.get_db_engine().connect() as conn: + workspace = ( + (await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one() + ) + membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() + foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys')) + + assert workspace['created_by_account_uuid'] == membership['account_uuid'] + assert membership['role'] == 'owner' + assert membership['status'] == 'active' + assert foreign_keys == 1 + finally: + await manager.shutdown() + + async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') try: @@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): assert instance_uuid await conn.execute( sa.text( - "INSERT INTO workspace_metadata (workspace_uuid, key, value) " + 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' "VALUES (:workspace_uuid, 'migration_probe', 'present')" ), {'workspace_uuid': old_uuid}, @@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): await conn.execute( sa.text( "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value' ), {'workspace_uuid': old_uuid}, ) @@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): expected_uuid = workspace_uuid_from_instance_id(instance_uuid) async with engine.connect() as conn: assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid - assert await conn.scalar( - sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") - ) == expected_uuid - assert await conn.scalar( - sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'") - ) == expected_uuid + assert ( + await conn.scalar( + sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") + ) + == expected_uuid + ) + assert ( + await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")) + == expected_uuid + ) finally: await engine.dispose() From a08a177a114a5337939e31783a6c08b88d72f0c2 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:36:34 +0000 Subject: [PATCH 22/42] fix(runtime): adopt plugin SDK 0.5.5 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c8ea0836a..6e2f8879b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin==0.5.3", + "langbot-plugin==0.5.5", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/uv.lock b/uv.lock index 09f525a19..5603a7d77 100644 --- a/uv.lock +++ b/uv.lock @@ -2125,7 +2125,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.3" }, + { name = "langbot-plugin", specifier = "==0.5.5" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2191,7 +2191,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.3" +version = "0.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2212,9 +2212,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149, upload-time = "2026-08-13T10:17:45.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559, upload-time = "2026-08-13T10:17:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" }, ] [[package]] From 54c96a18e1ef1b7666c5a5f27501c03aef97a545 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 17 Aug 2026 10:31:03 +0800 Subject: [PATCH 23/42] test(migration): preserve legacy plugin storage payloads (#2444) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../persistence/resource_migration_support.py | 9 ++++++- .../test_resource_tenancy_migration.py | 24 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py index f3eea0f30..afb05ae15 100644 --- a/tests/integration/persistence/resource_migration_support.py +++ b/tests/integration/persistence/resource_migration_support.py @@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None: sa.Column('key', sa.String(255), nullable=False), sa.Column('owner_type', sa.String(255), nullable=False), sa.Column('owner', sa.String(255), nullable=False), + sa.Column('value', sa.LargeBinary, nullable=False), ) mcp_servers = _uuid_table( metadata, @@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None: await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now)) await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner')) await conn.execute( - binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo') + binary_storages.insert().values( + unique_key='plugin:demo:key', + key='key', + owner_type='plugin', + owner='demo', + value=b'legacy-plugin-value', + ) ) await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now)) await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai')) diff --git a/tests/integration/persistence/test_resource_tenancy_migration.py b/tests/integration/persistence/test_resource_tenancy_migration.py index 6ae288061..8bc3797a1 100644 --- a/tests/integration/persistence/test_resource_tenancy_migration.py +++ b/tests/integration/persistence/test_resource_tenancy_migration.py @@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path): ) assert legacy_kb['collection_id'] == 'collection-1' assert legacy_kb['legacy_vector_collection'] == 1 + legacy_binary_storage = ( + ( + await conn.execute( + sa.text( + 'SELECT workspace_uuid, unique_key, key, owner_type, owner, value ' + "FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'" + ) + ) + ) + .mappings() + .one() + ) + assert legacy_binary_storage == { + 'workspace_uuid': workspace_uuid, + 'unique_key': 'plugin:demo:key', + 'key': 'key', + 'owner_type': 'plugin', + 'owner': 'demo', + 'value': b'legacy-plugin-value', + } assert ( await conn.scalar( sa.text( @@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac await conn.execute( sa.text( 'INSERT INTO binary_storages ' - '(workspace_uuid, unique_key, key, owner_type, owner) ' - "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')" + '(workspace_uuid, unique_key, key, owner_type, owner, value) ' + "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')" ), {'workspace_uuid': second_workspace_uuid}, ) From 7803d562546ab4d56d57ef61e2ebbb94f1a767d1 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 17 Aug 2026 11:49:26 +0800 Subject: [PATCH 24/42] fix(plugin): keep pre-tenancy plugin storage readable (#2446) * fix(plugin): adopt legacy scoped storage rows * fix(plugin): delete adopted legacy storage rows * test(plugin): cover legacy storage deletion * fix(plugin): avoid mutating legacy storage on reads * fix(plugin): make legacy storage adoption atomic * fix(plugin): resolve concurrent legacy adoption * fix(plugin): upsert concurrent storage writes * fix(plugin): retry reads after legacy adoption * fix(plugin): deduplicate migrated storage keys --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- src/langbot/pkg/plugin/handler.py | 130 ++++++++++++++-- .../unit_tests/plugin/test_handler_actions.py | 142 +++++++++++++++++- 2 files changed, 253 insertions(+), 19 deletions(-) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 4805f2668..8dae74f6f 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -11,6 +11,8 @@ import traceback from dataclasses import dataclass import sqlalchemy +import sqlalchemy.dialects.postgresql +import sqlalchemy.dialects.sqlite from langbot_plugin.runtime.io import handler from langbot_plugin.runtime.io.connection import Connection @@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler): return f'{identity.plugin_author}/{identity.plugin_name}' raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}') + @staticmethod + def _legacy_binary_storage_key( + action_context: ActionContext, + *, + owner_type: str, + owner: str, + key: str, + ) -> str: + """Return the pre-tenancy key shape for a row already scoped to this Workspace.""" + + legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner + return f'{owner_type}:{legacy_owner}:{key}' + @classmethod def _binary_storage_key( cls, @@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler): .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key) ) + storage = result.first() + if storage is None: + legacy_key = self._legacy_binary_storage_key( + action_context, + owner_type=owner_type, + owner=owner, + key=key, + ) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) + .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) + ) + storage = result.first() + if storage is not None: + update_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) + .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) + .values(unique_key=unique_key, value=value) + ) + if update_result.rowcount: + return handler.ActionResponse.success(data={}) + canonical_update = await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) + .where(persistence_bstorage.BinaryStorage.unique_key == unique_key) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) + .values(value=value) + ) + if canonical_update.rowcount: + return handler.ActionResponse.success(data={}) + storage = None - if result.first() is not None: + if storage is not None: await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_bstorage.BinaryStorage) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) .values(value=value) ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_bstorage.BinaryStorage).values( - workspace_uuid=action_context.workspace_uuid, - unique_key=unique_key, - key=key, - owner_type=owner_type, - owner=owner, - value=value, - ) + return handler.ActionResponse.success(data={}) + + dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name + insert = { + 'postgresql': sqlalchemy.dialects.postgresql.insert, + 'sqlite': sqlalchemy.dialects.sqlite.insert, + }.get(dialect_name) + if insert is None: + return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}') + await self.ap.persistence_mgr.execute_async( + insert(persistence_bstorage.BinaryStorage) + .values( + workspace_uuid=action_context.workspace_uuid, + unique_key=unique_key, + key=key, + owner_type=owner_type, + owner=owner, + value=value, ) + .on_conflict_do_update( + index_elements=['workspace_uuid', 'unique_key'], + set_={'value': value}, + ) + ) return handler.ActionResponse.success( data={}, @@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler): ) storage = result.first() + if storage is None: + legacy_key = self._legacy_binary_storage_key( + action_context, + owner_type=owner_type, + owner=owner, + key=key, + ) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) + .where(persistence_bstorage.BinaryStorage.unique_key == legacy_key) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) + ) + storage = result.first() + if storage is None: + retry_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bstorage.BinaryStorage) + .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) + .where(persistence_bstorage.BinaryStorage.unique_key == unique_key) + ) + storage = retry_result.first() if storage is None: return handler.ActionResponse.error( message=f'Storage with key {key} not found', @@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler): message=str(e), ) + legacy_key = self._legacy_binary_storage_key( + action_context, + owner_type=owner_type, + owner=owner, + key=key, + ) await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_bstorage.BinaryStorage) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) - .where(persistence_bstorage.BinaryStorage.unique_key == unique_key) + .where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key))) + .where(persistence_bstorage.BinaryStorage.key == key) + .where(persistence_bstorage.BinaryStorage.owner_type == owner_type) + .where(persistence_bstorage.BinaryStorage.owner == owner) ) return handler.ActionResponse.success( @@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler): return handler.ActionResponse.success( data={ - 'keys': result.scalars().all(), + 'keys': list(dict.fromkeys(result.scalars().all())), }, ) diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 4089e23be..aa0762471 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -234,6 +234,7 @@ class TestSetBinaryStorage: }, } mock_app.persistence_mgr = Mock() + mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite')) mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result()) mock_app.logger = Mock() return mock_app @@ -270,8 +271,8 @@ class TestSetBinaryStorage: ) assert response.code == 0 - assert app.persistence_mgr.execute_async.await_count == 2 - insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert app.persistence_mgr.execute_async.await_count == 3 + insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0]) assert insert_params['workspace_uuid'] == 'workspace-a' assert insert_params['unique_key'] == canonical_binary_key( 'plugin', @@ -301,6 +302,69 @@ class TestSetBinaryStorage: assert expected_key in update_params.values() assert update_params['value'] == b'new' + @pytest.mark.asyncio + async def test_adopts_legacy_storage_before_updating(self, app): + """A migrated pre-tenancy row is updated in place rather than duplicated.""" + runtime_handler = make_handler(app) + legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key') + adopted = SimpleNamespace(rowcount=1) + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + make_result(legacy_storage), + adopted, + ] + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new')) + + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 3 + adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0]) + expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') + assert expected_key in adoption_params.values() + assert adoption_params['value'] == b'new' + + @pytest.mark.asyncio + async def test_legacy_adoption_race_updates_winning_canonical_row(self, app): + runtime_handler = make_handler(app) + legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key') + lost_race = SimpleNamespace(rowcount=0) + canonical_winner = SimpleNamespace(rowcount=1) + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + make_result(legacy_storage), + lost_race, + canonical_winner, + ] + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new')) + + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 4 + winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0]) + assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values() + assert winner_update['value'] == b'new' + + @pytest.mark.asyncio + async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app): + runtime_handler = make_handler(app) + legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key') + lost_race = SimpleNamespace(rowcount=0) + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + make_result(legacy_storage), + lost_race, + SimpleNamespace(rowcount=0), + make_result(), + ] + + response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new')) + + assert response.code == 0 + assert app.persistence_mgr.execute_async.await_count == 5 + insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0]) + assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') + assert insert_params['value'] == b'new' + @pytest.mark.asyncio async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app): """Invalid max_value_bytes uses the 10MB default limit.""" @@ -525,6 +589,46 @@ class TestGetBinaryStorage: in statement_params.values() ) + @pytest.mark.asyncio + async def test_reads_legacy_storage_without_mutating_key(self, app): + runtime_handler = make_handler(app) + legacy_storage = SimpleNamespace( + unique_key='plugin:test-author/test-plugin:test-key', + value=b'legacy bytes', + ) + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + make_result(legacy_storage), + ] + + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]( + {'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'} + ) + + assert response.code == 0 + assert base64.b64decode(response.data['value_base64']) == b'legacy bytes' + assert app.persistence_mgr.execute_async.await_count == 2 + + @pytest.mark.asyncio + async def test_retries_canonical_after_concurrent_legacy_adoption(self, app): + runtime_handler = make_handler(app) + canonical_storage = SimpleNamespace(value=b'adopted bytes') + app.persistence_mgr.execute_async.side_effect = [ + make_result(), + make_result(), + make_result(canonical_storage), + ] + + response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]( + {'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'} + ) + + assert response.code == 0 + assert base64.b64decode(response.data['value_base64']) == b'adopted bytes' + assert app.persistence_mgr.execute_async.await_count == 3 + retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0]) + assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values() + @pytest.mark.asyncio async def test_returns_error_when_not_found(self, app): """Missing binary storage rows return an error response.""" @@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage: assert response.code == 0 statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) - assert 'workspace-a' in statement_params.values() + flat_values = [ + item for value in statement_params.values() for item in (value if isinstance(value, list) else [value]) + ] + assert 'workspace-a' in flat_values assert ( canonical_binary_key( 'plugin', 'test-author/test-plugin', 'test-key', ) - in statement_params.values() + in flat_values ) - assert 'forged-owner' not in statement_params.values() + assert 'forged-owner' not in flat_values + + @pytest.mark.asyncio + async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app): + runtime_handler = make_handler(app) + + response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value]( + { + 'key': 'test-key', + 'owner_type': 'plugin', + 'owner': 'forged-owner', + } + ) + + assert response.code == 0 + statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + values = [ + item for value in statement_params.values() for item in (value if isinstance(value, list) else [value]) + ] + assert 'workspace-a' in values + assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values + assert 'plugin:test-author/test-plugin:test-key' in values + assert 'test-author/test-plugin' in values + assert 'forged-owner' not in values @pytest.mark.asyncio async def test_list_keys_uses_trusted_plugin_owner(self, app): result = Mock() - result.scalars.return_value.all.return_value = ['first', 'second'] + result.scalars.return_value.all.return_value = ['first', 'second', 'first'] app.persistence_mgr.execute_async.return_value = result runtime_handler = make_handler(app) From e934f08adf5da8077c6bc584b920947765a3db30 Mon Sep 17 00:00:00 2001 From: DongXiaoming <41072499+MIKAZE3@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:56:20 +0800 Subject: [PATCH 25/42] fix(wecombot): deliver sandbox outbox media through full pipeline chain (#2328) * fix(wecombot): align media upload protocol * fix(wecombot): deliver outbox media in reply and fix tool call recording - Integrate _send_media into reply_message and reply_message_chunk so sandbox outbox images/voices/files are uploaded and sent instead of being silently dropped. - Add missing import base64 that caused _send_media to fail with a NameError swallowed by its except clause. - Change yiri2target to return component dicts (text/image/voice/file) so callers can distinguish text from media. - Fix _get_message_for_tool_context using result.first()/row[0] which returned a raw string instead of the ORM object, causing "'str' object has no attribute 'pipeline_id'" in tool call recording. Use result.scalars().first() per SQLAlchemy 2.0 convention. * fix(pipeline): collect outbox attachments on final chunk with empty content When the last streaming chunk has is_final=True but empty content (e.g. the LLM sends all text in earlier chunks), the 'if result.content' branch is skipped entirely, so _append_outbound_attachments never runs and sandbox outbox images are silently dropped. Add an elif branch for _is_final_assistant_message that creates an empty MessageChain and still collects outbox attachments, so images are delivered even when the final chunk carries no text. * fix(box): bypass stdout truncation when reading outbox via exec _read_outbox_via_exec used execute_tool which returns _serialize_result where stdout is truncated to output_limit_chars (4000). A 7KB JPEG encodes to ~9400 base64 chars, so the JSON payload was truncated and json.loads failed silently, returning an empty list. Call client.execute directly to get the raw BoxExecutionResult with untruncated stdout, so base64 file data is preserved. * fix(tests): adapt box and wrapper tests for client.execute and strict is_final check - wrapper.py: restrict outbox collection on empty-content chunks to actual MessageChunk instances with is_final=True, not generic Mock objects that happen to have role='assistant' - test_box_service.py: update _read_outbox_via_exec tests to mock client.execute (returning BoxExecutionResult) instead of execute_tool, matching the implementation change * chore(wecombot): remove temporary upload log * test(box): preserve direct outbox read and cleanup coverage --------- Co-authored-by: fdc310 <2213070223@qq.com> Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../libs/wecom_ai_bot_api/ws_client.py | 147 ++++++++++++++++++ src/langbot/pkg/box/service.py | 25 ++- src/langbot/pkg/pipeline/wrapper/wrapper.py | 12 ++ src/langbot/pkg/platform/sources/wecombot.py | 126 ++++++++++++--- tests/unit_tests/box/test_box_service.py | 66 +++++--- .../platform/test_wecombot_media.py | 127 +++++++++++++++ 6 files changed, 458 insertions(+), 45 deletions(-) create mode 100644 tests/unit_tests/platform/test_wecombot_media.py diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 3fe985255..997338211 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg' CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' CMD_SEND_MSG = 'aibot_send_msg' +# Media upload protocol (3 steps: init -> chunk * N -> finish). The +# command names below match the WeCom AI Bot long-connection protocol. +CMD_UPLOAD_INIT = 'aibot_upload_media_init' +CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk' +CMD_UPLOAD_FINISH = 'aibot_upload_media_finish' + +# Default upload chunk size: 512 KB before base64 encoding. +_UPLOAD_CHUNK_SIZE = 512 * 1024 _DEDUP_CACHE_MAX = 4096 _STREAM_CACHE_MAX = 1024 @@ -495,6 +503,145 @@ class WecomBotWsClient: body['chatid'] = chat_id return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) + # ------------------------------------------------------------------ + # Media upload (image / voice / file) + # ------------------------------------------------------------------ + + async def upload_media( + self, + data: bytes, + filename: str = 'attachment', + media_type: str = 'file', + ) -> Optional[dict]: + """Upload *data* to the WeCom AI Bot CDN and return the parsed ACK. + + Implements the three-step protocol documented for the WeCom + AI Bot: + + 1. ``aibot_upload_media_init`` — declare media type, file name, + size, MD5 and chunk count; receive ``upload_id``. + 2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded + bytes) until done; receive per-chunk ACK. + 3. ``aibot_upload_media_finish`` — finalize the upload; receive + ``media_id``. + + Returns a dict with the final ``media_id`` (and the raw + ``finish`` ACK) on success, or ``None`` on any failure. The + caller is expected to ignore the result and continue + gracefully — the framework will keep working without media + delivery. + """ + import base64 as _b64 + import hashlib as _hl + + if not data: + return None + + file_size = len(data) + file_md5 = _hl.md5(data).hexdigest() + total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE + if total_chunks == 0: + total_chunks = 1 + + # Step 1: init. + init_req_id = _generate_req_id(CMD_UPLOAD_INIT) + init_body = { + 'type': media_type, + 'filename': filename, + 'total_size': file_size, + 'total_chunks': total_chunks, + 'md5': file_md5, + } + init_ack = await self._send_reply( + init_req_id, + init_body, + cmd=CMD_UPLOAD_INIT, + ) + if not init_ack or init_ack.get('errcode', 0) != 0: + await self.logger.warning(f'upload_media init failed: ack={init_ack!r}') + return None + upload_id = ( + init_ack.get('upload_id') + or init_ack.get('body', {}).get('upload_id') + or init_ack.get('data', {}).get('upload_id') + ) + if not upload_id: + await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}') + return None + + # Step 2: chunks. + for index in range(total_chunks): + start = index * _UPLOAD_CHUNK_SIZE + end = min(start + _UPLOAD_CHUNK_SIZE, file_size) + chunk_bytes = data[start:end] + chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK) + chunk_body = { + 'upload_id': upload_id, + 'chunk_index': index, + 'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'), + } + chunk_ack = await self._send_reply( + chunk_req_id, + chunk_body, + cmd=CMD_UPLOAD_CHUNK, + ) + if not chunk_ack or chunk_ack.get('errcode', 0) != 0: + await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}') + return None + + # Step 3: finish. + finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH) + finish_body = {'upload_id': upload_id} + finish_ack = await self._send_reply( + finish_req_id, + finish_body, + cmd=CMD_UPLOAD_FINISH, + ) + if not finish_ack or finish_ack.get('errcode', 0) != 0: + await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}') + return None + + media_id = ( + finish_ack.get('media_id') + or finish_ack.get('body', {}).get('media_id') + or finish_ack.get('data', {}).get('media_id') + ) + if not media_id: + await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}') + return None + return {'media_id': media_id, 'ack': finish_ack} + + async def _reply_media( + self, + req_id: str, + media_id: str, + kind: str, + ) -> Optional[dict]: + """Send a media reply (image / voice / file) referencing *media_id*. + + ``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses + the standard ``aibot_respond_msg`` command with a per-kind + body key (matches the convention documented for the WeCom + AI Bot SDK). + """ + if kind not in {'image', 'voice', 'file'}: + await self.logger.warning(f'_reply_media called with unknown kind={kind!r}') + return None + body = { + 'msgtype': kind, + kind: {'media_id': media_id}, + } + return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG) + + async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]: + return await self._reply_media(req_id, media_id, 'image') + + async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]: + return await self._reply_media(req_id, media_id, 'file') + + async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]: + return await self._reply_media(req_id, media_id, 'voice') + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """Push a streaming chunk for a given message ID. diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 56c8ae59b..c8554cc52 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -1210,8 +1210,9 @@ class BoxService: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: """Fallback: read the outbox over the exec channel (E2B / remote). - Note: exec stdout is truncated by ``output_limit_chars``, so this path - only reliably transfers small files. The host path is preferred. + Uses ``client.execute`` directly (bypassing ``_serialize_result``) + so stdout is NOT truncated by ``output_limit_chars`` - the raw + base64 payload can be far larger than the 4000-char display limit. """ import json as _json @@ -1265,14 +1266,22 @@ class BoxService: ' break\n' 'print(json.dumps(out))\n' ) - result = await self.execute_tool( - {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, - query, - ) - if not result.get('ok'): + spec_payload: dict = { + 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY", + 'timeout_sec': 120, + 'session_id': self.resolve_box_session_id(query), + } + if 'extra_mounts' not in spec_payload: + spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) + try: + spec = self.build_spec(spec_payload) + result = await self.client.execute(spec) + except Exception: + return [] + if not result.ok: return [] try: - return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) + return _json.loads(str(result.stdout or '').strip().splitlines()[-1]) except Exception: return [] diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index 50db693d4..eff976bfa 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage): result_type=entities.ResultType.CONTINUE, new_query=query, ) + elif ( + isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls + ): + # Final streaming chunk with no text content but + # possibly carrying sandbox outbox attachments. + reply_chain = platform_message.MessageChain([]) + await self._append_outbound_attachments(query, reply_chain) + query.resp_message_chain.append(reply_chain) + yield entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 0990773ad..a6bf0cc8d 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -3,8 +3,10 @@ import typing import asyncio import time import traceback +import base64 import datetime + import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): - content = '' + """Convert a MessageChain into a list of component dicts. + + Each dict has a ``type`` key (``'text'``, ``'image'``, + ``'voice'``, ``'file'``). Text items carry ``text``; media + items carry ``base64`` (may include a ``data:...;base64,`` + prefix) and optionally ``name``. + """ + items: list[dict] = [] for msg in message_chain: if type(msg) is platform_message.Plain: - content += msg.text - return content + items.append({'type': 'text', 'text': msg.text}) + elif type(msg) is platform_message.Image: + items.append({'type': 'image', 'base64': msg.base64 or ''}) + elif type(msg) is platform_message.Voice: + items.append({'type': 'voice', 'base64': msg.base64 or ''}) + elif type(msg) is platform_message.File: + items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''}) + return items @staticmethod async def target2yiri(event: WecomBotEvent, bot_name: str = ''): @@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): } ) + @staticmethod + def _join_text_components(items: list[dict]) -> str: + """Concatenate ``text`` items in order, leaving media items alone.""" + return ''.join(item['text'] for item in items if item.get('type') == 'text') + + @staticmethod + def _iter_media_components(items: list[dict]): + """Yield non-text items in order.""" + for item in items: + if item.get('type') in {'image', 'voice', 'file'}: + yield item + + @staticmethod + async def _send_media( + bot, + req_id: str, + item: dict, + ) -> bool: + """Upload *item* to the WeCom AI Bot CDN and send it as a media reply. + + Returns True on success. Falls back to a no-op (with a warning log) + if the SDK does not yet implement ``upload_media`` / + ``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework + will keep working, just without image delivery. + """ + kind = item.get('type') + upload = getattr(bot, 'upload_media', None) + if upload is None: + return False + b64_text = item.get('base64') or '' + if not b64_text: + return False + if b64_text.startswith('data:') and ',' in b64_text: + b64_text = b64_text.split(',', 1)[1] + try: + data = base64.b64decode(b64_text, validate=False) + except Exception: + return False + if not data: + return False + try: + upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind) + except Exception: + return False + media_id = getattr(upload_result, 'media_id', None) or ( + isinstance(upload_result, dict) and upload_result.get('media_id') + ) + if not media_id: + return False + reply_fn = { + 'image': getattr(bot, 'reply_image', None), + 'file': getattr(bot, 'reply_file', None), + 'voice': getattr(bot, 'reply_voice', None), + }.get(kind) + if reply_fn is None: + return False + try: + await reply_fn(req_id, media_id) + return True + except Exception: + return False + async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, ): - content = await self.message_converter.yiri2target(message) + items = await self.message_converter.yiri2target(message) + text = self._join_text_components(items) _ws_mode = not self.config.get('enable-webhook', False) event = message_source.source_platform_object @@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: chat_id = str(message_source.sender.id) try: - await self.bot.send_message(chat_id, content) + await self.bot.send_message(chat_id, text) except Exception: await self.logger.error( f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}' @@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if _ws_mode: req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') - if req_id: - await self.bot.reply_text(req_id, content) - else: - await self.bot.set_message(event.message_id, content) + if text: + if req_id: + await self.bot.reply_text(req_id, text) + else: + await self.bot.set_message(event.message_id, text) + for item in self._iter_media_components(items): + await self._send_media(self.bot, req_id, item) else: - await self.bot.set_message(event.message_id, content) + await self.bot.set_message(event.message_id, text) async def reply_message_chunk( self, @@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): quote_origin: bool = False, is_final: bool = False, ): - content = await self.message_converter.yiri2target(message) + items = await self.message_converter.yiri2target(message) + text = self._join_text_components(items) _ws_mode = not self.config.get('enable-webhook', False) # Synthetic events (e.g. button-click triggered form resume) have @@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # of the stream/reply path. spo = message_source.source_platform_object if spo is None: - return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode) + return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode) msg_id = spo.message_id @@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): form_data.get('actions', []) or [], ) except Exception: - fallback = content or '(人工输入)' + fallback = text or '(人工输入)' if _ws_mode: event = message_source.source_platform_object req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') @@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return {'stream': False, 'form': True, 'fallback': True} if _ws_mode: - success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final) if not success and is_final: event = message_source.source_platform_object req_id = event.get('req_id', '') if req_id: - await self.bot.reply_text(req_id, content) + await self.bot.reply_text(req_id, text) + if is_final: + event = message_source.source_platform_object + req_id = event.get('req_id', '') + for item in self._iter_media_components(items): + await self._send_media(self.bot, req_id, item) return {'stream': success} else: - success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) + success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final) if not success and is_final: - await self.bot.set_message(msg_id, content) + await self.bot.set_message(msg_id, text) return {'stream': success} async def is_stream_output_supported(self) -> bool: @@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): async def send_message(self, target_type, target_id, message): _ws_mode = not self.config.get('enable-webhook', False) if _ws_mode: - content = await self.message_converter.yiri2target(message) - await self.bot.send_message(target_id, content) + items = await self.message_converter.yiri2target(message) + text = self._join_text_components(items) + await self.bot.send_message(target_id, text) else: pass diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 9c78f771c..ce6e7bc5f 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip: calls = [] - async def fake_execute_tool(parameters, q): - calls.append(parameters['command']) - if 'os.scandir' in parameters['command']: - return { - 'ok': True, - 'stdout': '[{"name": "out.png", "b64": "QUJD"}]', - 'stderr': '', - } + async def fake_client_execute(spec): + cmd = spec.cmd + calls.append(cmd) + if 'os.scandir' in cmd: + return BoxExecutionResult( + session_id='s', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout='[{"name": "out.png", "b64": "QUJD"}]', + duration_ms=10, + ) # the rm -rf cleanup call - return {'ok': True, 'stdout': '', 'stderr': ''} + return BoxExecutionResult( + session_id='s', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout='', + duration_ms=10, + ) - service.execute_tool = AsyncMock(side_effect=fake_execute_tool) + service.client.execute = AsyncMock(side_effect=fake_client_execute) + service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''}) attachments = await service.collect_outbound_attachments(query) assert len(attachments) == 1 assert attachments[0]['type'] == 'Image' assert attachments[0]['name'] == 'out.png' # cleanup (rm -rf) must have been issued after a successful collection - assert any('rm -rf' in c for c in calls) + service.execute_tool.assert_awaited_once() + assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] @pytest.mark.asyncio async def test_collect_outbound_empty_still_clears(self): @@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip: calls = [] - async def fake_execute_tool(parameters, q): - calls.append(parameters['command']) - if 'os.scandir' in parameters['command']: - return {'ok': True, 'stdout': '[]', 'stderr': ''} - return {'ok': True, 'stdout': '', 'stderr': ''} + async def fake_client_execute(spec): + cmd = spec.cmd + calls.append(cmd) + if 'os.scandir' in cmd: + return BoxExecutionResult( + session_id='s', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout='[]', + duration_ms=10, + ) + return BoxExecutionResult( + session_id='s', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout='', + duration_ms=10, + ) - service.execute_tool = AsyncMock(side_effect=fake_execute_tool) + service.client.execute = AsyncMock(side_effect=fake_client_execute) + service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''}) assert await service.collect_outbound_attachments(query) == [] # cleanup (rm -rf) is issued unconditionally now - assert any('rm -rf' in c for c in calls) + service.execute_tool.assert_awaited_once() + assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] @pytest.mark.asyncio async def test_passthrough_noop_when_unavailable(self): diff --git a/tests/unit_tests/platform/test_wecombot_media.py b/tests/unit_tests/platform/test_wecombot_media.py new file mode 100644 index 000000000..8bbff53e1 --- /dev/null +++ b/tests/unit_tests/platform/test_wecombot_media.py @@ -0,0 +1,127 @@ +import base64 + +import pytest + +import langbot.pkg.core.app # noqa: F401 +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient +from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter + + +class Logger: + def __init__(self): + self.warnings = [] + self.errors = [] + + async def warning(self, message): + self.warnings.append(message) + + async def error(self, message): + self.errors.append(message) + + async def info(self, message): + return None + + +class UploadClient(WecomBotWsClient): + def __init__(self): + super().__init__(bot_id='bot', secret='secret', logger=Logger()) + self.frames = [] + + async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'): + self.frames.append((cmd, body)) + if cmd == 'aibot_upload_media_init': + return {'errcode': 0, 'body': {'upload_id': 'upload-1'}} + if cmd == 'aibot_upload_media_finish': + return {'errcode': 0, 'body': {'media_id': 'media-1'}} + return {'errcode': 0} + + +class Bot: + def __init__(self): + self.calls = [] + + async def upload_media(self, data, filename='attachment', media_type='file'): + self.calls.append(('upload_media', media_type, filename, data)) + return {'media_id': 'media-1'} + + async def reply_text(self, req_id, content): + self.calls.append(('reply_text', req_id, content)) + + async def reply_image(self, req_id, media_id): + self.calls.append(('reply_image', req_id, media_id)) + + async def send_message(self, target_id, content): + self.calls.append(('send_message', target_id, content)) + + +def make_adapter(bot): + return WecomBotAdapter.model_construct( + bot=bot, + config={'enable-webhook': False}, + logger=Logger(), + message_converter=WecomBotMessageConverter(), + ) + + +@pytest.mark.asyncio +async def test_ws_client_upload_media_uses_chunk_protocol(): + client = UploadClient() + data = b'a' * (_UPLOAD_CHUNK_SIZE + 1) + + upload_result = await client.upload_media(data, 'image.png', media_type='image') + + assert upload_result['media_id'] == 'media-1' + assert [cmd for cmd, _ in client.frames] == [ + 'aibot_upload_media_init', + 'aibot_upload_media_chunk', + 'aibot_upload_media_chunk', + 'aibot_upload_media_finish', + ] + init_body = client.frames[0][1] + assert init_body['type'] == 'image' + assert init_body['filename'] == 'image.png' + assert init_body['total_size'] == len(data) + assert init_body['total_chunks'] == 2 + assert client.frames[1][1]['chunk_index'] == 0 + assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE + assert client.frames[2][1]['chunk_index'] == 1 + assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a' + + +@pytest.mark.asyncio +async def test_reply_message_uploads_and_replies_image_media(): + bot = Bot() + adapter = make_adapter(bot) + png_data = b'\x89PNG\r\n\x1a\nimage' + image_b64 = base64.b64encode(png_data).decode('utf-8') + chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')]) + + items = await WecomBotMessageConverter.yiri2target(chain) + await adapter._send_media(bot, 'req-1', items[0]) + + assert bot.calls == [ + ('upload_media', 'image', 'attachment.image', png_data), + ('reply_image', 'req-1', 'media-1'), + ] + + +@pytest.mark.asyncio +async def test_send_message_sends_text_and_skips_proactive_image(): + bot = Bot() + adapter = make_adapter(bot) + jpg_data = b'\xff\xd8\xffimage' + image_b64 = base64.b64encode(jpg_data).decode('utf-8') + chain = platform_message.MessageChain( + [ + platform_message.Plain(text='before'), + platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'), + platform_message.Plain(text='after'), + ] + ) + + await adapter.send_message('group', 'chat-1', chain) + + assert bot.calls == [ + ('send_message', 'chat-1', 'beforeafter'), + ] From 3d4a726cd81eead6b95bd12572eba34e1fc30c2a Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 21 Aug 2026 01:47:39 +0800 Subject: [PATCH 26/42] chore: release v4.10.8 (#2450) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6e2f8879b..9e226a9c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.7" +version = "4.10.8" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] diff --git a/uv.lock b/uv.lock index 5603a7d77..bce5afde2 100644 --- a/uv.lock +++ b/uv.lock @@ -2008,7 +2008,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.7" +version = "4.10.8" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, From c4bad508d278bc76590cfb6218a7d192d8593151 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:44:03 +0000 Subject: [PATCH 27/42] fix(runtime): honor configured cold reconcile timeout --- src/langbot/pkg/plugin/connector.py | 12 ++++++++++-- src/langbot/pkg/plugin/handler.py | 4 +++- .../unit_tests/plugin/test_connector_reconcile.py | 15 ++++++++++++++- tests/unit_tests/plugin/test_handler.py | 12 ++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 0279ab285..bcdf4fe8e 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -701,7 +701,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): } self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states}) - result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values())) + reconcile_timeout_seconds = max(300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get("plugin", {}))) + result = await runtime_handler.reconcile_plugin_installations( + tuple(self._known_desired_states.values()), + timeout=reconcile_timeout_seconds, + ) await self._repair_reconcile_missing_artifacts(self._known_desired_states, result) self._record_reconcile_failures(self._known_desired_states, result) @@ -736,7 +740,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if state.binding.installation_uuid in all_states: raise ValueError('Duplicate plugin installation UUID across projected Workspaces') all_states[state.binding.installation_uuid] = state - result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values())) + reconcile_timeout_seconds = max(300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get("plugin", {}))) + result = await runtime_handler.reconcile_plugin_installations( + tuple(all_states.values()), + timeout=reconcile_timeout_seconds, + ) await self._repair_reconcile_missing_artifacts(all_states, result) self._record_reconcile_failures(all_states, result) for installation_uuid, previous in tuple(self._known_desired_states.items()): diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 8dae74f6f..8463750f0 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1677,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler): async def reconcile_plugin_installations( self, installations: tuple[PluginInstallationDesiredState, ...], + *, + timeout: float = 300, ) -> dict[str, Any]: request = ReconcilePluginInstallationsRequest(installations=installations) with self.installation_scope(None): return await self.call_action( LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS, request.model_dump(), - timeout=300, + timeout=timeout, ) async def apply_plugin_installation( diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index 8759a537e..d4d9b6b5b 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -107,6 +107,19 @@ def shared_connector( return connector +@pytest.mark.asyncio +async def test_shared_reconcile_uses_configured_cold_start_timeout(): + binding = execution_binding("workspace-a") + setting = plugin_setting("01", "a" * 64) + connector = shared_connector([[binding]], {"workspace-a": [setting]}) + connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900 + connector.handler = runtime_handler() + + await connector._prepare_connected_runtime() + + assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900 + + @pytest.mark.asyncio async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection(): binding_a = execution_binding('workspace-a') @@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets(): assert connector._workspace_installations == {} assert connector._known_desired_states == {} - connector.handler.reconcile_plugin_installations.assert_awaited_once_with(()) + connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0) @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 952d59134..844dcdc12 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish( assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300 +@pytest.mark.asyncio +async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout(): + runtime_handler = make_handler(SimpleNamespace()) + runtime_handler.call_action = AsyncMock(return_value={}) + binding = next(iter(runtime_handler._installation_bindings.values()))[0] + desired = PluginInstallationDesiredState(binding=binding, enabled=True) + + await runtime_handler.reconcile_plugin_installations((desired,), timeout=900) + + assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900 + + class TestHandlerQueryVariables: """Tests for handler query variable logic.""" From c3fe312a43d965d24034da48f64ac598a7840861 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:22:08 +0000 Subject: [PATCH 28/42] fix(cloud): refresh directory during plugin startup --- src/langbot/pkg/core/app.py | 6 +++--- src/langbot/pkg/core/stages/build_app.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 4b82b3efc..3c1a74402 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -304,10 +304,10 @@ class Application: async def run(self): self.event_loop_monitor.start() try: - if self.directory_projection_service is not None: - self.task_mgr.create_task( + if self.directory_projection_service is not None and getattr(self, "directory_projection_task", None) is None: + self.directory_projection_task = self.task_mgr.create_task( self.directory_projection_service.run(), - name='cloud-directory-projection', + name="cloud-directory-projection", scopes=[core_entities.LifecycleControlScope.APPLICATION], ) if self.cloud_model_catalog_service is not None: diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 14d533dcc..001696d45 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -292,6 +292,16 @@ class BuildAppStage(stage.BootingStage): async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: connector.schedule_reconnect() + if ap.directory_projection_service is not None: + # Keep the projection fresh while shared Runtime cold restore runs. + # BuildApp initializes the connector before Application.run() starts + # its long-lived tasks, so start the single refresh task here. + ap.directory_projection_task = ap.task_mgr.create_task( + ap.directory_projection_service.run(), + name="cloud-directory-projection", + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) try: await plugin_connector_inst.initialize() From 693c59b726f16895612d0eb12a4e5f1aa37044fc Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:44:36 +0000 Subject: [PATCH 29/42] fix(cloud): import lifecycle task scope --- src/langbot/pkg/core/stages/build_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 001696d45..83f3b10fd 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .. import stage, app +from .. import stage, app, entities as core_entities from ...utils import version, proxy, constants from ...pipeline import pool, controller, pipelinemgr from ...pipeline import aggregator as message_aggregator From f36542135ab07049801a03115047b7c109cce0de Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:13:06 +0000 Subject: [PATCH 30/42] fix(cloud): start API before runtime reconcile --- src/langbot/pkg/core/app.py | 19 +++++++++++++++++-- src/langbot/pkg/core/stages/build_app.py | 7 ------- tests/unit_tests/core/test_app_shutdown.py | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 3c1a74402..493e37363 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -301,6 +301,22 @@ class Application: async def initialize(self): pass + async def _initialize_plugin_runtime(self) -> None: + try: + await self.plugin_connector.initialize() + except asyncio.CancelledError: + raise + except Exception as exc: + self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}') + self.plugin_connector.schedule_reconnect() + + def _start_plugin_runtime_initialization(self): + return self.task_mgr.create_task( + self._initialize_plugin_runtime(), + name='plugin-runtime-initialization', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + async def run(self): self.event_loop_monitor.start() try: @@ -322,8 +338,6 @@ class Application: name='cloud-manifest-refresh', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) - await self.plugin_connector.initialize_plugins() - # 后续可能会允许动态重启其他任务 # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 async def never_ending(): @@ -348,6 +362,7 @@ class Application: name='http-api-controller', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + self._start_plugin_runtime_initialization() # Telemetry instance heartbeat (startup + daily); respects # space.disable_telemetry via TelemetryManager.send(). diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 83f3b10fd..fed6031fc 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -303,13 +303,6 @@ class BuildAppStage(stage.BootingStage): ) plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) - try: - await plugin_connector_inst.initialize() - except Exception as exc: - # Keep the API/UI available while an external or managed runtime is - # starting, then recover in the background with bounded backoff. - ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}') - plugin_connector_inst.schedule_reconnect() ap.plugin_connector = plugin_connector_inst workspace_service_inst.release_startup_execution_bindings() diff --git a/tests/unit_tests/core/test_app_shutdown.py b/tests/unit_tests/core/test_app_shutdown.py index ad6dcfad6..d810eb052 100644 --- a/tests/unit_tests/core/test_app_shutdown.py +++ b/tests/unit_tests/core/test_app_shutdown.py @@ -144,3 +144,18 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None: assert stats['models']['providers'] == 1 assert stats['runtimes']['plugin_installations'] == 1 assert stats['runtimes']['plugin_runtime_connected'] is True + + +@pytest.mark.asyncio +async def test_start_plugin_runtime_initialization_is_scheduled() -> None: + app = Application() + app.plugin_connector = SimpleNamespace(initialize=AsyncMock()) + captured = {} + app.task_mgr = SimpleNamespace(create_task=lambda coro, **kwargs: captured.update(coro=coro, kwargs=kwargs)) + + app._start_plugin_runtime_initialization() + + assert captured['kwargs']['name'] == 'plugin-runtime-initialization' + assert captured['kwargs']['scopes'] + await captured['coro'] + app.plugin_connector.initialize.assert_awaited_once_with() From 0bec72a3f9702b16069e6a27f9cf12c65a737568 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:53:43 +0000 Subject: [PATCH 31/42] fix(cloud): run runtime initialization outside transaction gate --- src/langbot/pkg/core/app.py | 17 +++++++++-- tests/unit_tests/core/test_app_shutdown.py | 35 +++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 493e37363..79459b706 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -310,12 +310,18 @@ class Application: self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}') self.plugin_connector.schedule_reconnect() - def _start_plugin_runtime_initialization(self): - return self.task_mgr.create_task( + def _start_plugin_runtime_initialization(self) -> asyncio.Task | None: + task = getattr(self, '_plugin_runtime_initialization_task', None) + if task is not None and not task.done(): + return task + # This is application lifecycle work, not a request side effect. It must + # not wait on PersistenceManager's after-commit gate at boot. + task = asyncio.create_task( self._initialize_plugin_runtime(), name='plugin-runtime-initialization', - scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + self._plugin_runtime_initialization_task = task + return task async def run(self): self.event_loop_monitor.start() @@ -544,6 +550,11 @@ class Application: if self.task_mgr is not None: self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION) + plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None) + if plugin_runtime_task is not None and not plugin_runtime_task.done(): + plugin_runtime_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await plugin_runtime_task with contextlib.suppress(Exception): await self.event_loop_monitor.stop() mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None) diff --git a/tests/unit_tests/core/test_app_shutdown.py b/tests/unit_tests/core/test_app_shutdown.py index d810eb052..2c953aaac 100644 --- a/tests/unit_tests/core/test_app_shutdown.py +++ b/tests/unit_tests/core/test_app_shutdown.py @@ -147,15 +147,36 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None: @pytest.mark.asyncio -async def test_start_plugin_runtime_initialization_is_scheduled() -> None: +async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None: app = Application() app.plugin_connector = SimpleNamespace(initialize=AsyncMock()) - captured = {} - app.task_mgr = SimpleNamespace(create_task=lambda coro, **kwargs: captured.update(coro=coro, kwargs=kwargs)) + app.task_mgr = SimpleNamespace(create_task=AsyncMock()) - app._start_plugin_runtime_initialization() + task = app._start_plugin_runtime_initialization() + await task - assert captured['kwargs']['name'] == 'plugin-runtime-initialization' - assert captured['kwargs']['scopes'] - await captured['coro'] app.plugin_connector.initialize.assert_awaited_once_with() + app.task_mgr.create_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None: + app = Application() + app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60)) + app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[]) + app.event_loop_monitor = SimpleNamespace(stop=AsyncMock()) + app.http_ctrl = SimpleNamespace(mcp_mount=None) + app.platform_mgr = None + app.tool_mgr = None + app.model_mgr = None + app.box_service = None + app.plugin_connector = None + app.telemetry = None + app.vector_db_mgr = None + app.storage_mgr = None + app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock()))) + app.deployment = None + + await app.shutdown() + + assert app._plugin_runtime_initialization_task.cancelled() From 6bf1546df20350a6df7f8c24fdcd76165dcb6c2a Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:08:41 +0000 Subject: [PATCH 32/42] style: format Cloud runtime readiness fixes --- src/langbot/pkg/core/app.py | 8 ++++++-- src/langbot/pkg/core/stages/build_app.py | 2 +- src/langbot/pkg/plugin/connector.py | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 79459b706..f1bff1c5b 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -326,10 +326,13 @@ class Application: async def run(self): self.event_loop_monitor.start() try: - if self.directory_projection_service is not None and getattr(self, "directory_projection_task", None) is None: + if ( + self.directory_projection_service is not None + and getattr(self, 'directory_projection_task', None) is None + ): self.directory_projection_task = self.task_mgr.create_task( self.directory_projection_service.run(), - name="cloud-directory-projection", + name='cloud-directory-projection', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) if self.cloud_model_catalog_service is not None: @@ -344,6 +347,7 @@ class Application: name='cloud-manifest-refresh', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + # 后续可能会允许动态重启其他任务 # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 async def never_ending(): diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index fed6031fc..7941a5553 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -298,7 +298,7 @@ class BuildAppStage(stage.BootingStage): # its long-lived tasks, so start the single refresh task here. ap.directory_projection_task = ap.task_mgr.create_task( ap.directory_projection_service.run(), - name="cloud-directory-projection", + name='cloud-directory-projection', scopes=[core_entities.LifecycleControlScope.APPLICATION], ) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index bcdf4fe8e..03893cf4c 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -701,7 +701,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): } self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states}) - reconcile_timeout_seconds = max(300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get("plugin", {}))) + reconcile_timeout_seconds = max( + 300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {})) + ) result = await runtime_handler.reconcile_plugin_installations( tuple(self._known_desired_states.values()), timeout=reconcile_timeout_seconds, @@ -740,7 +742,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if state.binding.installation_uuid in all_states: raise ValueError('Duplicate plugin installation UUID across projected Workspaces') all_states[state.binding.installation_uuid] = state - reconcile_timeout_seconds = max(300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get("plugin", {}))) + reconcile_timeout_seconds = max( + 300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {})) + ) result = await runtime_handler.reconcile_plugin_installations( tuple(all_states.values()), timeout=reconcile_timeout_seconds, From e699358a5a1f4cf88d5fcb6744985d7c90c3747b Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:16:34 +0000 Subject: [PATCH 33/42] ci: publish immutable Core image tags --- .github/workflows/build-dev-image.yaml | 45 ++++++++++++++++++-------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml index 062a72d01..623a9bf99 100644 --- a/.github/workflows/build-dev-image.yaml +++ b/.github/workflows/build-dev-image.yaml @@ -7,23 +7,42 @@ on: jobs: build-dev-image: runs-on: ubuntu-latest - # 如果是tag则跳过 if: ${{ !startsWith(github.ref, 'refs/tags/') }} + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: persist-credentials: false - - name: Generate Tag - id: generate_tag + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate image metadata + id: image + shell: bash run: | - # 获取分支名称,把/替换为- - echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g' - echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g') - - name: Login to Registry - run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} - - name: Build Docker Image - run: | - docker buildx create --name mybuilder --use - docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push + set -euo pipefail + branch_tag="${GITHUB_REF#refs/heads/}" + branch_tag="${branch_tag//\//-}" + echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT" + echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push immutable Core image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + rockchin/langbot:${{ steps.image.outputs.branch_tag }} + rockchin/langbot:${{ steps.image.outputs.sha_tag }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} From 962366c5072d385afb94f9a1088872983c543fa8 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 21 Aug 2026 12:07:36 +0800 Subject: [PATCH 34/42] fix(deps): make SeekDB optional for native installs --- Dockerfile | 2 +- docs/PYPI_INSTALLATION.md | 19 ++++- docs/SEEKDB_INTEGRATION.md | 77 ++++++++----------- pyproject.toml | 6 +- .../modelmgr/requesters/seekdbembed.py | 5 +- src/langbot/pkg/vector/vdbs/seekdb.py | 5 +- src/langbot/templates/config.yaml | 5 ++ .../unit_tests/test_optional_dependencies.py | 15 ++++ .../unit_tests/vector/test_seekdb_optional.py | 34 ++++++++ uv.lock | 13 +++- 10 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 tests/unit_tests/test_optional_dependencies.py create mode 100644 tests/unit_tests/vector/test_seekdb_optional.py diff --git a/Dockerfile b/Dockerfile index c8569fb95..1d1351c72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,7 +62,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends nodejs \ && rm -f /tmp/nodesource_setup.sh \ && python -m pip install --no-cache-dir uv \ - && uv sync \ + && uv sync --extra seekdb \ && apt-get purge -y --auto-remove curl git gnupg \ && rm -rf /var/lib/apt/lists/* \ && touch /.dockerenv diff --git a/docs/PYPI_INSTALLATION.md b/docs/PYPI_INSTALLATION.md index 1144d5cb3..6b55aed59 100644 --- a/docs/PYPI_INSTALLATION.md +++ b/docs/PYPI_INSTALLATION.md @@ -10,6 +10,19 @@ uvx 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 You can also install LangBot as a regular Python package: @@ -20,6 +33,10 @@ pip install langbot # Using uv uv pip install langbot + +# Include optional SeekDB support +pip install 'langbot[seekdb]' +# or: uv pip install 'langbot[seekdb]' ``` Then run it: @@ -101,7 +118,7 @@ uvx langbot ## System Requirements -- Python 3.10.1 or higher +- Python 3.11 or higher (lower than Python 4) - Operating System: Linux, macOS, or Windows ## Differences from Source Installation diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md index b5ae7f9c3..a38eb9f08 100644 --- a/docs/SEEKDB_INTEGRATION.md +++ b/docs/SEEKDB_INTEGRATION.md @@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe ## 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 -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 @@ -30,31 +38,36 @@ pip install pyseekdb | Platform | Status | Notes | |----------|--------|-------| -| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` | -| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | -| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | +| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` | +| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel | +| 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) | Platform | Status | Notes | |----------|--------|-------| | Linux | ✅ Supported | Full Docker support | -| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) | -| Windows | ⚠️ Untested | Should work but not yet tested | - -**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 +| 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 | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation | ### Server Mode (Remote Connection) | 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 @@ -170,22 +183,23 @@ Key methods: ### Import Error -If you see: `ImportError: pyseekdb is not installed` +If you see: `SeekDB support is not installed` Solution: ```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**: ``` 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: 1. Deploy SeekDB on a Linux server or VM @@ -208,29 +222,6 @@ vdb: 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) If SeekDB server is not reachable, check: diff --git a/pyproject.toml b/pyproject.toml index 9e226a9c0..4692b7b94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,6 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "pyseekdb==1.1.0.post3", "langbot-plugin==0.5.5", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", @@ -108,6 +107,11 @@ classifiers = [ "Topic :: Communications :: Chat", ] +[project.optional-dependencies] +seekdb = [ + "pyseekdb==1.1.0.post3", +] + [project.urls] Homepage = "https://langbot.app" Documentation = "https://docs.langbot.app" diff --git a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py index 4b881dbfe..ff7d1d642 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py @@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester): try: import pyseekdb 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() diff --git a/src/langbot/pkg/vector/vdbs/seekdb.py b/src/langbot/pkg/vector/vdbs/seekdb.py index fc82298e0..5be28b458 100644 --- a/src/langbot/pkg/vector/vdbs/seekdb.py +++ b/src/langbot/pkg/vector/vdbs/seekdb.py @@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase): def __init__(self, ap: app.Application): 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 config = self.ap.instance_config.data['vdb']['seekdb'] diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index c21c9b320..ab98d7af8 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -181,6 +181,11 @@ vdb: host: localhost port: 6333 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: mode: embedded # 'embedded' or 'server' # Embedded mode options: diff --git a/tests/unit_tests/test_optional_dependencies.py b/tests/unit_tests/test_optional_dependencies.py new file mode 100644 index 000000000..841344906 --- /dev/null +++ b/tests/unit_tests/test_optional_dependencies.py @@ -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'] diff --git a/tests/unit_tests/vector/test_seekdb_optional.py b/tests/unit_tests/vector/test_seekdb_optional.py new file mode 100644 index 000000000..f30e6ac20 --- /dev/null +++ b/tests/unit_tests/vector/test_seekdb_optional.py @@ -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() diff --git a/uv.lock b/uv.lock index bce5afde2..743cdf599 100644 --- a/uv.lock +++ b/uv.lock @@ -9,10 +9,10 @@ resolution-markers = [ "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.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' 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'", ] @@ -2063,7 +2063,6 @@ dependencies = [ { name = "pymilvus" }, { name = "pynacl" }, { name = "pypdf2" }, - { name = "pyseekdb" }, { name = "python-docx" }, { name = "python-multipart" }, { name = "python-socks" }, @@ -2089,6 +2088,11 @@ dependencies = [ { name = "websockets" }, ] +[package.optional-dependencies] +seekdb = [ + { name = "pyseekdb" }, +] + [package.dev-dependencies] dev = [ { name = "moto" }, @@ -2153,7 +2157,7 @@ requires-dist = [ { name = "pymilvus", specifier = ">=2.6.4" }, { name = "pynacl", specifier = ">=1.5.0" }, { 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-multipart", specifier = ">=0.0.27" }, { 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 = "websockets", specifier = ">=15.0.1" }, ] +provides-extras = ["seekdb"] [package.metadata.requires-dev] dev = [ From 1336f47cb4d9ae91d294ab68bbe563de9c30d699 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 24 Aug 2026 10:28:45 +0800 Subject: [PATCH 35/42] fix(auth): enable local registration for OSS invitations (#2460) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 1 + tests/integration/api/test_smoke.py | 23 +++++ .../integration/api/test_user_space_oauth.py | 23 +++++ web/src/app/infra/http/BackendClient.ts | 1 + web/src/app/invitations/accept/page.tsx | 20 +++- web/tests/e2e/fixtures/langbot-api.ts | 2 + web/tests/e2e/invitations.spec.ts | 96 +++++++++++++++++++ web/tests/e2e/owner-space-billing.spec.ts | 63 ++++++++++++ 8 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 web/tests/e2e/owner-space-billing.spec.ts diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 6b42d4ea3..5568047ca 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup): if cloud_mode: capabilities['password_login_enabled'] = False capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode + capabilities['invitation_registration_enabled'] = not cloud_mode return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index dfd5054f2..642efaf2b 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -307,6 +307,7 @@ class TestUserInitEndpoint: assert data['data'] == { 'initialized': True, 'authenticated_invitation_acceptance_enabled': False, + 'invitation_registration_enabled': True, 'password_login_enabled': True, 'space_login_enabled': False, } @@ -330,6 +331,28 @@ class TestUserInitEndpoint: assert data['data'] == { 'initialized': 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, 'space_login_enabled': True, } diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 9d22e5b3c..be0a9f021 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -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') +@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 async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): application, client = space_oauth_api diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 1bfe1fcf2..07df4bf7c 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1181,6 +1181,7 @@ export class BackendClient extends BaseHttpClient { public getAccountInfo(): Promise<{ initialized: boolean; authenticated_invitation_acceptance_enabled?: boolean; + invitation_registration_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; }> { diff --git a/web/src/app/invitations/accept/page.tsx b/web/src/app/invitations/accept/page.tsx index 0d83331b5..1d9591a54 100644 --- a/web/src/app/invitations/accept/page.tsx +++ b/web/src/app/invitations/accept/page.tsx @@ -91,7 +91,9 @@ export default function AcceptInvitationPage() { const [errorMessage, setErrorMessage] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = + const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] = + useState(false); + const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] = useState(false); const [ authenticatedInvitationAcceptanceEnabled, @@ -116,12 +118,16 @@ export default function AcceptInvitationPage() { backendClient .getAccountInfo() .then((info) => { - setPasswordRegistrationEnabled(info.password_login_enabled !== false); + setInvitationRegistrationEnabled( + info.invitation_registration_enabled ?? + info.password_login_enabled !== false, + ); setAuthenticatedInvitationAcceptanceEnabled( info.authenticated_invitation_acceptance_enabled === true, ); }) - .catch(() => setPasswordRegistrationEnabled(false)); + .catch(() => setInvitationRegistrationEnabled(false)) + .finally(() => setInvitationCapabilitiesLoaded(true)); if (!invitationToken) { setErrorMessage(t('workspace.invitationMissing')); setStatus('error'); @@ -311,7 +317,11 @@ export default function AcceptInvitationPage() { )} - {hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( + {!invitationCapabilitiesLoaded ? ( +
+ +
+ ) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( ) : ( + + + + `, + }); + }, + ); + let pageSdkRequests = 0; + await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => { + pageSdkRequests += 1; + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: `window.langbot = { + api(endpoint, body, method) { + return new Promise((resolve) => { + const requestId = 'request-' + Date.now(); + const handler = (event) => { + if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) { + window.removeEventListener('message', handler); + resolve(event.data.data); + } + }; + window.addEventListener('message', handler); + window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*'); + }); + }, + };`, + }); + }); + let pageApiRequests = 0; + await page.route( + '**/api/v1/plugins/langbot-team/LangRAG/page-api', + async (route) => { + pageApiRequests += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: wrapped({ saved: true }), }); }, ); @@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ '/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability', ); + const pluginFrame = page.frameLocator('iframe'); await expect( - page - .frameLocator('iframe') - .getByRole('heading', { name: 'LangRAG Observability' }), + pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }), ).toBeVisible(); + await pluginFrame.getByRole('button', { name: 'Save' }).click(); + await expect(pluginFrame.locator('body')).toHaveAttribute( + 'data-saved', + 'true', + ); expect(authenticatedAssetRequests).toBeGreaterThan(0); + expect(pageSdkRequests).toBe(1); + expect(pageApiRequests).toBe(1); await expect(page.getByText('Loading...')).toHaveCount(0); }); From a45e27e76e86d053c378b2e6041ee356095274cf Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 24 Aug 2026 14:14:56 +0800 Subject: [PATCH 40/42] style(cloud): redesign workspace invitation email (#2464) * style(cloud): redesign workspace invitation email * fix(email): harden Outlook spacing and text contrast --------- Co-authored-by: Junyan Qin --- .../pkg/workspace/invitation_delivery.py | 92 ++++++++++++++----- .../workspace/test_invitation_delivery.py | 18 ++++ 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py index a3e2e8c86..75f5f5136 100644 --- a/src/langbot/pkg/workspace/invitation_delivery.py +++ b/src/langbot/pkg/workspace/invitation_delivery.py @@ -258,30 +258,80 @@ class InvitationDeliveryService: + Join {escaped_workspace} on LangBot Cloud - +
You have been invited to join {escaped_workspace} on LangBot Cloud.
- - +
- - - - -
-
LangBot Cloud
-
You’re invited
-
-

You have been invited to collaborate in this Workspace:

-
{escaped_workspace}
-
- Accept invitation -
-

This invitation expires in 7 days and is bound to the email address that received it.

-

If the button does not work, copy and paste this URL into your browser:

-

{escaped_link}

-
If you were not expecting this invitation, you can safely ignore this email.
-
+ + +
+ + + + + + + + + + +
+ + LangBot + LangBot Cloud + +
+ + + + +
+
Workspace invitation
+

You’re invited to collaborate

+

Join your team on LangBot Cloud and start building together in this Workspace.

+ + + + + + +
  +
Workspace
+
{escaped_workspace}
+
+ + + +
 
+ + + + + +
+ Accept invitation +
+ + + +
 
+ + + + + +
+

For your security, this invitation expires in 7 days and only works for the email address that received it.

+ Open invitation link → +
+
+
+ Sent by LangBot Cloud
+ If you were not expecting this invitation, you can safely ignore this email. +
+
''' diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py index 34a7f5512..4bfd368d6 100644 --- a/tests/unit_tests/workspace/test_invitation_delivery.py +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -103,3 +103,21 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry assert 'Research & Development' in html assert 'expires in 7 days' in html assert 'lbi_secret&next=<unsafe>' in html + + +async def test_cloud_invitation_email_uses_quiet_cloud_lockup_and_compact_fallback_link(): + service = InvitationDeliveryService(_app({})) + link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret' + + html = service._html("RockChinQ's Workspace", link) + + assert 'https://docs.langbot.app/langbot-logo.png' in html + assert 'LangBot Cloud' in html + assert 'Workspace invitation' in html + assert 'Open invitation link' in html + assert 'linear-gradient' not in html + assert 'box-shadow' not in html + assert 'height="28"' in html + assert 'height="32"' in html + assert 'margin-top:32px' not in html + assert f'>{link}<' not in html From f0ee57c1e0f40c3b90963daadeca86b0b818ad77 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 24 Aug 2026 14:53:59 +0800 Subject: [PATCH 41/42] style(email): use generic LangBot invitation branding (#2466) Co-authored-by: Junyan Qin --- .../pkg/workspace/invitation_delivery.py | 23 +++++----- .../workspace/test_invitation_delivery.py | 43 +++++++++++++++++-- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py index 75f5f5136..c9f4f888c 100644 --- a/src/langbot/pkg/workspace/invitation_delivery.py +++ b/src/langbot/pkg/workspace/invitation_delivery.py @@ -240,7 +240,7 @@ class InvitationDeliveryService: @staticmethod def _plain_text(workspace_name: str, invitation_link: str) -> str: return ( - 'You have been invited to LangBot Cloud\n\n' + 'You have been invited to join a Workspace in LangBot\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 ' @@ -259,34 +259,31 @@ class InvitationDeliveryService: - Join {escaped_workspace} on LangBot Cloud + Join {escaped_workspace} in LangBot -
You have been invited to join {escaped_workspace} on LangBot Cloud.
+
You have been invited to join {escaped_workspace} in LangBot.
-
- - LangBot - LangBot Cloud - + LangBot + LangBot
+
-
Workspace invitation
+
Workspace invitation

You’re invited to collaborate

-

Join your team on LangBot Cloud and start building together in this Workspace.

+

Join your team in LangBot and start building together in this Workspace.

- +
- diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py index 4bfd368d6..91a7136ae 100644 --- a/tests/unit_tests/workspace/test_invitation_delivery.py +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke assert service.capability() == {'enabled': True, 'provider': 'smtp'} -async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy(): +async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy(): service = InvitationDeliveryService(_app({})) link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=' text = service._plain_text('Research & Development', link) html = service._html('Research & Development', link) - assert 'LangBot Cloud' in text + assert 'LangBot' in text + assert 'LangBot Cloud' not in text assert 'Research & Development' in text assert '7 days' in text assert link in text @@ -103,21 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry assert 'Research & Development' in html assert 'expires in 7 days' in html assert 'lbi_secret&next=<unsafe>' in html + assert 'LangBot Cloud' not in html -async def test_cloud_invitation_email_uses_quiet_cloud_lockup_and_compact_fallback_link(): +async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link(): service = InvitationDeliveryService(_app({})) link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret' html = service._html("RockChinQ's Workspace", link) assert 'https://docs.langbot.app/langbot-logo.png' in html - assert 'LangBot Cloud' in html + assert '>LangBot<' in html assert 'Workspace invitation' in html assert 'Open invitation link' in html assert 'linear-gradient' not in html assert 'box-shadow' not in html + assert 'border-top:4px solid' not in html + assert 'border:1px solid #dfe6f0' not in html assert 'height="28"' in html assert 'height="32"' in html assert 'margin-top:32px' not in html assert f'>{link}<' not in html + + +async def test_oss_smtp_configuration_delivers_the_generic_invitation_email(): + service = InvitationDeliveryService( + _app( + { + 'workspace': { + 'invitations': { + 'email': { + 'provider': 'smtp', + 'from': 'LangBot ', + 'smtp': {'host': 'smtp.example.com'}, + } + } + } + } + ) + ) + service._send_smtp = AsyncMock(return_value=True) + link = 'https://self-hosted.example/invitations/accept#token=lbi_secret' + + result = await service.deliver_invitation( + recipient_email='member@example.com', + workspace_name='Self-hosted Workspace', + invitation_link=link, + ) + + assert result == InvitationDeliveryResult(status='sent', provider='smtp') + service._send_smtp.assert_awaited_once() + assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link) + assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link) From 777fe1f20b5929bfa64acb7a61d60c908c4083ba Mon Sep 17 00:00:00 2001 From: leonoxo Date: Tue, 25 Aug 2026 12:30:29 +0800 Subject: [PATCH 42/42] fix(line): use stable source id for session identity (#2398) LINEEventConverter.target2yiri() built Friend.id/Group.id from event.message.id, which is unique per message. Every incoming message therefore mapped to a new session key, so LINE users and groups lost conversation context on every turn. Use event.source.user_id/group_id/room_id instead, matching the stable identifiers other adapters (e.g. Telegram) use for session identity. Falls back to the group/room id when user_id is absent, per LINE's documented behavior for some group/room members. --- src/langbot/pkg/platform/sources/line.py | 14 ++-- .../platform/test_line_session_identity.py | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/platform/test_line_session_identity.py diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py index 496a5ed81..fc4a93019 100644 --- a/src/langbot/pkg/platform/sources/line.py +++ b/src/langbot/pkg/platform/sources/line.py @@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): if event.source.type == 'user': return platform_events.FriendMessage( sender=platform_entities.Friend( - id=event.message.id, + id=event.source.user_id, nickname=event.source.user_id, remark='', ), @@ -110,13 +110,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=event, ) else: + # 'group' and 'room' sources carry the stable chat id under different + # field names; user_id may be absent for some members, so fall back + # to the group/room id rather than the per-message id. + group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id + member_id = event.source.user_id or group_id + return platform_events.GroupMessage( sender=platform_entities.GroupMember( - id=event.event.sender.sender_id.open_id, - member_name=event.event.sender.sender_id.union_id, + id=member_id, + member_name=member_id, permission=platform_entities.Permission.Member, group=platform_entities.Group( - id=event.message.id, + id=group_id, name='', permission=platform_entities.Permission.Member, ), diff --git a/tests/unit_tests/platform/test_line_session_identity.py b/tests/unit_tests/platform/test_line_session_identity.py new file mode 100644 index 000000000..565acbe4b --- /dev/null +++ b/tests/unit_tests/platform/test_line_session_identity.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from linebot.v3.webhooks import TextMessageContent + +from langbot.pkg.platform import botmgr as _botmgr # noqa: F401 +from langbot.pkg.platform.sources import line + + +def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'): + event = MagicMock() + event.timestamp = 1700000000000 + event.message = MagicMock(spec=TextMessageContent) + event.message.id = message_id + event.message.text = text + event.message.webhook_event_id = f'webhook-{message_id}' + event.message.timestamp = event.timestamp + + source = MagicMock() + source.type = source_type + source.user_id = user_id + if group_id is not None: + source.group_id = group_id + if room_id is not None: + source.room_id = room_id + event.source = source + + return event + + +@pytest.mark.asyncio +async def test_user_message_launcher_id_stable_across_messages() -> None: + """Two distinct messages from the same LINE user must resolve to the same + sender id, otherwise every message starts a brand new session (context loss). + """ + event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1') + event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2') + + result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None) + result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) + + assert result1.sender.id == 'U-stable-user' + assert result1.sender.id == result2.sender.id + assert result1.sender.id != event1.message.id + + +@pytest.mark.asyncio +async def test_group_message_uses_group_id_not_message_id() -> None: + event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1') + event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2') + + result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None) + result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) + + assert result1.sender.group.id == 'G-stable-group' + assert result1.sender.group.id == result2.sender.group.id + assert result1.sender.id == 'U-member' + + +@pytest.mark.asyncio +async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None: + event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1') + + result = await line.LINEEventConverter.target2yiri(event, bot_client=None) + + assert result.sender.group.id == 'R-stable-room' + assert result.sender.id == 'R-stable-room'
 
Workspace
{escaped_workspace}
@@ -325,7 +322,7 @@ class InvitationDeliveryService:
- Sent by LangBot Cloud
+ Sent by LangBot
If you were not expecting this invitation, you can safely ignore this email.