diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 76f185b21..d740221f1 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -243,7 +243,11 @@ class AdaptersRouterGroup(group.RouterGroup): snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid) adapters = self.ap.platform_mgr.get_available_adapters_info() if snapshot is not None: - adapters = [a for a in adapters if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))] + adapters = [ + a + for a in adapters + if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name'])) + ] return self.success(data={'adapters': adapters}) @self.route('/', methods=['GET']) diff --git a/src/langbot/pkg/cloud/plan_policy.py b/src/langbot/pkg/cloud/plan_policy.py index 743cf6baa..dc64b32f5 100644 --- a/src/langbot/pkg/cloud/plan_policy.py +++ b/src/langbot/pkg/cloud/plan_policy.py @@ -7,8 +7,8 @@ from ..platform.adapter_names import canonical_adapter_name def platform_feature_names(adapter: str) -> set[str]: canonical = canonical_adapter_name(adapter) names = {adapter, canonical} - if adapter.lower() == "line": - names.update({"line", "LINE"}) + if adapter.lower() == 'line': + names.update({'line', 'LINE'}) if canonical.endswith('-omni'): names.add(canonical[:-5]) names.add(canonical[:-5] + '-eba') diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index f0d68ccef..78c567da5 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -427,7 +427,10 @@ class Application: monitoring_cfg = self.instance_config.data.get('monitoring', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) - monitoring_enabled = auto_cleanup_cfg.get('enabled', True) or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud' + monitoring_enabled = ( + auto_cleanup_cfg.get('enabled', True) + or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud' + ) retention_days = self._get_positive_int_config( auto_cleanup_cfg.get('retention_days', 30), default=30, diff --git a/tests/integration/api/test_box_security.py b/tests/integration/api/test_box_security.py index 0a40f230a..a0409d908 100644 --- a/tests/integration/api/test_box_security.py +++ b/tests/integration/api/test_box_security.py @@ -35,6 +35,7 @@ async def box_security_api(): 'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'), } application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.persistence_mgr = SimpleNamespace(tenant_uow=None) application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token]) diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index 52442e371..2f773595a 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -88,6 +88,7 @@ async def plugin_security_api(plugin_module): } application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token]) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index df72d9084..bedd1d871 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -329,7 +329,7 @@ class TestModelEndpoints: assert response.status_code == 400 data = await response.get_json() assert data['code'] == 'model_test_failed' - assert data['msg'] == '模型请求失败: API key invalid' + assert data['msg'] == 'Model request failed: API key invalid' @pytest.mark.usefixtures('mock_circular_import_chain') diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py index baafe1f18..af4f21722 100644 --- a/tests/integration/api/test_skills_entitlements.py +++ b/tests/integration/api/test_skills_entitlements.py @@ -27,6 +27,7 @@ async def skills_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.persistence_mgr = SimpleNamespace(tenant_uow=None) application.user_service.get_authenticated_account = AsyncMock(return_value=account) @@ -76,4 +77,6 @@ async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api): }, ) - assert response.status_code == 500 + assert response.status_code == 403 + payload = await response.get_json() + assert payload['code'] == 'entitlement_unavailable' diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index e71cb4374..dd578794f 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -24,6 +24,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService from langbot.pkg.cloud.support_admin import SupportAdminSessionService from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession from langbot.pkg.entity.persistence.user import User from langbot.pkg.entity.persistence.workspace import ( @@ -124,6 +125,9 @@ class _TenantUow: self._transaction = await self.session.begin() return self + async def execute(self, statement): + return await self.session.execute(statement) + async def __aexit__(self, exc_type, exc, traceback): try: if exc_type is None: @@ -173,6 +177,7 @@ async def support_admin_api(tmp_path): tables=[ User.__table__, Workspace.__table__, + WorkspaceMetadata.__table__, WorkspaceExecutionState.__table__, WorkspaceMembership.__table__, SupportAdminTemporarySession.__table__, diff --git a/tests/integration/api/test_totp_workspace_scope.py b/tests/integration/api/test_totp_workspace_scope.py index 460dcbbb2..4f822062b 100644 --- a/tests/integration/api/test_totp_workspace_scope.py +++ b/tests/integration/api/test_totp_workspace_scope.py @@ -46,6 +46,7 @@ async def _resolve(account_uuid: str, _workspace_uuid: str | None) -> SimpleName async def totp_admin_api(): accounts = {'member-token': SimpleNamespace(uuid=MEMBER_UUID, user='member@example.com')} application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.instance_config.data = {'system': {'allow_modify_login_info': True}} application.persistence_mgr = SimpleNamespace(tenant_uow=None) diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index af2bab140..ad49c83a6 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -27,6 +27,7 @@ async def space_oauth_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss') application.directory_projection_service = None application.persistence_mgr = None diff --git a/tests/unit_tests/platform/test_botmgr_execution_trace.py b/tests/unit_tests/platform/test_botmgr_execution_trace.py index 2002f3045..1d805f77f 100644 --- a/tests/unit_tests/platform/test_botmgr_execution_trace.py +++ b/tests/unit_tests/platform/test_botmgr_execution_trace.py @@ -39,6 +39,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None): bot = object.__new__(RuntimeBot) bot.bot_entity = SimpleNamespace( uuid='bot-1', + adapter='aiocqhttp', workspace_uuid=TEST_CONTEXT.workspace_uuid, event_bindings=event_bindings, plugin_processors=[], @@ -49,6 +50,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None): bot.logger = SimpleNamespace(info=AsyncMock(), warning=AsyncMock(), error=AsyncMock(), debug=AsyncMock()) bot.adapter = FakeAdapter() bot.ap = SimpleNamespace( + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()), telemetry=SimpleNamespace(execution=counters), agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=agent)), pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(return_value=None)), @@ -505,11 +507,13 @@ async def test_pending_event_cancelled_during_dispatch_never_uploads(): bot._dispatch_eba_event_to_processor = waiting_dispatch task = asyncio.create_task(bot._handle_platform_event(message_received_event(), FakeAdapter())) - await started.wait() - await counters.flush() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task + try: + await asyncio.wait_for(started.wait(), timeout=5) + await counters.flush() + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task await assert_no_upload(counters, manager) diff --git a/web/src/app/home/agents/components/AgentApiToolPicker.tsx b/web/src/app/home/agents/components/AgentApiToolPicker.tsx index 812e739ca..ca32fe68a 100644 --- a/web/src/app/home/agents/components/AgentApiToolPicker.tsx +++ b/web/src/app/home/agents/components/AgentApiToolPicker.tsx @@ -319,8 +319,7 @@ export default function AgentApiToolPicker({ const expanded = expandedTool === tool.key; const parameterNames = Object.keys( (tool.parameters.properties as - | Record - | undefined) ?? {}, + Record | undefined) ?? {}, ); return (
diff --git a/web/src/app/home/agents/runner-marketplace.ts b/web/src/app/home/agents/runner-marketplace.ts index 328d68029..ebec6c3e9 100644 --- a/web/src/app/home/agents/runner-marketplace.ts +++ b/web/src/app/home/agents/runner-marketplace.ts @@ -20,9 +20,7 @@ const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-runner-install'; const RUNNER_INSTALL_INTENT_EVENT = 'langbot-runner-install-change'; export type RunnerMarketplaceErrorCode = - | 'version-unavailable' - | 'install-timeout' - | 'registration-timeout'; + 'version-unavailable' | 'install-timeout' | 'registration-timeout'; export class RunnerMarketplaceError extends Error { constructor(public readonly code: RunnerMarketplaceErrorCode) { diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 0d036c221..0ae7d6dff 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -498,8 +498,7 @@ const BotForm = forwardRef(function BotForm( .then((res) => { const bot = res.bot; const runtimeValues = bot.adapter_runtime_values as - | Record - | undefined; + Record | undefined; resolve({ adapter: bot.adapter, description: bot.description, @@ -509,11 +508,9 @@ const BotForm = forwardRef(function BotForm( event_bindings: bot.event_bindings ?? [], plugin_processors: bot.plugin_processors ?? [], webhook_full_url: runtimeValues?.webhook_full_url as - | string - | undefined, + string | undefined, extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as - | string - | undefined, + string | undefined, }); }) .catch((err) => { diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index f8b18954f..06fcae3d7 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -144,17 +144,9 @@ interface EventBindingsEditorProps { } type FilterField = - | 'chat_type' - | 'chat_id' - | 'message_text' - | 'message_element_types'; + 'chat_type' | 'chat_id' | 'message_text' | 'message_element_types'; type FilterOperator = - | 'eq' - | 'neq' - | 'contains' - | 'not_contains' - | 'starts_with' - | 'regex'; + 'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'regex'; interface FilterRow { field: FilterField; diff --git a/web/src/app/home/components/dynamic-form/StructuredFieldValue.ts b/web/src/app/home/components/dynamic-form/StructuredFieldValue.ts index 09a4c34e0..6e4415452 100644 --- a/web/src/app/home/components/dynamic-form/StructuredFieldValue.ts +++ b/web/src/app/home/components/dynamic-form/StructuredFieldValue.ts @@ -1,10 +1,5 @@ export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { [key: string]: JsonValue }; + null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; export type PromptMessage = { role: string; content?: string | null | JsonValue[]; diff --git a/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx index df729d634..4b06b991c 100644 --- a/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx +++ b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx @@ -204,19 +204,17 @@ function buildToolSourceGroups( (rightIndex === -1 ? TOOL_SOURCE_ORDER.length : rightIndex) ); }) - .map( - ([source, providerGroups]): ToolSourceGroup => ({ - key: source, - label: sourceLabels[source] || source, - groups: Array.from(providerGroups.values()).sort((left, right) => - left.name.localeCompare(right.name), - ), - total: Array.from(providerGroups.values()).reduce( - (count, group) => count + group.tools.length, - 0, - ), - }), - ); + .map(([source, providerGroups]): ToolSourceGroup => ({ + key: source, + label: sourceLabels[source] || source, + groups: Array.from(providerGroups.values()).sort((left, right) => + left.name.localeCompare(right.name), + ), + total: Array.from(providerGroups.values()).reduce( + (count, group) => count + group.tools.length, + 0, + ), + })); } function buildMCPToolOwners(servers: MCPServer[]) { diff --git a/web/src/app/home/components/guided-tour/TourOverlay.tsx b/web/src/app/home/components/guided-tour/TourOverlay.tsx index e1a05476f..a52b542a9 100644 --- a/web/src/app/home/components/guided-tour/TourOverlay.tsx +++ b/web/src/app/home/components/guided-tour/TourOverlay.tsx @@ -45,6 +45,12 @@ export function getTourPosition( ? rect.bottom + VIEWPORT_GAP : rect.top - height - VIEWPORT_GAP; } + // The mobile sidebar can still be sliding in when its target is measured. + // Clamp the card independently of that temporarily off-screen target. + left = Math.max( + VIEWPORT_GAP, + Math.min(left, viewportWidth - width - VIEWPORT_GAP), + ); top = Math.max( VIEWPORT_GAP, Math.min(top, viewportHeight - height - VIEWPORT_GAP), diff --git a/web/src/app/home/components/models-dialog/types.ts b/web/src/app/home/components/models-dialog/types.ts index f8f72e0b1..6b4eaf614 100644 --- a/web/src/app/home/components/models-dialog/types.ts +++ b/web/src/app/home/components/models-dialog/types.ts @@ -28,8 +28,7 @@ export interface ProviderModels { } export type TestResult = - | { success: true; duration: number } - | { success: false; message: string }; + { success: true; duration: number } | { success: false; message: string }; export type SelectedScannedModel = { model: ScannedProviderModel; diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 1a095bdbd..0be94ce32 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -19,11 +19,7 @@ import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; import { getBackendBaseUrl } from '@/app/infra/http/backendUrl'; export type QrLoginPlatform = - | 'feishu' - | 'weixin' - | 'dingtalk' - | 'wecombot' - | 'qqofficial'; + 'feishu' | 'weixin' | 'dingtalk' | 'wecombot' | 'qqofficial'; interface PlatformConfig { titleKey: string; diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index b2b3dac30..7940d4873 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -495,8 +495,7 @@ export default function OperationTracePanel({ }); const response = await backendClient.downloadFile(url); const disposition = response.headers['content-disposition'] as - | string - | undefined; + string | undefined; const match = disposition?.match(/filename="?([^";\n]+)"?/); const filename = match?.[1] ?? `operation-logs-${Date.now()}.csv`; const blob = new Blob([response.data], { diff --git a/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts b/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts index 2abd6ee3f..7850026a2 100644 --- a/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts +++ b/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts @@ -13,9 +13,7 @@ const INSTALL_INTENT_KEY_PREFIX = 'langbot-knowledge-engine-install'; const INSTALL_INTENT_EVENT = 'langbot-knowledge-engine-install-change'; export type KnowledgeEngineMarketplaceErrorCode = - | 'version-unavailable' - | 'install-timeout' - | 'registration-timeout'; + 'version-unavailable' | 'install-timeout' | 'registration-timeout'; export class KnowledgeEngineMarketplaceError extends Error { constructor(public readonly code: KnowledgeEngineMarketplaceErrorCode) { diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index 9403febf2..66cb0607f 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -31,11 +31,7 @@ import { useCurrentWorkspace } from '@/app/infra/http'; type MCPRuntimeState = 'connected' | 'connecting' | 'error'; type MCPConnectionState = - | 'connected' - | 'connecting' - | 'error' - | 'disabled' - | 'disconnected'; + 'connected' | 'connecting' | 'error' | 'disabled' | 'disconnected'; export default function MCPDetailContent({ id }: { id: string }) { const isCreateMode = id === 'new'; diff --git a/web/src/app/home/monitoring/utils/callLinks.ts b/web/src/app/home/monitoring/utils/callLinks.ts index 4872a20df..91d29e6ce 100644 --- a/web/src/app/home/monitoring/utils/callLinks.ts +++ b/web/src/app/home/monitoring/utils/callLinks.ts @@ -12,8 +12,7 @@ export interface CallLinkSource { /** A record the dashboard can open for a recorded model or tool call. */ export type CallTarget = - | { kind: 'execution'; row: ExecutionRow } - | { kind: 'message'; id: string }; + { kind: 'execution'; row: ExecutionRow } | { kind: 'message'; id: string }; /** Calls are matched against a run's window; clock skew must not lose the link. */ const WINDOW_SLACK_MS = 2000; diff --git a/web/src/app/infra/entities/operation-log.ts b/web/src/app/infra/entities/operation-log.ts index 651b2f957..f9b0b618a 100644 --- a/web/src/app/infra/entities/operation-log.ts +++ b/web/src/app/infra/entities/operation-log.ts @@ -73,10 +73,7 @@ export interface OperationLogRecord { /** Verification filter accepted by the listing endpoint. */ export type OperationIntegrityFilter = - | 'all' - | 'issues' - | 'hash_mismatch' - | 'chain_broken'; + 'all' | 'issues' | 'hash_mismatch' | 'chain_broken'; export interface OperationLogPage { records: OperationLogRecord[]; diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts index 8ce39485e..f08d7ab75 100644 --- a/web/src/app/infra/entities/workspace.ts +++ b/web/src/app/infra/entities/workspace.ts @@ -1,9 +1,5 @@ export type WorkspaceRole = - | 'owner' - | 'admin' - | 'developer' - | 'operator' - | 'viewer'; + 'owner' | 'admin' | 'developer' | 'operator' | 'viewer'; export interface Workspace { uuid: string; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 6083526ec..0409568f0 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -391,8 +391,7 @@ export default function WizardPage() { // Restore webhook URLs const runtimeValues = botData.bot.adapter_runtime_values as - | Record - | undefined; + Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', @@ -735,8 +734,7 @@ export default function WizardPage() { try { const botData = await httpClient.getBot(resp.uuid); const runtimeValues = botData.bot.adapter_runtime_values as - | Record - | undefined; + Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', @@ -902,8 +900,7 @@ export default function WizardPage() { try { const botData = await httpClient.getBot(createdBotUuid); const runtimeValues = botData.bot.adapter_runtime_values as - | Record - | undefined; + Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 3d53b82f5..f85452893 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2566,8 +2566,7 @@ const enUS = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index eefc6e6fa..2584471c9 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2617,8 +2617,7 @@ const esES = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 83b3f7eb1..fd2cbca68 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2589,8 +2589,7 @@ const ruRU = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 68b41a341..05391e114 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2510,8 +2510,7 @@ const thTH = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index ff79fe340..86b99a65c 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2548,8 +2548,7 @@ const viVN = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9ae41a3d5..b50310112 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2406,8 +2406,7 @@ const zhHans = { upgradePlan: '切换或升级计划', ossSingletonDescription: '当前自托管实例只有一个工作区,但可以包含多个用户。', - cloudManagedDescription: - '此工作区托管于 LangBot Cloud。', + cloudManagedDescription: '此工作区托管于 LangBot Cloud。', loadFailed: '加载工作区信息失败', members: '成员', you: '你', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index fca0b0b26..5b881d246 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2404,8 +2404,7 @@ const zhHant = { upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', - cloudManagedDescription: - 'This Workspace is hosted by LangBot Cloud.', + cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 32e0fb207..f73ce5675 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -272,8 +272,7 @@ function executionText(raw: unknown): string { /** Derive the unified execution list from a mocked monitoring payload. */ export function buildExecutionList(monitoringData: unknown) { const payload = monitoringData as - | { messages?: RawMonitoringMessage[] } - | undefined; + { messages?: RawMonitoringMessage[] } | undefined; const items = (payload?.messages ?? []) .filter((message) => message.role !== 'assistant') .map((message) => { diff --git a/web/tests/unit/embed-widget-assistant-dedupe.test.mjs b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs index b15d00a2c..51f93aee4 100644 --- a/web/tests/unit/embed-widget-assistant-dedupe.test.mjs +++ b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs @@ -129,7 +129,7 @@ function launchWidget() { FakeWebSocket.instances = []; const document = new FakeDocument(); const window = { - location: {href: "https://embed.test/"}, + location: { href: 'https://embed.test/' }, crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' }, sessionStorage: { getItem: () => null, setItem: () => {} }, }; diff --git a/web/tests/unit/websocket-client.test.mjs b/web/tests/unit/websocket-client.test.mjs index 6f6a1f1a7..c89e7d7f4 100644 --- a/web/tests/unit/websocket-client.test.mjs +++ b/web/tests/unit/websocket-client.test.mjs @@ -2,51 +2,174 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import fs from 'node:fs'; import ts from 'typescript'; -function load(file,deps={},globals={}) { - const source=fs.readFileSync(new URL('../../src/app/infra/websocket/'+file,import.meta.url),'utf8').replaceAll('import.meta.env.VITE_API_BASE_URL','undefined'); - const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; - const m={exports:{}};new Function('require','module','exports',...Object.keys(globals),code)(id=>deps[id],m,m.exports,...Object.values(globals));return m.exports; +function load(file, deps = {}, globals = {}) { + const source = fs + .readFileSync( + new URL('../../src/app/infra/websocket/' + file, import.meta.url), + 'utf8', + ) + .replaceAll('import.meta.env.VITE_API_BASE_URL', 'undefined'); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const m = { exports: {} }; + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (id) => deps[id], + m, + m.exports, + ...Object.values(globals), + ); + return m.exports; } -const urls=load('url.ts'); -test('OSS and Cloud URLs follow HTTP backend scheme, port and prefix',()=>{ - for(const [base,page,expected] of [ - [undefined,'http://localhost:3000/home','ws://localhost:3000/api/ws'], - [undefined,'https://cloud.test/home','wss://cloud.test/api/ws'], - ['http://localhost:5300','http://localhost:3000','ws://localhost:5300/api/ws'], - ['https://api.test/root/','https://ui.test','wss://api.test/root/api/ws'], - ['/backend/','https://ui.test/home','wss://ui.test/backend/api/ws'], - ['https://api.test','http://ui.test','wss://api.test/api/ws'], - ]) assert.equal(urls.backendWebSocketURL(base,page,'/api/ws').toString(),expected); +const urls = load('url.ts'); +test('OSS and Cloud URLs follow HTTP backend scheme, port and prefix', () => { + for (const [base, page, expected] of [ + [undefined, 'http://localhost:3000/home', 'ws://localhost:3000/api/ws'], + [undefined, 'https://cloud.test/home', 'wss://cloud.test/api/ws'], + [ + 'http://localhost:5300', + 'http://localhost:3000', + 'ws://localhost:5300/api/ws', + ], + ['https://api.test/root/', 'https://ui.test', 'wss://api.test/root/api/ws'], + ['/backend/', 'https://ui.test/home', 'wss://ui.test/backend/api/ws'], + ['https://api.test', 'http://ui.test', 'wss://api.test/api/ws'], + ]) + assert.equal( + urls.backendWebSocketURL(base, page, '/api/ws').toString(), + expected, + ); }); -function setup(){ - let workspace='one',next=0;const timers=new Map(),win=new EventTarget(),doc=new EventTarget();win.location={href:'https://cloud.test/home'};doc.visibilityState='visible'; - class WS{ - static CONNECTING=0;static OPEN=1;static instances=[];readyState=0; - constructor(url){this.url=url;WS.instances.push(this)} - send(p){this.sent=JSON.parse(p)} close(){this.readyState=3} closed(){this.readyState=3;this.onclose?.()} - open(){this.readyState=1;this.onopen?.()} receive(data){this.onmessage?.({data:JSON.stringify(data)})} - } - const {WebSocketClient}=load('WebSocketClient.ts',{'./url':urls,'@/app/infra/http/workspaceContext':{getActiveWorkspaceUuid:()=>workspace}},{ - window:win,document:doc,WebSocket:WS,localStorage:{getItem:()=> 'token'}, - setTimeout:fn=>{timers.set(++next,fn);return next},clearTimeout:id=>timers.delete(id),setInterval:()=>1,clearInterval:()=>{},console:{warn(){},error(){},debug(){}}, - }); - return {client:new WebSocketClient('pipeline'),WS,win,timers,setWorkspace:v=>workspace=v}; +function setup() { + let workspace = 'one', + next = 0; + const timers = new Map(), + win = new EventTarget(), + doc = new EventTarget(); + win.location = { href: 'https://cloud.test/home' }; + doc.visibilityState = 'visible'; + class WS { + static CONNECTING = 0; + static OPEN = 1; + static instances = []; + readyState = 0; + constructor(url) { + this.url = url; + WS.instances.push(this); + } + send(p) { + this.sent = JSON.parse(p); + } + close() { + this.readyState = 3; + } + closed() { + this.readyState = 3; + this.onclose?.(); + } + open() { + this.readyState = 1; + this.onopen?.(); + } + receive(data) { + this.onmessage?.({ data: JSON.stringify(data) }); + } + } + const { WebSocketClient } = load( + 'WebSocketClient.ts', + { + './url': urls, + '@/app/infra/http/workspaceContext': { + getActiveWorkspaceUuid: () => workspace, + }, + }, + { + window: win, + document: doc, + WebSocket: WS, + localStorage: { getItem: () => 'token' }, + setTimeout: (fn) => { + timers.set(++next, fn); + return next; + }, + clearTimeout: (id) => timers.delete(id), + setInterval: () => 1, + clearInterval: () => {}, + console: { warn() {}, error() {}, debug() {} }, + }, + ); + return { + client: new WebSocketClient('pipeline'), + WS, + win, + timers, + setWorkspace: (v) => (workspace = v), + }; } -test('only authenticated sockets are connected; disposal removes recovery',async()=>{ - const {client,WS,win}=setup(),pending=client.connect(),ws=WS.instances[0];ws.open();assert.equal(client.isConnected(),false);assert.equal(ws.sent.workspace_uuid,'one'); - ws.receive({type:'connected',connection_id:'id'});assert.equal(await pending,'id');assert(client.isConnected());client.disconnect();ws.closed();win.dispatchEvent(new Event('focus'));assert.equal(WS.instances.length,1); +test('only authenticated sockets are connected; disposal removes recovery', async () => { + const { client, WS, win } = setup(), + pending = client.connect(), + ws = WS.instances[0]; + ws.open(); + assert.equal(client.isConnected(), false); + assert.equal(ws.sent.workspace_uuid, 'one'); + ws.receive({ type: 'connected', connection_id: 'id' }); + assert.equal(await pending, 'id'); + assert(client.isConnected()); + client.disconnect(); + ws.closed(); + win.dispatchEvent(new Event('focus')); + assert.equal(WS.instances.length, 1); }); -test('closed handshakes settle and transient retries survive more than five failures',async()=>{ - const {client,WS,timers}=setup();const first=client.connect().catch(()=>{});WS.instances[0].closed();await first; - for(let i=0;i<7;i++){const [id,fn]=[...timers][0];timers.delete(id);fn();WS.instances.at(-1).closed();await Promise.resolve()} - assert.equal(WS.instances.length,8);assert.equal(timers.size,1);client.disconnect();assert.equal(timers.size,0); +test('closed handshakes settle and transient retries survive more than five failures', async () => { + const { client, WS, timers } = setup(); + const first = client.connect().catch(() => {}); + WS.instances[0].closed(); + await first; + for (let i = 0; i < 7; i++) { + const [id, fn] = [...timers][0]; + timers.delete(id); + fn(); + WS.instances.at(-1).closed(); + await Promise.resolve(); + } + assert.equal(WS.instances.length, 8); + assert.equal(timers.size, 1); + client.disconnect(); + assert.equal(timers.size, 0); }); -test('stale closes do not affect replacement sockets and workspace changes block recovery',async()=>{ - const {client,WS,win,setWorkspace}=setup();const pending=client.connect().catch(()=>{}),old=WS.instances[0];client.disconnect(); - const current=client.connect(),ws=WS.instances[1];old.closed();await pending;ws.open();ws.receive({type:'connected',connection_id:'new'});await current; - assert(client.isConnected());setWorkspace('two');ws.closed();win.dispatchEvent(new Event('focus'));assert.equal(WS.instances.length,2);client.disconnect(); +test('stale closes do not affect replacement sockets and workspace changes block recovery', async () => { + const { client, WS, win, setWorkspace } = setup(); + const pending = client.connect().catch(() => {}), + old = WS.instances[0]; + client.disconnect(); + const current = client.connect(), + ws = WS.instances[1]; + old.closed(); + await pending; + ws.open(); + ws.receive({ type: 'connected', connection_id: 'new' }); + await current; + assert(client.isConnected()); + setWorkspace('two'); + ws.closed(); + win.dispatchEvent(new Event('focus')); + assert.equal(WS.instances.length, 2); + client.disconnect(); }); -test('authentication rejection does not reconnect',async()=>{ - const {client,WS,timers,win}=setup();const pending=client.connect().catch(()=>{}),ws=WS.instances[0];ws.open();ws.receive({type:'error',message:'Unauthorized'});ws.closed();await pending; - win.dispatchEvent(new Event('focus'));assert.equal(timers.size,0);assert.equal(WS.instances.length,1);client.disconnect(); +test('authentication rejection does not reconnect', async () => { + const { client, WS, timers, win } = setup(); + const pending = client.connect().catch(() => {}), + ws = WS.instances[0]; + ws.open(); + ws.receive({ type: 'error', message: 'Unauthorized' }); + ws.closed(); + await pending; + win.dispatchEvent(new Event('focus')); + assert.equal(timers.size, 0); + assert.equal(WS.instances.length, 1); + client.disconnect(); });