diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 4e6ec614..7a06b1cc 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -49,7 +49,7 @@ class _AssistantPageState extends State { 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 { 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 { }); }, 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 modelOptions; final List<_ComposerAttachment> attachments; final List<_ComposerSkillOption> availableSkills; final List selectedSkillKeys; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final ValueChanged onToggleSkill; + final ValueChanged onThinkingChanged; + final Future Function(String modelId) onModelChanged; final VoidCallback onOpenGateway; final Future 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 modelOptions; final List<_ComposerAttachment> attachments; final List<_ComposerSkillOption> availableSkills; final List selectedSkillKeys; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final ValueChanged onToggleSkill; + final ValueChanged onThinkingChanged; + final Future Function(String modelId) onModelChanged; final VoidCallback onOpenGateway; final Future 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( + 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( + 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( + key: const Key('assistant-execution-target-button'), + tooltip: appText('本地或远程', 'Local or remote'), + onSelected: (value) { + controller.setAssistantExecutionTarget(value); + }, + itemBuilder: (context) => AssistantExecutionTarget.values + .map( + (value) => PopupMenuItem( + 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( - tooltip: appText('输入区操作', 'Composer actions'), - offset: const Offset(0, -180), - onSelected: (value) { - switch (value) { - case 'attach': - onPickAttachments(); - break; - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - 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( + key: const Key('assistant-permission-button'), + tooltip: appText('权限', 'Permissions'), + onSelected: (value) { + controller.setAssistantPermissionLevel(value); + }, + itemBuilder: (context) => AssistantPermissionLevel.values + .map( + (value) => PopupMenuItem( + 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( + key: const Key('assistant-model-button'), + tooltip: appText('模型', 'Model'), + onSelected: onModelChanged, + itemBuilder: (context) => modelOptions + .map( + (value) => PopupMenuItem( + 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( + key: const Key('assistant-thinking-button'), + tooltip: appText('推理强度', 'Reasoning'), + onSelected: onThinkingChanged, + itemBuilder: (context) => const [ + 'low', + 'medium', + 'high', + 'max', + ] + .map( + (value) => PopupMenuItem( + 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) { diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index deafb0e3..2a8befcc 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -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')), );