Refine assistant composer controls

This commit is contained in:
Haitao Pan 2026-03-18 17:49:08 +08:00
parent f6c9d219cf
commit 985e7d7b79
2 changed files with 250 additions and 39 deletions

View File

@ -49,7 +49,7 @@ class _AssistantPageState extends State<AssistantPage> {
late final ScrollController _conversationController;
late final FocusNode _composerFocusNode;
final String _mode = 'ask';
final String _thinkingLabel = 'high';
String _thinkingLabel = 'high';
double _threadRailWidth = 312;
String _threadQuery = '';
bool _sidePaneCollapsed = false;
@ -381,6 +381,11 @@ class _AssistantPageState extends State<AssistantPage> {
child: _AssistantLowerPane(
inputController: _inputController,
focusNode: _composerFocusNode,
thinkingLabel: _thinkingLabel,
modelLabel: controller.resolvedDefaultModel.isEmpty
? appText('未选择模型', 'No model selected')
: controller.resolvedDefaultModel,
modelOptions: controller.aiGatewayModelChoices,
attachments: _attachments,
availableSkills: _availableSkillOptions(controller),
selectedSkillKeys: _selectedSkillKeys,
@ -393,6 +398,10 @@ class _AssistantPageState extends State<AssistantPage> {
});
},
onToggleSkill: _toggleSelectedSkill,
onThinkingChanged: (value) {
setState(() => _thinkingLabel = value);
},
onModelChanged: controller.selectDefaultModel,
onOpenGateway: _showConnectDialog,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments,
@ -1219,11 +1228,16 @@ class _AssistantLowerPane extends StatelessWidget {
required this.controller,
required this.inputController,
required this.focusNode,
required this.thinkingLabel,
required this.modelLabel,
required this.modelOptions,
required this.attachments,
required this.availableSkills,
required this.selectedSkillKeys,
required this.onRemoveAttachment,
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onReconnectGateway,
required this.onPickAttachments,
@ -1233,11 +1247,16 @@ class _AssistantLowerPane extends StatelessWidget {
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String thinkingLabel;
final String modelLabel;
final List<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
@ -1253,11 +1272,16 @@ class _AssistantLowerPane extends StatelessWidget {
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: thinkingLabel,
modelLabel: modelLabel,
modelOptions: modelOptions,
attachments: attachments,
availableSkills: availableSkills,
selectedSkillKeys: selectedSkillKeys,
onRemoveAttachment: onRemoveAttachment,
onToggleSkill: onToggleSkill,
onThinkingChanged: onThinkingChanged,
onModelChanged: onModelChanged,
onOpenGateway: onOpenGateway,
onReconnectGateway: onReconnectGateway,
onPickAttachments: onPickAttachments,
@ -1887,11 +1911,16 @@ class _ComposerBar extends StatelessWidget {
required this.controller,
required this.inputController,
required this.focusNode,
required this.thinkingLabel,
required this.modelLabel,
required this.modelOptions,
required this.attachments,
required this.availableSkills,
required this.selectedSkillKeys,
required this.onRemoveAttachment,
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onReconnectGateway,
required this.onPickAttachments,
@ -1901,11 +1930,16 @@ class _ComposerBar extends StatelessWidget {
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String thinkingLabel;
final String modelLabel;
final List<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
@ -1919,6 +1953,8 @@ class _ComposerBar extends StatelessWidget {
final reconnectAvailable = controller.canQuickConnectGateway;
final connecting =
controller.connection.status == RuntimeConnectionStatus.connecting;
final executionTarget = controller.assistantExecutionTarget;
final permissionLevel = controller.assistantPermissionLevel;
final selectedSkills = availableSkills
.where((skill) => selectedSkillKeys.contains(skill.key))
.toList(growable: false);
@ -1935,6 +1971,70 @@ class _ComposerBar extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
PopupMenuButton<String>(
key: const Key('assistant-attachment-menu-button'),
tooltip: appText('添加文件等', 'Add files'),
offset: const Offset(0, 48),
onSelected: (value) {
switch (value) {
case 'attach':
onPickAttachments();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'attach',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.attach_file_rounded),
title: Text('添加照片和文件'),
),
),
],
child: const _ComposerIconButton(
icon: Icons.add_rounded,
),
),
const SizedBox(width: 10),
PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('本地或远程', 'Local or remote'),
onSelected: (value) {
controller.setAssistantExecutionTarget(value);
},
itemBuilder: (context) => AssistantExecutionTarget.values
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: executionTarget.icon,
label: executionTarget.label,
showChevron: true,
maxLabelWidth: 96,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 11,
),
),
),
],
),
const SizedBox(height: 12),
if (attachments.isNotEmpty) ...[
Wrap(
spacing: 8,
@ -2004,31 +2104,6 @@ class _ComposerBar extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<String>(
tooltip: appText('输入区操作', 'Composer actions'),
offset: const Offset(0, -180),
onSelected: (value) {
switch (value) {
case 'attach':
onPickAttachments();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'attach',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.attach_file_rounded),
title: Text('添加照片和文件'),
),
),
],
child: const _ComposerIconButton(
icon: Icons.add_rounded,
),
),
const SizedBox(width: 8),
InkWell(
key: const Key('assistant-skill-picker-button'),
borderRadius: BorderRadius.circular(AppRadius.chip),
@ -2045,6 +2120,108 @@ class _ComposerBar extends StatelessWidget {
maxLabelWidth: 132,
),
),
const SizedBox(width: 8),
PopupMenuButton<AssistantPermissionLevel>(
key: const Key('assistant-permission-button'),
tooltip: appText('权限', 'Permissions'),
onSelected: (value) {
controller.setAssistantPermissionLevel(value);
},
itemBuilder: (context) => AssistantPermissionLevel.values
.map(
(value) => PopupMenuItem<AssistantPermissionLevel>(
value: value,
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == permissionLevel)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: permissionLevel.icon,
label: permissionLevel.label,
showChevron: true,
maxLabelWidth: 120,
),
),
const SizedBox(width: 8),
modelOptions.isEmpty
? _ComposerToolbarChip(
key: const Key('assistant-model-button'),
icon: Icons.bolt_rounded,
label: modelLabel,
showChevron: false,
maxLabelWidth: 140,
)
: PopupMenuButton<String>(
key: const Key('assistant-model-button'),
tooltip: appText('模型', 'Model'),
onSelected: onModelChanged,
itemBuilder: (context) => modelOptions
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(child: Text(value)),
if (value == modelLabel)
const Icon(
Icons.check_rounded,
size: 18,
),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: Icons.bolt_rounded,
label: modelLabel,
showChevron: true,
maxLabelWidth: 140,
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
key: const Key('assistant-thinking-button'),
tooltip: appText('推理强度', 'Reasoning'),
onSelected: onThinkingChanged,
itemBuilder: (context) => const <String>[
'low',
'medium',
'high',
'max',
]
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(
child: Text(
_assistantThinkingLabel(value),
),
),
if (value == thinkingLabel)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: Icons.psychology_alt_outlined,
label: _assistantThinkingLabel(thinkingLabel),
showChevron: true,
maxLabelWidth: 96,
),
),
],
),
),
@ -2201,11 +2378,11 @@ class _ComposerIconButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
width: 44,
height: 44,
decoration: BoxDecoration(
color: context.palette.surfaceSecondary,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: context.palette.shadow.withValues(alpha: 0.04),
@ -2214,23 +2391,29 @@ class _ComposerIconButton extends StatelessWidget {
),
],
),
child: Icon(icon, size: 16, color: context.palette.textMuted),
child: Icon(icon, size: 20, color: context.palette.textMuted),
);
}
}
class _ComposerToolbarChip extends StatelessWidget {
const _ComposerToolbarChip({
super.key,
required this.icon,
required this.label,
required this.showChevron,
this.maxLabelWidth = 220,
this.padding = const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 6,
),
});
final IconData icon;
final String label;
final bool showChevron;
final double maxLabelWidth;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
@ -2238,10 +2421,7 @@ class _ComposerToolbarChip extends StatelessWidget {
final palette = context.palette;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 6,
),
padding: padding,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.chip),
@ -2283,6 +2463,20 @@ class _ComposerToolbarChip extends StatelessWidget {
}
}
extension on AssistantExecutionTarget {
IconData get icon => switch (this) {
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,
};
}
extension on AssistantPermissionLevel {
IconData get icon => switch (this) {
AssistantPermissionLevel.defaultAccess => Icons.verified_user_outlined,
AssistantPermissionLevel.fullAccess => Icons.error_outline_rounded,
};
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({
required this.label,
@ -3003,6 +3197,13 @@ String _toolCallStatusLabel(String status) =>
_ => appText('已完成', 'Completed'),
};
String _assistantThinkingLabel(String level) => switch (level) {
'low' => appText('', 'Low'),
'medium' => appText('', 'Medium'),
'max' => appText('超高', 'Max'),
_ => appText('', 'High'),
};
String _sessionDisplayTitle(GatewaySessionSummary session) {
final label = session.label.trim();
if (label.isEmpty || label == session.key) {

View File

@ -180,14 +180,15 @@ void main() {
expect(find.text('深度研究'), findsNothing);
expect(find.text('自动化'), findsNothing);
expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget);
expect(find.byKey(const Key('assistant-attachment-menu-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-execution-target-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-skill-picker-button')), findsOneWidget);
expect(find.byTooltip('执行目标'), findsNothing);
expect(find.byTooltip('权限'), findsNothing);
expect(find.byTooltip('模型'), findsNothing);
expect(find.byKey(const Key('assistant-permission-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-model-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-thinking-button')), findsOneWidget);
expect(find.byTooltip('模式'), findsNothing);
expect(find.byTooltip('推理强度'), findsNothing);
await tester.tap(find.byTooltip('输入区操作'));
await tester.tap(find.byKey(const Key('assistant-attachment-menu-button')));
await tester.pumpAndSettle();
expect(find.text('添加照片和文件'), findsOneWidget);
@ -198,6 +199,15 @@ void main() {
await tester.tapAt(const Offset(24, 24));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('assistant-execution-target-button')));
await tester.pumpAndSettle();
expect(find.text('本地'), findsWidgets);
expect(find.text('远程'), findsOneWidget);
await tester.tapAt(const Offset(24, 24));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('assistant-skill-picker-button')),
);