fix: recover bridge server sync state and hide stale model labels
This commit is contained in:
parent
edd9a5993c
commit
c1d9b64a2c
@ -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,
|
||||
|
||||
@ -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>[];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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() ?? '';
|
||||
|
||||
@ -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',
|
||||
|
||||
63
test/runtime/assistant_model_display_test.dart
Normal file
63
test/runtime/assistant_model_display_test.dart
Normal 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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user