xworkmate-app/lib/features/assistant/assistant_page_composer_bar.dart

837 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path_provider/path_provider.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/assistant_artifact_sidebar.dart';
import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
import 'assistant_page_main.dart';
import 'assistant_page_components.dart';
import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
class ComposerBarInternal extends StatefulWidget {
const ComposerBarInternal({
super.key,
required this.controller,
required this.inputController,
required this.focusNode,
required this.thinkingLabel,
required this.showModelControl,
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.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onPickAttachments,
required this.onAddAttachment,
required this.onPasteImageAttachment,
required this.onContentHeightChanged,
required this.onInputHeightChanged,
required this.onSend,
});
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String thinkingLabel;
final bool showModelControl;
final String modelLabel;
final List<String> modelOptions;
final List<ComposerAttachmentInternal> attachments;
final List<ComposerSkillOptionInternal> availableSkills;
final List<String> selectedSkillKeys;
final ValueChanged<ComposerAttachmentInternal> onRemoveAttachment;
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;
final ValueChanged<double> onContentHeightChanged;
final ValueChanged<double> onInputHeightChanged;
final Future<void> Function() onSend;
@override
State<ComposerBarInternal> createState() => ComposerBarStateInternal();
}
class ComposerBarStateInternal extends State<ComposerBarInternal> {
static const double minInputHeightInternal = 68;
static const double defaultInputHeightInternal =
assistantComposerDefaultInputHeightInternal;
static const double maxInputHeightInternal = 220;
static const Map<ShortcutActivator, Intent> pasteShortcutsInternal =
<ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyV, meta: true):
AssistantPasteIntent(),
SingleActivator(LogicalKeyboardKey.keyV, control: true):
AssistantPasteIntent(),
};
late double inputHeightInternal;
final GlobalKey skillPickerTargetKeyInternal = GlobalKey(
debugLabel: 'assistant-skill-picker-target',
);
final GlobalKey contentKeyInternal = GlobalKey(
debugLabel: 'assistant-composer-bar',
);
final LayerLink skillPickerLayerLinkInternal = LayerLink();
final OverlayPortalController skillPickerPortalControllerInternal =
OverlayPortalController(debugLabel: 'assistant-skill-picker');
late final TextEditingController skillPickerSearchControllerInternal;
late final FocusNode skillPickerSearchFocusNodeInternal;
bool handlingPasteShortcutInternal = false;
bool refreshingSingleAgentSkillsInternal = false;
String skillPickerQueryInternal = '';
@override
void initState() {
super.initState();
inputHeightInternal = defaultInputHeightInternal;
skillPickerSearchControllerInternal = TextEditingController();
skillPickerSearchFocusNodeInternal = FocusNode();
widget.controller.addListener(handleControllerChangedInternal);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
widget.onInputHeightChanged(inputHeightInternal);
reportContentHeightInternal();
});
}
@override
void didUpdateWidget(covariant ComposerBarInternal oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(handleControllerChangedInternal);
widget.controller.addListener(handleControllerChangedInternal);
}
reportContentHeightInternal();
}
Future<void> refreshSingleAgentSkillsInternal() async {
if (refreshingSingleAgentSkillsInternal) {
return;
}
setState(() {
refreshingSingleAgentSkillsInternal = true;
});
try {
await widget.controller.refreshSingleAgentLocalSkillsForSession(
widget.controller.currentSessionKey,
);
} finally {
if (mounted) {
setState(() {
refreshingSingleAgentSkillsInternal = false;
});
}
}
}
List<ComposerSkillOptionInternal> activeSkillOptionsInternal() {
if (widget.controller.isSingleAgentMode) {
return widget.controller
.assistantImportedSkillsForSession(
widget.controller.currentSessionKey,
)
.map(skillOptionFromThreadSkillInternal)
.toList(growable: false);
}
return widget.availableSkills;
}
List<ComposerSkillOptionInternal> filteredSkillOptionsInternal() {
final normalizedQuery = skillPickerQueryInternal.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return activeSkillOptionsInternal();
}
return activeSkillOptionsInternal()
.where((skill) {
final haystack =
'${skill.label}\n${skill.description}\n${skill.sourceLabel}'
.toLowerCase();
return haystack.contains(normalizedQuery);
})
.toList(growable: false);
}
Widget buildSkillPickerOverlayInternal(BuildContext context) =>
buildSkillPickerOverlayForInternal(this, context);
void hideSkillPickerInternal() {
if (skillPickerPortalControllerInternal.isShowing) {
skillPickerPortalControllerInternal.hide();
}
if (skillPickerQueryInternal.isNotEmpty ||
skillPickerSearchControllerInternal.text.isNotEmpty) {
setState(resetSkillPickerSearchInternal);
}
}
void toggleSkillPickerInternal() {
if (skillPickerPortalControllerInternal.isShowing) {
hideSkillPickerInternal();
return;
}
setState(resetSkillPickerSearchInternal);
skillPickerPortalControllerInternal.show();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !skillPickerPortalControllerInternal.isShowing) {
return;
}
skillPickerSearchFocusNodeInternal.requestFocus();
});
if (widget.controller.isSingleAgentMode) {
unawaited(refreshSingleAgentSkillsInternal());
}
}
@override
void dispose() {
widget.controller.removeListener(handleControllerChangedInternal);
if (skillPickerPortalControllerInternal.isShowing) {
skillPickerPortalControllerInternal.hide();
}
skillPickerSearchControllerInternal.dispose();
skillPickerSearchFocusNodeInternal.dispose();
super.dispose();
}
void handleControllerChangedInternal() {
if (!mounted || !skillPickerPortalControllerInternal.isShowing) {
return;
}
setState(() {});
}
void reportContentHeightInternal() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final height = contentKeyInternal.currentContext?.size?.height;
if (height == null || !height.isFinite || height <= 0) {
return;
}
widget.onContentHeightChanged(height);
});
}
void resizeInputInternal(double delta) {
final nextHeight = (inputHeightInternal + delta).clamp(
minInputHeightInternal,
maxInputHeightInternal,
);
if (nextHeight == inputHeightInternal) {
return;
}
setState(() {
inputHeightInternal = nextHeight;
});
widget.onInputHeightChanged(inputHeightInternal);
}
Future<void> handlePasteShortcutInternal() async {
if (handlingPasteShortcutInternal) {
return;
}
handlingPasteShortcutInternal = true;
try {
if (widget.controller
.featuresFor(resolveUiFeaturePlatformFromContext(context))
.supportsFileAttachments) {
final imageFile = await widget.onPasteImageAttachment();
if (!mounted) {
return;
}
if (imageFile != null) {
widget.onAddAttachment(
ComposerAttachmentInternal.fromXFile(imageFile),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'已从剪贴板添加图片附件',
'Added image from clipboard as attachment',
),
),
),
);
return;
}
}
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
if (!mounted || text == null || text.isEmpty) {
return;
}
insertTextAtSelectionInternal(text);
} finally {
handlingPasteShortcutInternal = false;
}
}
void insertTextAtSelectionInternal(String text) {
final currentValue = widget.inputController.value;
final selection = currentValue.selection;
final textLength = currentValue.text.length;
final start = selection.isValid
? math.min(selection.start, selection.end).clamp(0, textLength)
: textLength;
final end = selection.isValid
? math.max(selection.start, selection.end).clamp(0, textLength)
: textLength;
final updatedText = currentValue.text.replaceRange(start, end, text);
final cursorOffset = start + text.length;
widget.inputController.value = currentValue.copyWith(
text: updatedText,
selection: TextSelection.collapsed(offset: cursorOffset),
composing: TextRange.empty,
);
}
void resetSkillPickerSearchInternal() {
skillPickerSearchControllerInternal.clear();
skillPickerQueryInternal = '';
}
void setSkillPickerQueryInternal(String value) {
setState(() {
skillPickerQueryInternal = value;
});
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final controller = widget.controller;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final connectionState = controller.currentAssistantConnectionState;
final singleAgent = connectionState.isSingleAgent;
final connected = connectionState.connected;
final reconnectAvailable = controller.canQuickConnectGateway;
final connecting = connectionState.connecting;
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
final currentExecutionTarget = controller.assistantExecutionTarget;
final executionTarget =
visibleExecutionTargets.contains(currentExecutionTarget)
? currentExecutionTarget
: (visibleExecutionTargets.isNotEmpty
? visibleExecutionTargets.first
: currentExecutionTarget);
final compactExecutionTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final compactExecutionTarget = collapseAssistantExecutionTargetForDisplay(
executionTarget,
);
final permissionLevel = controller.assistantPermissionLevel;
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
final displayedSingleAgentProvider =
controller.currentSingleAgentResolvedProvider ??
controller.currentSingleAgentProvider;
final submitLabel = connected
? appText('提交', 'Submit')
: singleAgent
? appText('提交', 'Submit')
: connecting
? appText('连接中…', 'Connecting…')
: reconnectAvailable
? appText('重连', 'Reconnect')
: appText('连接', 'Connect');
reportContentHeightInternal();
return Padding(
key: contentKeyInternal,
padding: const EdgeInsets.fromLTRB(10, 8, 10, 0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (uiFeatures.supportsFileAttachments) ...[
PopupMenuButton<String>(
key: const Key('assistant-attachment-menu-button'),
tooltip: appText('添加文件等', 'Add files'),
offset: const Offset(0, 48),
onSelected: (value) {
switch (value) {
case 'attach':
widget.onPickAttachments();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'attach',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.attach_file_rounded),
title: Text('添加照片和文件'),
),
),
],
child: const ComposerIconButtonInternal(
icon: Icons.add_rounded,
),
),
const SizedBox(width: 6),
],
if (compactExecutionTargets.isNotEmpty) ...[
PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) {
final resolvedTarget =
value == AssistantExecutionTarget.singleAgent
? AssistantExecutionTarget.singleAgent
: resolveGatewayExecutionTargetFromVisibleTargets(
visibleExecutionTargets,
currentTarget: executionTarget,
);
controller.setAssistantExecutionTarget(resolvedTarget);
},
itemBuilder: (context) => compactExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
key: Key(
'assistant-execution-target-menu-item-${value.name}',
),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.compactLabel)),
if (value == compactExecutionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: ComposerToolbarChipInternal(
icon: compactExecutionTarget.icon,
tooltip: executionTargetTooltipInternal(
compactExecutionTarget,
),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (singleAgent) ...[
PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-single-agent-provider-button'),
tooltip: appText('Bridge Provider', 'Bridge Provider'),
onSelected: (value) {
unawaited(controller.setSingleAgentProvider(value));
},
itemBuilder: (context) => controller
.singleAgentProviderOptions
.map(
(value) => PopupMenuItem<SingleAgentProvider>(
value: value,
key: Key(
'assistant-single-agent-provider-menu-item-${value.providerId}',
),
child: Row(
children: [
SingleAgentProviderBadgeInternal(provider: value),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value ==
controller.currentSingleAgentProvider)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: ComposerToolbarChipInternal(
leading: SingleAgentProviderBadgeInternal(
provider: displayedSingleAgentProvider,
),
tooltip: singleAgentProviderTooltipInternal(
displayedSingleAgentProvider,
),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
] else ...[
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: [
Icon(Icons.cloud_outlined, size: 18),
SizedBox(width: 10),
Expanded(child: Text(kCanonicalGatewayProviderLabel)),
Icon(Icons.check_rounded, size: 18),
],
),
),
],
child: ComposerToolbarChipInternal(
icon: Icons.cloud_outlined,
tooltip: gatewayProviderTooltipInternal(),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (widget.showModelControl) ...[
widget.modelOptions.isEmpty
? ComposerToolbarChipInternal(
key: const Key('assistant-model-button'),
icon: Icons.bolt_rounded,
tooltip: modelTooltipInternal(widget.modelLabel),
showChevron: false,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
)
: PopupMenuButton<String>(
key: const Key('assistant-model-button'),
tooltip: appText('模型', 'Model'),
onSelected: widget.onModelChanged,
itemBuilder: (context) => widget.modelOptions
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(child: Text(value)),
if (value == widget.modelLabel)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: ComposerToolbarChipInternal(
icon: Icons.bolt_rounded,
tooltip: modelTooltipInternal(widget.modelLabel),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
],
),
const SizedBox(height: 8),
if (widget.attachments.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: widget.attachments
.map(
(attachment) => InputChip(
avatar: Icon(attachment.icon, size: 16),
label: Text(attachment.name),
onDeleted: () => widget.onRemoveAttachment(attachment),
),
)
.toList(),
),
const SizedBox(height: 6),
],
SizedBox(
key: const Key('assistant-composer-input-area'),
height: inputHeightInternal,
child: Shortcuts(
shortcuts: pasteShortcutsInternal,
child: Actions(
actions: <Type, Action<Intent>>{
AssistantPasteIntent: CallbackAction<AssistantPasteIntent>(
onInvoke: (_) {
unawaited(handlePasteShortcutInternal());
return null;
},
),
},
child: TextField(
key: const Key('assistant-input-field'),
controller: widget.inputController,
focusNode: widget.focusNode,
autofocus: true,
expands: true,
minLines: null,
maxLines: null,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
isCollapsed: true,
filled: true,
fillColor: palette.surfacePrimary,
contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: palette.strokeSoft),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: palette.accent.withValues(alpha: 0.24),
),
),
hintText: appText(
'输入需求、补充上下文XWorkmate 会沿用当前任务上下文持续处理。',
'Describe the task or add context. XWorkmate keeps the current task context.',
),
),
onSubmitted: (_) => widget.onSend(),
),
),
),
),
ComposerResizeHandleInternal(
key: const Key('assistant-composer-resize-handle'),
onDelta: resizeInputInternal,
),
if (selectedSkills.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: selectedSkills
.map(
(skill) => ComposerSelectedSkillChipInternal(
key: ValueKey<String>(
'assistant-selected-skill-${skill.key}',
),
option: skill,
onDeleted: () => widget.onToggleSkill(skill.key),
),
)
.toList(growable: false),
),
],
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CompositedTransformTarget(
key: skillPickerTargetKeyInternal,
link: skillPickerLayerLinkInternal,
child: OverlayPortal(
controller: skillPickerPortalControllerInternal,
overlayChildBuilder: buildSkillPickerOverlayInternal,
child: InkWell(
key: const Key('assistant-skill-picker-button'),
borderRadius: BorderRadius.circular(AppRadius.chip),
onTap: toggleSkillPickerInternal,
child: ComposerToolbarChipInternal(
icon: Icons.auto_awesome_rounded,
tooltip: skillsTooltipInternal(
selectedSkills.length,
),
showChevron: true,
),
),
),
),
const SizedBox(width: 6),
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: ComposerToolbarChipInternal(
icon: permissionLevel.icon,
tooltip: permissionTooltipInternal(permissionLevel),
showChevron: true,
),
),
const SizedBox(width: 6),
PopupMenuButton<String>(
key: const Key('assistant-thinking-button'),
tooltip: appText('推理强度', 'Reasoning'),
onSelected: widget.onThinkingChanged,
itemBuilder: (context) =>
const <String>['low', 'medium', 'high', 'max']
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(
child: Text(
assistantThinkingLabelInternal(
value,
),
),
),
if (value == widget.thinkingLabel)
const Icon(
Icons.check_rounded,
size: 18,
),
],
),
),
)
.toList(),
child: ComposerToolbarChipInternal(
icon: Icons.psychology_alt_outlined,
tooltip: thinkingTooltipInternal(
widget.thinkingLabel,
),
showChevron: true,
),
),
],
),
),
),
const SizedBox(width: 8),
Tooltip(
message: submitLabel,
child: FilledButton(
key: const Key('assistant-send-button'),
onPressed: connecting
? null
: connected
? widget.onSend
: singleAgent
? widget.onSend
: reconnectAvailable
? () async {
await widget.onReconnectGateway();
}
: widget.onOpenGateway,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
minimumSize: const Size(64, 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
connected
? Icons.arrow_upward_rounded
: singleAgent
? Icons.arrow_upward_rounded
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
size: 18,
),
const SizedBox(width: 4),
Text(submitLabel),
],
),
),
),
],
),
],
),
);
}
}