Merge branch 'main' into feat/link_preview

This commit is contained in:
Morn 2025-04-10 00:29:19 +08:00
commit b844aebd00
71 changed files with 883 additions and 316 deletions

View file

@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi" LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.8" APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart" FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy" PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0" MACOSX_DEPLOYMENT_TARGET = "11.0"

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0); expect(editorState.selection!.start.offset, 0);
}); });
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
}); });
} }

View file

@ -133,7 +133,6 @@ Future<bool> _afLaunchLocalUri(
}; };
if (context != null && context.mounted) { if (context != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: message, message: message,
type: result.type == ResultType.done type: result.type == ResultType.done
? ToastificationType.success ? ToastificationType.success

View file

@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) { listener: (context, state) {
if (state.isLocked) { if (state.isLocked) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(), message: LocaleKeys.lockPage_pageLockedToast.tr(),
); );
@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) { listener: (context, state) {
if (state.isLocked) { if (state.isLocked) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(), message: LocaleKeys.lockPage_pageLockedToast.tr(),
); );
} }

View file

@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop(); context.pop();
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(), message: LocaleKeys.button_duplicateSuccessfully.tr(),
); );
} }
@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context); _toggleFavorite(context);
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(), message: LocaleKeys.button_favoriteSuccessfully.tr(),
); );
} }
@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context); _toggleFavorite(context);
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(), message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
); );
} }
@ -202,7 +199,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
), ),
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }
@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
), ),
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(), message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
); );
} else { } else {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) { if (state.publishResult != null) {
state.publishResult!.fold( state.publishResult!.fold(
(value) => showToastNotification( (value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(), message: LocaleKeys.publish_publishSuccessfully.tr(),
), ),
(error) => showToastNotification( (error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error, type: ToastificationType.error,
), ),
@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) { } else if (state.unpublishResult != null) {
state.unpublishResult!.fold( state.unpublishResult!.fold(
(value) => showToastNotification( (value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(), message: LocaleKeys.publish_unpublishSuccessfully.tr(),
), ),
(error) => showToastNotification( (error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(), message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg, description: error.msg,
type: ToastificationType.error, type: ToastificationType.error,
@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess( state.updatePathNameResult!.onSuccess(
(value) { (value) {
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
); );

View file

@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context); Navigator.pop(context);
context.read<ViewBloc>().add(const ViewEvent.duplicate()); context.read<ViewBloc>().add(const ViewEvent.duplicate());
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(), message: LocaleKeys.button_duplicateSuccessfully.tr(),
); );
break; break;
@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
.read<FavoriteBloc>() .read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view)); .add(FavoriteEvent.toggle(widget.view));
showToastNotification( showToastNotification(
context,
message: !widget.view.isFavorite message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr() ? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(), : LocaleKeys.button_unfavoriteSuccessfully.tr(),
@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context); Navigator.pop(context);
showToastNotification( showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(), message: LocaleKeys.sideBar_removeSuccess.tr(),
); );
}, },

View file

@ -45,7 +45,6 @@ enum MobilePaneActionType {
size: 24.0, size: 24.0,
onPressed: (context) { onPressed: (context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(), message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
); );
@ -61,7 +60,6 @@ enum MobilePaneActionType {
size: 24.0, size: 24.0,
onPressed: (context) { onPressed: (context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(), message: LocaleKeys.button_favoriteSuccessfully.tr(),
); );

View file

@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
Log.info('Open row page(${widget.documentId})'); Log.info('Open row page(${widget.documentId})');
if (view == null) { if (view == null) {
showToastNotification(context, message: 'Failed to open row page'); showToastNotification(message: 'Failed to open row page');
// reload the view again // reload the view again
unawaited(_preloadView(context)); unawaited(_preloadView(context));
Log.error('Failed to open row page(${widget.documentId})'); Log.error('Failed to open row page(${widget.documentId})');

View file

@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
} }
if (message != null) { if (message != null) {
showToastNotification(context, message: message, type: toastType); showToastNotification(message: message, type: toastType);
} }
} }
} }

View file

@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(const SpaceEvent.duplicate()); context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification( showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(), message: LocaleKeys.space_success_duplicateSpace.tr(),
); );
@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
.add(SpaceEvent.rename(space: widget.space, name: name)); .add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification( showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(), message: LocaleKeys.space_success_renameSpace.tr(),
); );
}, },
@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(), message: LocaleKeys.space_success_updateSpace.tr(),
); );
@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space)); context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification( showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(), message: LocaleKeys.space_success_deleteSpace.tr(),
); );

View file

@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
} }
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess .settings_notifications_markAsReadNotifications_allSuccess
.tr(), .tr(),
@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
} }
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(), .tr(),
); );

View file

@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onMarkAllAsRead(BuildContext context) { void _onMarkAllAsRead(BuildContext context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess .settings_notifications_markAsReadNotifications_allSuccess
.tr(), .tr(),
@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onArchiveAll(BuildContext context) { void _onArchiveAll(BuildContext context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(), .tr(),
); );
@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
} }
showToastNotification( showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)', message: 'Unarchive all success (Debug Mode)',
); );

View file

@ -31,7 +31,6 @@ enum NotificationPaneActionType {
size: 24.0, size: 24.0,
onPressed: (context) { onPressed: (context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_notifications_markAsReadNotifications_success .settings_notifications_markAsReadNotifications_success
.tr(), .tr(),
@ -55,7 +54,6 @@ enum NotificationPaneActionType {
size: 24.0, size: 24.0,
onPressed: (context) { onPressed: (context) {
showToastNotification( showToastNotification(
context,
message: 'Unarchive notification success', message: 'Unarchive notification success',
); );
@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(), .tr(),
); );
@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
void _onArchive(BuildContext context) { void _onArchive(BuildContext context) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr() .tr()
.tr(), .tr(),

View file

@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(), message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
); );
} }

View file

@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
); );
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(), message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
); );
} }

View file

@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold( result.fold(
(s) { (s) {
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight, bottomPadding: keyboardHeight,
@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
}); });
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
bottomPadding: keyboardHeight, bottomPadding: keyboardHeight,
message: message, message: message,
@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold( result.fold(
(s) { (s) {
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight, bottomPadding: keyboardHeight,
@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
}); });
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: message, message: message,
bottomPadding: keyboardHeight, bottomPadding: keyboardHeight,
@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold( result.fold(
(s) { (s) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess .settings_appearance_members_removeFromWorkspaceSuccess
.tr(), .tr(),
@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
}, },
(f) { (f) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: LocaleKeys message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed .settings_appearance_members_removeFromWorkspaceFailed
@ -283,7 +277,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final email = emailController.text; final email = emailController.text;
if (!isEmail(email)) { if (!isEmail(email)) {
return showToastNotification( return showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
); );

View file

@ -184,7 +184,6 @@ class CopyButton extends StatelessWidget {
); );
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }

View file

@ -376,7 +376,6 @@ class ChatAIMessagePopup extends StatelessWidget {
} }
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }

View file

