From 7186fa3c5d0e06db43d169f7decf485feb2026a2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 21 Apr 2026 13:48:48 +0800 Subject: [PATCH] refactor(account): optimize bridge state flow and align with production vhost routing --- ...ccount-sync-settings-bridge-state-model.md | 57 ++++++------ lib/app/app_controller_desktop_core.dart | 10 +++ ...pp_controller_desktop_runtime_helpers.dart | 33 +++---- ...pp_controller_desktop_thread_sessions.dart | 88 ++++++++++++++----- lib/runtime/acp_endpoint_paths.dart | 2 +- lib/runtime/runtime_models_account.dart | 4 +- .../assistant_connection_status_test.dart | 40 +++++++++ 7 files changed, 169 insertions(+), 65 deletions(-) diff --git a/docs/architecture/account-sync-settings-bridge-state-model.md b/docs/architecture/account-sync-settings-bridge-state-model.md index 64872df4..6e667f3e 100644 --- a/docs/architecture/account-sync-settings-bridge-state-model.md +++ b/docs/architecture/account-sync-settings-bridge-state-model.md @@ -43,39 +43,46 @@ flowchart TD ```mermaid stateDiagram-v2 - [*] --> SignedOut + [*] --> SignedOut: App Start / Logout - SignedOut: no account session - SignedOut --> SignedOut: do not send\nno fallback\nno stale token read - SignedOut --> Syncing: svc.plus login + state SignedOut { + [*] --> NoSession: No token / No endpoint + NoSession: 不能连接 / 不能发送 / 不读旧Secret + } - Syncing: sync bridge config after login - Syncing --> SyncBlocked: missing BRIDGE_AUTH_TOKEN\nor sync failed - Syncing --> BridgeDiscovering: bridge URL + token synced + SignedOut --> Syncing: Login (svc.plus) + Syncing --> SyncBlocked: Sync Error / Token Missing / Endpoint Missing + Syncing --> BridgeDiscovering: URL + Token available + + state SyncBlocked { + [*] --> BlockedError: Status Error + BlockedError: 显示具体错误 / 不可发送 + } - SyncBlocked: signed in but bridge unavailable - SyncBlocked --> Syncing: user syncs again - SyncBlocked --> SignedOut: logout clears session/token/catalog + SyncBlocked --> Syncing: Retry Sync / Refresh Session - BridgeDiscovering: load acp.capabilities from /acp/rpc - BridgeDiscovering --> SyncBlocked: 401/403/token missing\nor endpoint missing - BridgeDiscovering --> BridgeReady: providerCatalog/gatewayProviders valid + state BridgeDiscovering { + [*] --> RefreshingCapabilities: Load /acp/rpc + RefreshingCapabilities: Catalog is empty / 不可发送 + } - BridgeReady: assistant can send - BridgeReady --> ProviderDispatch: user submits message - BridgeReady --> SignedOut: logout clears session/token/catalog + BridgeDiscovering --> BridgeReady: Catalog valid (providers found) + BridgeDiscovering --> SyncBlocked: 401 Unauthorized / Connection Error - ProviderDispatch: resolve endpoint by selected provider - ProviderDispatch --> AgentEndpoint: Hermes/Codex/Gemini/OpenCode - ProviderDispatch --> GatewayEndpoint: OpenClaw Gateway + state BridgeReady { + [*] --> Connected: Catalog populated + Connected: 允许发送对话 + Connected --> ProviderRouting: User message + } - AgentEndpoint: /acp-server/{provider}/acp/rpc - GatewayEndpoint: /gateway/openclaw/acp/rpc + state ProviderRouting { + Gateway: /gateway/openclaw/acp/rpc + Agent: /acp-server/{id}/acp/rpc + } - AgentEndpoint --> BridgeReady: result returned - GatewayEndpoint --> BridgeReady: result returned - AgentEndpoint --> SyncBlocked: auth failure - GatewayEndpoint --> SyncBlocked: auth failure + BridgeReady --> SignedOut: Logout + SyncBlocked --> SignedOut: Logout + BridgeDiscovering --> SignedOut: Logout ``` ## Field Semantics diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index bb857250..9899b44e 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -598,6 +598,16 @@ class AppController extends ChangeNotifier { for (final p in catalog) { if (p.providerId == normalizedId) return p; } + + // If not in catalog but we have an ID, return a synthetic provider to allow routing + return SingleAgentProvider( + providerId: normalizedId, + label: providerFallbackLabelInternal(normalizedId), + badge: providerFallbackBadgeInternal( + providerId: normalizedId, + label: providerFallbackLabelInternal(normalizedId), + ), + ); } return (defaultToCatalog && catalog.isNotEmpty) ? catalog.first diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 084442b0..700bc504 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -638,6 +638,13 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Uri? resolveBridgeAcpEndpointInternal() { final modeConfig = settings.acpBridgeServerModeConfig; + // Prioritize BRIDGE_SERVER_URL from environment or override + final envEndpoint = runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL'); + if (envEndpoint != null && isSupportedExternalAcpEndpoint(envEndpoint)) { + final uri = Uri.tryParse(envEndpoint); + if (uri != null) return uri.replace(query: null, fragment: null); + } + // Prioritize the cloud endpoint if available or if we're connected to svc.plus final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal(); if (cloudEndpoint.isNotEmpty) { @@ -699,12 +706,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : ''; } - // Fallback: If we are logged in with an svc.plus account, default to the known bridge URL. - if (settings.accountUsername.endsWith('@svc.plus') || - settings.accountBaseUrl.contains('svc.plus')) { - return 'https://xworkmate-bridge.svc.plus'; - } - return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : ''; } @@ -732,6 +733,11 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedHost == bridgeHost && (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { + final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); + if (envToken != null && envToken.isNotEmpty) { + return envToken; + } + final modeConfig = settings.acpBridgeServerModeConfig; if (modeConfig.usesSelfHostedBase) { final manualToken = await settingsControllerInternal @@ -741,15 +747,12 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } return null; } - final syncState = settingsControllerInternal.accountSyncState; - if (syncState?.syncState.trim().toLowerCase() == 'ready' && - syncState?.tokenConfigured.bridge == true) { - final bridgeToken = (await storeInternal.loadAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - ))?.trim(); - if (bridgeToken?.isNotEmpty == true) { - return bridgeToken; - } + + final bridgeToken = (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ))?.trim(); + if (bridgeToken?.isNotEmpty == true) { + return bridgeToken; } } return null; diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index db811758..05cedd5e 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -53,6 +53,8 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ required bool bridgeReady, required String bridgeLabel, required AccountSyncState? accountSyncState, + required bool accountSignedIn, + required bool bridgeConfigured, }) { if (bridgeReady) { return AssistantThreadConnectionState( @@ -66,36 +68,75 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ ); } + if (!accountSignedIn) { + return AssistantThreadConnectionState( + executionTarget: target, + status: RuntimeConnectionStatus.offline, + primaryLabel: appText('已退出登录', 'Signed out'), + detailLabel: appText('请先登录 svc.plus', 'Please sign in to svc.plus first'), + ready: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + final syncState = accountSyncState?.syncState.trim().toLowerCase() ?? ''; final syncMessage = accountSyncState?.syncMessage.trim() ?? ''; final tokenMissing = syncMessage == 'Bridge authorization is unavailable'; final endpointMissing = syncMessage == 'Bridge endpoint is unavailable'; final blocked = syncState == 'blocked'; final failed = blocked && !tokenMissing && !endpointMissing; - final status = tokenMissing || failed - ? RuntimeConnectionStatus.error - : RuntimeConnectionStatus.offline; - final primaryLabel = tokenMissing - ? appText('缺少令牌', 'Missing Token') - : failed - ? appText('连接失败', 'Connection Failed') - : status.label; - final detailLabel = tokenMissing - ? appText( - 'xworkmate-bridge 授权不可用', - 'xworkmate-bridge authorization unavailable', - ) - : failed - ? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed') - : appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'); + + // SyncBlocked logic + if (tokenMissing || failed || blocked) { + final status = RuntimeConnectionStatus.error; + final primaryLabel = tokenMissing + ? appText('缺少令牌', 'Missing Token') + : failed + ? appText('连接失败', 'Connection Failed') + : status.label; + final detailLabel = tokenMissing + ? appText( + 'xworkmate-bridge 授权不可用', + 'xworkmate-bridge authorization unavailable', + ) + : failed + ? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed') + : appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'); + return AssistantThreadConnectionState( + executionTarget: target, + status: status, + primaryLabel: primaryLabel, + detailLabel: detailLabel, + ready: false, + gatewayTokenMissing: tokenMissing, + lastError: failed ? syncMessage : null, + ); + } + + // BridgeDiscovering logic (Signed in, not blocked, but not ready yet) + if (bridgeConfigured) { + return AssistantThreadConnectionState( + executionTarget: target, + status: RuntimeConnectionStatus.offline, + primaryLabel: appText('正在发现', 'Discovering'), + detailLabel: + appText('正在加载 Bridge 能力...', 'Loading Bridge capabilities...'), + ready: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + + // Default Offline/Unconnected return AssistantThreadConnectionState( executionTarget: target, - status: status, - primaryLabel: primaryLabel, - detailLabel: detailLabel, + status: RuntimeConnectionStatus.offline, + primaryLabel: RuntimeConnectionStatus.offline.label, + detailLabel: appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'), ready: false, - gatewayTokenMissing: tokenMissing, - lastError: failed ? syncMessage : null, + gatewayTokenMissing: false, + lastError: null, ); } @@ -286,8 +327,9 @@ extension AppControllerDesktopThreadSessions on AppController { final target = assistantExecutionTargetForSession(normalizedSessionKey); final providers = providerCatalogForExecutionTarget(target); final availableTargets = bridgeAvailableExecutionTargets; + final bridgeConfigured = isBridgeAcpRuntimeConfiguredInternal(); final bridgeReady = - isBridgeAcpRuntimeConfiguredInternal() && + bridgeConfigured && providers.isNotEmpty && (availableTargets.isEmpty || availableTargets.contains(target)); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); @@ -299,6 +341,8 @@ extension AppControllerDesktopThreadSessions on AppController { bridgeReady: bridgeReady, bridgeLabel: bridgeLabel, accountSyncState: settingsControllerInternal.accountSyncState, + accountSignedIn: settingsControllerInternal.accountSignedIn, + bridgeConfigured: bridgeConfigured, ); } diff --git a/lib/runtime/acp_endpoint_paths.dart b/lib/runtime/acp_endpoint_paths.dart index aa8ab6bb..8631d03d 100644 --- a/lib/runtime/acp_endpoint_paths.dart +++ b/lib/runtime/acp_endpoint_paths.dart @@ -104,7 +104,7 @@ Uri? resolveBridgeProviderBaseEndpoint( basePath = basePath.replaceFirst(RegExp(r'/+$'), ''); final providerPath = gateway - ? '$basePath/gateway/$normalizedProviderId' + ? '$basePath/acp-server/gateway/$normalizedProviderId' : '$basePath/acp-server/$normalizedProviderId'; return bridgeBaseEndpoint.replace( diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart index 0fd1b4a2..d61ee821 100644 --- a/lib/runtime/runtime_models_account.dart +++ b/lib/runtime/runtime_models_account.dart @@ -520,7 +520,7 @@ class AcpBridgeServerEffectiveConfig { factory AcpBridgeServerEffectiveConfig.defaults() { return const AcpBridgeServerEffectiveConfig( - endpoint: kManagedBridgeServerUrl, + endpoint: '', tokenRef: '', source: 'default', reason: 'No active source configured', @@ -552,7 +552,7 @@ class AcpBridgeServerEffectiveConfig { factory AcpBridgeServerEffectiveConfig.fromJson(Map json) { return AcpBridgeServerEffectiveConfig( - endpoint: json['endpoint'] as String? ?? kManagedBridgeServerUrl, + endpoint: json['endpoint'] as String? ?? '', tokenRef: json['tokenRef'] as String? ?? '', source: json['source'] as String? ?? 'default', reason: json['reason'] as String? ?? '', diff --git a/test/features/assistant/assistant_connection_status_test.dart b/test/features/assistant/assistant_connection_status_test.dart index f8a08e53..26c1aa6d 100644 --- a/test/features/assistant/assistant_connection_status_test.dart +++ b/test/features/assistant/assistant_connection_status_test.dart @@ -16,6 +16,8 @@ void main() { syncMessage: 'Bridge authorization is unavailable', lastSyncError: 'Bridge authorization is unavailable', ), + accountSignedIn: true, + bridgeConfigured: true, ); expect(state.connected, isTrue); @@ -37,6 +39,8 @@ void main() { lastSyncError: 'Bridge authorization is unavailable', profileScope: 'bridge', ), + accountSignedIn: true, + bridgeConfigured: true, ); expect(state.connected, isFalse); @@ -52,6 +56,8 @@ void main() { bridgeReady: false, bridgeLabel: 'xworkmate-bridge.svc.plus', accountSyncState: null, + accountSignedIn: true, + bridgeConfigured: false, ); expect(state.connected, isFalse); @@ -60,5 +66,39 @@ void main() { expect(state.detailLabel, 'xworkmate-bridge 未连接'); expect(state.gatewayTokenMissing, isFalse); }); + + test('surfaces signed-out status when not signed in', () { + final state = resolveGatewayThreadConnectionStateInternal( + target: AssistantExecutionTarget.gateway, + bridgeReady: false, + bridgeLabel: 'xworkmate-bridge.svc.plus', + accountSyncState: null, + accountSignedIn: false, + bridgeConfigured: false, + ); + + expect(state.connected, isFalse); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '已退出登录'); + expect(state.detailLabel, '请先登录 svc.plus'); + expect(state.gatewayTokenMissing, isFalse); + }); + + test('surfaces discovering status when configured but not ready', () { + final state = resolveGatewayThreadConnectionStateInternal( + target: AssistantExecutionTarget.gateway, + bridgeReady: false, + bridgeLabel: 'xworkmate-bridge.svc.plus', + accountSyncState: null, + accountSignedIn: true, + bridgeConfigured: true, + ); + + expect(state.connected, isFalse); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '正在发现'); + expect(state.detailLabel, '正在加载 Bridge 能力...'); + expect(state.gatewayTokenMissing, isFalse); + }); }); }