From b5cdf2f8d5074d3eecd9bf637347a4b8282da94c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 24 Mar 2026 13:02:01 +0800 Subject: [PATCH] feat(assistant): paste clipboard images as attachments --- ios/Podfile.lock | 12 + lib/features/assistant/assistant_page.dart | 258 ++++++++++++++++-- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile.lock | 12 + pubspec.lock | 56 ++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 10 files changed, 335 insertions(+), 26 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 63c310a4..79871570 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,19 +6,25 @@ PODS: - Flutter (1.0.0) - integration_test (0.0.1): - Flutter + - irondash_engine_context (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - super_native_extensions (0.0.1): + - Flutter DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - integration_test (from `.symlinks/plugins/integration_test/ios`) + - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) EXTERNAL SOURCES: device_info_plus: @@ -29,18 +35,24 @@ EXTERNAL SOURCES: :path: Flutter integration_test: :path: ".symlinks/plugins/integration_test/ios" + irondash_engine_context: + :path: ".symlinks/plugins/irondash_engine_context/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + super_native_extensions: + :path: ".symlinks/plugins/super_native_extensions/ios" SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 844762fa..5ee369b4 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -5,8 +5,11 @@ import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; 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'; @@ -29,6 +32,8 @@ const double _assistantHorizontalResizeHandleWidth = 6; const double _assistantHorizontalPaneGap = 2; const double _assistantVerticalResizeHandleHeight = 10; +typedef AssistantClipboardImageReader = Future Function(); + class AssistantPage extends StatefulWidget { const AssistantPage({ super.key, @@ -37,6 +42,7 @@ class AssistantPage extends StatefulWidget { this.navigationPanelBuilder, this.showStandaloneTaskRail = true, this.unifiedPaneStartsCollapsed = false, + this.clipboardImageReader, }); final AppController controller; @@ -44,6 +50,7 @@ class AssistantPage extends StatefulWidget { final Widget Function(double contentWidth)? navigationPanelBuilder; final bool showStandaloneTaskRail; final bool unifiedPaneStartsCollapsed; + final AssistantClipboardImageReader? clipboardImageReader; @override State createState() => _AssistantPageState(); @@ -507,6 +514,13 @@ class _AssistantPageState extends State { onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onPickAttachments: _pickAttachments, + onAddAttachment: (attachment) { + setState(() { + _attachments = [..._attachments, attachment]; + }); + }, + onPasteImageAttachment: + widget.clipboardImageReader ?? _readClipboardImageAsXFile, onComposerInputHeightChanged: _handleComposerInputHeightChanged, onSend: _submitPrompt, ), @@ -1645,6 +1659,8 @@ class _AssistantLowerPane extends StatelessWidget { required this.onOpenAiGatewaySettings, required this.onReconnectGateway, required this.onPickAttachments, + required this.onAddAttachment, + required this.onPasteImageAttachment, required this.onComposerInputHeightChanged, required this.onSend, }); @@ -1667,6 +1683,8 @@ class _AssistantLowerPane extends StatelessWidget { final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; + final ValueChanged<_ComposerAttachment> onAddAttachment; + final AssistantClipboardImageReader onPasteImageAttachment; final ValueChanged onComposerInputHeightChanged; final Future Function() onSend; @@ -1695,6 +1713,8 @@ class _AssistantLowerPane extends StatelessWidget { onOpenAiGatewaySettings: onOpenAiGatewaySettings, onReconnectGateway: onReconnectGateway, onPickAttachments: onPickAttachments, + onAddAttachment: onAddAttachment, + onPasteImageAttachment: onPasteImageAttachment, onInputHeightChanged: onComposerInputHeightChanged, onSend: onSend, ), @@ -2526,6 +2546,8 @@ class _ComposerBar extends StatefulWidget { required this.onOpenAiGatewaySettings, required this.onReconnectGateway, required this.onPickAttachments, + required this.onAddAttachment, + required this.onPasteImageAttachment, required this.onInputHeightChanged, required this.onSend, }); @@ -2548,6 +2570,8 @@ class _ComposerBar extends StatefulWidget { final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; + final ValueChanged<_ComposerAttachment> onAddAttachment; + final AssistantClipboardImageReader onPasteImageAttachment; final ValueChanged onInputHeightChanged; final Future Function() onSend; @@ -2560,8 +2584,16 @@ class _ComposerBarState extends State<_ComposerBar> { static const double _defaultInputHeight = _assistantComposerDefaultInputHeight; static const double _maxInputHeight = 220; + static const Map _pasteShortcuts = + { + SingleActivator(LogicalKeyboardKey.keyV, meta: true): + AssistantPasteIntent(), + SingleActivator(LogicalKeyboardKey.keyV, control: true): + AssistantPasteIntent(), + }; late double _inputHeight; + bool _handlingPasteShortcut = false; @override void initState() { @@ -2589,6 +2621,65 @@ class _ComposerBarState extends State<_ComposerBar> { widget.onInputHeightChanged(_inputHeight); } + Future _handlePasteShortcut() async { + if (_handlingPasteShortcut) { + return; + } + _handlingPasteShortcut = true; + try { + if (widget.controller + .featuresFor(resolveUiFeaturePlatformFromContext(context)) + .supportsFileAttachments) { + final imageFile = await widget.onPasteImageAttachment(); + if (!mounted) { + return; + } + if (imageFile != null) { + widget.onAddAttachment(_ComposerAttachment.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; + } + _insertTextAtSelection(text); + } finally { + _handlingPasteShortcut = false; + } + } + + void _insertTextAtSelection(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, + ); + } + @override Widget build(BuildContext context) { final palette = context.palette; @@ -2805,35 +2896,48 @@ class _ComposerBarState extends State<_ComposerBar> { SizedBox( key: const Key('assistant-composer-input-area'), height: _inputHeight, - child: TextField( - 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.chromeSurface, - contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: palette.chromeStroke), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: palette.accent.withValues(alpha: 0.18), + child: Shortcuts( + shortcuts: _pasteShortcuts, + child: Actions( + actions: >{ + AssistantPasteIntent: CallbackAction( + onInvoke: (_) { + unawaited(_handlePasteShortcut()); + return null; + }, ), - ), - hintText: appText( - '输入需求、补充上下文,XWorkmate 会沿用当前任务上下文持续处理。', - 'Describe the task or add context. XWorkmate keeps the current task context.', + }, + child: TextField( + 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.chromeSurface, + contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: palette.chromeStroke), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: palette.accent.withValues(alpha: 0.18), + ), + ), + hintText: appText( + '输入需求、补充上下文,XWorkmate 会沿用当前任务上下文持续处理。', + 'Describe the task or add context. XWorkmate keeps the current task context.', + ), + ), + onSubmitted: (_) => widget.onSend(), ), ), - onSubmitted: (_) => widget.onSend(), ), ), _ComposerResizeHandle( @@ -4566,3 +4670,105 @@ class _ComposerAttachment { ); } } + +class AssistantPasteIntent extends Intent { + const AssistantPasteIntent(); +} + +Future _readClipboardImageAsXFile() async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + return null; + } + final reader = await clipboard.read(); + return await _readClipboardImageForFormat( + reader, + format: Formats.png, + extension: 'png', + mimeType: 'image/png', + ) ?? + await _readClipboardImageForFormat( + reader, + format: Formats.jpeg, + extension: 'jpg', + mimeType: 'image/jpeg', + ) ?? + await _readClipboardImageForFormat( + reader, + format: Formats.gif, + extension: 'gif', + mimeType: 'image/gif', + ) ?? + await _readClipboardImageForFormat( + reader, + format: Formats.webp, + extension: 'webp', + mimeType: 'image/webp', + ); +} + +Future _readClipboardImageForFormat( + ClipboardReader reader, { + required FileFormat format, + required String extension, + required String mimeType, +}) async { + if (!reader.canProvide(format)) { + return null; + } + final bytes = await _readClipboardFileBytes(reader, format); + if (bytes == null || bytes.isEmpty) { + return null; + } + final temporaryDirectory = await _resolveClipboardAttachmentTempDirectory(); + final fileName = + 'clipboard-image-${DateTime.now().microsecondsSinceEpoch}.$extension'; + final file = File('${temporaryDirectory.path}/$fileName'); + await file.writeAsBytes(bytes, flush: true); + return XFile(file.path, mimeType: mimeType, name: fileName); +} + +Future _readClipboardFileBytes( + ClipboardReader reader, + FileFormat format, +) { + final completer = Completer(); + final progress = reader.getFile( + format, + (file) async { + try { + final bytes = await file.readAll(); + if (!completer.isCompleted) { + completer.complete(bytes); + } + } catch (error, stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + } + }, + onError: (error) { + if (!completer.isCompleted) { + completer.completeError(error); + } + }, + ); + if (progress == null) { + return Future.value(null); + } + return completer.future; +} + +Future _resolveClipboardAttachmentTempDirectory() async { + Directory rootDirectory; + try { + rootDirectory = await getTemporaryDirectory(); + } catch (_) { + rootDirectory = Directory.systemTemp; + } + final clipboardDirectory = Directory( + '${rootDirectory.path}/xworkmate-clipboard-attachments', + ); + await clipboardDirectory.create(recursive: true); + return clipboardDirectory; +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 64a0ecea..0dbee6e6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2db3c22a..c7ac82b9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + irondash_engine_context + super_native_extensions ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e0ddee57..9aa45ec2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,16 @@ import Foundation import device_info_plus import file_selector_macos +import irondash_engine_context import package_info_plus import shared_preferences_foundation +import super_native_extensions func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index df49e9ad..219c2e6b 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,18 +4,24 @@ PODS: - file_selector_macos (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - irondash_engine_context (0.0.1): + - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS DEPENDENCIES: - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) EXTERNAL SOURCES: device_info_plus: @@ -24,17 +30,23 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos FlutterMacOS: :path: Flutter/ephemeral + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos SPEC CHECKSUMS: device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba package_info_plus: f0052d280d17aa382b932f399edf32507174e870 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index dcec7904..3871389f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -284,6 +292,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" leak_tracker: dependency: transitive description: @@ -444,6 +468,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" platform: dependency: transitive description: @@ -569,6 +601,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" sync_http: dependency: transitive description: @@ -601,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e0a16362..6e2980f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: package_info_plus: ^8.3.1 path_provider: ^2.1.5 shared_preferences: ^2.5.3 + super_clipboard: ^0.9.0 web_socket_channel: ^3.0.3 yaml: ^3.1.3 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 77ab7a09..4fcbf216 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a423a024..829fcfea 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + irondash_engine_context + super_native_extensions ) list(APPEND FLUTTER_FFI_PLUGIN_LIST