@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart';
void openPageFromMessage(BuildContext context, ViewPB? view) { void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) { if (view == null) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(), message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
return; return;
} }
showToastNotification( showToastNotification(
context,
richMessage: TextSpan( richMessage: TextSpan(
children: [ children: [
TextSpan( TextSpan(

View file

@ -442,7 +442,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final context = AppGlobals.rootNavKey.currentContext; final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) { if (context != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: 'document integrity check failed', message: 'document integrity check failed',
type: ToastificationType.error, type: ToastificationType.error,
); );

View file

@ -150,7 +150,6 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
}); });
} else { } else {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
); );
} }
@ -196,7 +195,6 @@ class ImproveWritingButton extends StatelessWidget {
_insertAiNode(editorState, AiWriterCommand.improveWriting); _insertAiNode(editorState, AiWriterCommand.improveWriting);
} else { } else {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
); );
} }

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
@ -15,6 +16,11 @@ import 'ai_writer_block_operations.dart';
import 'ai_writer_entities.dart'; import 'ai_writer_entities.dart';
import 'ai_writer_node_extension.dart'; import 'ai_writer_node_extension.dart';
/// Enable the debug log for the AiWriterCubit.
///
/// This is useful for debugging the AI writer cubit.
const _aiWriterCubitDebugLog = false;
class AiWriterCubit extends Cubit<AiWriterState> { class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCubit({ AiWriterCubit({
required this.documentId, required this.documentId,
@ -95,6 +101,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
final command = node.aiWriterCommand; final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command); final (run, prompt) = await _addSelectionTextToRecords(command);
_aiWriterCubitLog(
'command: $command, run: $run, prompt: $prompt',
);
if (!run) { if (!run) {
await exit(); await exit();
return; return;
@ -211,20 +221,26 @@ class AiWriterCubit extends Cubit<AiWriterState> {
return; return;
} }
// Accept
//
// If the user clicks accept, we need to replace the selection with the AI's response
if (action case SuggestionAction.accept) { if (action case SuggestionAction.accept) {
await _textRobot.persist(); // trim the markdown text to avoid extra new lines
await formatSelection( final trimmedMarkdownText = _textRobot.markdownText.trim();
editorState,
selection, _aiWriterCubitLog(
ApplySuggestionFormatType.clear, 'trigger accept action, markdown text: $trimmedMarkdownText',
); );
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes); await _textRobot.deleteAINodes();
await editorState.apply(
transaction, await _textRobot.replace(
withUpdateSelection: false, selection: selection,
markdownText: trimmedMarkdownText,
); );
await exit(withDiscard: false, withUnformat: false); await exit(withDiscard: false, withUnformat: false);
return; return;
} }
@ -276,17 +292,24 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCommand command, AiWriterCommand command,
) async { ) async {
final node = aiWriterNode; final node = aiWriterNode;
// check the node is registered
if (node == null) { if (node == null) {
return (false, ''); return (false, '');
} }
// check the selection is valid
final selection = node.aiWriterSelection?.normalized; final selection = node.aiWriterSelection?.normalized;
if (selection == null) { if (selection == null) {
return (false, ''); return (false, '');
} }
// if the command is continue writing, we don't need to get the selection text
if (command == AiWriterCommand.continueWriting) { if (command == AiWriterCommand.continueWriting) {
return (true, ''); return (true, '');
} }
// if the selection is collapsed, we don't need to get the selection text
if (selection.isCollapsed) { if (selection.isCollapsed) {
return (true, ''); return (true, '');
} }
@ -297,6 +320,7 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add( records.add(
AiWriterRecord.user(content: selectionText, format: null), AiWriterRecord.user(content: selectionText, format: null),
); );
return (true, ''); return (true, '');
} else { } else {
return (true, selectionText); return (true, selectionText);
@ -540,6 +564,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
attributes: ApplySuggestionFormatType.replace.attributes, attributes: ApplySuggestionFormatType.replace.attributes,
); );
onAppendToDocument?.call(); onAppendToDocument?.call();
_aiWriterCubitLog(
'received message: $text',
);
}, },
processAssistMessage: (text) async { processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) { if (state case final GeneratingAiWriterState generatingState) {
@ -551,6 +579,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
), ),
); );
} }
_aiWriterCubitLog(
'received assist message: $text',
);
}, },
onEnd: () async { onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) { if (state case final GeneratingAiWriterState generatingState) {
@ -567,6 +599,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add( records.add(
AiWriterRecord.ai(content: _textRobot.markdownText), AiWriterRecord.ai(content: _textRobot.markdownText),
); );
_aiWriterCubitLog(
'returned response: ${_textRobot.markdownText}',
);
} }
}, },
onError: (error) async { onError: (error) async {
@ -658,6 +694,12 @@ class AiWriterCubit extends Cubit<AiWriterState> {
); );
} }
} }
void _aiWriterCubitLog(String message) {
if (_aiWriterCubitDebugLog) {
Log.debug('[AiWriterCubit] $message');
}
}
} }
mixin RegisteredAiWriter { mixin RegisteredAiWriter {

View file

@ -57,11 +57,30 @@ extension AiWriterNodeExtension on EditorState {
slicedNodes.add(copiedNode); slicedNodes.add(copiedNode);
} }
for (final (i, node) in slicedNodes.indexed) {
final childNodesShouldBeDeleted = <Node>[];
for (final child in node.children) {
if (!child.path.inSelection(selection)) {
childNodesShouldBeDeleted.add(child);
}
}
for (final child in childNodesShouldBeDeleted) {
slicedNodes[i] = node.copyWith(
children: node.children.where((e) => e.id != child.id).toList(),
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
);
}
}
// use \n\n as line break to improve the ai response
// using \n will cause the ai response treat the text as a single line
final markdown = await customDocumentToMarkdown( final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes), Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
); );
return markdown; // trim the last \n if it exists
return markdown.trimRight();
} }
List<String> getPlainTextInSelection(Selection? selection) { List<String> getPlainTextInSelection(Selection? selection) {

View file

@ -110,10 +110,13 @@ class MarkdownTextRobot {
} }
/// Persist the text into the document /// Persist the text into the document
Future<void> persist({String? markdownText}) async { Future<void> persist({
String? markdownText,
}) async {
if (markdownText != null) { if (markdownText != null) {
_markdownText = markdownText; _markdownText = markdownText;
} }
await _lock.synchronized(() async { await _lock.synchronized(() async {
await _refresh(inMemoryUpdate: false); await _refresh(inMemoryUpdate: false);
}); });
@ -124,6 +127,34 @@ class MarkdownTextRobot {
} }
} }
/// Replace the selected content with the AI's response
Future<void> replace({
required Selection selection,
required String markdownText,
}) async {
if (selection.isSingle) {
await _replaceInSameLine(
selection: selection,
markdownText: markdownText,
);
} else {
await _replaceInMultiLines(
selection: selection,
markdownText: markdownText,
);
}
}
/// Delete the temporary inserted AI nodes
Future<void> deleteAINodes() async {
final nodes = getInsertedNodes();
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
);
}
/// Discard the inserted content /// Discard the inserted content
Future<void> discard() async { Future<void> discard() async {
final start = _insertPosition; final start = _insertPosition;
@ -282,6 +313,161 @@ class MarkdownTextRobot {
children: children, children: children,
); );
} }
/// If the selected content is in the same line,
/// keep the selected node and replace the delta.
Future<void> _replaceInSameLine({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is not a single node, do nothing.
if (!selection.isSingle) {
assert(false, 'Expected single node selection');
Log.error('Expected single node selection');
return;
}
final startIndex = selection.startIndex;
final endIndex = selection.endIndex;
final length = endIndex - startIndex;
// Get the selected node.
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
assert(false, 'Expected non-null node and delta');
Log.error('Expected non-null node and delta');
return;
}
// Convert the markdown text to delta.
// Question: Why we need to convert the markdown to document first?
// Answer: Because the markdown text may contain the list item,
// if we convert the markdown to delta directly, the list item will be
// treated as a normal text node, and the delta will be incorrect.
// For example, the markdown text is:
// ```
// 1. item1
// ```
// if we convert the markdown to delta directly, the delta will be:
// ```
// [
// {
// "insert": "1. item1"
// }
// ]
// ```
// if we convert the markdown to document first, the document will be:
// ```
// [
// {
// "type": "numbered_list",
// "children": [
// {
// "insert": "item1"
// }
// ]
// }
// ]
final document = customMarkdownToDocument(markdownText);
final decoder = DeltaMarkdownDecoder();
final markdownDelta =
document.nodeAtPath([0])?.delta ?? decoder.convert(markdownText);
// Replace the delta of the selected node.
final transaction = editorState.transaction;
transaction
..deleteText(node, startIndex, length)
..insertTextDelta(node, startIndex, markdownDelta);
await editorState.apply(transaction);
}
/// If the selected content is in multiple lines
Future<void> _replaceInMultiLines({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is a single node, do nothing.
if (selection.isSingle) {
assert(false, 'Expected multi-line selection');
Log.error('Expected multi-line selection');
return;
}
final markdownNodes = customMarkdownToDocument(
markdownText,
tableWidth: 250.0,
).root.children;
// Get the selected nodes.
final nodes = editorState.getNodesInSelection(selection);
// Note: Don't change its order, otherwise the delta will be incorrect.
// step 1. merge the first selected node and the first node from the ai response
// step 2. merge the last selected node and the last node from the ai response
// step 3. insert the middle nodes from the ai response
// step 4. delete the middle nodes
final transaction = editorState.transaction;
// step 1
final firstNode = nodes.firstOrNull;
final delta = firstNode?.delta;
final firstMarkdownNode = markdownNodes.firstOrNull;
final firstMarkdownDelta = firstMarkdownNode?.delta;
if (firstNode != null &&
delta != null &&
firstMarkdownNode != null &&
firstMarkdownDelta != null) {
final startIndex = selection.startIndex;
final length = delta.length - startIndex;
transaction
..deleteText(firstNode, startIndex, length)
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
}
// step 2
final lastNode = nodes.lastOrNull;
final lastDelta = lastNode?.delta;
final lastMarkdownNode = markdownNodes.lastOrNull;
final lastMarkdownDelta = lastMarkdownNode?.delta;
if (lastNode != null &&
lastDelta != null &&
lastMarkdownNode != null &&
lastMarkdownDelta != null) {
final endIndex = selection.endIndex;
transaction.deleteText(lastNode, 0, endIndex);
// if the last node is same as the first node, it means we have replaced the
// selected text in the first node.
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
}
}
// step 3
final insertedPath = selection.start.path.nextNPath(1);
if (markdownNodes.length > 2) {
transaction.insertNodes(
insertedPath,
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
);
}
// step 4
final length = nodes.length - 2;
if (length > 0) {
final middleNodes = nodes.skip(1).take(length).toList();
transaction.deleteNodes(middleNodes);
}
await editorState.apply(transaction);
}
} }
class AINodeExternalValues extends NodeExternalValues { class AINodeExternalValues extends NodeExternalValues {

View file

@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
); );
} }

View file

@ -73,7 +73,6 @@ extension PasteFromImage on EditorState {
Log.info('unsupported format: $format'); Log.info('unsupported format: $format');
if (UniversalPlatform.isMobile) { if (UniversalPlatform.isMobile) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(),
); );
} }
@ -112,7 +111,6 @@ extension PasteFromImage on EditorState {
if (errorMessage != null && context.mounted) { if (errorMessage != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: errorMessage, message: errorMessage,
); );
return false; return false;
@ -131,7 +129,6 @@ extension PasteFromImage on EditorState {
Log.error('cannot copy image file', e); Log.error('cannot copy image file', e);
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), message: LocaleKeys.document_imageBlock_error_invalidImage.tr(),
); );
} }

View file

@ -154,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
void _copyBlockContent() { void _copyBlockContent() {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(),
); );

View file

@ -105,7 +105,6 @@ Future<void> downloadMediaFile(
} else { } else {
if (userProfile == null) { if (userProfile == null) {
return showToastNotification( return showToastNotification(
context,
message: LocaleKeys.grid_media_downloadFailedToken.tr(), message: LocaleKeys.grid_media_downloadFailedToken.tr(),
); );
} }
@ -128,14 +127,12 @@ Future<void> downloadMediaFile(
if (result != null && context.mounted) { if (result != null && context.mounted) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: LocaleKeys.grid_media_downloadSuccess.tr(), message: LocaleKeys.grid_media_downloadSuccess.tr(),
); );
} }
} else if (context.mounted) { } else if (context.mounted) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
); );
@ -159,13 +156,11 @@ Future<void> downloadMediaFile(
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.grid_media_downloadSuccess.tr(), message: LocaleKeys.grid_media_downloadSuccess.tr(),
); );
} }
} else if (context.mounted) { } else if (context.mounted) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
); );

View file

@ -378,7 +378,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
onTap: () async { onTap: () async {
context.pop(); context.pop();
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
); );
await getIt<ClipboardService>().setPlainText(url); await getIt<ClipboardService>().setPlainText(url);
@ -431,7 +430,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
); );
if (mounted) { if (mounted) {
showToastNotification( showToastNotification(
context,
message: result.isSuccess message: result.isSuccess
? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr() ? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr()
: LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(), : LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(),

View file

@ -117,14 +117,12 @@ class _ImageMenuState extends State<ImageMenu> {
if (mounted) { if (mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_fail.tr(), message: LocaleKeys.message_copy_fail.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );

View file

@ -218,7 +218,6 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
ClipboardData(text: images[widget.indexNotifier.value].url), ClipboardData(text: images[widget.indexNotifier.value].url),
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
); );
} }

View file

@ -1,16 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CustomLinkPreviewMenu extends StatefulWidget { class CustomLinkPreviewMenu extends StatefulWidget {
@ -122,14 +121,7 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
break; break;
case LinkPreviewMenuCommand.copyLink: case LinkPreviewMenuCommand.copyLink:
if (url != null) { if (url != null) {
await Clipboard.setData(ClipboardData(text: url)); await context.copyLink(url);
if (context.mounted) {
showToastNotification(
// ignore: use_build_context_synchronously
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
} }
break; break;
case LinkPreviewMenuCommand.replace: case LinkPreviewMenuCommand.replace:

View file

@ -100,7 +100,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler {
Log.error(error); Log.error(error);
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage
.tr(), .tr(),
); );

View file

@ -38,10 +38,6 @@ Future<bool> emojiCommandHandler(
return false; return false;
} }
if (!selection.isCollapsed) {
await editorState.deleteSelection(selection);
}
final node = editorState.getNodeAtPath(selection.end.path); final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta; final delta = node?.delta;
if (node == null || if (node == null ||
@ -58,6 +54,8 @@ Future<bool> emojiCommandHandler(
if (previousCharacter != _emojiCharacter) return false; if (previousCharacter != _emojiCharacter) return false;
if (!context.mounted) return false; if (!context.mounted) return false;
if (!selection.isCollapsed) return false;
await editorState.insertTextAtPosition( await editorState.insertTextAtPosition(
character, character,
position: selection.start, position: selection.start,

View file

@ -174,11 +174,10 @@ class ExportTab extends StatelessWidget {
ClipboardServiceData(plainText: markdown), ClipboardServiceData(plainText: markdown),
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
}, },
(error) => showToastNotification(context, message: error.msg), (error) => showToastNotification(message: error.msg),
); );
} }
} }

View file

@ -85,11 +85,9 @@ class PublishTab extends StatelessWidget {
if (state.publishResult != null) { if (state.publishResult != null) {
state.publishResult!.fold( state.publishResult!.fold(
(value) => showToastNotification( (value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(), message: LocaleKeys.publish_publishSuccessfully.tr(),
), ),
(error) => showToastNotification( (error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error, type: ToastificationType.error,
), ),
@ -97,11 +95,9 @@ class PublishTab extends StatelessWidget {
} else if (state.unpublishResult != null) { } else if (state.unpublishResult != null) {
state.unpublishResult!.fold( state.unpublishResult!.fold(
(value) => showToastNotification( (value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(), message: LocaleKeys.publish_unpublishSuccessfully.tr(),
), ),
(error) => showToastNotification( (error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(), message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg, description: error.msg,
type: ToastificationType.error, type: ToastificationType.error,
@ -110,14 +106,12 @@ class PublishTab extends StatelessWidget {
} else if (state.updatePathNameResult != null) { } else if (state.updatePathNameResult != null) {
state.updatePathNameResult!.fold( state.updatePathNameResult!.fold(
(value) => showToastNotification( (value) => showToastNotification(
context,
message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
), ),
(error) { (error) {
Log.error('update path name failed: $error'); Log.error('update path name failed: $error');
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
description: error.code.publishErrorMessage, description: error.code.publishErrorMessage,
@ -182,7 +176,6 @@ class _PublishedWidgetState extends State<_PublishedWidget> {
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
}, },
@ -292,7 +285,6 @@ class _PublishWidgetState extends State<_PublishWidget> {
// check if any database is selected // check if any database is selected
if (_selectedViews.isEmpty) { if (_selectedViews.isEmpty) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.publish_noDatabaseSelected.tr(), message: LocaleKeys.publish_noDatabaseSelected.tr(),
); );
return; return;
@ -611,7 +603,6 @@ class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> {
// unable to deselect the primary database // unable to deselect the primary database
if (isPrimaryDatabase) { if (isPrimaryDatabase) {
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(), LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(),
); );

View file

@ -70,7 +70,6 @@ class ShareButton extends StatelessWidget {
case ShareType.html: case ShareType.html:
case ShareType.csv: case ShareType.csv:
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_files_exportFileSuccess.tr(), message: LocaleKeys.settings_files_exportFileSuccess.tr(),
); );
break; break;
@ -81,7 +80,6 @@ class ShareButton extends StatelessWidget {
void _handleExportError(BuildContext context, FlowyError error) { void _handleExportError(BuildContext context, FlowyError error) {
showToastNotification( showToastNotification(
context,
message: message:
'${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}', '${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}',
); );

View file

@ -117,7 +117,6 @@ class _ShareTabContent extends StatelessWidget {
); );
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }

View file

@ -45,7 +45,6 @@ class _MobileSyncErrorPage extends StatelessWidget {
onTapUp: () { onTapUp: () {
getIt<ClipboardService>().setPlainText(error.toString()); getIt<ClipboardService>().setPlainText(error.toString());
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
bottomPadding: 0, bottomPadding: 0,
); );
@ -101,7 +100,7 @@ class _DesktopSyncErrorPage extends StatelessWidget {
onTapUp: () { onTapUp: () {
getIt<ClipboardService>().setPlainText(error.toString()); getIt<ClipboardService>().setPlainText(error.toString());
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
bottomPadding: 0, bottomPadding: 0,
); );

View file

@ -27,6 +27,7 @@ Future<String> customDocumentToMarkdown(
Document document, { Document document, {
String path = '', String path = '',
AsyncValueSetter<Archive>? onArchive, AsyncValueSetter<Archive>? onArchive,
String lineBreak = '',
}) async { }) async {
final List<Future<ArchiveFile>> fileFutures = []; final List<Future<ArchiveFile>> fileFutures = [];
@ -41,6 +42,7 @@ Future<String> customDocumentToMarkdown(
try { try {
markdown = documentToMarkdown( markdown = documentToMarkdown(
document, document,
lineBreak: lineBreak,
customParsers: [ customParsers: [
const MathEquationNodeParser(), const MathEquationNodeParser(),
const CalloutNodeParser(), const CalloutNodeParser(),

View file

@ -129,7 +129,7 @@ class AppFlowyCloudDeepLink {
final context = AppGlobals.rootNavKey.currentState?.context; final context = AppGlobals.rootNavKey.currentState?.context;
if (context != null) { if (context != null) {
showToastNotification( showToastNotification(
context,
message: err.msg, message: err.msg,
); );
} }

View file

@ -17,14 +17,14 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) {
case ErrorCode.InvalidEncryptSecret: case ErrorCode.InvalidEncryptSecret:
case ErrorCode.NetworkError: case ErrorCode.NetworkError:
showToastNotification( showToastNotification(
context,
message: error.msg, message: error.msg,
type: ToastificationType.error, type: ToastificationType.error,
); );
break; break;
default: default:
showToastNotification( showToastNotification(
context,
message: error.msg, message: error.msg,
type: ToastificationType.error, type: ToastificationType.error,
callbacks: ToastificationCallbacks( callbacks: ToastificationCallbacks(

View file

@ -65,7 +65,7 @@ class _SignInWithMagicLinkButtonsState
void _sendMagicLink(BuildContext context, String email) { void _sendMagicLink(BuildContext context, String email) {
if (!isEmail(email)) { if (!isEmail(email)) {
return showToastNotification( return showToastNotification(
context,
message: LocaleKeys.signIn_invalidEmail.tr(), message: LocaleKeys.signIn_invalidEmail.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );

View file

@ -25,7 +25,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
if (archiveLogFiles.isEmpty) { if (archiveLogFiles.isEmpty) {
if (context != null && context.mounted) { if (context != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.noLogFiles.tr(), message: LocaleKeys.noLogFiles.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -42,7 +42,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
if (zip == null) { if (zip == null) {
if (context != null && context.mounted) { if (context != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.noLogFiles.tr(), message: LocaleKeys.noLogFiles.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -72,7 +72,7 @@ Future<void> shareLogFiles(BuildContext? context) async {
} catch (e) { } catch (e) {
if (context != null && context.mounted) { if (context != null && context.mounted) {
showToastNotification( showToastNotification(
context,
message: e.toString(), message: e.toString(),
type: ToastificationType.error, type: ToastificationType.error,
); );

View file

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/util/color_to_hex_string.dart';
import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
@ -17,6 +19,7 @@ import 'package:flowy_infra/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:universal_platform/universal_platform.dart';
part 'appearance_cubit.freezed.dart'; part 'appearance_cubit.freezed.dart';
@ -97,7 +100,19 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
Future<void> setTheme(String themeName) async { Future<void> setTheme(String themeName) async {
_appearanceSettings.theme = themeName; _appearanceSettings.theme = themeName;
unawaited(_saveAppearanceSettings()); unawaited(_saveAppearanceSettings());
emit(state.copyWith(appTheme: await AppTheme.fromName(themeName))); try {
final theme = await AppTheme.fromName(themeName);
emit(state.copyWith(appTheme: theme));
} catch (e) {
Log.error("Error setting theme: $e");
if (UniversalPlatform.isMacOS) {
showToastNotification(
message:
LocaleKeys.settings_workspacePage_theme_failedToLoadThemes.tr(),
type: ToastificationType.error,
);
}
}
} }
/// Reset the current user selected theme back to the default /// Reset the current user selected theme back to the default

View file

@ -169,7 +169,7 @@ class _SidebarWorkspaceState extends State<SidebarWorkspace> {
if (message != null) { if (message != null) {
showToastNotification( showToastNotification(
context,
message: message, message: message,
type: result.fold( type: result.fold(
(_) => ToastificationType.success, (_) => ToastificationType.success,

View file

@ -193,7 +193,7 @@ Future<void> deleteMyAccount(
if (!isChecked) { if (!isChecked) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.warning, type: ToastificationType.warning,
bottomPadding: bottomPadding, bottomPadding: bottomPadding,
message: LocaleKeys message: LocaleKeys
@ -208,7 +208,7 @@ Future<void> deleteMyAccount(
if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) {
showToastNotification( showToastNotification(
context,
type: ToastificationType.warning, type: ToastificationType.warning,
bottomPadding: bottomPadding, bottomPadding: bottomPadding,
message: LocaleKeys message: LocaleKeys
@ -226,7 +226,7 @@ Future<void> deleteMyAccount(
loading.stop(); loading.stop();
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.newSettings_myAccount_deleteAccount_deleteAccountSuccess .newSettings_myAccount_deleteAccount_deleteAccountSuccess
.tr(), .tr(),
@ -245,7 +245,7 @@ Future<void> deleteMyAccount(
loading.stop(); loading.stop();
showToastNotification( showToastNotification(
context,
type: ToastificationType.error, type: ToastificationType.error,
bottomPadding: bottomPadding, bottomPadding: bottomPadding,
message: f.msg, message: f.msg,

View file

@ -140,10 +140,8 @@ class LocalAISettingPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const LocalAIStatusIndicator(), const LocalAIStatusIndicator(),
if (state.showSettings) ...[ const VSpace(10),
const VSpace(10), OllamaSettingPage(),
OllamaSettingPage(),
],
], ],
); );
}, },

View file

@ -288,6 +288,10 @@ class _LackOfResource extends StatelessWidget {
text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(), text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(),
style: textStyle, style: textStyle,
), ),
TextSpan(
text: modelNames.join(', '),
style: textStyle,
),
TextSpan( TextSpan(
text: ' ', text: ' ',
style: textStyle, style: textStyle,

View file

@ -157,7 +157,7 @@ class SettingsManageDataView extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_manageDataPage_cache_dialog_successHint .settings_manageDataPage_cache_dialog_successHint
.tr(), .tr(),

View file

@ -53,7 +53,6 @@ class SettingsPageSitesEvent {
); );
getIt<ClipboardService>().setData(ClipboardServiceData(plainText: url)); getIt<ClipboardService>().setData(ClipboardServiceData(plainText: url));
showToastNotification( showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(), message: LocaleKeys.message_copy_success.tr(),
); );
} }

View file

@ -253,7 +253,6 @@ class _FreePlanUpgradeButton extends StatelessWidget {
onTap: () { onTap: () {
if (isOwner) { if (isOwner) {
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), LocaleKeys.settings_sites_namespace_redirectToPayment.tr(),
type: ToastificationType.info, type: ToastificationType.info,
@ -264,7 +263,6 @@ class _FreePlanUpgradeButton extends StatelessWidget {
); );
} else { } else {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_sites_namespace_pleaseAskOwnerToSetHomePage .settings_sites_namespace_pleaseAskOwnerToSetHomePage
.tr(), .tr(),

View file

@ -216,7 +216,6 @@ class _DomainSettingsDialogState extends State<DomainSettingsDialog> {
result.fold( result.fold(
(s) { (s) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(), message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(),
); );
@ -234,7 +233,6 @@ class _DomainSettingsDialogState extends State<DomainSettingsDialog> {
Log.error('Failed to update namespace: $f'); Log.error('Failed to update namespace: $f');
showToastNotification( showToastNotification(
context,
message: basicErrorMessage, message: basicErrorMessage,
type: ToastificationType.error, type: ToastificationType.error,
description: errorMessage, description: errorMessage,

View file

@ -203,7 +203,6 @@ class _PublishedViewSettingsDialogState
result.fold( result.fold(
(s) { (s) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -212,7 +211,6 @@ class _PublishedViewSettingsDialogState
Log.error('update path name failed: $f'); Log.error('update path name failed: $f');
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
description: f.code.publishErrorMessage, description: f.code.publishErrorMessage,

View file

@ -178,7 +178,6 @@ class _SettingsSitesPageView extends StatelessWidget {
Log.error('Failed to generate payment link for Pro Plan: ${f.msg}'); Log.error('Failed to generate payment link for Pro Plan: ${f.msg}');
showToastNotification( showToastNotification(
context,
message: message:
LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(),
type: ToastificationType.error, type: ToastificationType.error,
@ -188,14 +187,12 @@ class _SettingsSitesPageView extends StatelessWidget {
result != null) { result != null) {
result.fold((_) { result.fold((_) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(), message: LocaleKeys.publish_unpublishSuccessfully.tr(),
); );
}, (f) { }, (f) {
Log.error('Failed to unpublish view: ${f.msg}'); Log.error('Failed to unpublish view: ${f.msg}');
showToastNotification( showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(), message: LocaleKeys.publish_unpublishFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
description: f.msg, description: f.msg,
@ -204,14 +201,12 @@ class _SettingsSitesPageView extends StatelessWidget {
} else if (type == SettingsSitesActionType.setHomePage && result != null) { } else if (type == SettingsSitesActionType.setHomePage && result != null) {
result.fold((s) { result.fold((s) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(),
); );
}, (f) { }, (f) {
Log.error('Failed to set homepage: ${f.msg}'); Log.error('Failed to set homepage: ${f.msg}');
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -220,14 +215,12 @@ class _SettingsSitesPageView extends StatelessWidget {
result != null) { result != null) {
result.fold((s) { result.fold((s) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(),
); );
}, (f) { }, (f) {
Log.error('Failed to remove homepage: ${f.msg}'); Log.error('Failed to remove homepage: ${f.msg}');
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(), message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );

View file

@ -363,7 +363,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
}) async { }) async {
if (cloudUrl.isEmpty || webUrl.isEmpty) { if (cloudUrl.isEmpty || webUrl.isEmpty) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -375,7 +374,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
if (mounted) { if (mounted) {
if (isValid) { if (isValid) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]), message: LocaleKeys.settings_menu_changeUrl.tr(args: [cloudUrl]),
); );
@ -387,7 +385,6 @@ class _SelfHostSettingsState extends State<_SelfHostSettings> {
await runAppFlowy(); await runAppFlowy();
} else { } else {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(),
type: ToastificationType.error, type: ToastificationType.error,
); );
@ -522,7 +519,6 @@ class _SupportSettings extends StatelessWidget {
await getIt<FlowyCacheManager>().clearAllCache(); await getIt<FlowyCacheManager>().clearAllCache();
if (context.mounted) { if (context.mounted) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys message: LocaleKeys
.settings_manageDataPage_cache_dialog_successHint .settings_manageDataPage_cache_dialog_successHint
.tr(), .tr(),

View file

@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget {
const VSpace(8), const VSpace(8),
const AppFlowyCloudEnableSync(), const AppFlowyCloudEnableSync(),
const VSpace(6), const VSpace(6),
const AppFlowyCloudSyncLogEnabled(), // const AppFlowyCloudSyncLogEnabled(),
const VSpace(12), const VSpace(12),
RestartButton( RestartButton(
onClick: () { onClick: () {

View file

@ -157,7 +157,6 @@ class _NavigatorTextFieldDialogState extends State<NavigatorTextFieldDialog> {
onOkPressed: () { onOkPressed: () {
if (newValue.isEmpty) { if (newValue.isEmpty) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(),
); );
return; return;
@ -363,8 +362,7 @@ class OkCancelButton extends StatelessWidget {
} }
} }
void showToastNotification( void showToastNotification({
BuildContext context, {
String? message, String? message,
TextSpan? richMessage, TextSpan? richMessage,
String? description, String? description,

View file

@ -51,7 +51,6 @@ class FlowyVersionSection extends CustomActionCell {
} }
enableDocumentInternalLog = !enableDocumentInternalLog; enableDocumentInternalLog = !enableDocumentInternalLog;
showToastNotification( showToastNotification(
context,
message: enableDocumentInternalLog message: enableDocumentInternalLog
? 'Enabled Internal Log' ? 'Enabled Internal Log'
: 'Disabled Internal Log', : 'Disabled Internal Log',

View file

@ -74,7 +74,6 @@ class ViewTitleBar extends StatelessWidget {
listener: (context, state) { listener: (context, state) {
if (state.isLocked) { if (state.isLocked) {
showToastNotification( showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(), message: LocaleKeys.lockPage_pageLockedToast.tr(),
); );
} }

View file

@ -98,8 +98,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "552f95f" ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
resolved-ref: "552f95fd15627e10a138c6db2a6d0a8089bc9a25" resolved-ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git" url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git source: git
version: "5.1.0" version: "5.1.0"

View file

@ -2,13 +2,13 @@ name: appflowy
description: Bring projects, wikis, and teams together with AI. AppFlowy is an description: Bring projects, wikis, and teams together with AI. AppFlowy is an
AI collaborative workspace where you achieve more without losing control of AI collaborative workspace where you achieve more without losing control of
your data. The best open source alternative to Notion. your data. The best open source alternative to Notion.
publish_to: "none" publish_to: 'none'
version: 0.8.8 version: 0.8.9
environment: environment:
flutter: ">=3.27.4" flutter: '>=3.27.4'
sdk: ">=3.3.0 <4.0.0" sdk: '>=3.3.0 <4.0.0'
dependencies: dependencies:
any_date: ^1.0.4 any_date: ^1.0.4
@ -41,7 +41,7 @@ dependencies:
calendar_view: calendar_view:
git: git:
url: https://github.com/Xazin/flutter_calendar_view url: https://github.com/Xazin/flutter_calendar_view
ref: "6fe0c98" ref: '6fe0c98'
collection: ^1.17.1 collection: ^1.17.1
connectivity_plus: ^5.0.2 connectivity_plus: ^5.0.2
cross_file: ^0.3.4+1 cross_file: ^0.3.4+1
@ -77,7 +77,7 @@ dependencies:
flutter_emoji_mart: flutter_emoji_mart:
git: git:
url: https://github.com/LucasXu0/emoji_mart.git url: https://github.com/LucasXu0/emoji_mart.git
ref: "355aa56" ref: '355aa56'
flutter_math_fork: ^0.7.3 flutter_math_fork: ^0.7.3
flutter_slidable: ^3.0.0 flutter_slidable: ^3.0.0
@ -187,13 +187,13 @@ dependency_overrides:
appflowy_editor: appflowy_editor:
git: git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "552f95f" ref: '361b99c38370abeeb19656f89e8c31cb3666623b'
appflowy_editor_plugins: appflowy_editor_plugins:
git: git:
url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git
path: "packages/appflowy_editor_plugins" path: 'packages/appflowy_editor_plugins'
ref: "4efcff7" ref: '4efcff7'
sheet: sheet:
git: git:

View file

@ -375,7 +375,7 @@ void main() {
await blocResponseFuture(); await blocResponseFuture();
bloc.runResponseAction(SuggestionAction.accept); bloc.runResponseAction(SuggestionAction.accept);
await blocResponseFuture(); await blocResponseFuture();
expect(editorState.document.root.children.length, 1); expect(editorState.document.root.children.length, 2);
expect( expect(
editorState.getNodeAtPath([0])!.delta!.toPlainText(), editorState.getNodeAtPath([0])!.delta!.toPlainText(),
'Hello World', 'Hello World',

View file

@ -294,6 +294,514 @@ void main() {
); );
}); });
}); });
group('markdown text robot - replace in same line:', () {
final text1 =
'''The introduction of the World Wide Web in the early 1990s marked a turning point. ''';
final text2 =
'''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. ''';
final text3 =
'''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.''';
Document buildTestDocument() {
return Document(
root: pageNode(
children: [
paragraphNode(delta: Delta()..insert(text1 + text2 + text3)),
],
),
);
}
// 1. create a document with a paragraph node
// 2. use the text robot to replace the selected content in the same line
// 3. check the document
test('the selection is in the middle of the text', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: text1.length,
),
end: Position(
path: [0],
offset: text1.length + text2.length,
),
);
final markdownText =
'''Tim Berners-Lee's invention of the **World Wide Web** transformed the internet, making it accessible to _non-technical users_ and opening the floodgates for global mass adoption.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 5);
final d1 = afterDelta[0] as TextInsert;
expect(d1.text, '${text1}Tim Berners-Lee\'s invention of the ');
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'World Wide Web');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(d3.text, ' transformed the internet, making it accessible to ');
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'non-technical users');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(
d5.text,
' and opening the floodgates for global mass adoption.$text3',
);
expect(d5.attributes, null);
});
test('replace markdown text with selection from start to middle', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
),
end: Position(
path: [0],
offset: text1.length,
),
);
final markdownText =
'''The **invention** of the _World Wide Web_ by Tim Berners-Lee transformed how we access information.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 5);
final d1 = afterDelta[0] as TextInsert;
expect(d1.text, 'The ');
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'invention');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(d3.text, ' of the ');
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'World Wide Web');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(
d5.text,
' by Tim Berners-Lee transformed how we access information.$text2$text3',
);
expect(d5.attributes, null);
});
test('replace markdown text with selection from middle to end', () async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: text1.length + text2.length,
),
end: Position(
path: [0],
offset: text1.length + text2.length + text3.length,
),
);
final markdownText =
'''**Email** became widespread, and instant messaging services like *ICQ* and **AOL Instant Messenger** gained tremendous popularity, allowing for seamless real-time text communication across the globe.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterDelta = editorState.document.root.children[0].delta!.toList();
expect(afterDelta.length, 7);
final d1 = afterDelta[0] as TextInsert;
expect(
d1.text,
text1 + text2,
);
expect(d1.attributes, null);
final d2 = afterDelta[1] as TextInsert;
expect(d2.text, 'Email');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = afterDelta[2] as TextInsert;
expect(
d3.text,
' became widespread, and instant messaging services like ',
);
expect(d3.attributes, null);
final d4 = afterDelta[3] as TextInsert;
expect(d4.text, 'ICQ');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = afterDelta[4] as TextInsert;
expect(d5.text, ' and ');
expect(d5.attributes, null);
final d6 = afterDelta[5] as TextInsert;
expect(
d6.text,
'AOL Instant Messenger',
);
expect(d6.attributes, {AppFlowyRichTextKeys.bold: true});
final d7 = afterDelta[6] as TextInsert;
expect(
d7.text,
' gained tremendous popularity, allowing for seamless real-time text communication across the globe.',
);
expect(d7.attributes, null);
});
});
group('markdown text robot - replace in multiple lines:', () {
final text1 =
'''The introduction of the World Wide Web in the early 1990s marked a turning point. ''';
final text2 =
'''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption. ''';
final text3 =
'''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.''';
Document buildTestDocument() {
return Document(
root: pageNode(
children: [
paragraphNode(delta: Delta()..insert(text1)),
paragraphNode(delta: Delta()..insert(text2)),
paragraphNode(delta: Delta()..insert(text3)),
],
),
);
}
// 1. create a document with 3 paragraph nodes
// 2. use the text robot to replace the selected content in the multiple lines
// 3. check the document
test(
'the selection starts with the first paragraph and ends with the middle of second paragraph',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
),
end: Position(
path: [1],
offset: text2.length -
', opening the floodgates for mass adoption. '.length,
),
);
final markdownText =
'''The **introduction** of the World Wide Web in the *early 1990s* marked a significant turning point.
Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 3);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, 'The ');
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, 'introduction');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, ' of the World Wide Web in the ');
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, 'early 1990s');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, ' marked a significant turning point.');
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 3);
final d1 = delta2[0] as TextInsert;
expect(d1.text, "Tim Berners-Lee's ");
expect(d1.attributes, null);
final d2 = delta2[1] as TextInsert;
expect(d2.text, "revolutionary invention");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta2[2] as TextInsert;
expect(
d3.text,
" made the internet accessible to non-technical users, opening the floodgates for mass adoption. ",
);
expect(d3.attributes, null);
}
{
// third paragraph
final delta3 = afterNodes[2].delta!.toList();
expect(delta3.length, 1);
final d1 = delta3[0] as TextInsert;
expect(d1.text, text3);
expect(d1.attributes, null);
}
});
test(
'the selection starts with the middle of the first paragraph and ends with the middle of last paragraph',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: 'The introduction of the World Wide Web'.length,
),
end: Position(
path: [2],
offset:
'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity'
.length,
),
);
final markdownText =
''' in the **early 1990s** marked a *significant turning point* in technological history.
Tim Berners-Lee's **revolutionary invention** made the internet accessible to non-technical users, opening the floodgates for *unprecedented mass adoption*.
Email became **widely prevalent**, and instant messaging services like *ICQ* and *AOL Instant Messenger* gained tremendous popularity
''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 3);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, 'The introduction of the World Wide Web in the ');
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, 'early 1990s');
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, ' marked a ');
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, 'significant turning point');
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, ' in technological history.');
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 5);
final d1 = delta2[0] as TextInsert;
expect(d1.text, "Tim Berners-Lee's ");
expect(d1.attributes, null);
final d2 = delta2[1] as TextInsert;
expect(d2.text, "revolutionary invention");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta2[2] as TextInsert;
expect(
d3.text,
" made the internet accessible to non-technical users, opening the floodgates for ",
);
expect(d3.attributes, null);
final d4 = delta2[3] as TextInsert;
expect(d4.text, "unprecedented mass adoption");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta2[4] as TextInsert;
expect(d5.text, ".");
expect(d5.attributes, null);
}
{
// third paragraph
// third paragraph
final delta3 = afterNodes[2].delta!.toList();
expect(delta3.length, 7);
final d1 = delta3[0] as TextInsert;
expect(d1.text, "Email became ");
expect(d1.attributes, null);
final d2 = delta3[1] as TextInsert;
expect(d2.text, "widely prevalent");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta3[2] as TextInsert;
expect(d3.text, ", and instant messaging services like ");
expect(d3.attributes, null);
final d4 = delta3[3] as TextInsert;
expect(d4.text, "ICQ");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta3[4] as TextInsert;
expect(d5.text, " and ");
expect(d5.attributes, null);
final d6 = delta3[5] as TextInsert;
expect(d6.text, "AOL Instant Messenger");
expect(d6.attributes, {AppFlowyRichTextKeys.italic: true});
final d7 = delta3[6] as TextInsert;
expect(
d7.text,
" gained tremendous popularity, allowing for real-time text communication.",
);
expect(d7.attributes, null);
}
});
test(
'the length of the returned response less than the length of the selected text',
() async {
final document = buildTestDocument();
final editorState = EditorState(document: document);
editorState.selection = Selection(
start: Position(
path: [0],
offset: 'The introduction of the World Wide Web'.length,
),
end: Position(
path: [2],
offset:
'Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity'
.length,
),
);
final markdownText =
''' in the **early 1990s** marked a *significant turning point* in technological history.''';
final markdownTextRobot = MarkdownTextRobot(
editorState: editorState,
);
await markdownTextRobot.replace(
selection: editorState.selection!,
markdownText: markdownText,
);
final afterNodes = editorState.document.root.children;
expect(afterNodes.length, 2);
{
// first paragraph
final delta1 = afterNodes[0].delta!.toList();
expect(delta1.length, 5);
final d1 = delta1[0] as TextInsert;
expect(d1.text, "The introduction of the World Wide Web in the ");
expect(d1.attributes, null);
final d2 = delta1[1] as TextInsert;
expect(d2.text, "early 1990s");
expect(d2.attributes, {AppFlowyRichTextKeys.bold: true});
final d3 = delta1[2] as TextInsert;
expect(d3.text, " marked a ");
expect(d3.attributes, null);
final d4 = delta1[3] as TextInsert;
expect(d4.text, "significant turning point");
expect(d4.attributes, {AppFlowyRichTextKeys.italic: true});
final d5 = delta1[4] as TextInsert;
expect(d5.text, " in technological history.");
expect(d5.attributes, null);
}
{
// second paragraph
final delta2 = afterNodes[1].delta!.toList();
expect(delta2.length, 1);
final d1 = delta2[0] as TextInsert;
expect(d1.text, ", allowing for real-time text communication.");
expect(d1.attributes, null);
}
});
});
} }
const _sample1 = '''# The Curious Cat const _sample1 = '''# The Curious Cat

View file

@ -628,7 +628,8 @@
"theme": { "theme": {
"title": "Theme", "title": "Theme",
"description": "Select a preset theme, or upload your own custom theme.", "description": "Select a preset theme, or upload your own custom theme.",
"uploadCustomThemeTooltip": "Upload a custom theme" "uploadCustomThemeTooltip": "Upload a custom theme",
"failedToLoadThemes": "Failed to load themes, please check your permission settings in System Settings > Privacy and Security > Files and Folders > @:appName"
}, },
"workspaceFont": { "workspaceFont": {
"title": "Workspace font", "title": "Workspace font",
@ -884,7 +885,7 @@
"pleaseFollowThese": "Please follow these", "pleaseFollowThese": "Please follow these",
"instructions": "instructions", "instructions": "instructions",
"installOllamaLai": "to set up Ollama and AppFlowy Local AI.", "installOllamaLai": "to set up Ollama and AppFlowy Local AI.",
"modelsMissing": "Cannot find the required models.", "modelsMissing": "Cannot find the required models: ",
"downloadModel": "to download them." "downloadModel": "to download them."
} }
}, },

View file

@ -345,12 +345,11 @@ dependencies = [
[[package]] [[package]]
name = "af-local-ai" name = "af-local-ai"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [ dependencies = [
"af-plugin", "af-plugin",
"anyhow", "anyhow",
"bytes", "bytes",
"futures",
"reqwest 0.11.27", "reqwest 0.11.27",
"serde", "serde",
"serde_json", "serde_json",
@ -358,14 +357,12 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"tracing", "tracing",
"zip 2.2.0",
"zip-extensions",
] ]
[[package]] [[package]]
name = "af-mcp" name = "af-mcp"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures-util", "futures-util",
@ -379,7 +376,7 @@ dependencies = [
[[package]] [[package]]
name = "af-plugin" name = "af-plugin"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cfg-if", "cfg-if",
@ -2504,7 +2501,6 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"validator 0.18.1", "validator 0.18.1",
"winreg 0.55.0",
"zip 2.2.0", "zip 2.2.0",
"zip-extensions", "zip-extensions",
] ]

View file

@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl
# To update the commit ID, run: # To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id # scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }
af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }
af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" }

View file

@ -53,11 +53,6 @@ collab-integrate.workspace = true
notify = "6.1.1" notify = "6.1.1"
af-mcp = { version = "0.1.0" } af-mcp = { version = "0.1.0" }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
#cmd_lib = { version = "1.9.5" }
[dev-dependencies] [dev-dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"
uuid.workspace = true uuid.workspace = true

View file

@ -13,9 +13,9 @@ use futures::Sink;
use lib_infra::async_trait::async_trait; use lib_infra::async_trait::async_trait;
use std::collections::HashMap; use std::collections::HashMap;
use crate::local_ai::watch::is_plugin_ready;
use crate::stream_message::StreamMessage; use crate::stream_message::StreamMessage;
use af_local_ai::ollama_plugin::OllamaAIPlugin; use af_local_ai::ollama_plugin::OllamaAIPlugin;
use af_plugin::core::path::is_plugin_ready;
use af_plugin::core::plugin::RunningState; use af_plugin::core::plugin::RunningState;
use arc_swap::ArcSwapOption; use arc_swap::ArcSwapOption;
use futures_util::SinkExt; use futures_util::SinkExt;

View file

@ -5,13 +5,13 @@ use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use lib_infra::async_trait::async_trait; use lib_infra::async_trait::async_trait;
use crate::entities::LackOfAIResourcePB; use crate::entities::LackOfAIResourcePB;
use crate::local_ai::watch::{is_plugin_ready, ollama_plugin_path};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::local_ai::watch::{watch_offline_app, WatchContext}; use crate::local_ai::watch::{watch_offline_app, WatchContext};
use crate::notification::{ use crate::notification::{
chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY, chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY,
}; };
use af_local_ai::ollama_plugin::OllamaPluginConfig; use af_local_ai::ollama_plugin::OllamaPluginConfig;
use af_plugin::core::path::{is_plugin_ready, ollama_plugin_path};
use lib_infra::util::{get_operating_system, OperatingSystem}; use lib_infra::util::{get_operating_system, OperatingSystem};
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
@ -195,9 +195,14 @@ impl LocalAIResourceController {
let tags: TagsResponse = resp.json().await.inspect_err(|e| { let tags: TagsResponse = resp.json().await.inspect_err(|e| {
log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}") log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}")
})?; })?;
// Check each required model is present in the response. // Check if each of our required models exists in the list of available models
trace!("[LLM Resource] ollama available models: {:?}", tags.models);
for required in &required_models { for required in &required_models {
if !tags.models.iter().any(|m| m.name.contains(required)) { if !tags
.models
.iter()
.any(|m| m.name == *required || m.name == format!("{}:latest", required))
{
log::trace!( log::trace!(
"[LLM Resource] required model '{}' not found in API response", "[LLM Resource] required model '{}' not found in API response",
required required

View file

@ -1,13 +1,10 @@
use crate::local_ai::resource::WatchDiskEvent; use crate::local_ai::resource::WatchDiskEvent;
use af_plugin::core::path::{install_path, ollama_plugin_path};
use flowy_error::{FlowyError, FlowyResult}; use flowy_error::{FlowyError, FlowyResult};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
use tracing::{error, trace}; use tracing::{error, trace};
#[cfg(windows)]
use winreg::{enums::*, RegKey};
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[allow(dead_code)] #[allow(dead_code)]
pub struct WatchContext { pub struct WatchContext {
@ -61,131 +58,3 @@ pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver<Watch
rx, rx,
)) ))
} }
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub(crate) fn install_path() -> Option<PathBuf> {
None
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub(crate) fn install_path() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
return None;
#[cfg(target_os = "macos")]
return Some(PathBuf::from("/usr/local/bin"));
#[cfg(target_os = "linux")]
return None;
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub fn is_plugin_ready() -> bool {
ollama_plugin_path().exists() || ollama_plugin_command_available()
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub fn is_plugin_ready() -> bool {
false
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub(crate) fn ollama_plugin_path() -> PathBuf {
PathBuf::new()
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub(crate) fn ollama_plugin_path() -> std::path::PathBuf {
#[cfg(target_os = "windows")]
{
// Use LOCALAPPDATA for a user-specific installation path on Windows.
let local_appdata =
std::env::var("LOCALAPPDATA").unwrap_or_else(|_| "C:\\Program Files".to_string());
std::path::PathBuf::from(local_appdata).join("Programs\\appflowy_plugin\\af_ollama_plugin.exe")
}
#[cfg(target_os = "macos")]
{
let offline_app = "af_ollama_plugin";
std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app))
}
#[cfg(target_os = "linux")]
{
let offline_app = "af_ollama_plugin";
std::path::PathBuf::from(format!("/usr/local/bin/{}", offline_app))
}
}
pub(crate) fn ollama_plugin_command_available() -> bool {
if cfg!(windows) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("cmd")
.args(&["/C", "where", "af_ollama_plugin"])
.creation_flags(CREATE_NO_WINDOW)
.output();
if let Ok(output) = output {
if !output.stdout.is_empty() {
return true;
}
}
// 2. Fallback: Check registry PATH for the executable
let path_dirs = get_windows_path_dirs();
let plugin_exe = "af_ollama_plugin.exe"; // Adjust name if needed
path_dirs.iter().any(|dir| {
let full_path = std::path::Path::new(dir).join(plugin_exe);
full_path.exists()
})
}
#[cfg(not(windows))]
false
} else {
let output = Command::new("command")
.args(["-v", "af_ollama_plugin"])
.output();
match output {
Ok(o) => !o.stdout.is_empty(),
_ => false,
}
}
}
#[cfg(windows)]
fn get_windows_path_dirs() -> Vec<String> {
let mut paths = Vec::new();
// Check HKEY_CURRENT_USER\Environment
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(env) = hkcu.open_subkey("Environment") {
if let Ok(path) = env.get_value::<String, _>("Path") {
paths.extend(path.split(';').map(|s| s.trim().to_string()));
}
}
// Check HKEY_LOCAL_MACHINE\SYSTEM\...\Environment
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
if let Ok(env) = hklm.open_subkey(r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")
{
if let Ok(path) = env.get_value::<String, _>("Path") {
paths.extend(path.split(';').map(|s| s.trim().to_string()));
}
}
paths
}
#[cfg(test)]
mod tests {
use crate::local_ai::watch::ollama_plugin_command_available;
#[test]
fn test_command_import() {
let result = ollama_plugin_command_available();
println!("ollama plugin exist: {:?}", result);
}
}