refactor(account): optimize bridge state flow and align with production vhost routing
This commit is contained in:
parent
7b550562e8
commit
7186fa3c5d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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? ?? '',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user