From c1d9b64a2cb46bbcf4d4baed3f0059d52515dfdb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 12 Apr 2026 21:03:29 +0800 Subject: [PATCH] fix: recover bridge server sync state and hide stale model labels --- ...pp_controller_desktop_thread_sessions.dart | 27 +++++ ...op_thread_sessions_collaboration_impl.dart | 18 +-- .../assistant_page_composer_support.dart | 58 ++++----- .../assistant_page_state_closure.dart | 7 +- .../assistant_page_tooltip_labels.dart | 9 +- .../runtime_controllers_settings_account.dart | 5 +- ...ime_controllers_settings_account_impl.dart | 66 +++++++++-- .../runtime/assistant_model_display_test.dart | 63 ++++++++++ ...ime_controllers_settings_account_test.dart | 112 ++++++++++++++++++ 9 files changed, 306 insertions(+), 59 deletions(-) create mode 100644 test/runtime/assistant_model_display_test.dart diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 316e1581..0de5bb06 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -161,6 +161,33 @@ extension AppControllerDesktopThreadSessions on AppController { ); } + String assistantDisplayModelForSession(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final availableChoices = assistantModelChoicesForSessionInternal( + normalizedSessionKey, + ); + if (availableChoices.isEmpty) { + return ''; + } + final thread = taskThreadForSessionInternal(normalizedSessionKey); + final latestResolvedModel = thread?.latestResolvedRuntimeModel.trim() ?? ''; + if (availableChoices.contains(latestResolvedModel)) { + return latestResolvedModel; + } + final selectedModel = thread?.assistantModelId.trim() ?? ''; + if (availableChoices.contains(selectedModel)) { + return selectedModel; + } + final target = assistantExecutionTargetForSession(normalizedSessionKey); + final defaultModel = resolvedAssistantModelForTargetInternal(target).trim(); + if (availableChoices.contains(defaultModel)) { + return defaultModel; + } + return availableChoices.length == 1 ? availableChoices.first : ''; + } + String assistantWorkspacePathForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index 8df7bda7..3541669f 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -346,19 +346,13 @@ List assistantModelChoicesForSessionThreadSessionInternal( AppController controller, String sessionKey, ) { - final runtimeModels = connectedGatewayModelChoicesThreadSessionInternal( - controller, - ); - if (runtimeModels.isNotEmpty) { - return runtimeModels; + final target = controller.assistantExecutionTargetForSession(sessionKey); + if (target.isGateway) { + return connectedGatewayModelChoicesThreadSessionInternal(controller); } - final resolved = resolvedDefaultModelThreadSessionInternal(controller).trim(); - if (resolved.isNotEmpty) { - return [resolved]; - } - final localDefault = controller.settings.ollamaLocal.defaultModel.trim(); - if (localDefault.isNotEmpty) { - return [localDefault]; + final aiGatewayModels = controller.aiGatewayConversationModelChoices; + if (aiGatewayModels.isNotEmpty) { + return aiGatewayModels; } return const []; } diff --git a/lib/features/assistant/assistant_page_composer_support.dart b/lib/features/assistant/assistant_page_composer_support.dart index 429ab142..b8d56878 100644 --- a/lib/features/assistant/assistant_page_composer_support.dart +++ b/lib/features/assistant/assistant_page_composer_support.dart @@ -158,38 +158,40 @@ class ComposerToolbarChipStateInternal Widget build(BuildContext context) { final palette = context.palette; - return Tooltip( - message: widget.tooltip, - child: MouseRegion( - onEnter: (_) => setState(() => hoveredInternal = true), - onExit: (_) => setState(() => hoveredInternal = false), - child: Container( - padding: widget.padding, - decoration: BoxDecoration( - color: hoveredInternal - ? palette.surfaceSecondary - : palette.surfacePrimary, - borderRadius: BorderRadius.circular(AppRadius.chip), - border: Border.all(color: palette.strokeSoft), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - widget.leading ?? - Icon(widget.icon, size: 16, color: palette.textMuted), - if (widget.showChevron) ...[ - const SizedBox(width: 1), - Icon( - Icons.keyboard_arrow_down_rounded, - size: 14, - color: palette.textMuted, - ), - ], + final chip = MouseRegion( + onEnter: (_) => setState(() => hoveredInternal = true), + onExit: (_) => setState(() => hoveredInternal = false), + child: Container( + padding: widget.padding, + decoration: BoxDecoration( + color: hoveredInternal + ? palette.surfaceSecondary + : palette.surfacePrimary, + borderRadius: BorderRadius.circular(AppRadius.chip), + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.leading ?? + Icon(widget.icon, size: 16, color: palette.textMuted), + if (widget.showChevron) ...[ + const SizedBox(width: 1), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 14, + color: palette.textMuted, + ), ], - ), + ], ), ), ); + final tooltip = widget.tooltip.trim(); + if (tooltip.isEmpty) { + return chip; + } + return Tooltip(message: tooltip, child: chip); } } diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 67c94be0..555f0d7a 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -180,10 +180,9 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { focusNode: composerFocusNodeInternal, thinkingLabel: thinkingLabelInternal, showModelControl: true, - modelLabel: - controller.resolvedAssistantModel.isEmpty - ? appText('未选择模型', 'No model selected') - : controller.resolvedAssistantModel, + modelLabel: controller.assistantDisplayModelForSession( + controller.currentSessionKey, + ), modelOptions: controller.assistantModelChoices, attachments: attachmentsInternal, availableSkills: AssistantPageStateActionsInternal( diff --git a/lib/features/assistant/assistant_page_tooltip_labels.dart b/lib/features/assistant/assistant_page_tooltip_labels.dart index c4f5c27c..45e1730c 100644 --- a/lib/features/assistant/assistant_page_tooltip_labels.dart +++ b/lib/features/assistant/assistant_page_tooltip_labels.dart @@ -48,8 +48,13 @@ String providerTooltipInternal(SingleAgentProvider provider) => appText( 'Agent provider: ${provider.label}', ); -String modelTooltipInternal(String modelLabel) => - appText('模型: $modelLabel', 'Model: $modelLabel'); +String modelTooltipInternal(String modelLabel) { + final normalized = modelLabel.trim(); + if (normalized.isEmpty) { + return ''; + } + return appText('模型: $normalized', 'Model: $normalized'); +} String skillsTooltipInternal(int selectedCount) => selectedCount <= 0 ? appText('技能', 'Skills') diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index a6b6a78c..9e97f1d6 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -139,7 +139,10 @@ extension SettingsControllerAccountExtension on SettingsController { accountSessionTokenInternal = (await storeInternal.loadAccountSessionToken())?.trim() ?? ''; accountSessionInternal = await storeInternal.loadAccountSessionSummary(); - accountSyncStateInternal = await storeInternal.loadAccountSyncState(); + accountSyncStateInternal = await recoverBridgeAccountSyncStateInternal( + this, + await storeInternal.loadAccountSyncState(), + ); if (!accountBusyInternal) { if (accountSignedIn) { final email = accountSessionInternal?.email.trim() ?? ''; diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 0c3d66a7..273fea0b 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -292,18 +292,8 @@ Future syncAccountSettingsInternal( final resolvedBridgeServerUrl = bridgeServerUrlOverride.trim().isNotEmpty ? bridgeServerUrlOverride.trim() : controller.accountSyncStateInternal?.syncedDefaults.bridgeServerUrl - .trim() - .isNotEmpty == - true - ? controller.accountSyncStateInternal!.syncedDefaults.bridgeServerUrl - .trim() - : controller - .snapshotInternal - .acpBridgeServerModeConfig - .cloudSynced - .remoteServerSummary - .endpoint - .trim(); + .trim() ?? + ''; if (!isSupportedExternalAcpEndpoint(resolvedBridgeServerUrl)) { const result = AccountSyncResult( state: 'blocked', @@ -393,6 +383,58 @@ Future syncAccountSettingsInternal( ); } +Future recoverBridgeAccountSyncStateInternal( + SettingsController controller, + AccountSyncState? currentState, +) async { + final currentBridgeServerUrl = + currentState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; + if (currentBridgeServerUrl.isNotEmpty) { + return currentState; + } + if (controller.snapshotInternal.accountLocalMode) { + return currentState; + } + + final cloudSynced = + controller.snapshotInternal.acpBridgeServerModeConfig.cloudSynced; + final legacyBridgeServerUrl = cloudSynced.remoteServerSummary.endpoint.trim(); + if (!isSupportedExternalAcpEndpoint(legacyBridgeServerUrl)) { + return currentState; + } + + final defaults = AccountSyncState.defaults(); + final baseline = currentState ?? defaults; + final hasBridgeToken = controller.secureRefsInternal.containsKey( + kAccountManagedSecretTargetBridgeAuthToken, + ); + final recoveredState = baseline.copyWith( + syncedDefaults: baseline.syncedDefaults.copyWith( + bridgeServerUrl: legacyBridgeServerUrl, + ), + syncState: baseline.syncState == defaults.syncState + ? 'ready' + : baseline.syncState, + syncMessage: baseline.syncMessage == defaults.syncMessage + ? 'Bridge access synced' + : baseline.syncMessage, + lastSyncAtMs: baseline.lastSyncAtMs > 0 + ? baseline.lastSyncAtMs + : cloudSynced.lastSyncAt, + lastSyncSource: baseline.lastSyncSource.trim().isNotEmpty + ? baseline.lastSyncSource + : legacyBridgeServerUrl, + profileScope: baseline.profileScope.trim().isNotEmpty + ? baseline.profileScope + : 'bridge', + tokenConfigured: baseline.tokenConfigured.copyWith( + bridge: baseline.tokenConfigured.bridge || hasBridgeToken, + ), + ); + await controller.storeInternal.saveAccountSyncState(recoveredState); + return recoveredState; +} + Future logoutAccountSettingsInternal( SettingsController controller, { String statusMessage = 'Signed out', diff --git a/test/runtime/assistant_model_display_test.dart b/test/runtime/assistant_model_display_test.dart new file mode 100644 index 00000000..03431447 --- /dev/null +++ b/test/runtime/assistant_model_display_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + group('Assistant model display', () { + test('hides stale model display when no runtime model matches', () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + + expect(controller.resolvedAssistantModel, isNotEmpty); + expect(controller.assistantModelChoices, isEmpty); + expect( + controller.assistantDisplayModelForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + }); + + test( + 'shows matched runtime model when gateway catalog is available', + () async { + final controller = AppController(); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + controller.runtimeInternal.snapshotInternal = controller + .runtimeInternal + .snapshot + .copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + ); + controller.modelsControllerInternal.itemsInternal = + const [ + GatewayModelSummary( + id: 'qwen2.5-coder:latest', + name: 'Qwen 2.5 Coder', + provider: 'ollama', + contextWindow: null, + maxOutputTokens: null, + ), + ]; + + expect(controller.assistantModelChoices, const [ + 'qwen2.5-coder:latest', + ]); + expect( + controller.assistantDisplayModelForSession( + controller.currentSessionKey, + ), + 'qwen2.5-coder:latest', + ); + }, + ); + }); +} diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 1bd0b2dd..19a1aaec 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -137,5 +137,117 @@ void main() { expect(controller.accountSyncState!.profileScope, 'bridge'); }, ); + + test( + 'recovers bridge sync state from cloud-synced snapshot when support state is missing', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-recover-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountLocalMode: false, + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced + .copyWith( + lastSyncAt: DateTime( + 2026, + 4, + 12, + 11, + ).millisecondsSinceEpoch, + remoteServerSummary: + AcpBridgeServerModeConfig.defaults() + .cloudSynced + .remoteServerSummary + .copyWith(endpoint: 'https://bridge.svc.plus'), + ), + ), + ), + ); + await store.saveSecretValueByRef( + kAccountManagedSecretTargetBridgeAuthToken, + 'bridge-token', + ); + + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + expect(controller.accountSyncState, isNotNull); + expect( + controller.accountSyncState!.syncedDefaults.bridgeServerUrl, + 'https://bridge.svc.plus', + ); + expect(controller.accountSyncState!.syncState, 'ready'); + expect(controller.accountSyncState!.profileScope, 'bridge'); + + final persisted = await store.loadAccountSyncState(); + expect(persisted, isNotNull); + expect( + persisted!.syncedDefaults.bridgeServerUrl, + 'https://bridge.svc.plus', + ); + }, + ); + + test( + 'does not recover bridge sync state from cloud-synced snapshot in local mode', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-local-mode-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + accountLocalMode: true, + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced + .copyWith( + remoteServerSummary: + AcpBridgeServerModeConfig.defaults() + .cloudSynced + .remoteServerSummary + .copyWith(endpoint: 'https://bridge.svc.plus'), + ), + ), + ), + ); + + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + expect(controller.accountSyncState, isNull); + expect(await store.loadAccountSyncState(), isNull); + }, + ); }); }