From 5c1777d4cbd57f7d8c0335b668d8493e883241bc Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 12:29:47 +0800 Subject: [PATCH] Fix assistant thread connection status --- lib/app/app_controller_desktop.dart | 91 ++++++++++++--- lib/app/app_controller_web.dart | 61 +++++++--- lib/features/assistant/assistant_page.dart | 44 ++++---- lib/runtime/runtime_models.dart | 30 +++++ ...troller_execution_target_switch_suite.dart | 106 ++++++++++++++++++ 5 files changed, 273 insertions(+), 59 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index b486ec45..1e45f400 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -375,26 +375,65 @@ class AppController extends ChangeNotifier { return model.isEmpty ? appText('AI Gateway', 'AI Gateway') : model; } - String get assistantConnectionStatusLabel => isAiGatewayOnlyMode - ? appText('仅 AI Gateway', 'AI Gateway Only') - : connection.status.label; + AssistantThreadConnectionState get currentAssistantConnectionState => + assistantConnectionStateForSession(currentSessionKey); + + AssistantThreadConnectionState assistantConnectionStateForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); + if (target == AssistantExecutionTarget.aiGatewayOnly) { + final model = assistantModelForSession(normalizedSessionKey); + final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); + final detail = _joinConnectionParts([model, host]); + return AssistantThreadConnectionState( + executionTarget: target, + status: canUseAiGatewayConversation + ? RuntimeConnectionStatus.connected + : RuntimeConnectionStatus.offline, + primaryLabel: target.label, + detailLabel: detail.isEmpty + ? appText('AI Gateway 未配置', 'AI Gateway not configured') + : detail, + ready: canUseAiGatewayConversation, + pairingRequired: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + + final expectedMode = target == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + final matchesTarget = connection.mode == expectedMode; + final fallbackProfile = _gatewayProfileForAssistantExecutionTarget(target); + final fallbackAddress = _gatewayAddressLabel(fallbackProfile); + final detail = matchesTarget + ? (connection.remoteAddress?.trim().isNotEmpty == true + ? connection.remoteAddress!.trim() + : fallbackAddress) + : fallbackAddress; + final status = matchesTarget + ? connection.status + : RuntimeConnectionStatus.offline; + return AssistantThreadConnectionState( + executionTarget: target, + status: status, + primaryLabel: status.label, + detailLabel: detail, + ready: status == RuntimeConnectionStatus.connected, + pairingRequired: matchesTarget && connection.pairingRequired, + gatewayTokenMissing: matchesTarget && connection.gatewayTokenMissing, + lastError: matchesTarget ? connection.lastError?.trim() : null, + ); + } + + String get assistantConnectionStatusLabel => + currentAssistantConnectionState.primaryLabel; String get assistantConnectionTargetLabel { - if (!isAiGatewayOnlyMode) { - return connection.remoteAddress ?? appText('未连接目标', 'No target'); - } - final model = resolvedAssistantModel; - final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); - if (model.isNotEmpty && host.isNotEmpty) { - return '$model · $host'; - } - if (model.isNotEmpty) { - return model; - } - if (host.isNotEmpty) { - return host; - } - return appText('AI Gateway 未配置', 'AI Gateway not configured'); + return currentAssistantConnectionState.detailLabel; } Future loadAiGatewayApiKey() async { @@ -664,6 +703,22 @@ class AppController extends ChangeNotifier { profile.mode != defaults.mode; } + String _joinConnectionParts(List parts) { + final normalized = parts + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + return normalized.join(' · '); + } + + String _gatewayAddressLabel(GatewayConnectionProfile profile) { + final host = profile.host.trim(); + if (host.isEmpty || profile.port <= 0) { + return appText('未连接目标', 'No target'); + } + return '$host:${profile.port}'; + } + List get secretReferences => _settingsController.buildSecretReferences(); List get secretAuditTrail => _settingsController.auditTrail; diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index ed3bc533..6fd7f394 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -213,25 +213,52 @@ class AppController extends ChangeNotifier { _aiGatewayApiKeyCache.trim().isNotEmpty && resolvedAiGatewayModel.isNotEmpty; - String get assistantConnectionStatusLabel => isAiGatewayOnlyMode - ? (canUseAiGatewayConversation - ? appText('可用', 'Ready') - : appText('未配置', 'Not configured')) - : connection.status.label; + AssistantThreadConnectionState get currentAssistantConnectionState { + final target = currentAssistantExecutionTarget; + if (target == AssistantExecutionTarget.aiGatewayOnly) { + final host = _hostLabel(_settings.aiGateway.baseUrl); + final model = resolvedAiGatewayModel; + final detail = _joinConnectionParts([model, host]); + return AssistantThreadConnectionState( + executionTarget: target, + status: canUseAiGatewayConversation + ? RuntimeConnectionStatus.connected + : RuntimeConnectionStatus.offline, + primaryLabel: target.label, + detailLabel: detail.isEmpty + ? appText('Direct AI 未配置', 'Direct AI not configured') + : detail, + ready: canUseAiGatewayConversation, + pairingRequired: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + return AssistantThreadConnectionState( + executionTarget: target, + status: connection.status, + primaryLabel: connection.status.label, + detailLabel: + connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline'), + ready: connection.status == RuntimeConnectionStatus.connected, + pairingRequired: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + + String get assistantConnectionStatusLabel => + currentAssistantConnectionState.primaryLabel; String get assistantConnectionTargetLabel { - if (!isAiGatewayOnlyMode) { - return connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline'); - } - final host = _hostLabel(_settings.aiGateway.baseUrl); - final model = resolvedAiGatewayModel; - if (host.isEmpty && model.isEmpty) { - return appText('Direct AI 未配置', 'Direct AI not configured'); - } - if (host.isNotEmpty && model.isNotEmpty) { - return '$model · $host'; - } - return host.isNotEmpty ? host : model; + return currentAssistantConnectionState.detailLabel; + } + + String _joinConnectionParts(List parts) { + return parts + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .join(' · '); } String get conversationPersistenceSummary { diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 56967bb6..5a70e3ee 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -606,6 +606,7 @@ class _AssistantPageState extends State { .map((item) => item.name) .toList(growable: false); final selectedSkillLabels = _resolveSelectedSkillLabels(controller); + final connectionState = controller.currentAssistantConnectionState; final prompt = _composePrompt( mode: _mode, prompt: rawPrompt, @@ -632,8 +633,7 @@ class _AssistantPageState extends State { status: controller.hasAssistantPendingRun || executionTarget == AssistantExecutionTarget.aiGatewayOnly || - controller.connection.status == - RuntimeConnectionStatus.connected + connectionState.connected ? 'running' : 'queued', owner: autoAgent?.name ?? _conversationOwnerLabel(controller), @@ -2214,11 +2214,9 @@ class _AssistantEmptyState extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final connection = controller.connection; - final aiGatewayOnly = controller.isAiGatewayOnlyMode; - final connected = aiGatewayOnly - ? controller.canUseAiGatewayConversation - : connection.status == RuntimeConnectionStatus.connected; + final connectionState = controller.currentAssistantConnectionState; + final aiGatewayOnly = connectionState.isAiGatewayOnly; + final connected = connectionState.connected; final reconnectAvailable = controller.canQuickConnectGateway; final title = aiGatewayOnly ? connected @@ -2226,7 +2224,7 @@ class _AssistantEmptyState extends StatelessWidget { : appText('先配置 AI Gateway', 'Configure AI Gateway first') : connected ? appText('开始对话或运行任务', 'Start a chat or run a task') - : connection.status == RuntimeConnectionStatus.error + : connectionState.status == RuntimeConnectionStatus.error ? appText('Gateway 连接失败', 'Gateway connection failed') : appText('先连接 Gateway', 'Connect a gateway first'); final description = aiGatewayOnly @@ -2244,18 +2242,18 @@ class _AssistantEmptyState extends StatelessWidget { '输入需求后即可开始执行,结果会回到当前会话并同步到任务页。', 'Type a request to start execution. Results return to this session and the Tasks page.', ) - : connection.pairingRequired + : connectionState.pairingRequired ? appText( '当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。', 'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.', ) - : connection.gatewayTokenMissing + : connectionState.gatewayTokenMissing ? appText( '首次连接需要共享 Token;配对完成后可继续使用本机的 device token。', 'The first connection requires a shared token; after pairing, this device can continue with its device token.', ) - : (connection.lastError?.trim().isNotEmpty == true - ? connection.lastError!.trim() + : (connectionState.lastError?.trim().isNotEmpty == true + ? connectionState.lastError!.trim() : appText( '连接后可直接对话、创建任务,并在当前会话查看结果。', 'After connecting, you can chat, create tasks, and read results in this session.', @@ -2445,14 +2443,11 @@ class _ComposerBarState extends State<_ComposerBar> { final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); - final aiGatewayOnly = controller.isAiGatewayOnlyMode; - final connected = aiGatewayOnly - ? controller.canUseAiGatewayConversation - : controller.connection.status == RuntimeConnectionStatus.connected; + final connectionState = controller.currentAssistantConnectionState; + final aiGatewayOnly = connectionState.isAiGatewayOnly; + final connected = connectionState.connected; final reconnectAvailable = controller.canQuickConnectGateway; - final connecting = - !aiGatewayOnly && - controller.connection.status == RuntimeConnectionStatus.connecting; + final connecting = connectionState.connecting; final executionTarget = controller.assistantExecutionTarget; final permissionLevel = controller.assistantPermissionLevel; final selectedSkills = widget.availableSkills @@ -3810,11 +3805,12 @@ class _ConnectionChip extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final connection = controller.connection; - final aiGatewayOnly = controller.isAiGatewayOnlyMode; - final color = aiGatewayOnly - ? context.palette.accentMuted - : switch (connection.status) { + final connectionState = controller.currentAssistantConnectionState; + final color = connectionState.isAiGatewayOnly + ? (connectionState.connected + ? context.palette.accentMuted + : context.palette.surfaceSecondary) + : switch (connectionState.status) { RuntimeConnectionStatus.connected => context.palette.accentMuted, RuntimeConnectionStatus.connecting => context.palette.surfaceSecondary, diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 8245b94b..0bd55a5b 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -63,6 +63,36 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { } } +class AssistantThreadConnectionState { + const AssistantThreadConnectionState({ + required this.executionTarget, + required this.status, + required this.primaryLabel, + required this.detailLabel, + required this.ready, + required this.pairingRequired, + required this.gatewayTokenMissing, + required this.lastError, + }); + + final AssistantExecutionTarget executionTarget; + final RuntimeConnectionStatus status; + final String primaryLabel; + final String detailLabel; + final bool ready; + final bool pairingRequired; + final bool gatewayTokenMissing; + final String? lastError; + + bool get isAiGatewayOnly => + executionTarget == AssistantExecutionTarget.aiGatewayOnly; + + bool get connected => ready; + + bool get connecting => + !isAiGatewayOnly && status == RuntimeConnectionStatus.connecting; +} + enum AssistantMessageViewMode { rendered, raw } extension AssistantMessageViewModeCopy on AssistantMessageViewMode { diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 7374e0d2..39ab1a38 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -20,6 +20,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { final List connectedProfiles = []; + final Set _failingModes = {}; int disconnectCount = 0; GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); @@ -39,6 +40,17 @@ class _FakeGatewayRuntime extends GatewayRuntime { String authPasswordOverride = '', }) async { connectedProfiles.add(profile); + if (_failingModes.remove(profile.mode)) { + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode) + .copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Error', + remoteAddress: '${profile.host}:${profile.port}', + lastError: 'Failed to connect ${profile.mode.name}', + ); + notifyListeners(); + throw StateError('Failed to connect ${profile.mode.name}'); + } _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( status: RuntimeConnectionStatus.connected, statusText: 'Connected', @@ -99,6 +111,10 @@ class _FakeGatewayRuntime extends GatewayRuntime { return {}; } } + + void failNextConnect(RuntimeConnectionMode mode) { + _failingModes.add(mode); + } } class _FakeCodexRuntime extends CodexRuntime { @@ -353,6 +369,96 @@ void main() { }, ); + test( + 'AppController keeps the thread connection chip aligned with the selected target', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-connection-chip-', + ); + addTearDown(() async { + await _deleteDirectoryWithRetry(tempDirectory); + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final gateway = _FakeGatewayRuntime(store: store); + final controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: gateway, + codex: _FakeCodexRuntime(), + ), + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: 'http://127.0.0.1:11434/v1', + availableModels: const ['qwen2.5-coder:latest'], + selectedModels: const ['qwen2.5-coder:latest'], + ), + defaultModel: 'qwen2.5-coder:latest', + gateway: controller.settings.gateway.copyWith( + mode: RuntimeConnectionMode.remote, + host: 'gateway.example.com', + port: 9443, + tls: true, + ), + ), + refreshAfterSave: false, + ); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.local, + ); + expect(controller.assistantConnectionStatusLabel, '已连接'); + expect(controller.assistantConnectionTargetLabel, '127.0.0.1:18789'); + + controller.initializeAssistantThreadContext( + 'remote-thread', + executionTarget: AssistantExecutionTarget.remote, + ); + await Future.delayed(const Duration(milliseconds: 20)); + gateway.failNextConnect(RuntimeConnectionMode.remote); + + await controller.switchSession('remote-thread'); + + expect( + controller.assistantExecutionTarget, + AssistantExecutionTarget.remote, + ); + expect(controller.assistantConnectionStatusLabel, '错误'); + expect( + controller.assistantConnectionTargetLabel, + 'gateway.example.com:9443', + ); + expect( + controller.currentAssistantConnectionState.lastError, + 'Failed to connect remote', + ); + + controller.initializeAssistantThreadContext( + 'main', + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + ); + await controller.switchSession('main'); + + expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway'); + expect( + controller.assistantConnectionTargetLabel, + 'qwen2.5-coder:latest · 127.0.0.1:11434', + ); + }, + ); + test('AppController persists markdown view mode per thread', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp(