refactor(account): optimize bridge state flow and align with production vhost routing

This commit is contained in:
Haitao Pan 2026-04-21 13:48:48 +08:00
parent 7b550562e8
commit 7186fa3c5d
7 changed files with 169 additions and 65 deletions

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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,
);
}

View File

@ -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(

View File

@ -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<String, dynamic> 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? ?? '',

View File

@ -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);
});
});
}