Fix assistant provider selector and submit action

This commit is contained in:
Haitao Pan 2026-04-12 19:06:06 +08:00
parent 1171f12937
commit 7934b1b1d9
14 changed files with 340 additions and 118 deletions

View File

@ -567,6 +567,14 @@ class AppController extends ChangeNotifier {
List<SingleAgentProvider> get bridgeProviderCatalog =>
normalizeSingleAgentProviderList(bridgeProviderCatalogInternal);
List<SingleAgentProvider> get assistantProviderCatalog {
final catalog = bridgeProviderCatalog;
if (catalog.isNotEmpty) {
return catalog;
}
return kPresetExternalAcpProviders;
}
SingleAgentProvider? bridgeProviderForId(String providerId) {
final normalizedProviderId = normalizeSingleAgentProviderId(providerId);
if (normalizedProviderId.isEmpty) {
@ -580,6 +588,30 @@ class AppController extends ChangeNotifier {
return null;
}
SingleAgentProvider resolveAssistantProvider(String? providerId) {
final normalizedProviderId = normalizeSingleAgentProviderId(providerId ?? '');
final catalog = assistantProviderCatalog;
if (normalizedProviderId.isNotEmpty) {
for (final provider in catalog) {
if (provider.providerId == normalizedProviderId) {
return provider;
}
}
}
if (catalog.isNotEmpty) {
return catalog.first;
}
return SingleAgentProvider.unspecified;
}
SingleAgentProvider assistantProviderForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final thread = taskThreadForSessionInternal(normalizedSessionKey);
return resolveAssistantProvider(thread?.executionBinding.providerId);
}
List<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) => const <AssistantExecutionTarget>[AssistantExecutionTarget.gateway];

View File

@ -64,7 +64,12 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
const resolvedExplicitProviderId = '';
final resolvedProvider = assistantProviderForSession(normalizedSessionKey);
final resolvedExplicitProviderId =
thread?.hasExplicitProviderSelection == true &&
!resolvedProvider.isUnspecified
? resolvedProvider.providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? assistantModelForSession(normalizedSessionKey)
: '';

View File

@ -290,8 +290,18 @@ extension AppControllerDesktopSkillPermissions on AppController {
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
final nextProvider = SingleAgentProvider.unspecified;
const nextProviderSource = ThreadSelectionSource.inherited;
final requestedProvider = singleAgentProvider?.isUnspecified == false
? singleAgentProvider
: null;
final nextProvider = resolveAssistantProvider(
requestedProvider?.providerId ??
existing?.executionBinding.providerId ??
existing?.contextState.latestResolvedProviderId,
);
final nextProviderSource =
singleAgentProviderSource ??
existing?.executionBinding.providerSource ??
ThreadSelectionSource.inherited;
final nextExecutionBinding =
(executionBinding ??
existing?.executionBinding ??

View File

@ -301,6 +301,7 @@ extension AppControllerDesktopThreadActions on AppController {
sessionId: sessionKey,
threadId: sessionKey,
target: currentTarget,
provider: assistantProviderForSession(sessionKey),
prompt: message,
workingDirectory: workingDirectory,
model: assistantModelForSession(sessionKey),

View File

@ -219,19 +219,22 @@ extension AppControllerDesktopThreadBinding on AppController {
required AssistantExecutionTarget executionTarget,
ExecutionBinding? existingBinding,
}) {
const selectedProviderId = kCanonicalGatewayProviderId;
final selectedProvider = resolveAssistantProvider(
existingBinding?.providerId,
);
return (existingBinding ??
ExecutionBinding(
executionMode: ThreadExecutionMode.gateway,
executorId: selectedProviderId,
providerId: selectedProviderId,
executorId: selectedProvider.providerId,
providerId: selectedProvider.providerId,
endpointId: '',
))
.copyWith(
executionMode: ThreadExecutionMode.gateway,
executorId: selectedProviderId,
providerId: selectedProviderId,
providerSource: ThreadSelectionSource.inherited,
executorId: selectedProvider.providerId,
providerId: selectedProvider.providerId,
providerSource:
existingBinding?.providerSource ?? ThreadSelectionSource.inherited,
);
}

View File

@ -101,6 +101,43 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
notifyIfActiveInternal();
}
Future<void> setAssistantSingleAgentProvider(
SingleAgentProvider provider,
) async {
final resolvedProvider = resolveAssistantProvider(provider.providerId);
final sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
);
if (sessionKey.isEmpty) {
return;
}
final existing = taskThreadForSessionInternal(sessionKey);
if (existing != null &&
normalizeSingleAgentProviderId(existing.executionBinding.providerId) ==
resolvedProvider.providerId &&
existing.executionBinding.providerSource ==
ThreadSelectionSource.explicit) {
return;
}
if (!assistantThreadRecordsInternal.containsKey(sessionKey)) {
initializeAssistantThreadContext(
sessionKey,
executionTarget: assistantExecutionTargetForSession(sessionKey),
messageViewMode: assistantMessageViewModeForSession(sessionKey),
);
}
upsertTaskThreadInternal(
sessionKey,
singleAgentProvider: resolvedProvider,
singleAgentProviderSource: ThreadSelectionSource.explicit,
latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await flushAssistantThreadPersistenceInternal();
recomputeTasksInternal();
notifyIfActiveInternal();
}
Future<void> setAssistantMessageViewMode(
AssistantMessageViewMode mode,
) async {

View File

@ -53,9 +53,6 @@ class ComposerBarInternal extends StatefulWidget {
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onPickAttachments,
required this.onAddAttachment,
required this.onPasteImageAttachment,
@ -78,9 +75,6 @@ class ComposerBarInternal extends StatefulWidget {
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final ValueChanged<ComposerAttachmentInternal> onAddAttachment;
final AssistantClipboardImageReader onPasteImageAttachment;
@ -316,10 +310,6 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final connectionState = controller.currentAssistantConnectionState;
final connected = connectionState.connected;
final reconnectAvailable = controller.canQuickConnectGateway;
final connecting = connectionState.connecting;
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
@ -337,16 +327,14 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
executionTarget,
);
final permissionLevel = controller.assistantPermissionLevel;
final availableProviders = controller.assistantProviderCatalog;
final selectedProvider = controller.assistantProviderForSession(
controller.currentSessionKey,
);
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
final submitLabel = connected
? appText('提交', 'Submit')
: connecting
? appText('连接中…', 'Connecting…')
: reconnectAvailable
? appText('重连', 'Reconnect')
: appText('连接', 'Connect');
final submitLabel = appText('提交', 'Submit');
reportContentHeightInternal();
@ -432,32 +420,48 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
const SizedBox(width: 4),
],
if (!connecting) ...[
if (availableProviders.isNotEmpty) ...[
PopupMenuButton<String>(
key: const Key('assistant-gateway-provider-button'),
tooltip: appText('Gateway Provider', 'Gateway Provider'),
onSelected: (_) {},
itemBuilder: (context) => const <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: kCanonicalGatewayProviderId,
key: Key('assistant-gateway-provider-menu-item-openclaw'),
child: Row(
children: [
GatewayProviderBadgeInternal(
key: Key('assistant-gateway-provider-menu-badge'),
key: const Key('assistant-provider-button'),
tooltip: appText('智能体 Provider', 'Agent Provider'),
onSelected: (providerId) async {
await controller.setAssistantSingleAgentProvider(
controller.resolveAssistantProvider(providerId),
);
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => availableProviders
.map(
(provider) => PopupMenuItem<String>(
value: provider.providerId,
key: Key(
'assistant-provider-menu-item-${provider.providerId}',
),
SizedBox(width: 10),
Expanded(child: Text(kCanonicalGatewayProviderLabel)),
Icon(Icons.check_rounded, size: 18),
],
),
),
],
child: Row(
children: [
SingleAgentProviderBadgeInternal(
key: Key(
'assistant-provider-menu-badge-${provider.providerId}',
),
provider: provider,
),
const SizedBox(width: 10),
Expanded(child: Text(provider.label)),
if (provider == selectedProvider)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(growable: false),
child: ComposerToolbarChipInternal(
leading: const GatewayProviderBadgeInternal(
key: Key('assistant-gateway-provider-badge'),
leading: SingleAgentProviderBadgeInternal(
key: const Key('assistant-provider-badge'),
provider: selectedProvider,
),
tooltip: gatewayProviderTooltipInternal(),
tooltip: providerTooltipInternal(selectedProvider),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
@ -707,15 +711,7 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
message: submitLabel,
child: FilledButton(
key: const Key('assistant-send-button'),
onPressed: connecting
? null
: connected
? widget.onSend
: reconnectAvailable
? () async {
await widget.onReconnectGateway();
}
: widget.onOpenGateway,
onPressed: widget.onSend,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10,
@ -729,14 +725,7 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
connected
? Icons.arrow_upward_rounded
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
size: 18,
),
const Icon(Icons.arrow_upward_rounded, size: 18),
const SizedBox(width: 4),
Text(submitLabel),
],

View File

@ -245,35 +245,3 @@ class SingleAgentProviderBadgeInternal extends StatelessWidget {
);
}
}
class GatewayProviderBadgeInternal extends StatelessWidget {
const GatewayProviderBadgeInternal({
super.key,
this.size = 18,
this.fontSize = 11,
});
final double size;
final double fontSize;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Text(
'🦞',
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(fontSize: fontSize, height: 1),
),
);
}
}

View File

@ -527,9 +527,6 @@ class AssistantLowerPaneInternal extends StatelessWidget {
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onPickAttachments,
required this.onAddAttachment,
required this.onPasteImageAttachment,
@ -553,9 +550,6 @@ class AssistantLowerPaneInternal extends StatelessWidget {
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final ValueChanged<ComposerAttachmentInternal> onAddAttachment;
final AssistantClipboardImageReader onPasteImageAttachment;
@ -587,9 +581,6 @@ class AssistantLowerPaneInternal extends StatelessWidget {
onToggleSkill: onToggleSkill,
onThinkingChanged: onThinkingChanged,
onModelChanged: onModelChanged,
onOpenGateway: onOpenGateway,
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
onReconnectGateway: onReconnectGateway,
onPickAttachments: onPickAttachments,
onAddAttachment: onAddAttachment,
onPasteImageAttachment: onPasteImageAttachment,

View File

@ -219,15 +219,6 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
controller.currentSessionKey,
modelId,
),
onOpenGateway: AssistantPageStateActionsInternal(
this,
).openGatewaySettingsInternal,
onOpenAiGatewaySettings: AssistantPageStateActionsInternal(
this,
).openAiGatewaySettingsInternal,
onReconnectGateway: AssistantPageStateActionsInternal(
this,
).connectFromSavedSettingsOrShowDialogInternal,
onPickAttachments: AssistantPageStateActionsInternal(
this,
).pickAttachmentsInternal,

View File

@ -43,9 +43,9 @@ String executionTargetTooltipInternal(AssistantExecutionTarget target) =>
'Task dialog mode: ${target.compactLabel}',
);
String gatewayProviderTooltipInternal() => appText(
'Gateway Provider: 🦞 $kCanonicalGatewayProviderLabel',
'Gateway Provider: 🦞 $kCanonicalGatewayProviderLabel',
String providerTooltipInternal(SingleAgentProvider provider) => appText(
'智能体 Provider: ${provider.label}',
'Agent provider: ${provider.label}',
);
String modelTooltipInternal(String modelLabel) =>

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
void main() {
group('AssistantLowerPaneInternal', () {
testWidgets('shows assistant providers and allows switching provider', (
tester,
) async {
final controller = AppController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await tester.pumpWidget(
_buildTestApp(
child: _buildLowerPane(controller: controller),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
);
await tester.pumpAndSettle();
expect(
controller.assistantProviderForSession(controller.currentSessionKey)
.providerId,
'opencode',
);
});
testWidgets('uses submit button instead of connect action', (tester) async {
final controller = AppController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
var sendCount = 0;
await tester.pumpWidget(
_buildTestApp(
child: _buildLowerPane(
controller: controller,
inputController: TextEditingController(text: 'hello'),
onSend: () async {
sendCount += 1;
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('提交'), findsOneWidget);
expect(find.text('连接'), findsNothing);
await tester.tap(find.byKey(const Key('assistant-send-button')));
await tester.pump();
expect(sendCount, 1);
});
});
}
Widget _buildTestApp({required Widget child}) {
return MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(
child: SizedBox(width: 1400, height: 360, child: child),
),
),
);
}
Widget _buildLowerPane({
required AppController controller,
TextEditingController? inputController,
Future<void> Function()? onSend,
}) {
final composerController = inputController ?? TextEditingController();
return SurfaceCard(
child: AssistantLowerPaneInternal(
bottomContentInset: 0,
controller: controller,
inputController: composerController,
focusNode: FocusNode(),
thinkingLabel: 'medium',
showModelControl: false,
modelLabel: 'gpt-5.4',
modelOptions: const <String>[],
attachments: const <ComposerAttachmentInternal>[],
availableSkills: const <ComposerSkillOptionInternal>[],
selectedSkillKeys: const <String>[],
onRemoveAttachment: (_) {},
onToggleSkill: (_) {},
onThinkingChanged: (_) {},
onModelChanged: (_) async {},
onPickAttachments: () {},
onAddAttachment: (_) {},
onPasteImageAttachment: () async => null,
onComposerContentHeightChanged: (_) {},
onComposerInputHeightChanged: (_) {},
onSend: onSend ?? () async {},
),
);
}

View File

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
void main() {
testWidgets('assistant lower pane matches desktop baseline', (tester) async {
final controller = AppController();
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await tester.binding.setSurfaceSize(const Size(1400, 360));
addTearDown(() async {
await tester.binding.setSurfaceSize(null);
});
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(
child: SizedBox(
width: 1400,
height: 360,
child: SurfaceCard(
child: AssistantLowerPaneInternal(
bottomContentInset: 0,
controller: controller,
inputController: TextEditingController(text: '修复智能体模式'),
focusNode: FocusNode(),
thinkingLabel: 'medium',
showModelControl: false,
modelLabel: 'gpt-5.4',
modelOptions: const <String>[],
attachments: const <ComposerAttachmentInternal>[],
availableSkills: const <ComposerSkillOptionInternal>[],
selectedSkillKeys: const <String>[],
onRemoveAttachment: (_) {},
onToggleSkill: (_) {},
onThinkingChanged: (_) {},
onModelChanged: (_) async {},
onPickAttachments: () {},
onAddAttachment: (_) {},
onPasteImageAttachment: () async => null,
onComposerContentHeightChanged: (_) {},
onComposerInputHeightChanged: (_) {},
onSend: () async {},
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/assistant_lower_pane.png'),
);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB