fix: recover bridge server sync state and hide stale model labels

This commit is contained in:
Haitao Pan 2026-04-12 21:03:29 +08:00
parent edd9a5993c
commit c1d9b64a2c
9 changed files with 306 additions and 59 deletions

View File

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

View File

@ -346,19 +346,13 @@ List<String> 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 <String>[resolved];
}
final localDefault = controller.settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return <String>[localDefault];
final aiGatewayModels = controller.aiGatewayConversationModelChoices;
if (aiGatewayModels.isNotEmpty) {
return aiGatewayModels;
}
return const <String>[];
}

View File

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

View File

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

View File

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

View File

@ -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() ?? '';

View File

@ -292,18 +292,8 @@ Future<AccountSyncResult> 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<AccountSyncResult> syncAccountSettingsInternal(
);
}
Future<AccountSyncState?> 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<void> logoutAccountSettingsInternal(
SettingsController controller, {
String statusMessage = 'Signed out',

View File

@ -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>[
GatewayModelSummary(
id: 'qwen2.5-coder:latest',
name: 'Qwen 2.5 Coder',
provider: 'ollama',
contextWindow: null,
maxOutputTokens: null,
),
];
expect(controller.assistantModelChoices, const <String>[
'qwen2.5-coder:latest',
]);
expect(
controller.assistantDisplayModelForSession(
controller.currentSessionKey,
),
'qwen2.5-coder:latest',
);
},
);
});
}

View File

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