Compare commits

..
Author SHA1 Message Date
Nathan 89647ae683 chore: logs for windows 2025-03-31 00:15:59 +08:00
181 changed files with 2986 additions and 4499 deletions

No files matched your search

-11
View File
@@ -1,15 +1,4 @@
# Release Notes
## Version 0.8.8 - 01/04/2025
### New Features
- Added support for selecting AI models in AI writer
- Revamped link menu in toolbar
- Added support for using ":" to add emojis in documents
- Passed the history of past AI prompts and responses to AI writer
### Bug Fixes
- Improved AI writer scrolling user experience
- Fixed issue where checklist items would disappear during reordering
- Fixed numbered lists generated by AI to maintain the same index as the input
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama
@@ -15,7 +15,6 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@@ -30,11 +29,6 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
// create another field, New field 1 to be hidden still
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
tester.noFieldWithName('New field 1');
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@@ -66,40 +60,5 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
testWidgets('field cell width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// check the width of the field
expect(tester.getFieldWidth('New field 1'), 150);
// change the width of the field
await tester.changeFieldWidth('New field 1', 200);
expect(tester.getFieldWidth('New field 1'), 205);
// create another field, New field 1 to be same width
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
expect(tester.getFieldWidth('New field 1'), 205);
// go back to inline database view, expect New field 1 to be 150px
await tester.tapTabBarLinkedViewByViewName('Untitled');
expect(tester.getFieldWidth('New field 1'), 150);
// go back to linked database view, expect New field 1 to be 205px
await tester.tapTabBarLinkedViewByViewName('Grid');
expect(tester.getFieldWidth('New field 1'), 205);
});
});
}
@@ -1,6 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -48,41 +47,5 @@ void main() {
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));
});
});
}
@@ -184,45 +184,6 @@ void main() {
3,
);
});
testWidgets('toolbar will not rebuild after click item', (tester) async {
const text = 'Test rebuilding';
await prepareForToolbar(tester, text);
Finder toolbar = find.byType(DesktopFloatingToolbar);
Element toolbarElement = toolbar.evaluate().first;
final elementHashcode = toolbarElement.hashCode;
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
/// tap format buttons
await tester.tapButton(boldButton);
await tester.tapButton(underlineButton);
await tester.tapButton(italicButton);
toolbar = find.byType(DesktopFloatingToolbar);
toolbarElement = toolbar.evaluate().first;
/// check if the toolbar is not rebuilt
expect(elementHashcode, toolbarElement.hashCode);
final editorState = tester.editor.getCurrentEditorState();
/// check text formats
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
true,
);
});
});
group('document toolbar: link', () {
@@ -267,7 +228,6 @@ void main() {
expect(toolbar, findsNothing);
/// show toolbar again
await tester.editor.tapLineOfEditorAt(0);
await selectText(tester, text);
await tester.tapButton(linkButton);
@@ -43,12 +43,12 @@ void main() {
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':${search ?? 'a'}');
await tester.ime.insertText(':');
await tester.pumpAndSettle(Duration(seconds: 1));
}
@@ -106,10 +106,11 @@ void main() {
});
testWidgets('insert with searching', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: 's');
await createNewDocumentAndShowEmojiList(tester);
/// search for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyS,
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyL,
@@ -137,10 +138,16 @@ void main() {
});
testWidgets('start searching with sapce', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: ' ');
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
/// input space
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
/// emoji list is dismissed
expect(emojiHandler, findsNothing);
});
});
@@ -942,31 +942,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> changeFieldWidth(String fieldName, double width) async {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
await hoverOnWidget(
field,
onHover: () async {
final dragHandle = find.descendant(
of: field,
matching: find.byType(DragToExpandLine),
);
await drag(dragHandle, Offset(width - getSize(field).width, 0));
await pumpAndSettle(const Duration(milliseconds: 200));
},
);
}
double getFieldWidth(String fieldName) {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
return getSize(field).width;
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
@@ -307,11 +307,9 @@ class EditorOperations {
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find
.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
)
.first,
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
@@ -23,7 +23,6 @@ class DesktopPromptInput extends StatefulWidget {
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.hideFormats = false,
this.extraBottomActionButton,
});
@@ -35,7 +34,6 @@ class DesktopPromptInput extends StatefulWidget {
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final bool hideFormats;
final Widget? extraBottomActionButton;
@override
@@ -141,11 +139,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats && !widget.hideFormats,
state.showPredefinedFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats && !widget.hideFormats)
if (state.showPredefinedFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
@@ -170,9 +168,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormatBar:
showPredefinedFormats:
state.showPredefinedFormats,
showPredefinedFormatButton: !widget.hideFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
@@ -574,8 +571,7 @@ class PromptInputTextField extends StatelessWidget {
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormatBar,
required this.showPredefinedFormatButton,
required this.showPredefinedFormats,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
@@ -585,8 +581,7 @@ class _PromptBottomActions extends StatelessWidget {
this.extraBottomActionButton,
});
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final bool showPredefinedFormats;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@@ -605,12 +600,10 @@ class _PromptBottomActions extends StatelessWidget {
builder: (context, state) {
return Row(
children: [
if (showPredefinedFormatButton) ...[
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
@@ -648,7 +641,7 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormatBar,
showFormatBar: showPredefinedFormats,
onTap: onTogglePredefinedFormatSection,
);
}
@@ -173,6 +173,7 @@ class _ModelItem extends StatelessWidget {
model.i18n,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
if (model.desc.isNotEmpty)
FlowyText(
@@ -16,8 +16,6 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
typedef CompareFunction<T> = bool Function(T? left, T? right);
// Navigation shortcuts to move the selected menu items up or down.
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
<ShortcutActivator, Intent>{
@@ -88,7 +86,6 @@ class AFDropdownMenu<T> extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@@ -270,11 +267,6 @@ class AFDropdownMenu<T> extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Defines the compare function for the menu items.
///
/// Defaults to null. If this is null, the menu items will be sorted by the label.
final CompareFunction<T>? selectOptionCompare;
@override
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
}
@@ -309,16 +301,7 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) {
if (widget.selectOptionCompare != null) {
return widget.selectOptionCompare!(
entry.value,
widget.initialSelection,
);
} else {
return entry.value == widget.initialSelection;
}
},
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
);
if (index != -1) {
_textEditingController.value = TextEditingValue(
@@ -16,7 +16,6 @@ enum _MobileSettingsPopupMenuItem {
members,
trash,
help,
helpAndDocumentation,
}
class HomePageSettingsPopupMenu extends StatelessWidget {
@@ -63,16 +62,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
svg: FlowySvgs.help_and_documentation_s,
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.help,
svg: FlowySvgs.message_support_s,
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
),
],
onSelected: (_MobileSettingsPopupMenuItem value) {
@@ -89,9 +82,6 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
case _MobileSettingsPopupMenuItem.help:
_openHelpPage(context);
break;
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
_openHelpAndDocumentationPage(context);
break;
}
},
child: const Padding(
@@ -133,10 +123,6 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
void _openSettingsPage(BuildContext context) {
context.push(MobileHomeSettingPage.routeName);
}
void _openHelpAndDocumentationPage(BuildContext context) {
afLaunchUrlString('https://appflowy.com/guide');
}
}
class _PopupButton extends StatelessWidget {
@@ -411,28 +411,23 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final newFields = [...fieldInfos];
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
if (newFields.isEmpty) {
if (updatedField == null) {
return null;
}
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
if (index != -1) {
newFields[index] =
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
_fieldNotifier.fieldInfos = newFields;
_fieldSettings
..removeWhere(
(field) => field.fieldId == updatedFieldSettings.fieldId,
)
..add(updatedFieldSettings);
return newFields[index];
updatedField = newFields[index];
}
return null;
_fieldNotifier.fieldInfos = newFields;
return updatedField;
}
_fieldSettingsListener.start(
@@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
top: 0,
bottom: 0,
right: 0,
child: DragToExpandLine(),
child: _DragToExpandLine(),
);
return _GridHeaderCellContainer(
@@ -158,11 +158,8 @@ class _GridHeaderCellContainer extends StatelessWidget {
}
}
@visibleForTesting
class DragToExpandLine extends StatelessWidget {
const DragToExpandLine({
super.key,
});
class _DragToExpandLine extends StatelessWidget {
const _DragToExpandLine();
@override
Widget build(BuildContext context) {
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
@@ -14,7 +16,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -200,16 +201,19 @@ class _ChecklistItems extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
@@ -14,7 +14,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../application/cell/bloc/checklist_cell_bloc.dart';
import 'checklist_cell_textfield.dart';
@@ -126,16 +125,19 @@ class ChecklistItemList extends StatelessWidget {
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
child: Stack(
children: [
BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
@@ -272,9 +272,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
if (options.inMemoryUpdate) {
if (enableDocumentInternalLog) {
Log.trace('skip transaction for in-memory update');
}
Log.trace('skip transaction for in-memory update');
return;
}
@@ -432,50 +432,44 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
);
}
return Center(
child: BlocProvider.value(
value: context.read<DocumentBloc>(),
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
BlocProvider.value(
value: context.read<DocumentBloc>(),
child: DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (context, child, onDismiss, isMetricsChanged) =>
DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
);
}
@@ -490,12 +490,6 @@ class MainContentArea extends StatelessWidget {
return DesktopPromptInput(
isStreaming: false,
hideDecoration: true,
hideFormats: [
AiWriterCommand.fixSpellingAndGrammar,
AiWriterCommand.improveWriting,
AiWriterCommand.makeLonger,
AiWriterCommand.makeShorter,
].contains(state.command),
textController: textController,
onSubmitted: (message, format, _) {
cubit.runCommand(state.command, message, format);
@@ -16,11 +16,6 @@ import 'ai_writer_block_operations.dart';
import 'ai_writer_entities.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> {
AiWriterCubit({
required this.documentId,
@@ -101,10 +96,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command);
_aiWriterCubitLog(
'command: $command, run: $run, prompt: $prompt',
);
if (!run) {
await exit();
return;
@@ -221,26 +212,20 @@ class AiWriterCubit extends Cubit<AiWriterState> {
return;
}
// Accept
//
// If the user clicks accept, we need to replace the selection with the AI's response
if (action case SuggestionAction.accept) {
// trim the markdown text to avoid extra new lines
final trimmedMarkdownText = _textRobot.markdownText.trim();
_aiWriterCubitLog(
'trigger accept action, markdown text: $trimmedMarkdownText',
await _textRobot.persist();
await formatSelection(
editorState,
selection,
ApplySuggestionFormatType.clear,
);
await _textRobot.deleteAINodes();
await _textRobot.replace(
selection: selection,
markdownText: trimmedMarkdownText,
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
withUpdateSelection: false,
);
await exit(withDiscard: false, withUnformat: false);
return;
}
@@ -292,35 +277,30 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCommand command,
) async {
final node = aiWriterNode;
// check the node is registered
if (node == null) {
Log.warn('[AI writer] Node is null');
return (false, '');
}
// check the selection is valid
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
Log.warn('[AI writer]Selection is null');
return (false, '');
}
// if the command is continue writing, we don't need to get the selection text
if (command == AiWriterCommand.continueWriting) {
return (true, '');
}
// if the selection is collapsed, we don't need to get the selection text
if (selection.isCollapsed) {
return (true, '');
}
final selectionText = await editorState.getMarkdownInSelection(selection);
Log.warn('[AI writer] Selection is null');
if (command == AiWriterCommand.userQuestion) {
records.add(
AiWriterRecord.user(content: selectionText, format: null),
);
return (true, '');
} else {
return (true, selectionText);
@@ -564,10 +544,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
_aiWriterCubitLog(
'received message: $text',
);
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -579,10 +555,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
),
);
}
_aiWriterCubitLog(
'received assist message: $text',
);
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -599,10 +571,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
_aiWriterCubitLog(
'returned response: ${_textRobot.markdownText}',
);
}
},
onError: (error) async {
@@ -694,12 +662,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
);
}
}
void _aiWriterCubitLog(String message) {
if (_aiWriterCubitDebugLog) {
Log.debug('[AiWriterCubit] $message');
}
}
}
mixin RegisteredAiWriter {
@@ -57,30 +57,11 @@ extension AiWriterNodeExtension on EditorState {
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(
Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
);
// trim the last \n if it exists
return markdown.trimRight();
return markdown;
}
List<String> getPlainTextInSelection(Selection? selection) {
@@ -208,26 +208,11 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
throttler.call(() {
if (aiWriterCubit.aiWriterNode != null) {
final path = aiWriterCubit.aiWriterNode!.path;
if (path.isEmpty) {
return;
if (path.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
if (path.previous.isNotEmpty) {
final node = widget.editorState.getNodeAtPath(path.previous);
if (node != null && node.delta != null && node.delta!.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(
Position(path: path, offset: node.delta!.length),
),
);
return;
}
}
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
});
}
@@ -110,13 +110,10 @@ class MarkdownTextRobot {
}
/// Persist the text into the document
Future<void> persist({
String? markdownText,
}) async {
Future<void> persist({String? markdownText}) async {
if (markdownText != null) {
_markdownText = markdownText;
}
await _lock.synchronized(() async {
await _refresh(inMemoryUpdate: false);
});
@@ -127,34 +124,6 @@ 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
Future<void> discard() async {
final start = _insertPosition;
@@ -313,161 +282,6 @@ class MarkdownTextRobot {
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 {
@@ -1,9 +1,10 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_animation.dart';
import 'toolbar_cubit.dart';
class DesktopFloatingToolbar extends StatefulWidget {
const DesktopFloatingToolbar({
@@ -27,7 +28,6 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
EditorState get editorState => widget.editorState;
_Position? position;
final toolbarController = getIt<FloatingToolbarController>();
@override
void initState() {
@@ -39,32 +39,24 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
final selectionRect = editorState.selectionRects();
if (selectionRect.isEmpty) return;
position = calculateSelectionMenuOffset(selectionRect.first);
toolbarController._addCallback(dismiss);
}
@override
void dispose() {
toolbarController._removeCallback(dismiss);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (position == null) return Container();
return Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
return BlocProvider<ToolbarCubit>(
create: (_) => ToolbarCubit(widget.onDismiss),
child: Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
),
);
}
void dismiss() {
widget.onDismiss.call();
}
_Position calculateSelectionMenuOffset(
Rect rect,
) {
@@ -100,33 +92,3 @@ class _Position {
final double? top;
final double? right;
}
class FloatingToolbarController {
final Set<VoidCallback> _dismissCallbacks = {};
final Set<VoidCallback> _displayListeners = {};
void _addCallback(VoidCallback callback) {
_dismissCallbacks.add(callback);
for (final listener in Set.of(_displayListeners)) {
listener.call();
}
}
void _removeCallback(VoidCallback callback) =>
_dismissCallbacks.remove(callback);
bool get isToolbarShowing => _dismissCallbacks.isNotEmpty;
void addDisplayListener(VoidCallback listener) =>
_displayListeners.add(listener);
void removeDisplayListener(VoidCallback listener) =>
_displayListeners.remove(listener);
void hideToolbar() {
if (_dismissCallbacks.isEmpty) return;
for (final callback in _dismissCallbacks) {
callback.call();
}
}
}
@@ -19,15 +19,11 @@ class LinkCreateMenu extends StatefulWidget {
required this.onSubmitted,
required this.onDismiss,
required this.alignment,
required this.currentViewId,
required this.initialText,
});
final EditorState editorState;
final void Function(String link, bool isPage) onSubmitted;
final VoidCallback onDismiss;
final String currentViewId;
final String initialText;
final LinkMenuAlignment alignment;
@override
@@ -36,8 +32,6 @@ class LinkCreateMenu extends StatefulWidget {
class _LinkCreateMenuState extends State<LinkCreateMenu> {
late LinkSearchTextField searchTextField = LinkSearchTextField(
currentViewId: widget.currentViewId,
initialSearchText: widget.initialText,
onEnter: () {
searchTextField.onSearchResult(
onLink: () => onSubmittedLink(),
@@ -54,28 +48,17 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
},
);
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
bool get isButtonEnable => searchText.isNotEmpty;
String get searchText => searchTextField.searchText;
bool get showAtTop => widget.alignment.isTop;
bool showErrorText = false;
@override
void initState() {
super.initState();
searchTextField.requestFocus();
searchTextField.searchRecentViews();
final focusNode = searchTextField.focusNode;
bool hasFocus = focusNode.hasFocus;
focusNode.addListener(() {
if (hasFocus != focusNode.hasFocus && mounted) {
setState(() {
hasFocus = focusNode.hasFocus;
});
}
});
}
@override
@@ -115,45 +98,32 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
Widget buildSearchContainer() {
return Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: ValueListenableBuilder(
valueListenable: searchTextField.textEditingController,
builder: (context, _, __) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Row(
children: [
Row(
children: [
Expanded(
child: searchTextField.buildTextField(context: context),
),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: onSubmittedLink,
),
],
Expanded(child: searchTextField.buildTextField()),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor:
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
fillColor: isButtonEnable
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
hoverColor: LinkStyle.fillThemeThick,
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
},
@@ -161,15 +131,7 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
);
}
void onSubmittedLink() {
if (!isTextfieldEnable) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted(searchText, false);
}
void onSubmittedLink() => widget.onSubmitted(searchText, false);
void onSubmittedPageLink(ViewPB view) async {
final workspaceId = context
@@ -190,16 +152,13 @@ void showLinkCreateMenu(
BuildContext context,
EditorState editorState,
Selection selection,
String currentViewId,
) {
if (!context.mounted) return;
final (left, top, right, bottom, alignment) = _getPosition(editorState);
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final selectedText = editorState.getTextInSelection(selection).join();
OverlayEntry? overlay;
@@ -219,18 +178,12 @@ void showLinkCreateMenu(
builder: (context) {
return LinkCreateMenu(
alignment: alignment,
initialText: selectedText,
currentViewId: currentViewId,
editorState: editorState,
onSubmitted: (link, isPage) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: link,
kIsPageLink: isPage,
});
await editorState.updateSelectionWithReason(
null,
reason: SelectionUpdateReason.uiEvent,
);
dismissOverlay();
},
onDismiss: dismissOverlay,
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -11,8 +10,7 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'link_create_menu.dart';
import 'link_search_text_field.dart';
import 'link_styles.dart';
@@ -24,14 +22,12 @@ class LinkEditMenu extends StatefulWidget {
required this.onDismiss,
required this.onApply,
required this.onRemoveLink,
required this.currentViewId,
});
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final ValueChanged<LinkInfo> onRemoveLink;
final VoidCallback onRemoveLink;
final VoidCallback onDismiss;
final String currentViewId;
@override
State<LinkEditMenu> createState() => _LinkEditMenuState();
@@ -40,7 +36,7 @@ class LinkEditMenu extends StatefulWidget {
class _LinkEditMenuState extends State<LinkEditMenu> {
ValueChanged<LinkInfo> get onApply => widget.onApply;
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
VoidCallback get onRemoveLink => widget.onRemoveLink;
VoidCallback get onDismiss => widget.onDismiss;
@@ -51,7 +47,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool showErrorText = false;
bool get enableApply =>
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
@override
void initState() {
@@ -60,9 +58,18 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
initialViewId: linkInfo.viewId,
currentViewId: widget.currentViewId,
onEnter: onConfirm,
onEnter: () {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () =>
onPageSelected(searchTextField.currentRecentView),
onSearchViews: () =>
onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
},
onEscape: () {
if (isShowingSearchResult) {
hideSearchResult();
@@ -88,7 +95,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget build(BuildContext context) {
final showingRecent =
searchTextField.showingRecent && isShowingSearchResult;
final errorHeight = showErrorText ? 20.0 : 0.0;
return GestureDetector(
onTap: onDismiss,
child: Container(
@@ -101,8 +107,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
onTap: hideSearchResult,
child: Container(
width: 400,
height: 192 + errorHeight,
height: 192,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
),
),
Positioned(
@@ -116,7 +123,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 80 + errorHeight,
top: 80,
left: 20,
child: FlowyText.semibold(
LocaleKeys.document_toolbar_linkName.tr(),
@@ -126,12 +133,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 144 + errorHeight,
top: 152,
left: 20,
child: buildButtons(),
),
Positioned(
top: 100 + errorHeight,
top: 108,
left: 20,
child: buildNameTextField(),
),
@@ -148,53 +155,29 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget buildLinkField() {
final showPageView = linkInfo.isPage && !isShowingSearchResult;
Widget child;
if (showPageView) {
child = buildPageView();
} else if (!isShowingSearchResult) {
child = buildLinkView();
} else {
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
context: context,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
child,
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
if (showPageView) return buildPageView();
if (!isShowingSearchResult) return buildLinkView();
return SizedBox(
width: 360,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
),
),
],
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
@@ -214,15 +197,15 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
preferBelow: false,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
),
onPressed: () => onRemoveLink.call(linkInfo),
onPressed: onRemoveLink,
),
Spacer(),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
),
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
@@ -231,9 +214,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
fontColor: Theme.of(context).isLightMode
? LinkStyle.textPrimary
: Theme.of(context).iconTheme.color,
fontColor: LinkStyle.textPrimary,
fillColor: Colors.transparent,
fontWeight: FontWeight.w400,
onPressed: onDismiss,
@@ -251,26 +232,14 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
fontColor:
enableApply ? Colors.white : LinkStyle.textTertiary,
fillColor: enableApply
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
fontWeight: FontWeight.w400,
onPressed: () {
if (isShowingSearchResult) {
onConfirm();
return;
}
if (linkInfo.link.isEmpty) {
widget.onRemoveLink(linkInfo);
return;
}
if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onApply.call(linkInfo);
},
onPressed:
enableApply ? () => widget.onApply.call(linkInfo) : null,
);
},
),
@@ -303,7 +272,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
context,
),
),
);
@@ -320,34 +288,24 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
);
} else {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
child = GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyTooltip(
preferBelow: false,
message: displayName,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(4),
Flexible(
child: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(8),
Flexible(
child: FlowyText.regular(
view.name,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
@@ -366,28 +324,24 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
width: 360,
height: 32,
decoration: buildDecoration(),
child: FlowyTooltip(
preferBelow: false,
message: linkInfo.link,
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
],
),
),
],
),
),
),
@@ -395,21 +349,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
);
}
void onConfirm() {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final link = linkInfo.link;
final viewId = link.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
@@ -468,7 +413,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
BoxDecoration buildDecoration() => BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: LinkStyle.borderColor(context)),
border: Border.all(color: LinkStyle.borderColor),
);
}
@@ -481,6 +426,4 @@ class LinkInfo {
Attributes toAttribute() =>
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
}
@@ -3,25 +3,20 @@ import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'link_create_menu.dart';
import 'link_edit_menu.dart';
import 'link_styles.dart';
class LinkHoverTrigger extends StatefulWidget {
const LinkHoverTrigger({
@@ -50,7 +45,6 @@ class LinkHoverTrigger extends StatefulWidget {
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final hoverMenuController = PopoverController();
final editMenuController = PopoverController();
final toolbarController = getIt<FloatingToolbarController>();
bool isHoverMenuShowing = false;
bool isHoverMenuHovering = false;
bool isHoverTriggerHovering = false;
@@ -63,13 +57,12 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
Attributes get attribute => widget.attribute;
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
@override
void initState() {
super.initState();
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
toolbarController.addDisplayListener(onToolbarShow);
}
@override
@@ -77,7 +70,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
hoverMenuController.close();
editMenuController.close();
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
toolbarController.removeDisplayListener(onToolbarShow);
super.dispose();
}
@@ -140,9 +132,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
tryToDismissLinkHoverMenu();
},
onOpenLink: openLink,
onCopyLink: () => copyLink(context),
onCopyLink: copyLink,
onEditLink: showLinkEditMenu,
onRemoveLink: () => removeLink(editorState, selection),
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
@@ -152,7 +144,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final href = attribute.href ?? '',
isPage = attribute.isPage,
title = editorState.getTextInSelection(selection).join();
final currentViewId = context.read<DocumentBloc?>()?.documentId ?? '';
return AppFlowyPopover(
controller: editMenuController,
direction: PopoverDirection.bottomWithLeftAligned,
@@ -168,7 +159,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
minHeight: 282,
),
popupBuilder: (context) => LinkEditMenu(
currentViewId: currentViewId,
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) async {
@@ -183,19 +173,14 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
editMenuController.close();
await editorState.apply(transaction);
},
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
}
void onToolbarShow() => hoverMenuController.close();
void showLinkHoverMenu() {
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
return;
}
if (isHoverMenuShowing) return;
keepEditorFocusNotifier.increase();
hoverMenuController.show();
}
@@ -221,7 +206,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
if (isPage) {
final viewId = href.split('/').lastOrNull ?? '';
if (viewId.isEmpty) {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
await afLaunchUrlString(href);
} else {
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
@@ -230,28 +215,24 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
}
}
} else {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
await afLaunchUrlString(href);
}
}
Future<void> copyLink(BuildContext context) async {
Future<void> copyLink() async {
final href = widget.attribute.href ?? '';
if (href.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: href));
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
hoverMenuController.close();
}
void removeLink(
EditorState editorState,
Selection selection,
bool isHref,
) {
if (!isHref) return;
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
@@ -270,34 +251,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
);
editorState.apply(transaction);
}
void onRemoveAndReplaceLink(
EditorState editorState,
Selection selection,
String text,
) {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..replaceText(
node,
index,
length,
text,
attributes: {
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
}
class LinkHoverMenu extends StatefulWidget {
class LinkHoverMenu extends StatelessWidget {
const LinkHoverMenu({
super.key,
required this.attribute,
@@ -319,31 +275,17 @@ class LinkHoverMenu extends StatefulWidget {
final VoidCallback onEditLink;
final VoidCallback onRemoveLink;
@override
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
}
class _LinkHoverMenuState extends State<LinkHoverMenu> {
ViewPB? currentView;
late bool isPage = widget.attribute.isPage;
late String href = widget.attribute.href ?? '';
@override
void initState() {
super.initState();
if (isPage) getPageView();
}
@override
Widget build(BuildContext context) {
final href = attribute.href ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
onEnter: onEnter,
onExit: onExit,
child: SizedBox(
width: max(320, widget.triggerSize.width),
width: max(320, triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
@@ -351,15 +293,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
padding: EdgeInsets.all(8),
child: Row(
children: [
Expanded(child: buildLinkWidget()),
Expanded(
child: FlowyText.regular(
href,
fontSize: 14,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
),
Container(
height: 20,
width: 1,
color: Color(0xffE8ECF3)
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
color: LinkStyle.borderColor,
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
@@ -367,21 +315,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
tooltipText: LocaleKeys.editor_copyLink.tr(),
width: 36,
height: 32,
onPressed: widget.onCopyLink,
onPressed: onCopyLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
tooltipText: LocaleKeys.editor_editLink.tr(),
width: 36,
height: 32,
onPressed: widget.onEditLink,
onPressed: onEditLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
tooltipText: LocaleKeys.editor_removeLink.tr(),
width: 36,
height: 32,
onPressed: widget.onRemoveLink,
onPressed: onRemoveLink,
),
],
),
@@ -391,13 +339,13 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: widget.onEnter,
onExit: widget.onExit,
onEnter: onEnter,
onExit: onExit,
child: GestureDetector(
onTap: widget.onOpenLink,
onTap: onOpenLink,
child: Container(
width: widget.triggerSize.width,
height: widget.triggerSize.height,
width: triggerSize.width,
height: triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
@@ -405,46 +353,6 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
],
);
}
Future<void> getPageView() async {
final viewId = href.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
Widget buildLinkWidget() {
final view = currentView;
if (isPage && view == null) {
return SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
);
}
String text = '';
if (isPage && view != null) {
text = view.name;
if (text.isEmpty) {
text = LocaleKeys.document_title_placeholder.tr();
}
} else {
text = href;
}
return FlowyTooltip(
message: text,
preferBelow: false,
child: FlowyText.regular(
text,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
);
}
}
class HoverTriggerKey {
@@ -459,11 +367,7 @@ class HoverTriggerKey {
other is HoverTriggerKey &&
runtimeType == other.runtimeType &&
nodeId == other.nodeId &&
isSelectionSame(other.selection);
bool isSelectionSame(Selection other) =>
(selection.start == other.start && selection.end == other.end) ||
(selection.start == other.end && selection.end == other.start);
selection == other.selection;
@override
int get hashCode => nodeId.hashCode ^ selection.hashCode;
@@ -486,7 +390,8 @@ class LinkHoverTriggers {
void call(HoverTriggerKey key) {
final callbacks = _map[key] ?? {};
if (callbacks.isEmpty) return;
callbacks.first.call();
for (final callback in callbacks) {
callback.call();
}
}
}
@@ -12,8 +12,6 @@ import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -27,16 +25,12 @@ class LinkSearchTextField {
this.onEscape,
this.onEnter,
this.onDataRefresh,
this.initialViewId = '',
required this.currentViewId,
String? initialSearchText,
}) : textEditingController = TextEditingController(
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
text: initialSearchText ?? '',
);
final TextEditingController textEditingController;
final String initialViewId;
final String currentViewId;
final ItemScrollController searchController = ItemScrollController();
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
final List<ViewPB> searchedViews = [];
@@ -49,7 +43,7 @@ class LinkSearchTextField {
String get searchText => textEditingController.text;
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
bool get isButtonEnable => searchText.isNotEmpty;
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
@@ -64,11 +58,7 @@ class LinkSearchTextField {
recentViews.clear();
}
Widget buildTextField({
bool autofocus = false,
bool showError = false,
required BuildContext context,
}) {
Widget buildTextField({bool autofocus = false}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: autofocus,
@@ -91,8 +81,6 @@ class LinkSearchTextField {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkInputHint.tr(),
context,
showErrorBorder: showError,
),
);
}
@@ -189,41 +177,29 @@ class LinkSearchTextField {
bool isSelected,
ValueChanged<ViewPB>? onSubmittedPageLink,
) {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
final isCurrent = initialViewId == view.id;
return SizedBox(
height: 32,
child: FlowyButton(
isSelected: isSelected,
leftIcon: buildIcon(view, padding: EdgeInsets.zero),
leftIcon: buildIcon(view),
text: FlowyText.regular(
displayName,
view.name,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
onTap: () => onSubmittedPageLink?.call(view),
),
);
}
Widget buildIcon(
ViewPB view, {
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
}) {
Widget buildIcon(ViewPB view) {
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
final iconData = view.icon.toEmojiIconData();
return Padding(
padding: padding,
child: RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
),
return RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
);
}
@@ -312,7 +288,6 @@ class LinkSearchTextField {
final views = sectionViews
.unique((e) => e.item.id)
.map((e) => e.item)
.where((e) => e.id != currentViewId)
.take(5)
.toList();
recentViews.clear();
@@ -328,14 +303,13 @@ class LinkSearchTextField {
?.items
.where(
(view) =>
(view.id != currentViewId) &&
(view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase()))),
view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase())),
)
.take(10)
.toList();
@@ -1,31 +1,19 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
class LinkStyle {
static const borderColor = Color(0xFFE8ECF3);
static const textTertiary = Color(0xFF99A1A8);
static const textStatusError = Color(0xffE71D32);
static const fillThemeThick = Color(0xFF00B5FF);
static const shadowMedium = Color(0x1F22251F);
static const textPrimary = Color(0xFF1F2329);
static Color borderColor(BuildContext context) =>
Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD);
static InputDecoration buildLinkTextFieldInputDecoration(
String hintText,
BuildContext context, {
bool showErrorBorder = false,
}) {
final border = OutlineInputBorder(
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
const border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderSide: BorderSide(color: borderColor(context)),
borderSide: BorderSide(color: LinkStyle.borderColor),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(
color: showErrorBorder
? LinkStyle.textStatusError
: LinkStyle.fillThemeThick,
),
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
);
const hintStyle = TextStyle(
fontSize: 14,
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:bloc/bloc.dart';
class ToolbarCubit extends Cubit<ToolbarState> {
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
final VoidCallback onDismissCallback;
void dismiss() {
onDismissCallback.call();
}
}
class ToolbarState {
const ToolbarState._();
}
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
@@ -69,7 +68,7 @@ class _FormatToolbarItem extends ToolbarItem {
final hoverColor = isHighlight
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final isDark = !Theme.of(context).isLightMode;
final isDark = Theme.of(context).brightness == Brightness.dark;
final child = FlowyIconButton(
width: 36,
@@ -3,7 +3,6 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -79,7 +78,7 @@ class _HighlightColorPickerWidgetState
}
Widget buildChild(BuildContext context) {
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final iconColor = Theme.of(context).iconTheme.color;
final child = FlowyIconButton(
width: 36,
@@ -1,17 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.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/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_id_enum.dart';
const kIsPageLink = 'is_page_link';
@@ -30,10 +28,10 @@ final customLinkItem = ToolbarItem(
);
});
final isDark = !Theme.of(context).isLightMode;
final hoverColor = isHref
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final toolbarCubit = context.read<ToolbarCubit?>();
final child = FlowyIconButton(
width: 36,
@@ -43,20 +41,16 @@ final customLinkItem = ToolbarItem(
icon: FlowySvg(
FlowySvgs.toolbar_link_m,
size: Size.square(20.0),
color: (isDark && isHref)
? Color(0xFF282E3A)
: AFThemeExtensionV2.of(context).icon_primary,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
getIt<FloatingToolbarController>().hideToolbar();
if (!isHref) {
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
showLinkCreateMenu(context, editorState, selection, viewId);
toolbarCubit?.dismiss();
if (isHref) {
getIt<LinkHoverTriggers>().call(
HoverTriggerKey(nodes.first.id, selection),
);
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
getIt<LinkHoverTriggers>()
.call(HoverTriggerKey(nodes.first.id, selection));
});
showLinkCreateMenu(context, editorState, selection);
}
},
);
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_to
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'toolbar_id_enum.dart';
@@ -78,7 +77,7 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
}
Widget buildChild(BuildContext context) {
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final iconColor = Theme.of(context).iconTheme.color;
final child = FlowyIconButton(
width: 36,
height: 32,
@@ -1,10 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.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/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
@@ -289,7 +288,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
popoverController: suggestionsPopoverController,
popoverDirection: PopoverDirection.leftWithTopAligned,
showOffset: Offset(-8, height),
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
child: buildCommandItem(
MoreOptionCommand.suggestions,
rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m),
@@ -309,7 +308,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
popoverController: textAlignPopoverController,
popoverDirection: PopoverDirection.leftWithTopAligned,
showOffset: Offset(-8, 0),
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
highlightColor: highlightColor,
child: buildCommandItem(
MoreOptionCommand.textAlign,
@@ -380,14 +379,13 @@ enum MoreOptionCommand {
(attributes) => attributes[AppFlowyRichTextKeys.href] != null,
);
});
getIt<FloatingToolbarController>().hideToolbar();
context.read<ToolbarCubit?>()?.dismiss();
if (isHref) {
getIt<LinkHoverTriggers>().call(
HoverTriggerKey(nodes.first.id, selection),
);
} else {
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
showLinkCreateMenu(context, editorState, selection, viewId);
showLinkCreateMenu(context, editorState, selection);
}
} else if (this == strikethrough) {
await editorState.toggleAttribute(name);
@@ -84,14 +84,12 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
void initState() {
super.initState();
refreshSuggestions();
editorState.selectionNotifier.addListener(refreshSuggestions);
}
@override
void dispose() {
editorState.selectionNotifier.removeListener(refreshSuggestions);
popoverController.close();
super.dispose();
popoverController.close();
}
@override
@@ -292,7 +290,6 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
}
currentSuggestionItem =
suggestions.where((item) => item.type == suggestionType).first;
if (mounted) setState(() {});
}
}
@@ -568,6 +568,7 @@ class EditorStyleCustomizer {
if (style == null) {
return null;
}
final fontSize = style.fontSize ?? 14.0;
final isLight = Theme.of(context).isLightMode;
final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4);
final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4);
@@ -577,10 +578,17 @@ class EditorStyleCustomizer {
decoration: TextDecoration.lineThrough,
),
AiWriterBlockKeys.suggestionReplacement => style.copyWith(
color: textColor,
color: Colors.transparent,
decoration: TextDecoration.underline,
decorationColor: underlineColor,
decorationThickness: 1.0,
// hack: https://jtmuller5.medium.com/the-ultimate-guide-to-underlining-text-in-flutter-57936f5c79bb
shadows: [
Shadow(
color: textColor,
offset: Offset(0, -fontSize * 0.2),
),
],
),
_ => style,
};
@@ -1,27 +1,21 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/cupertino.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji_menu.dart';
const _emojiCharacter = ':';
final _letterRegExp = RegExp(r'^[a-zA-Z]$');
const emojiCharacter = ':';
CharacterShortcutEvent emojiCommand(BuildContext context) =>
CharacterShortcutEvent(
key: 'Opens Emoji Menu',
character: '',
regExp: _letterRegExp,
handler: (editorState) async {
return false;
},
handlerWithCharacter: (editorState, character) {
emojiMenuService = EmojiMenu(
character: emojiCharacter,
handler: (editorState) {
emojiMenuService ??= EmojiMenu(
context: context,
editorState: editorState,
);
return emojiCommandHandler(editorState, context, character);
return emojiCommandHandler(editorState, context);
},
);
@@ -30,7 +24,6 @@ EmojiMenuService? emojiMenuService;
Future<bool> emojiCommandHandler(
EditorState editorState,
BuildContext context,
String character,
) async {
final selection = editorState.selection;
@@ -38,32 +31,14 @@ Future<bool> emojiCommandHandler(
return false;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null ||
delta == null ||
delta.isEmpty ||
node.type == CodeBlockKeys.type) {
return false;
if (!selection.isCollapsed) {
await editorState.deleteSelection(selection);
}
if (selection.end.offset > 0) {
final plain = delta.toPlainText();
final previousCharacter = plain[selection.end.offset - 1];
if (previousCharacter != _emojiCharacter) return false;
if (!context.mounted) return false;
if (!selection.isCollapsed) return false;
await editorState.insertTextAtPosition(
character,
position: selection.start,
);
emojiMenuService?.show(character);
return true;
}
return false;
await editorState.insertTextAtPosition(
emojiCharacter,
position: selection.start,
);
emojiMenuService?.show();
return true;
}
@@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'emoji_menu.dart';
@@ -24,7 +23,6 @@ class EmojiHandler extends StatefulWidget {
required this.onEmojiSelect,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
this.initialSearchText = '',
});
final EditorState editorState;
@@ -33,7 +31,6 @@ class EmojiHandler extends StatefulWidget {
final VoidCallback onSelectionUpdate;
final SelectEmojiItemHandler onEmojiSelect;
final int startCharAmount;
final String initialSearchText;
final bool Function()? cancelBySpaceHandler;
@override
@@ -41,34 +38,30 @@ class EmojiHandler extends StatefulWidget {
}
class _EmojiHandlerState extends State<EmojiHandler> {
final focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
final scrollController = ScrollController();
final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
final ItemScrollController controller = ItemScrollController();
late EmojiData emojiData;
final List<Emoji> searchedEmojis = [];
bool loaded = false;
int invalidCounter = 0;
late int startOffset;
late String _search = widget.initialSearchText;
double emojiHeight = 36.0;
final configuration = EmojiPickerConfiguration(
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
);
String _search = '';
set search(String search) {
_search = search;
_doSearch();
}
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
(_) => _focusNode.requestFocus(),
);
startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1;
startOffset = widget.editorState.selection?.endIndex ?? 0;
if (kCachedEmojiData != null) {
loadEmojis(kCachedEmojiData!);
@@ -84,9 +77,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
@override
void dispose() {
focusNode.dispose();
selectedIndexNotifier.dispose();
scrollController.dispose();
_focusNode.dispose();
_selectedIndexNotifier.dispose();
super.dispose();
}
@@ -94,11 +86,10 @@ class _EmojiHandlerState extends State<EmojiHandler> {
Widget build(BuildContext context) {
final noEmojis = searchedEmojis.isEmpty;
return Focus(
focusNode: focusNode,
focusNode: _focusNode,
onKeyEvent: onKeyEvent,
child: Container(
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
padding: const EdgeInsets.symmetric(vertical: 16),
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Theme.of(context).cardColor,
@@ -110,73 +101,52 @@ class _EmojiHandlerState extends State<EmojiHandler> {
),
],
),
child: noEmojis ? buildLoading() : buildEmojis(),
),
);
}
Widget buildLoading() {
return SizedBox(
width: 400,
height: 40,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
),
),
);
}
Widget buildEmojis() {
return SizedBox(
height:
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
child: GridView.builder(
controller: scrollController,
itemCount: searchedEmojis.length,
padding: const EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: configuration.perLine,
),
itemBuilder: (context, index) {
final currentEmoji = searchedEmojis[index];
final emojiId = currentEmoji.id;
final emoji = emojiData.getEmojiById(
emojiId,
skinTone: configuration.defaultSkinTone,
);
return ValueListenableBuilder(
valueListenable: selectedIndexNotifier,
builder: (context, value, child) {
final isSelected = value == index;
return SizedBox.square(
dimension: emojiHeight,
child: FlowyButton(
isSelected: isSelected,
margin: EdgeInsets.zero,
radius: Corners.s8Border,
text: ManualTooltip(
key: ValueKey('$emojiId-$isSelected'),
message: currentEmoji.name,
showAutomaticlly: isSelected,
preferBelow: false,
child: FlowyText.emoji(
emoji,
fontSize: configuration.emojiSize,
),
child: noEmojis
? SizedBox(
width: 400,
height: 40,
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
),
onTap: () => onSelect(index),
),
);
},
);
},
)
: ScrollablePositionedList.builder(
itemCount: searchedEmojis.length,
itemScrollController: controller,
padding: EdgeInsets.all(8),
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemBuilder: (ctx, index) {
return ValueListenableBuilder(
valueListenable: _selectedIndexNotifier,
builder: (context, value, __) {
final selectedEmoji = searchedEmojis[index];
final displayedEmoji =
emojiData.getEmojiById(selectedEmoji.id);
final isSelected = value == index;
return SizedBox(
height: 32,
child: FlowyButton(
text: FlowyText.medium(
'$displayedEmoji ${selectedEmoji.name}',
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
isSelected: isSelected,
onTap: () => onSelect(index),
),
);
},
);
},
),
),
);
}
void changeSelectedIndex(int index) => selectedIndexNotifier.value = index;
void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index;
void loadEmojis(EmojiData data) {
emojiData = data;
@@ -187,14 +157,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
loaded = true;
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_doSearch();
});
}
void _doSearch() {
if (!loaded || !mounted) return;
if (_search.startsWith(' ') || _search.isEmpty) {
Future<void> _doSearch() async {
if (!loaded) return;
if (_search.startsWith(' ')) {
widget.onDismiss.call();
return;
}
@@ -203,7 +170,6 @@ class _EmojiHandlerState extends State<EmojiHandler> {
searchedEmojis.clear();
searchedEmojis.addAll(searchEmojiData.emojis.values);
changeSelectedIndex(0);
_scrollToItem();
});
if (searchedEmojis.isEmpty) {
widget.onDismiss.call();
@@ -211,19 +177,17 @@ class _EmojiHandlerState extends State<EmojiHandler> {
}
KeyEventResult onKeyEvent(focus, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
const moveKeys = [
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
];
if (event.logicalKey == LogicalKeyboardKey.enter) {
onSelect(selectedIndexNotifier.value);
onSelect(_selectedIndexNotifier.value);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
// Workaround to bring focus back to editor
@@ -250,7 +214,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
return KeyEventResult.handled;
} else if (event.character != null &&
!moveKeys.contains(event.logicalKey)) {
![
...moveKeys,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
].contains(event.logicalKey)) {
/// Prevents dismissal of context menu by notifying the parent
/// that the selection change occurred from the handler.
widget.onSelectionUpdate();
@@ -270,13 +238,42 @@ class _EmojiHandlerState extends State<EmojiHandler> {
return KeyEventResult.handled;
}
if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
.contains(event.logicalKey)) {
widget.onSelectionUpdate();
event.logicalKey == LogicalKeyboardKey.arrowLeft
? widget.editorState.moveCursorForward()
: widget.editorState.moveCursorBackward(SelectionMoveRange.character);
/// If cursor moves before @ then dismiss menu
/// If cursor moves after @search.length then dismiss menu
final selection = widget.editorState.selection;
if (selection != null &&
(selection.endIndex < startOffset ||
selection.endIndex > (startOffset + _search.length))) {
widget.onDismiss.call();
}
/// Workaround: When using the move cursor methods, it seems the
/// focus goes back to the editor, this makes sure this handler
/// receives the next keypress.
///
_focusNode.requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.handled;
}
void onSelect(int index) {
widget.onEmojiSelect.call(
context,
(startOffset - widget.startCharAmount, startOffset + _search.length),
(
startOffset - widget.startCharAmount,
_search.length + widget.startCharAmount
),
emojiData.getEmojiById(searchedEmojis[index].id),
);
widget.onDismiss.call();
@@ -299,70 +296,49 @@ class _EmojiHandlerState extends State<EmojiHandler> {
.getTextInSelection(
selection.copyWith(
start: selection.start.copyWith(offset: startOffset),
end: selection.start.copyWith(offset: startOffset + _search.length + 1),
end: selection.start
.copyWith(offset: startOffset + _search.length + 1),
),
)
.join();
}
void _moveSelection(LogicalKeyboardKey key) {
final index = selectedIndexNotifier.value,
perLine = configuration.perLine,
remainder = index % perLine,
length = searchedEmojis.length,
currentLine = index ~/ perLine,
maxLine = (length / perLine).ceil();
final heightBefore = currentLine * emojiHeight;
if (key == LogicalKeyboardKey.arrowUp) {
if (currentLine == 0) {
final exceptLine = max(0, maxLine - 1);
changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1));
} else if (currentLine > 0) {
changeSelectedIndex(index - perLine);
}
} else if (key == LogicalKeyboardKey.arrowDown) {
if (currentLine == maxLine - 1) {
changeSelectedIndex(remainder);
} else if (currentLine < maxLine - 1) {
changeSelectedIndex(min(index + perLine, length - 1));
}
} else if (key == LogicalKeyboardKey.arrowLeft) {
bool didChange = false;
final index = _selectedIndexNotifier.value;
if (key == LogicalKeyboardKey.arrowUp ||
(key == LogicalKeyboardKey.tab &&
HardwareKeyboard.instance.isShiftPressed)) {
if (index == 0) {
changeSelectedIndex(length - 1);
changeSelectedIndex(max(0, searchedEmojis.length - 1));
didChange = true;
} else if (index > 0) {
changeSelectedIndex(index - 1);
didChange = true;
}
} else if (key == LogicalKeyboardKey.arrowRight) {
if (index == length - 1) {
changeSelectedIndex(0);
} else if (index < length - 1) {
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
.contains(key)) {
if (index < searchedEmojis.length - 1) {
changeSelectedIndex(index + 1);
didChange = true;
} else if (index == searchedEmojis.length - 1) {
changeSelectedIndex(0);
didChange = true;
}
}
final heightAfter =
(selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight;
if (mounted && (heightAfter != heightBefore)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToItem();
});
if (mounted && didChange) {
_scrollToItem();
}
}
void _scrollToItem() {
final noEmojis = searchedEmojis.isEmpty;
if (noEmojis || !mounted) return;
final currentItem = selectedIndexNotifier.value;
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
final maxExtent = scrollController.position.maxScrollExtent;
final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight)
? exceptHeight
: min(exceptHeight, maxExtent);
scrollController.animateTo(
jumpTo,
duration: Duration(milliseconds: 300),
curve: Curves.linear,
if (noEmojis) return;
controller.scrollTo(
index: _selectedIndexNotifier.value,
duration: const Duration(milliseconds: 200),
alignment: 0.5,
);
}
@@ -380,7 +356,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
search = delta.toPlainText().substring(
startOffset,
startOffset + _search.length - 1,
startOffset - 1 + _search.length,
);
}
@@ -5,7 +5,7 @@ import 'emoji_actions_command.dart';
import 'emoji_handler.dart';
abstract class EmojiMenuService {
void show(String character);
void show();
void dismiss();
}
@@ -16,22 +16,16 @@ class EmojiMenu extends EmojiMenuService {
required this.editorState,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
this.menuHeight = 400,
this.menuWidth = 300,
});
final BuildContext context;
final EditorState editorState;
final double menuHeight;
final double menuWidth;
final bool Function()? cancelBySpaceHandler;
final int startCharAmount;
Offset _offset = Offset.zero;
Alignment _alignment = Alignment.topLeft;
OverlayEntry? _menuEntry;
bool selectionChangedByMenu = false;
String initialCharacter = '';
@override
void dismiss() {
@@ -57,8 +51,7 @@ class EmojiMenu extends EmojiMenuService {
void _onSelectionUpdate() => selectionChangedByMenu = true;
@override
void show(String character) {
initialCharacter = character;
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
@@ -69,11 +62,41 @@ class EmojiMenu extends EmojiMenuService {
return;
}
const menuHeight = 400.0, menuWidth = 300.0;
const Offset menuOffset = Offset(0, 10);
final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final Size editorSize = editorState.renderBox!.size;
final editorHeight = editorSize.height, editorWidth = editorSize.width;
// Default to opening the overlay below
Alignment alignment = Alignment.topLeft;
calculateSelectionMenuOffset(selectionRects.first);
final firstRect = selectionRects.first;
Offset offset = firstRect.bottomRight + menuOffset;
final (left, top, right, bottom) = _getPosition();
// Show above
if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
offset = firstRect.topRight - menuOffset;
alignment = Alignment.bottomLeft;
offset = Offset(
offset.dx,
editorHeight - offset.dy,
);
}
// Show on the left
if (offset.dx > (editorWidth - menuWidth)) {
alignment = alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
offset = Offset(
editorWidth - offset.dx,
offset.dy,
);
}
final (left, top, right, bottom) = _getPosition(alignment, offset);
_menuEntry = OverlayEntry(
builder: (context) => SizedBox(
@@ -99,7 +122,6 @@ class EmojiMenu extends EmojiMenuService {
onSelectionUpdate: _onSelectionUpdate,
startCharAmount: startCharAmount,
cancelBySpaceHandler: cancelBySpaceHandler,
initialSearchText: initialCharacter,
onEmojiSelect: (
BuildContext context,
(int, int) replacement,
@@ -112,14 +134,10 @@ class EmojiMenu extends EmojiMenuService {
editorState.document.nodeAtPath(selection.end.path);
if (node == null) return;
final transaction = editorState.transaction
..deleteText(
node,
replacement.$1,
replacement.$2 - replacement.$1,
)
..insertText(
..replaceText(
node,
replacement.$1,
replacement.$2,
emoji,
);
await editorState.apply(transaction);
@@ -157,77 +175,30 @@ class EmojiMenu extends EmojiMenuService {
selectionChangedByMenu = false;
}
(double? left, double? top, double? right, double? bottom) _getPosition() {
(double? left, double? top, double? right, double? bottom) _getPosition(
Alignment alignment,
Offset offset,
) {
double? left, top, right, bottom;
switch (_alignment) {
switch (alignment) {
case Alignment.topLeft:
left = _offset.dx;
top = _offset.dy;
left = offset.dx;
top = offset.dy;
break;
case Alignment.bottomLeft:
left = _offset.dx;
bottom = _offset.dy;
left = offset.dx;
bottom = offset.dy;
break;
case Alignment.topRight:
right = _offset.dx;
top = _offset.dy;
right = offset.dx;
top = offset.dy;
break;
case Alignment.bottomRight:
right = _offset.dx;
bottom = _offset.dy;
right = offset.dx;
bottom = offset.dy;
break;
}
return (left, top, right, bottom);
}
void calculateSelectionMenuOffset(Rect rect) {
// Workaround: We can customize the padding through the [EditorStyle],
// but the coordinates of overlay are not properly converted currently.
// Just subtract the padding here as a result.
const menuOffset = Offset(0, 10);
final editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final editorHeight = editorState.renderBox!.size.height;
final editorWidth = editorState.renderBox!.size.width;
// show below default
_alignment = Alignment.topLeft;
final bottomRight = rect.bottomRight;
final topRight = rect.topRight;
var offset = bottomRight + menuOffset;
_offset = Offset(
offset.dx,
offset.dy,
);
// show above
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
_offset = Offset(
offset.dx,
editorHeight + editorOffset.dy - offset.dy,
);
}
// show on right
if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) {
_offset = Offset(
_offset.dx,
_offset.dy,
);
} else if (offset.dx - editorOffset.dx > menuWidth) {
// show on left
_alignment = _alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
_offset = Offset(
editorWidth - _offset.dx + editorOffset.dx,
_offset.dy,
);
}
}
}
@@ -27,7 +27,6 @@ Future<String> customDocumentToMarkdown(
Document document, {
String path = '',
AsyncValueSetter<Archive>? onArchive,
String lineBreak = '',
}) async {
final List<Future<ArchiveFile>> fileFutures = [];
@@ -42,7 +41,6 @@ Future<String> customDocumentToMarkdown(
try {
markdown = documentToMarkdown(
document,
lineBreak: lineBreak,
customParsers: [
const MathEquationNodeParser(),
const CalloutNodeParser(),
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/util/expand_views.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
@@ -188,9 +187,6 @@ Future<void> initGetIt(
getIt.registerSingleton<PluginSandbox>(PluginSandbox());
getIt.registerSingleton<ViewExpanderRegistry>(ViewExpanderRegistry());
getIt.registerSingleton<LinkHoverTriggers>(LinkHoverTriggers());
getIt.registerSingleton<FloatingToolbarController>(
FloatingToolbarController(),
);
await DependencyResolver.resolve(getIt, mode);
}
@@ -1,145 +1,94 @@
import 'dart:async';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'local_llm_listener.dart';
part 'local_ai_bloc.freezed.dart';
class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) {
on<LocalAiPluginEvent>(_handleEvent);
_startListening();
_getLocalAiState();
}
final listener = LocalAIStateListener();
@override
Future<void> close() async {
await listener.stop();
return super.close();
class LocalAIToggleBloc extends Bloc<LocalAIToggleEvent, LocalAIToggleState> {
LocalAIToggleBloc() : super(const LocalAIToggleState()) {
on<LocalAIToggleEvent>(_handleEvent);
}
Future<void> _handleEvent(
LocalAiPluginEvent event,
Emitter<LocalAiPluginState> emit,
LocalAIToggleEvent event,
Emitter<LocalAIToggleState> emit,
) async {
await event.when(
didReceiveAiState: (aiState) {
started: () async {
final result = await AIEventGetLocalAIState().send();
_handleResult(emit, result);
},
toggle: () async {
emit(
LocalAiPluginState.ready(
isEnabled: aiState.enabled,
version: aiState.pluginVersion,
runningState: aiState.state,
lackOfResource:
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
state.copyWith(
pageIndicator: const LocalAIToggleStateIndicator.loading(),
),
);
unawaited(
AIEventToggleLocalAI().send().then(
(result) {
if (!isClosed) {
add(LocalAIToggleEvent.handleResult(result));
}
},
),
);
},
didReceiveLackOfResources: (resources) {
state.maybeMap(
ready: (readyState) {
emit(readyState.copyWith(lackOfResource: resources));
},
orElse: () {},
);
},
toggle: () async {
emit(LocalAiPluginState.loading());
await AIEventToggleLocalAI().send().fold(
(aiState) {
add(LocalAiPluginEvent.didReceiveAiState(aiState));
},
Log.error,
);
},
restart: () async {
emit(LocalAiPluginState.loading());
await AIEventRestartLocalAI().send();
handleResult: (result) {
_handleResult(emit, result);
},
);
}
void _startListening() {
listener.start(
stateCallback: (pluginState) {
add(LocalAiPluginEvent.didReceiveAiState(pluginState));
void _handleResult(
Emitter<LocalAIToggleState> emit,
FlowyResult<LocalAIPB, FlowyError> result,
) {
result.fold(
(localAI) {
emit(
state.copyWith(
pageIndicator:
LocalAIToggleStateIndicator.isEnabled(localAI.enabled),
),
);
},
resourceCallback: (data) {
add(LocalAiPluginEvent.didReceiveLackOfResources(data));
(err) {
emit(
state.copyWith(
pageIndicator: LocalAIToggleStateIndicator.error(err),
),
);
},
);
}
void _getLocalAiState() {
AIEventGetLocalAIState().send().fold(
(aiState) {
add(LocalAiPluginEvent.didReceiveAiState(aiState));
},
Log.error,
);
}
}
@freezed
class LocalAiPluginEvent with _$LocalAiPluginEvent {
const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) =
_DidReceiveAiState;
const factory LocalAiPluginEvent.didReceiveLackOfResources(
LackOfAIResourcePB resources,
) = _DidReceiveLackOfResources;
const factory LocalAiPluginEvent.toggle() = _Toggle;
const factory LocalAiPluginEvent.restart() = _Restart;
class LocalAIToggleEvent with _$LocalAIToggleEvent {
const factory LocalAIToggleEvent.started() = _Started;
const factory LocalAIToggleEvent.toggle() = _Toggle;
const factory LocalAIToggleEvent.handleResult(
FlowyResult<LocalAIPB, FlowyError> result,
) = _HandleResult;
}
@freezed
class LocalAiPluginState with _$LocalAiPluginState {
const LocalAiPluginState._();
const factory LocalAiPluginState.ready({
required bool isEnabled,
required String version,
required RunningStatePB runningState,
required LackOfAIResourcePB? lackOfResource,
}) = ReadyLocalAiPluginState;
const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState;
bool get isEnabled {
return maybeWhen(
ready: (isEnabled, _, __, ___) => isEnabled,
orElse: () => false,
);
}
bool get showIndicator {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) =>
runningState != RunningStatePB.Running || lackOfResource != null,
orElse: () => false,
);
}
bool get showSettings {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) {
final isConnecting = [
RunningStatePB.Connecting,
RunningStatePB.Connected,
].contains(runningState);
final resourcesReadyOrMissingModel = lackOfResource == null ||
lackOfResource.resourceType == LackOfAIResourceTypePB.MissingModel;
return !isConnecting && resourcesReadyOrMissingModel;
},
orElse: () => false,
);
}
class LocalAIToggleState with _$LocalAIToggleState {
const factory LocalAIToggleState({
@Default(LocalAIToggleStateIndicator.loading())
LocalAIToggleStateIndicator pageIndicator,
}) = _LocalAIToggleState;
}
@freezed
class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator {
// when start downloading the model
const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError;
const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready;
const factory LocalAIToggleStateIndicator.loading() = _Loading;
}
@@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc
_dispatch();
}
void _onPaymentSuccessful() {
Future<void> _onPaymentSuccessful() async {
if (isClosed) {
return;
}
@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_ai_setting_panel_bloc.freezed.dart';
class LocalAISettingPanelBloc
extends Bloc<LocalAISettingPanelEvent, LocalAISettingPanelState> {
LocalAISettingPanelBloc()
: listener = LocalAIStateListener(),
super(const LocalAISettingPanelState()) {
on<LocalAISettingPanelEvent>(_handleEvent);
listener.start(
stateCallback: (newState) {
if (!isClosed) {
add(LocalAISettingPanelEvent.updateAIState(newState));
}
},
);
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(LocalAISettingPanelEvent.updateAIState(localAIState));
}
},
Log.error,
);
}
final LocalAIStateListener listener;
/// Handles incoming events and dispatches them to the appropriate handler.
Future<void> _handleEvent(
LocalAISettingPanelEvent event,
Emitter<LocalAISettingPanelState> emit,
) async {
event.when(
updateAIState: (LocalAIPB pluginState) {
if (pluginState.pluginDownloaded) {
emit(
state.copyWith(
runningState: pluginState.state,
progressIndicator: const LocalAIProgress.checkPluginState(),
),
);
} else {
emit(
state.copyWith(
progressIndicator: const LocalAIProgress.downloadLocalAIApp(),
),
);
}
},
);
}
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
}
@freezed
class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent {
const factory LocalAISettingPanelEvent.updateAIState(
LocalAIPB aiState,
) = _UpdateAIState;
}
@freezed
class LocalAISettingPanelState with _$LocalAISettingPanelState {
const factory LocalAISettingPanelState({
LocalAIProgress? progressIndicator,
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
}) = _LocalAIChatSettingState;
}
@freezed
class LocalAIProgress with _$LocalAIProgress {
const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress;
const factory LocalAIProgress.downloadLocalAIApp() =
_DownloadLocalAIAppProgress;
}
@@ -80,9 +80,11 @@ class OllamaSettingBloc extends Bloc<OllamaSettingEvent, OllamaSettingState> {
}
add(OllamaSettingEvent.updateSetting(setting));
AIEventUpdateLocalAISetting(setting).send().fold(
(_) => Log.info('AI setting updated successfully'),
(err) => Log.error("update ai setting failed: $err"),
);
(_) {
Log.info('AI setting updated successfully');
},
(err) => Log.error("update ai setting failed: $err"),
);
},
);
}
@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'plugin_state_bloc.freezed.dart';
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
PluginStateBloc()
: listener = LocalAIStateListener(),
super(
const PluginStateState(
action: PluginStateAction.unknown(),
),
) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(PluginStateEvent.updateLocalAIState(pluginState));
}
},
resourceCallback: (data) {
if (!isClosed) {
add(PluginStateEvent.resourceStateChange(data));
}
},
);
on<PluginStateEvent>(_handleEvent);
}
final LocalAIStateListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _handleEvent(
PluginStateEvent event,
Emitter<PluginStateState> emit,
) async {
await event.when(
started: () async {
final result = await AIEventGetLocalAIState().send();
result.fold(
(pluginState) {
if (!isClosed) {
add(PluginStateEvent.updateLocalAIState(pluginState));
}
},
(err) => Log.error(err.toString()),
);
},
updateLocalAIState: (LocalAIPB aiState) {
// if the offline ai is not started, ask user to start it
if (aiState.hasLackOfResource()) {
emit(
PluginStateState(
action: PluginStateAction.lackOfResource(aiState.lackOfResource),
),
);
return;
}
// Chech state of the plugin
switch (aiState.state) {
case RunningStatePB.ReadyToRun:
emit(
const PluginStateState(
action: PluginStateAction.readToRun(),
),
);
case RunningStatePB.Connecting:
emit(
const PluginStateState(
action: PluginStateAction.initializingPlugin(),
),
);
case RunningStatePB.Connected:
emit(
const PluginStateState(
action: PluginStateAction.initializingPlugin(),
),
);
break;
case RunningStatePB.Running:
emit(
PluginStateState(
action: PluginStateAction.running(aiState.pluginVersion),
),
);
break;
case RunningStatePB.Stopped:
emit(
state.copyWith(action: const PluginStateAction.restartPlugin()),
);
default:
break;
}
},
restartLocalAI: () async {
emit(
const PluginStateState(action: PluginStateAction.restartPlugin()),
);
unawaited(AIEventRestartLocalAI().send());
},
resourceStateChange: (data) {
emit(
PluginStateState(
action: PluginStateAction.lackOfResource(data.resourceDesc),
),
);
},
);
}
}
@freezed
class PluginStateEvent with _$PluginStateEvent {
const factory PluginStateEvent.started() = _Started;
const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) =
_UpdateLocalAIState;
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) =
_ResourceStateChange;
}
@freezed
class PluginStateState with _$PluginStateState {
const factory PluginStateState({
required PluginStateAction action,
}) = _PluginStateState;
}
@freezed
class PluginStateAction with _$PluginStateAction {
const factory PluginStateAction.unknown() = _Unknown;
const factory PluginStateAction.readToRun() = _ReadyToRun;
const factory PluginStateAction.initializingPlugin() = _InitializingPlugin;
const factory PluginStateAction.running(String version) = _PluginRunning;
const factory PluginStateAction.restartPlugin() = _RestartPlugin;
const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource;
}
@@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
const _confirmText = 'DELETE MY ACCOUNT';
const _acceptableConfirmTexts = [
'delete my account',
'deletemyaccount',
@@ -134,8 +135,7 @@ class _AccountDeletionDialog extends StatelessWidget {
),
const VSpace(12.0),
FlowyTextField(
hintText:
LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(),
hintText: _confirmText,
controller: controller,
),
const VSpace(16),
@@ -176,8 +176,7 @@ class _AccountDeletionDialog extends StatelessWidget {
bool _isConfirmTextValid(String text) {
// don't convert the text to lower case or upper case,
// just check if the text is in the list
return _acceptableConfirmTexts.contains(text) ||
text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr();
return _acceptableConfirmTexts.contains(text);
}
Future<void> deleteMyAccount(
@@ -0,0 +1,82 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InitLocalAIIndicator extends StatelessWidget {
const InitLocalAIIndicator({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(
Radius.circular(4),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
builder: (context, state) {
switch (state.runningState) {
case RunningStatePB.Connecting:
case RunningStatePB.Connected:
return Row(
children: [
const HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing
.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
);
case RunningStatePB.Running:
return SizedBox(
height: 30,
child: Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
),
],
),
);
case RunningStatePB.Stopped:
return Row(
children: [
const HSpace(8),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStopped.tr(),
fontSize: 11,
color: const Color(0xFFC62828),
),
],
);
default:
return const SizedBox.shrink();
}
},
),
),
);
}
}
@@ -1,148 +1,128 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ollama_setting.dart';
import 'plugin_status_indicator.dart';
class LocalAISetting extends StatefulWidget {
class LocalAISetting extends StatelessWidget {
const LocalAISetting({super.key});
@override
State<LocalAISetting> createState() => _LocalAISettingState();
}
class _LocalAISettingState extends State<LocalAISetting> {
final expandableController = ExpandableController(initialExpanded: false);
@override
void dispose() {
expandableController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LocalAiPluginBloc(),
child: BlocConsumer<LocalAiPluginBloc, LocalAiPluginState>(
listener: (context, state) {
expandableController.value = state.isEnabled;
},
builder: (context, state) {
return ExpandablePanel(
controller: expandableController,
theme: ExpandableThemeData(
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
create: (context) =>
LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: ExpandableNotifier(
child: BlocListener<LocalAIToggleBloc, LocalAIToggleState>(
listener: (context, state) {
final controller =
ExpandableController.of(context, required: true)!;
state.pageIndicator.when(
error: (_) => controller.expanded = true,
isEnabled: (enabled) => controller.expanded = enabled,
loading: () => controller.expanded = true,
);
},
child: ExpandablePanel(
theme: const ExpandableThemeData(
headerAlignment: ExpandablePanelHeaderAlignment.center,
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
),
header: const LocalAISettingHeader(),
collapsed: const SizedBox.shrink(),
expanded: Column(
children: [
const VSpace(12),
DecoratedBox(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: const Padding(
padding:
EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: LocalAISettingPanel(),
),
),
],
),
),
header: LocalAiSettingHeader(
isEnabled: state.isEnabled,
isToggleable: state is ReadyLocalAiPluginState,
),
collapsed: const SizedBox.shrink(),
expanded: Padding(
padding: EdgeInsets.only(top: 12),
child: LocalAISettingPanel(),
),
);
},
),
),
),
);
}
}
class LocalAiSettingHeader extends StatelessWidget {
const LocalAiSettingHeader({
super.key,
required this.isEnabled,
required this.isToggleable,
});
final bool isEnabled;
final bool isToggleable;
class LocalAISettingHeader extends StatelessWidget {
const LocalAISettingHeader({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.medium(
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
),
const VSpace(4),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
maxLines: 3,
fontSize: 12,
),
],
),
),
IgnorePointer(
ignoring: !isToggleable,
child: Opacity(
opacity: isToggleable ? 1 : 0.5,
child: Toggle(
value: isEnabled,
onChanged: (_) => _onToggleChanged(context),
),
),
),
],
);
}
void _onToggleChanged(BuildContext context) {
if (isEnabled) {
showConfirmDialog(
context: context,
title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(),
description:
LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
context
.read<LocalAiPluginBloc>()
.add(const LocalAiPluginEvent.toggle());
},
);
} else {
context.read<LocalAiPluginBloc>().add(const LocalAiPluginEvent.toggle());
}
}
}
class LocalAISettingPanel extends StatelessWidget {
const LocalAISettingPanel({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
builder: (context, state) {
if (state is! ReadyLocalAiPluginState) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const LocalAIStatusIndicator(),
const VSpace(10),
OllamaSettingPage(),
],
return state.pageIndicator.when(
error: (error) => SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
isEnabled: (isEnabled) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText.medium(
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
),
const Spacer(),
Toggle(
value: isEnabled,
onChanged: (_) {
if (isEnabled) {
showConfirmDialog(
context: context,
title: LocaleKeys
.settings_aiPage_keys_disableLocalAITitle
.tr(),
description: LocaleKeys
.settings_aiPage_keys_disableLocalAIDescription
.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () => context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle()),
);
} else {
context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle());
}
},
),
],
),
const VSpace(4),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
maxLines: 3,
fontSize: 12,
),
],
);
},
);
},
);
@@ -0,0 +1,32 @@
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'plugin_state.dart';
class LocalAISettingPanel extends StatelessWidget {
const LocalAISettingPanel({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LocalAISettingPanelBloc(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OllamaSettingPage(),
VSpace(6),
PluginStateIndicator(),
],
);
},
),
),
);
}
}
@@ -20,44 +20,38 @@ class AIModelSelection extends StatelessWidget {
buildWhen: (previous, current) =>
previous.availableModels != current.availableModels,
builder: (context, state) {
final models = state.availableModels?.models;
if (models == null) {
if (state.availableModels == null) {
return const SizedBox(
// Using same height as SettingsDropdown to avoid layout shift
height: height,
);
}
final localModels = models.where((model) => model.isLocal).toList();
final cloudModels = models.where((model) => !model.isLocal).toList();
final selectedModel = state.availableModels!.selectedModel;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
Flexible(
child: FlowyText.medium(
LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
overflow: TextOverflow.ellipsis,
fontSize: 14,
),
),
const Spacer(),
Flexible(
child: SettingsDropdown<AIModelPB>(
key: const Key('_AIModelSelection'),
onChanged: (model) => context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(model)),
selectedOption: selectedModel,
selectOptionCompare: (left, right) =>
left?.name == right?.name,
options: [...localModels, ...cloudModels]
selectedOption: state.availableModels!.selectedModel,
options: state.availableModels!.models
.map(
(model) => buildDropdownMenuEntry<AIModelPB>(
context,
value: model,
label:
model.isLocal ? "${model.i18n} 🔐" : model.i18n,
label: model.i18n,
subLabel: model.desc,
maximumHeight: height,
),
@@ -1,114 +0,0 @@
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OllamaSettingPage extends StatelessWidget {
const OllamaSettingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
buildWhen: (previous, current) =>
previous.inputItems != current.inputItems ||
previous.isEdited != current.isEdited,
builder: (context, state) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
),
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
for (final item in state.inputItems)
_SettingItemWidget(item: item),
_SaveButton(isEdited: state.isEdited),
],
),
);
},
),
);
}
}
class _SettingItemWidget extends StatelessWidget {
const _SettingItemWidget({required this.item});
final SettingItem item;
@override
Widget build(BuildContext context) {
return Column(
key: ValueKey(item.content + item.settingType.title),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
item.settingType.title,
fontSize: 12,
figmaLineHeight: 16,
),
const VSpace(4),
SizedBox(
height: 32,
child: FlowyTextField(
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
),
),
],
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.isEdited});
final bool isEdited;
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowyTooltip(
message: isEdited ? null : 'No changes',
child: SizedBox(
child: FlowyButton(
text: FlowyText(
'Apply',
figmaLineHeight: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
disable: !isEdited,
expandText: false,
margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
backgroundColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200),
onTap: () {
if (isEdited) {
context
.read<OllamaSettingBloc>()
.add(const OllamaSettingEvent.submit());
}
},
),
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OllamaSettingPage extends StatelessWidget {
const OllamaSettingPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
buildWhen: (previous, current) =>
previous.inputItems != current.inputItems ||
previous.isEdited != current.isEdited,
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListView.separated(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: state.inputItems.length,
separatorBuilder: (_, __) => const VSpace(10),
itemBuilder: (context, index) {
final item = state.inputItems[index];
return _SettingItemWidget(item: item);
},
),
const VSpace(6),
Opacity(
opacity: 0.6,
child: _InstallOllamaInstruction(),
),
_SaveButton(isEdited: state.isEdited),
],
);
},
),
);
}
}
class _SettingItemWidget extends StatelessWidget {
const _SettingItemWidget({required this.item});
final SettingItem item;
@override
Widget build(BuildContext context) {
return Column(
key: ValueKey(item.content + item.settingType.title),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(item.settingType.title),
const VSpace(8),
FlowyTextField(
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
),
],
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.isEdited});
final bool isEdited;
@override
Widget build(BuildContext context) {
final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes';
return SizedBox(
height: 50,
child: Row(
children: [
const Spacer(),
SizedBox(
width: 120,
child: FlowyTooltip(
message: tooltipMessage,
child: Opacity(
opacity: isEdited ? 1 : 0.5,
child: FlowyTextButton(
'Apply',
mainAxisAlignment: MainAxisAlignment.center,
onPressed: isEdited
? () {
context
.read<OllamaSettingBloc>()
.add(const OllamaSettingEvent.submit());
}
: null,
),
),
),
),
],
),
);
}
}
class _InstallOllamaInstruction extends StatelessWidget {
const _InstallOllamaInstruction();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
maxLines: 3,
textAlign: TextAlign.left,
text: TextSpan(
children: <TextSpan>[
TextSpan(
text:
"${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
height: 1.5,
),
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
],
),
),
],
);
}
}
@@ -0,0 +1,168 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PluginStateIndicator extends StatelessWidget {
const PluginStateIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
PluginStateBloc()..add(const PluginStateEvent.started()),
child: BlocBuilder<PluginStateBloc, PluginStateState>(
builder: (context, state) {
return state.action.when(
unknown: () => const SizedBox.shrink(),
readToRun: () => const _PrepareRunning(),
initializingPlugin: () => const InitLocalAIIndicator(),
running: (version) => _LocalAIRunning(
key: ValueKey(version),
version: version,
),
restartPlugin: () => const _RestartPluginButton(),
lackOfResource: (desc) => _LackOfResource(desc: desc),
);
},
),
);
}
}
class _PrepareRunning extends StatelessWidget {
const _PrepareRunning();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
),
),
],
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@override
Widget build(BuildContext context) {
return Row(
children: [
const FlowySvg(
FlowySvgs.download_warn_s,
color: Color(0xFFC62828),
),
const HSpace(6),
FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()),
const Spacer(),
SizedBox(
height: 30,
child: FlowyButton(
useIntrinsicWidth: true,
text:
FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()),
onTap: () {
context.read<PluginStateBloc>().add(
const PluginStateEvent.restartLocalAI(),
);
},
),
),
],
);
}
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({required this.version, super.key});
final String version;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(
Radius.circular(4),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
if (version.isNotEmpty)
Flexible(
child: FlowyText(
"($version) ",
fontSize: 11,
color: const Color(0xFF1E4620),
),
),
Flexible(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIRunning.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
),
),
],
),
),
);
}
}
class _LackOfResource extends StatelessWidget {
const _LackOfResource({required this.desc});
final String desc;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySvg(
FlowySvgs.toast_warning_filled_s,
size: const Size.square(20.0),
blendMode: null,
),
const HSpace(6),
Expanded(
child: FlowyText(
desc,
maxLines: 3,
),
),
],
);
}
}
@@ -1,363 +0,0 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LocalAIStatusIndicator extends StatelessWidget {
const LocalAIStatusIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
builder: (context, state) {
return state.maybeWhen(
ready: (_, version, runningState, lackOfResource) {
if (lackOfResource != null) {
return _LackOfResource(resource: lackOfResource);
}
return switch (runningState) {
RunningStatePB.ReadyToRun => const _ReadyToRun(),
RunningStatePB.Connecting ||
RunningStatePB.Connected =>
_Initializing(),
RunningStatePB.Running => _LocalAIRunning(version: version),
RunningStatePB.Stopped => const _RestartPluginButton(),
_ => const SizedBox.shrink(),
};
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class _ReadyToRun extends StatelessWidget {
const _ReadyToRun();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
const HSpace(8.0),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _Initializing extends StatelessWidget {
const _Initializing();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@override
Widget build(BuildContext context) {
final textStyle =
Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const FlowySvg(
FlowySvgs.toast_error_filled_s,
size: Size.square(20.0),
blendMode: null,
),
const HSpace(8),
Expanded(
child: RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text:
LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
context
.read<LocalAiPluginBloc>()
.add(const LocalAiPluginEvent.restart());
},
),
],
),
),
),
],
),
);
}
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({
required this.version,
});
final String version;
@override
Widget build(BuildContext context) {
final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr();
final text = version.isEmpty ? runningText : "$runningText ($version)";
return Container(
decoration: const BoxDecoration(
color: Color(0xFFEDF7ED),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
Expanded(
child: FlowyText(
text,
color: const Color(0xFF1E4620),
maxLines: 3,
),
),
],
),
);
}
}
class _LackOfResource extends StatelessWidget {
const _LackOfResource({required this.resource});
final LackOfAIResourcePB resource;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).isLightMode
? const Color(0x80FFE7EE)
: const Color(0x80591734),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
FlowySvg(
FlowySvgs.toast_error_filled_s,
size: const Size.square(20.0),
blendMode: null,
),
const HSpace(8),
Expanded(
child: switch (resource.resourceType) {
LackOfAIResourceTypePB.PluginExecutableNotReady =>
_buildNoLAI(context),
LackOfAIResourceTypePB.OllamaServerNotReady =>
_buildNoOllama(context),
LackOfAIResourceTypePB.MissingModel =>
_buildNoModel(context, resource.missingModelNames),
_ => const SizedBox.shrink(),
},
),
],
),
);
}
TextStyle? _textStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
}
Widget _buildNoLAI(BuildContext context) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(),
style: textStyle,
),
TextSpan(text: ' ', style: _textStyle(context)),
..._downloadInstructions(textStyle),
],
),
);
}
Widget _buildNoOllama(BuildContext context) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(),
style: textStyle,
),
TextSpan(text: ' ', style: textStyle),
..._downloadInstructions(textStyle),
],
),
);
}
Widget _buildNoModel(BuildContext context, List<String> modelNames) {
final textStyle = _textStyle(context);
return RichText(
maxLines: 3,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(),
style: textStyle,
),
TextSpan(
text: modelNames.join(', '),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
);
},
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(),
style: textStyle,
),
],
),
);
}
List<TextSpan> _downloadInstructions(TextStyle? textStyle) {
return [
TextSpan(
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
style: textStyle,
),
TextSpan(
text: ' ',
style: textStyle,
),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
style: textStyle?.copyWith(
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
afLaunchUrlString(
"https://appflowy.com/guide/appflowy-local-ai-ollama",
);
},
),
TextSpan(text: ' ', style: textStyle),
TextSpan(
text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(),
style: textStyle,
),
];
}
}
@@ -16,11 +16,9 @@ class SettingsDropdown<T> extends StatefulWidget {
this.onChanged,
this.actions,
this.expandWidth = true,
this.selectOptionCompare,
});
final T selectedOption;
final CompareFunction<T>? selectOptionCompare;
final List<DropdownMenuEntry<T>> options;
final void Function(T)? onChanged;
final List<Widget>? actions;
@@ -54,7 +52,6 @@ class _SettingsDropdownState<T> extends State<SettingsDropdown<T>> {
expandedInsets: widget.expandWidth ? EdgeInsets.zero : null,
initialSelection: widget.selectedOption,
dropdownMenuEntries: widget.options,
selectOptionCompare: widget.selectOptionCompare,
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontFamily: fontFamilyUsed,
fontWeight: FontWeight.w400,
@@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget {
const VSpace(8),
const AppFlowyCloudEnableSync(),
const VSpace(6),
// const AppFlowyCloudSyncLogEnabled(),
const AppFlowyCloudSyncLogEnabled(),
const VSpace(12),
RestartButton(
onClick: () {
@@ -130,7 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget {
final List<Widget> children = [];
children.addAll([
const AppFlowyCloudEnableSync(),
// const AppFlowyCloudSyncLogEnabled(),
const AppFlowyCloudSyncLogEnabled(),
const VSpace(40),
]);
@@ -86,7 +86,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
),
buildChild: (controller) {
return FlowyTooltip(
message: LocaleKeys.questionBubble_getSupport.tr(),
message: LocaleKeys.questionBubble_help.tr(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
@@ -121,22 +121,22 @@ class _BubbleActionListState extends State<BubbleActionList> {
if (action is BubbleActionWrapper) {
switch (action.inner) {
case BubbleAction.whatsNews:
afLaunchUrlString('https://www.appflowy.io/what-is-new');
afLaunchUrlString("https://www.appflowy.io/what-is-new");
break;
case BubbleAction.getSupport:
afLaunchUrlString('https://discord.gg/9Q2xaN37tV');
case BubbleAction.help:
afLaunchUrlString("https://discord.gg/9Q2xaN37tV");
break;
case BubbleAction.debug:
_DebugToast().show();
break;
case BubbleAction.shortcuts:
afLaunchUrlString(
'https://docs.appflowy.io/docs/appflowy/product/shortcuts',
"https://docs.appflowy.io/docs/appflowy/product/shortcuts",
);
break;
case BubbleAction.markdown:
afLaunchUrlString(
'https://docs.appflowy.io/docs/appflowy/product/markdown',
"https://docs.appflowy.io/docs/appflowy/product/markdown",
);
break;
case BubbleAction.github:
@@ -144,11 +144,6 @@ class _BubbleActionListState extends State<BubbleActionList> {
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
);
break;
case BubbleAction.helpAndDocumentation:
afLaunchUrlString(
'https://appflowy.com/guide',
);
break;
}
}
@@ -160,7 +155,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
class _DebugToast {
void show() async {
String debugInfo = '';
String debugInfo = "";
debugInfo += await _getDeviceInfo();
debugInfo += await _getDocumentPath();
await Clipboard.setData(ClipboardData(text: debugInfo));
@@ -173,21 +168,20 @@ class _DebugToast {
final deviceInfo = await deviceInfoPlugin.deviceInfo;
return deviceInfo.data.entries
.fold('', (prev, el) => '$prev${el.key}: ${el.value}\n');
.fold('', (prev, el) => "$prev${el.key}: ${el.value}\n");
}
Future<String> _getDocumentPath() async {
return appFlowyApplicationDataDirectory().then((directory) {
final path = directory.path.toString();
return 'Document: $path\n';
return "Document: $path\n";
});
}
}
enum BubbleAction {
whatsNews,
helpAndDocumentation,
getSupport,
help,
debug,
shortcuts,
markdown,
@@ -210,10 +204,8 @@ extension QuestionBubbleExtension on BubbleAction {
switch (this) {
case BubbleAction.whatsNews:
return LocaleKeys.questionBubble_whatsNew.tr();
case BubbleAction.helpAndDocumentation:
return LocaleKeys.questionBubble_helpAndDocumentation.tr();
case BubbleAction.getSupport:
return LocaleKeys.questionBubble_getSupport.tr();
case BubbleAction.help:
return LocaleKeys.questionBubble_help.tr();
case BubbleAction.debug:
return LocaleKeys.questionBubble_debug_name.tr();
case BubbleAction.shortcuts:
@@ -229,12 +221,7 @@ extension QuestionBubbleExtension on BubbleAction {
switch (this) {
case BubbleAction.whatsNews:
return const FlowySvg(FlowySvgs.star_s);
case BubbleAction.helpAndDocumentation:
return const FlowySvg(
FlowySvgs.help_and_documentation_s,
size: Size.square(16.0),
);
case BubbleAction.getSupport:
case BubbleAction.help:
return const FlowySvg(FlowySvgs.message_support_s);
case BubbleAction.debug:
return const FlowySvg(FlowySvgs.debug_s);
@@ -25,13 +25,18 @@ class SocialMediaSection extends CustomActionCell {
action: SocialMediaWrapper(social),
itemHeight: ActionListSizes.itemHeight,
onSelected: (action) {
final url = switch (action.inner) {
SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/',
SocialMedia.twitter => 'https://x.com/appflowy',
SocialMedia.forum => 'https://forum.appflowy.com/',
};
afLaunchUrlString(url);
switch (action.inner) {
case SocialMedia.reddit:
afLaunchUrlString(
'https://www.reddit.com/r/AppFlowy/',
);
case SocialMedia.twitter:
afLaunchUrlString(
'https://x.com/appflowy',
);
case SocialMedia.forum:
afLaunchUrlString('https://forum.appflowy.io/');
}
},
);
},
@@ -80,11 +85,11 @@ extension QuestionBubbleExtension on SocialMedia {
String get name {
switch (this) {
case SocialMedia.forum:
return 'Community Forum';
return "Community Forum";
case SocialMedia.twitter:
return 'Twitter – @appflowy';
return "Twitter – @appflowy";
case SocialMedia.reddit:
return 'Reddit – r/appflowy';
return "Reddit – r/appflowy";
}
}
+23 -23
View File
@@ -144,34 +144,34 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
@@ -50,72 +50,6 @@ class FlowyTooltip extends StatelessWidget {
}
}
class ManualTooltip extends StatefulWidget {
const ManualTooltip({
super.key,
this.message,
this.richMessage,
this.preferBelow,
this.margin,
this.verticalOffset,
this.padding,
this.showAutomaticlly = false,
this.child,
});
final String? message;
final InlineSpan? richMessage;
final bool? preferBelow;
final EdgeInsetsGeometry? margin;
final Widget? child;
final double? verticalOffset;
final EdgeInsets? padding;
final bool showAutomaticlly;
@override
State<ManualTooltip> createState() => _ManualTooltipState();
}
class _ManualTooltipState extends State<ManualTooltip> {
final key = GlobalKey<TooltipState>();
@override
void initState() {
if (widget.showAutomaticlly) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) key.currentState?.ensureTooltipVisible();
});
}
super.initState();
}
@override
Widget build(BuildContext context) {
return Tooltip(
key: key,
margin: widget.margin,
verticalOffset: widget.verticalOffset ?? 16.0,
triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null,
padding: widget.padding ??
const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
decoration: BoxDecoration(
color: context.tooltipBackgroundColor(),
borderRadius: BorderRadius.circular(10.0),
),
waitDuration: _tooltipWaitDuration,
message: widget.message,
textStyle: widget.message != null ? context.tooltipTextStyle() : null,
richMessage: widget.richMessage,
preferBelow: widget.preferBelow,
child: widget.child,
);
}
}
extension FlowyToolTipExtension on BuildContext {
double tooltipFontSize() => 14.0;
+2 -2
View File
@@ -98,8 +98,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
resolved-ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
ref: "5ad9d77"
resolved-ref: "5ad9d771a8496dea95a9c5b1ec77f76df3983037"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.1.0"
+1 -1
View File
@@ -184,7 +184,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "361b99c38370abeeb19656f89e8c31cb3666623b"
ref: "5ad9d77"
appflowy_editor_plugins:
git:
@@ -375,7 +375,7 @@ void main() {
await blocResponseFuture();
bloc.runResponseAction(SuggestionAction.accept);
await blocResponseFuture();
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
expect(
editorState.getNodeAtPath([0])!.delta!.toPlainText(),
'Hello World',
@@ -294,514 +294,6 @@ 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
@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 8.8V10C18 13.7712 18 15.6569 16.8284 16.8284C15.6569 18 13.7712 18 10 18C6.22876 18 4.34314 18 3.17158 16.8284C2 15.6569 2 13.7712 2 10C2 6.22876 2 4.34314 3.17158 3.17158C4.34314 2 6.22876 2 10 2H11.2M6 11H13M6 14H10M13 4.5C13 5.16304 13.2634 5.79893 13.7322 6.26777C14.2011 6.73661 14.837 7 15.5 7C16.163 7 16.7989 6.73661 17.2678 6.26777C17.7366 5.79893 18 5.16304 18 4.5C18 3.83696 17.7366 3.20107 17.2678 2.73223C16.7989 2.26339 16.163 2 15.5 2C14.837 2 14.2011 2.26339 13.7322 2.73223C13.2634 3.20107 13 3.83696 13 4.5Z" stroke="#21232A" stroke-width="1.25" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 705 B

+10 -30
View File
@@ -256,12 +256,6 @@
"bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة",
"tableWithImageDescription": "@:chat.changeFormat.table مع الصورة"
},
"switchModel": {
"label": "تبديل النموذج",
"localModel": "النموذج المحلي",
"cloudModel": "نموذج السحابة",
"autoModel": "آلي"
},
"selectBanner": {
"saveButton": "أضف إلى...",
"selectMessages": "حدد الرسائل",
@@ -864,8 +858,6 @@
"localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...",
"localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي",
"localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل",
"localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا",
"localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.",
"localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك",
"localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي",
"failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي",
@@ -883,13 +875,9 @@
"activeOfflineAI": "نشط",
"downloadOfflineAI": "التنزيل",
"openModelDirectory": "افتح المجلد",
"laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.",
"ollamaNotReady": "خادم Ollama غير جاهز.",
"pleaseFollowThese": "اتبع هؤلاء",
"instructions": "التعليمات",
"installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل",
"modelsMissing": "لم يتم العثور على النماذج المطلوبة.",
"downloadModel": "لتنزيلها.",
"localAISetupInstruction1": "اتبع هؤلاء",
"localAISetupInstruction2": "التعليمات",
"localAISetupInstruction3": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل",
"startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي"
}
},
@@ -2145,7 +2133,6 @@
"toolbar": {
"resetToDefaultFont": "إعادة تعيين إلى الافتراضي",
"textSize": "حجم النص",
"textColor": "لون النص",
"h1": "العنوان 1",
"h2": "العنوان 2",
"h3": "العنوان 3",
@@ -2156,15 +2143,9 @@
"textAlign": "محاذاة النص",
"moreOptions": "المزيد من الخيارات",
"font": "الخط",
"inlineCode": "الكود المضمن",
"suggestions": "اقتراحات",
"turnInto": "تحول إلى",
"equation": "معادلة",
"insert": "إدراج",
"linkInputHint": "لصق الرابط أو البحث عن الصفحات",
"pageOrURL": "الصفحة أو عنوان URL",
"linkName": "اسم الرابط",
"linkNameHint": "اسم رابط الإدخال"
"equation": "معادلة"
},
"errorBlock": {
"theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.",
@@ -2274,11 +2255,11 @@
"layoutDateField": "تقويم التخطيط بواسطة",
"changeLayoutDateField": "تغيير حقل التخطيط",
"noDateTitle": "بدون تاريخ",
"noDateHint": "ستظهر الأحداث غير المجدولة هنا",
"unscheduledEventsTitle": "الأحداث غير المجدولة",
"clickToAdd": "انقر للإضافة إلى التقويم",
"name": "تخطيط التقويم",
"clickToOpen": "انقر لفتح السجل"
"clickToOpen": "انقر لفتح السجل",
"noDateHint": "ستظهر الأحداث غير المجدولة هنا"
},
"referencedCalendarPrefix": "نظرا ل",
"quickJumpYear": "انتقل إلى",
@@ -2629,12 +2610,12 @@
"dialogTitle": "حذف الحساب",
"dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟",
"dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.",
"confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.",
"confirmHint1": "من فضلك اكتب \"حذف حسابي\" للتأكيد.",
"confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.",
"confirmHint3": "حذف حسابي",
"checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف",
"failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم",
"confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
"confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"حذف حسابي\"",
"deleteAccountSuccess": "تم حذف الحساب بنجاح"
}
},
@@ -3206,8 +3187,7 @@
"editing": "تحرير",
"analyzing": "تحليل",
"continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ",
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!",
"more": "أكثر"
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!"
},
"autoUpdate": {
"criticalUpdateTitle": "التحديث ضروري للمتابعة",
@@ -3237,4 +3217,4 @@
"rewrite": "إعادة كتابة",
"insertBelow": "أدخل أدناه"
}
}
}
+2 -2
View File
@@ -2517,11 +2517,11 @@
"dialogTitle": "Benutzerkonto löschen",
"dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?",
"dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.",
"confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.",
"confirmHint1": "Geben Sie zur Bestätigung bitte „MEIN KONTO LÖSCHEN“ ein.",
"confirmHint3": "MEIN KONTO LÖSCHEN",
"checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen",
"failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.",
"confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.",
"confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „MEIN KONTO LÖSCHEN“ überein.",
"deleteAccountSuccess": "Konto erfolgreich gelöscht"
}
},
+2 -2
View File
@@ -723,7 +723,7 @@
},
"url": {
"launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης",
"copy": "Copied link to clipboard",
"copy": "Copy link to clipboard",
"textFieldHint": "Enter a URL",
"copiedNotification": "Copied to clipboard!"
},
@@ -1403,4 +1403,4 @@
}
}
}
}
}
+13 -18
View File
@@ -307,8 +307,7 @@
"questionBubble": {
"shortcuts": "Shortcuts",
"whatsNew": "What's new?",
"helpAndDocumentation": "Help & documentation",
"getSupport": "Get support",
"help": "Help & Support",
"markdown": "Markdown",
"debug": {
"name": "Debug Info",
@@ -508,8 +507,7 @@
"settings": "Settings",
"members": "Members",
"trash": "Trash",
"helpAndDocumentation": "Help & documentation",
"getSupport": "Get Support"
"helpAndSupport": "Help & Support"
},
"sites": {
"title": "Sites",
@@ -856,16 +854,16 @@
"downloadAIModelButton": "Download",
"downloadingModel": "Downloading",
"localAILoaded": "Local AI Model successfully added and ready to use",
"localAIStart": "Local AI is starting. If it's slow, try toggling it off and on",
"localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on",
"localAILoading": "Local AI Chat Model is loading...",
"localAIStopped": "Local AI stopped",
"localAIRunning": "Local AI is running",
"localAIInitializing": "Local AI is loading and may take a few seconds, depending on your device",
"localAINotReadyRetryLater": "Local AI is initializing, please retry later",
"localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model",
"localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device",
"localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading",
"failToLoadLocalAI": "Failed to start local AI.",
"restartLocalAI": "Restart",
"failToLoadLocalAI": "Failed to start local AI",
"restartLocalAI": "Restart Local AI",
"disableLocalAITitle": "Disable local AI",
"disableLocalAIDescription": "Do you want to disable local AI?",
"localAIToggleTitle": "AppFlowy Local AI (LAI)",
@@ -879,13 +877,10 @@
"activeOfflineAI": "Active",
"downloadOfflineAI": "Download",
"openModelDirectory": "Open folder",
"laiNotReady": "The Local AI app was not installed correctly.",
"ollamaNotReady": "The Ollama server is not ready.",
"pleaseFollowThese": "Please follow these",
"instructions": "instructions",
"installOllamaLai": "to set up Ollama and AppFlowy Local AI.",
"modelsMissing": "Cannot find the required models: ",
"downloadModel": "to download them."
"localAISetupInstruction1": "Follow these",
"localAISetupInstruction2": "instructions",
"localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI. Skip if you've already set it up",
"startLocalAI": "It may take a few seconds to start the local AI"
}
},
"planPage": {
@@ -1647,7 +1642,7 @@
},
"url": {
"launch": "Open link in browser",
"copy": "Copied link to clipboard",
"copy": "Copy link to clipboard",
"textFieldHint": "Enter a URL",
"copiedNotification": "Copied to clipboard!"
},
@@ -2601,12 +2596,12 @@
"dialogTitle": "Delete account",
"dialogContent1": "Are you sure you want to permanently delete your account?",
"dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.",
"confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.",
"confirmHint1": "Please type \"DELETE MY ACCOUNT\" to confirm.",
"confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.",
"confirmHint3": "DELETE MY ACCOUNT",
"checkToConfirmError": "You must check the box to confirm deletion",
"failedToGetCurrentUser": "Failed to get current user email",
"confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
"confirmTextValidationFailed": "Your confirmation text does not match \"DELETE MY ACCOUNT\"",
"deleteAccountSuccess": "Account deleted successfully"
}
},
+2 -2
View File
@@ -2519,12 +2519,12 @@
"dialogTitle": "Supprimer le compte",
"dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?",
"dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.",
"confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.",
"confirmHint1": "Veuillez taper « SUPPRIMER MON COMPTE » pour confirmer.",
"confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.",
"confirmHint3": "SUPPRIMER MON COMPTE",
"checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression",
"failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel",
"confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »",
"confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « SUPPRIMER MON COMPTE »",
"deleteAccountSuccess": "Compte supprimé avec succès"
}
},
+6 -6
View File
@@ -868,9 +868,9 @@
"activeOfflineAI": "활성화됨",
"downloadOfflineAI": "다운로드",
"openModelDirectory": "폴더 열기",
"pleaseFollowThese": "지침",
"instructions": "이 지침을 따르세요",
"installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요",
"localAISetupInstruction1": "이 지침을 따르세요",
"localAISetupInstruction2": "지침",
"localAISetupInstruction3": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요",
"startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다"
}
},
@@ -2579,12 +2579,12 @@
"dialogTitle": "계정 삭제",
"dialogContent1": "계정을 영구적으로 삭제하시겠습니까?",
"dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.",
"confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.",
"confirmHint1": "\"내 계정 삭제\"를 입력하여 확인하세요.",
"confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.",
"confirmHint3": "내 계정 삭제",
"checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다",
"failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다",
"confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다",
"confirmTextValidationFailed": "확인 텍스트가 \"내 계정 삭제\"와 일치하지 않습니다",
"deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다"
}
},
@@ -3185,4 +3185,4 @@
"rewrite": "다시 작성",
"insertBelow": "아래에 삽입"
}
}
}
+2 -2
View File
@@ -2518,12 +2518,12 @@
"dialogTitle": "Hesabı sil",
"dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?",
"dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.",
"confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.",
"confirmHint1": "Onaylamak için lütfen \"HESABIMI SİL\" yazın.",
"confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.",
"confirmHint3": "HESABIMI SİL",
"checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz",
"failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı",
"confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor",
"confirmTextValidationFailed": "Onay metniniz \"HESABIMI SİL\" ile eşleşmiyor",
"deleteAccountSuccess": "Hesap başarıyla silindi"
}
},
+2 -2
View File
@@ -2270,12 +2270,12 @@
"dialogTitle": "Xóa tài khoản",
"dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?",
"dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.",
"confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.",
"confirmHint1": "Vui lòng nhập \"XÓA TÀI KHOẢN CỦA TÔI\" để xác nhận.",
"confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.",
"confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI",
"checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa",
"failedToGetCurrentUser": "Không lấy được email người dùng hiện tại",
"confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
"confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"XÓA TÀI KHOẢN CỦA TÔI\"",
"deleteAccountSuccess": "Tài khoản đã được xóa thành công"
}
},
+1 -8
View File
@@ -1920,14 +1920,7 @@
"deleteMyAccount": "删除我的账户",
"dialogTitle": "删除帐户",
"dialogContent1": "你确定要永久删除您的帐户吗?",
"dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。",
"confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。",
"confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。",
"confirmHint3": "删除我的账户",
"checkToConfirmError": "你必须勾选以确认删除。",
"failedToGetCurrentUser": "获取当前用户邮箱失败",
"confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
"deleteAccountSuccess": "账户删除成功"
"dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。"
}
},
"workplace": {
+55 -33
View File
@@ -496,7 +496,7 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"bincode",
@@ -516,16 +516,16 @@ dependencies = [
[[package]]
name = "appflowy-ai-client"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"bytes",
"futures",
"pin-project",
"serde",
"serde_json",
"serde_repr",
"thiserror 1.0.64",
"uuid",
]
[[package]]
@@ -1137,7 +1137,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"again",
"anyhow",
@@ -1192,7 +1192,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -1205,7 +1205,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"futures-channel",
"futures-util",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"arc-swap",
@@ -1273,7 +1273,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"async-trait",
@@ -1313,7 +1313,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"arc-swap",
@@ -1334,7 +1334,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"bytes",
@@ -1354,7 +1354,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"arc-swap",
@@ -1376,7 +1376,7 @@ dependencies = [
[[package]]
name = "collab-importer"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"async-recursion",
@@ -1418,6 +1418,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"arc-swap",
"async-trait",
"collab",
"collab-database",
"collab-document",
@@ -1428,18 +1429,18 @@ dependencies = [
"diesel",
"flowy-error",
"flowy-sqlite",
"futures",
"lib-infra",
"serde",
"serde_json",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"async-stream",
@@ -1477,7 +1478,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"bincode",
@@ -1499,7 +1500,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"async-trait",
@@ -1510,14 +1511,13 @@ dependencies = [
"thiserror 1.0.64",
"tokio",
"tracing",
"uuid",
"yrs",
]
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=4e717c2c6a15c42feda9e1ff1b122c7b0baf1821#4e717c2c6a15c42feda9e1ff1b122c7b0baf1821"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=80d1c6147d1139289c2eaadab40557cc86c0f4b6#80d1c6147d1139289c2eaadab40557cc86c0f4b6"
dependencies = [
"anyhow",
"collab",
@@ -1764,7 +1764,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.8.0",
"phf 0.11.2",
"smallvec",
]
@@ -1947,7 +1947,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"bincode",
"bytes",
@@ -2513,13 +2513,13 @@ dependencies = [
name = "flowy-ai-pub"
version = "0.1.0"
dependencies = [
"bytes",
"client-api",
"flowy-error",
"futures",
"lib-infra",
"serde",
"serde_json",
"uuid",
]
[[package]]
@@ -2592,6 +2592,7 @@ dependencies = [
"flowy-storage-pub",
"flowy-user",
"flowy-user-pub",
"futures",
"futures-core",
"lib-dispatch",
"lib-infra",
@@ -2605,18 +2606,19 @@ dependencies = [
"tokio-stream",
"tracing",
"uuid",
"walkdir",
]
[[package]]
name = "flowy-database-pub"
version = "0.1.0"
dependencies = [
"anyhow",
"client-api",
"collab",
"collab-entity",
"flowy-error",
"lib-infra",
"uuid",
]
[[package]]
@@ -2665,7 +2667,6 @@ dependencies = [
"tokio-util",
"tracing",
"url",
"uuid",
"validator 0.18.1",
]
@@ -2743,11 +2744,11 @@ dependencies = [
name = "flowy-document-pub"
version = "0.1.0"
dependencies = [
"anyhow",
"collab",
"collab-document",
"flowy-error",
"lib-infra",
"uuid",
]
[[package]]
@@ -2777,7 +2778,6 @@ dependencies = [
"thiserror 1.0.64",
"tokio",
"url",
"uuid",
"validator 0.18.1",
]
@@ -2864,12 +2864,16 @@ dependencies = [
"bytes",
"collab",
"collab-folder",
"diesel",
"diesel_derives",
"diesel_migrations",
"flowy-codegen",
"flowy-derive",
"flowy-error",
"flowy-folder",
"flowy-notification",
"flowy-search-pub",
"flowy-sqlite",
"flowy-user",
"futures",
"lib-dispatch",
@@ -2883,7 +2887,7 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
"uuid",
"validator 0.18.1",
]
[[package]]
@@ -2894,8 +2898,8 @@ dependencies = [
"collab",
"collab-folder",
"flowy-error",
"futures",
"lib-infra",
"uuid",
]
[[package]]
@@ -2987,6 +2991,7 @@ name = "flowy-storage"
version = "0.1.0"
dependencies = [
"allo-isolate",
"anyhow",
"async-trait",
"bytes",
"chrono",
@@ -2998,6 +3003,8 @@ dependencies = [
"flowy-notification",
"flowy-sqlite",
"flowy-storage-pub",
"futures-util",
"fxhash",
"lib-dispatch",
"lib-infra",
"mime_guess",
@@ -3025,8 +3032,9 @@ dependencies = [
"mime",
"mime_guess",
"serde",
"serde_json",
"tokio",
"uuid",
"tracing",
]
[[package]]
@@ -3424,7 +3432,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"getrandom 0.2.10",
@@ -3439,7 +3447,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"app-error",
"jsonwebtoken",
@@ -4060,7 +4068,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"bytes",
@@ -5173,7 +5181,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
"phf_macros",
"phf_macros 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
]
@@ -5193,6 +5201,7 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.2",
]
@@ -5260,6 +5269,19 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.2",
"phf_shared 0.11.2",
"proc-macro2",
"quote",
"syn 2.0.94",
]
[[package]]
name = "phf_shared"
version = "0.8.0"
@@ -6760,7 +6782,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e#2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=f7288f46c27dc8e3c7829cda1b70b61118e88336#f7288f46c27dc8e3c7829cda1b70b61118e88336"
dependencies = [
"anyhow",
"app-error",
+10 -10
View File
@@ -103,8 +103,8 @@ dashmap = "6.0.1"
# Run the script.add_workspace_members:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "2922db6801ca23c5fd6fe8b4958f03bc54dbcb7e" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "f7288f46c27dc8e3c7829cda1b70b61118e88336" }
[profile.dev]
opt-level = 0
@@ -139,14 +139,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "4e717c2c6a15c42feda9e1ff1b122c7b0baf1821" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "80d1c6147d1139289c2eaadab40557cc86c0f4b6" }
# Working directory: frontend
# To update the commit ID, run:
@@ -19,13 +19,14 @@ serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
tracing.workspace = true
async-trait.workspace = true
tokio = { workspace = true, features = ["sync"] }
lib-infra = { workspace = true }
futures = "0.3.31"
arc-swap = "1.7"
flowy-sqlite = { workspace = true }
diesel.workspace = true
flowy-error.workspace = true
uuid.workspace = true
[features]
default = []
@@ -33,10 +33,8 @@ use collab_plugins::local_storage::kv::KVTransactionDB;
use collab_plugins::local_storage::CollabPersistenceConfig;
use collab_user::core::{UserAwareness, UserAwarenessNotifier};
use flowy_error::FlowyError;
use lib_infra::{if_native, if_wasm};
use tracing::{error, instrument, trace, warn};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub enum CollabPluginProviderType {
@@ -68,8 +66,8 @@ impl Display for CollabPluginProviderContext {
}
pub trait WorkspaceCollabIntegrate: Send + Sync {
fn workspace_id(&self) -> Result<Uuid, FlowyError>;
fn device_id(&self) -> Result<String, FlowyError>;
fn workspace_id(&self) -> Result<String, Error>;
fn device_id(&self) -> Result<String, Error>;
}
pub struct AppFlowyCollabBuilder {
@@ -121,15 +119,15 @@ impl AppFlowyCollabBuilder {
pub fn collab_object(
&self,
workspace_id: &Uuid,
workspace_id: &str,
uid: i64,
object_id: &Uuid,
object_id: &str,
collab_type: CollabType,
) -> Result<CollabObject, Error> {
// Compare the workspace_id with the currently opened workspace_id. Return an error if they do not match.
// This check is crucial in asynchronous code contexts where the workspace_id might change during operation.
let actual_workspace_id = self.workspace_integrate.workspace_id()?;
if workspace_id != &actual_workspace_id {
if workspace_id != actual_workspace_id {
return Err(anyhow::anyhow!(
"workspace_id not match when build collab. expect workspace_id: {}, actual workspace_id: {}",
workspace_id,
@@ -137,11 +135,12 @@ impl AppFlowyCollabBuilder {
));
}
let device_id = self.workspace_integrate.device_id()?;
let workspace_id = self.workspace_integrate.workspace_id()?;
Ok(CollabObject::new(
uid,
object_id.to_string(),
collab_type,
workspace_id.to_string(),
workspace_id,
device_id,
))
}
@@ -400,11 +399,11 @@ impl CollabBuilderConfig {
pub struct CollabPersistenceImpl {
pub db: Weak<CollabKVDB>,
pub uid: i64,
pub workspace_id: Uuid,
pub workspace_id: String,
}
impl CollabPersistenceImpl {
pub fn new(db: Weak<CollabKVDB>, uid: i64, workspace_id: Uuid) -> Self {
pub fn new(db: Weak<CollabKVDB>, uid: i64, workspace_id: String) -> Self {
Self {
db,
uid,
@@ -426,11 +425,10 @@ impl CollabPersistence for CollabPersistenceImpl {
let object_id = collab.object_id().to_string();
let rocksdb_read = collab_db.read_txn();
let workspace_id = self.workspace_id.to_string();
if rocksdb_read.is_exist(self.uid, &workspace_id, &object_id) {
if rocksdb_read.is_exist(self.uid, &self.workspace_id, &object_id) {
let mut txn = collab.transact_mut();
match rocksdb_read.load_doc_with_txn(self.uid, &workspace_id, &object_id, &mut txn) {
match rocksdb_read.load_doc_with_txn(self.uid, &self.workspace_id, &object_id, &mut txn) {
Ok(update_count) => {
trace!(
"did load collab:{}-{} from disk, update_count:{}",
@@ -455,7 +453,6 @@ impl CollabPersistence for CollabPersistenceImpl {
object_id: &str,
encoded_collab: EncodedCollab,
) -> Result<(), CollabError> {
let workspace_id = self.workspace_id.to_string();
let collab_db = self
.db
.upgrade()
@@ -464,7 +461,7 @@ impl CollabPersistence for CollabPersistenceImpl {
write_txn
.flush_doc(
self.uid,
workspace_id.as_str(),
self.workspace_id.as_str(),
object_id,
encoded_collab.state_vector.to_vec(),
encoded_collab.doc_state.to_vec(),
@@ -7,8 +7,6 @@ use flowy_sqlite::{
DBConnection, ExpressionMethods, Identifiable, Insertable, Queryable,
};
use std::collections::HashMap;
use std::str::FromStr;
use uuid::Uuid;
#[derive(Queryable, Insertable, Identifiable)]
#[diesel(table_name = af_collab_metadata)]
@@ -45,18 +43,13 @@ pub fn batch_insert_collab_metadata(
pub fn batch_select_collab_metadata(
mut conn: DBConnection,
object_ids: &[Uuid],
) -> FlowyResult<HashMap<Uuid, AFCollabMetadata>> {
let object_ids = object_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<String>>();
object_ids: &[String],
) -> FlowyResult<HashMap<String, AFCollabMetadata>> {
let metadata = dsl::af_collab_metadata
.filter(af_collab_metadata::object_id.eq_any(&object_ids))
.filter(af_collab_metadata::object_id.eq_any(object_ids))
.load::<AFCollabMetadata>(&mut conn)?
.into_iter()
.flat_map(|m| Uuid::from_str(&m.object_id).map(|v| (v, m)))
.map(|m| (m.object_id.clone(), m))
.collect();
Ok(metadata)
}
@@ -1,6 +1,8 @@
use collab::entity::EncodedCollab;
use std::collections::HashMap;
use serde_json::Value;
use flowy_document::entities::*;
use flowy_document::event_map::DocumentEvent;
use flowy_document::parser::parser_entities::{
@@ -9,8 +11,6 @@ use flowy_document::parser::parser_entities::{
};
use flowy_folder::entities::{CreateViewPayloadPB, ViewLayoutPB, ViewPB};
use flowy_folder::event_map::FolderEvent;
use serde_json::Value;
use uuid::Uuid;
use crate::document::utils::{gen_delta_str, gen_id, gen_text_block_data};
use crate::event_builder::EventBuilder;
@@ -37,7 +37,7 @@ impl DocumentEventTest {
Self { event_test: core }
}
pub async fn get_encoded_v1(&self, doc_id: &Uuid) -> EncodedCollab {
pub async fn get_encoded_v1(&self, doc_id: &str) -> EncodedCollab {
let doc = self
.event_test
.appflowy_core
@@ -1,5 +1,4 @@
use flowy_folder::view_operation::{GatherEncodedCollab, ViewData};
use std::str::FromStr;
use std::sync::Arc;
use collab_folder::{FolderData, View};
@@ -17,7 +16,6 @@ use flowy_user::entities::{
use flowy_user::errors::FlowyError;
use flowy_user::event_map::UserEvent;
use flowy_user_pub::entities::Role;
use uuid::Uuid;
use crate::event_builder::EventBuilder;
use crate::EventIntegrationTest;
@@ -125,10 +123,10 @@ impl EventIntegrationTest {
let create_view_params = views
.into_iter()
.map(|view| CreateViewParams {
parent_view_id: Uuid::from_str(&view.parent_view_id).unwrap(),
parent_view_id: view.parent_view_id,
name: view.name,
layout: view.layout.into(),
view_id: Uuid::from_str(&view.id).unwrap(),
view_id: view.id,
initial_data: ViewData::Empty,
meta: Default::default(),
set_as_current: false,
@@ -197,10 +195,9 @@ impl EventIntegrationTest {
view_id: &str,
layout: ViewLayout,
) -> GatherEncodedCollab {
let view_id = Uuid::from_str(view_id).unwrap();
self
.folder_manager
.gather_publish_encode_collab(&view_id, &layout)
.gather_publish_encode_collab(view_id, &layout)
.await
.unwrap()
}
@@ -1,4 +1,3 @@
use crate::user_event::TestNotificationSender;
use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab::preclude::Collab;
@@ -16,14 +15,14 @@ use nanoid::nanoid;
use semver::Version;
use std::env::temp_dir;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::select;
use tokio::task::LocalSet;
use tokio::time::sleep;
use uuid::Uuid;
use crate::user_event::TestNotificationSender;
mod chat_event;
pub mod database_event;
@@ -146,16 +145,10 @@ impl EventIntegrationTest {
) -> Result<Vec<u8>, FlowyError> {
let server = self.server_provider.get_server().unwrap();
let workspace_id = self.get_current_workspace().await.id;
let oid = Uuid::from_str(oid).unwrap();
let uid = self.get_user_profile().await?.id;
let doc_state = server
.folder_service()
.get_folder_doc_state(
&Uuid::from_str(&workspace_id).unwrap(),
uid,
collab_type,
&oid,
)
.get_folder_doc_state(&workspace_id, uid, collab_type, oid)
.await?;
Ok(doc_state)
@@ -3,12 +3,10 @@ use event_integration_test::user_event::use_localhost_af_cloud;
use event_integration_test::EventIntegrationTest;
use flowy_ai::entities::ChatMessageListPB;
use flowy_ai::notification::ChatNotification;
use std::str::FromStr;
use flowy_ai_pub::cloud::ChatMessageType;
use std::time::Duration;
use uuid::Uuid;
#[tokio::test]
async fn af_cloud_create_chat_message_test() {
@@ -23,8 +21,8 @@ async fn af_cloud_create_chat_message_test() {
for i in 0..10 {
let _ = chat_service
.create_question(
&Uuid::from_str(&current_workspace.id).unwrap(),
&Uuid::from_str(&chat_id).unwrap(),
&current_workspace.id,
&chat_id,
&format!("hello world {}", i),
ChatMessageType::System,
&[],
@@ -79,8 +77,8 @@ async fn af_cloud_load_remote_system_message_test() {
for i in 0..10 {
let _ = chat_service
.create_question(
&Uuid::from_str(&current_workspace.id).unwrap(),
&Uuid::from_str(&chat_id).unwrap(),
&current_workspace.id,
&chat_id,
&format!("hello server {}", i),
ChatMessageType::System,
&[],
@@ -8,8 +8,6 @@ use flowy_document::parser::parser_entities::{
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::str::FromStr;
use uuid::Uuid;
#[tokio::test]
async fn get_document_event_test() {
@@ -103,8 +101,8 @@ async fn document_size_test() {
let s = generate_random_string(string_size);
test.insert_index(&view.id, &s, 1, None).await;
}
let view_id = Uuid::from_str(&view.id).unwrap();
let encoded_v1 = test.get_encoded_v1(&view_id).await;
let encoded_v1 = test.get_encoded_v1(&view.id).await;
if encoded_v1.doc_state.len() > max_size {
panic!(
"The document size is too large. {}",
@@ -1,8 +1,8 @@
use collab_folder::ViewLayout;
use event_integration_test::EventIntegrationTest;
use flowy_folder::entities::icon::{ViewIconPB, ViewIconTypePB};
use flowy_folder::entities::ViewLayoutPB;
use uuid::Uuid;
use crate::folder::local_test::script::FolderScript::*;
use crate::folder::local_test::script::FolderTest;
@@ -338,11 +338,11 @@ async fn move_view_event_test() {
async fn create_orphan_child_view_and_get_its_ancestors_test() {
let test = EventIntegrationTest::new_anon().await;
let name = "Orphan View";
let view_id = Uuid::new_v4().to_string();
let view_id = "20240521";
test
.create_orphan_view(name, &view_id, ViewLayoutPB::Grid)
.create_orphan_view(name, view_id, ViewLayoutPB::Grid)
.await;
let ancestors = test.get_view_ancestors(&view_id).await;
let ancestors = test.get_view_ancestors(view_id).await;
assert_eq!(ancestors.len(), 1);
assert_eq!(ancestors[0].name, "Orphan View");
assert_eq!(ancestors[0].id, view_id);
@@ -6,7 +6,6 @@ use flowy_folder::view_operation::GatherEncodedCollab;
use flowy_folder_pub::entities::{
PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData,
};
use uuid::Uuid;
async fn mock_single_document_view_publish_payload(
test: &EventIntegrationTest,
@@ -141,11 +140,11 @@ async fn create_nested_document(test: &EventIntegrationTest, view_id: &str, name
#[tokio::test]
async fn single_document_get_publish_view_payload_test() {
let test = EventIntegrationTest::new_anon().await;
let view_id = Uuid::new_v4().to_string();
let view_id = "20240521";
let name = "Orphan View";
create_single_document(&test, &view_id, name).await;
let view = test.get_view(&view_id).await;
let payload = test.get_publish_payload(&view_id, true).await;
create_single_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, true).await;
let expect_payload = mock_single_document_view_publish_payload(
&test,
@@ -161,10 +160,10 @@ async fn single_document_get_publish_view_payload_test() {
async fn nested_document_get_publish_view_payload_test() {
let test = EventIntegrationTest::new_anon().await;
let name = "Orphan View";
let view_id = Uuid::new_v4().to_string();
create_nested_document(&test, &view_id, name).await;
let view = test.get_view(&view_id).await;
let payload = test.get_publish_payload(&view_id, true).await;
let view_id = "20240521";
create_nested_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, true).await;
let expect_payload = mock_nested_document_view_publish_payload(
&test,
@@ -181,10 +180,10 @@ async fn nested_document_get_publish_view_payload_test() {
async fn no_children_publish_view_payload_test() {
let test = EventIntegrationTest::new_anon().await;
let name = "Orphan View";
let view_id = Uuid::new_v4().to_string();
create_nested_document(&test, &view_id, name).await;
let view = test.get_view(&view_id).await;
let payload = test.get_publish_payload(&view_id, false).await;
let view_id = "20240521";
create_nested_document(&test, view_id, name).await;
let view = test.get_view(view_id).await;
let payload = test.get_publish_payload(view_id, false).await;
let data = mock_single_document_view_publish_payload(
&test,
@@ -1,9 +1,44 @@
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_folder::entities::ViewLayoutPB;
use std::time::Duration;
use crate::util::unzip;
#[tokio::test]
async fn migrate_020_historical_empty_document_test() {
let user_db_path = unzip(
"./tests/user/migration_test/history_user_db",
"020_historical_user_data",
)
.unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await;
let mut views = test.get_all_workspace_views().await;
assert_eq!(views.len(), 1);
// Check the parent view
let parent_view = views.pop().unwrap();
assert_eq!(parent_view.layout, ViewLayoutPB::Document);
let data = test.open_document(parent_view.id.clone()).await.data;
assert!(!data.page_id.is_empty());
assert_eq!(data.blocks.len(), 2);
assert!(!data.meta.children_map.is_empty());
// Check the child views of the parent view
let child_views = test.get_view(&parent_view.id).await.child_views;
assert_eq!(child_views.len(), 4);
assert_eq!(child_views[0].layout, ViewLayoutPB::Document);
assert_eq!(child_views[1].layout, ViewLayoutPB::Grid);
assert_eq!(child_views[2].layout, ViewLayoutPB::Calendar);
assert_eq!(child_views[3].layout, ViewLayoutPB::Board);
let database = test.get_database(&child_views[1].id).await;
assert_eq!(database.fields.len(), 8);
assert_eq!(database.rows.len(), 3);
}
#[tokio::test]
async fn migrate_036_fav_v1_workspace_array_test() {
// Used to test migration: FavoriteV1AndWorkspaceArrayMigration
+2 -2
View File
@@ -9,7 +9,7 @@ edition = "2021"
lib-infra = { workspace = true }
flowy-error = { workspace = true }
client-api = { workspace = true }
bytes.workspace = true
futures.workspace = true
serde_json.workspace = true
serde.workspace = true
uuid.workspace = true
serde.workspace = true
+28 -29
View File
@@ -19,7 +19,6 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use uuid::Uuid;
pub type ChatMessageStream = BoxStream<'static, Result<ChatMessage, AppResponseError>>;
pub type StreamAnswer = BoxStream<'static, Result<QuestionStreamValue, FlowyError>>;
@@ -82,15 +81,15 @@ pub trait ChatCloudService: Send + Sync + 'static {
async fn create_chat(
&self,
uid: &i64,
workspace_id: &Uuid,
chat_id: &Uuid,
rag_ids: Vec<Uuid>,
workspace_id: &str,
chat_id: &str,
rag_ids: Vec<String>,
) -> Result<(), FlowyError>;
async fn create_question(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message: &str,
message_type: ChatMessageType,
metadata: &[ChatMessageMetadata],
@@ -98,8 +97,8 @@ pub trait ChatCloudService: Send + Sync + 'static {
async fn create_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message: &str,
question_id: i64,
metadata: Option<serde_json::Value>,
@@ -107,8 +106,8 @@ pub trait ChatCloudService: Send + Sync + 'static {
async fn stream_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
@@ -116,68 +115,68 @@ pub trait ChatCloudService: Send + Sync + 'static {
async fn get_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
question_message_id: i64,
) -> Result<ChatMessage, FlowyError>;
async fn get_chat_messages(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
offset: MessageCursor,
limit: u64,
) -> Result<RepeatedChatMessage, FlowyError>;
async fn get_question_from_answer_id(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
answer_message_id: i64,
) -> Result<ChatMessage, FlowyError>;
async fn get_related_message(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message_id: i64,
) -> Result<RepeatedRelatedQuestion, FlowyError>;
async fn stream_complete(
&self,
workspace_id: &Uuid,
workspace_id: &str,
params: CompleteTextParams,
ai_model: Option<AIModel>,
) -> Result<StreamComplete, FlowyError>;
async fn embed_file(
&self,
workspace_id: &Uuid,
workspace_id: &str,
file_path: &Path,
chat_id: &Uuid,
chat_id: &str,
metadata: Option<HashMap<String, Value>>,
) -> Result<(), FlowyError>;
async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result<LocalAIConfig, FlowyError>;
async fn get_local_ai_config(&self, workspace_id: &str) -> Result<LocalAIConfig, FlowyError>;
async fn get_workspace_plan(
&self,
workspace_id: &Uuid,
workspace_id: &str,
) -> Result<Vec<SubscriptionPlan>, FlowyError>;
async fn get_chat_settings(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
) -> Result<ChatSettings, FlowyError>;
async fn update_chat_settings(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
params: UpdateChatParams,
) -> Result<(), FlowyError>;
async fn get_available_models(&self, workspace_id: &Uuid) -> Result<ModelList, FlowyError>;
async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result<String, FlowyError>;
async fn get_available_models(&self, workspace_id: &str) -> Result<ModelList, FlowyError>;
async fn get_workspace_default_model(&self, workspace_id: &str) -> Result<String, FlowyError>;
}
+43 -90
View File
@@ -3,7 +3,7 @@ use crate::entities::{
AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB,
FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams,
};
use crate::local_ai::controller::{LocalAIController, LocalAISetting};
use crate::local_ai::controller::LocalAIController;
use crate::middleware::chat_service_mw::AICloudServiceMiddleware;
use crate::persistence::{insert_chat, read_chat_metadata, ChatTable};
use std::collections::HashMap;
@@ -27,16 +27,14 @@ use flowy_storage_pub::storage::StorageService;
use lib_infra::async_trait::async_trait;
use lib_infra::util::timestamp;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Weak};
use tokio::sync::RwLock;
use tracing::{error, info, instrument, trace};
use uuid::Uuid;
pub trait AIUserService: Send + Sync + 'static {
fn user_id(&self) -> Result<i64, FlowyError>;
fn device_id(&self) -> Result<String, FlowyError>;
fn workspace_id(&self) -> Result<Uuid, FlowyError>;
fn workspace_id(&self) -> Result<String, FlowyError>;
fn sqlite_connection(&self, uid: i64) -> Result<DBConnection, FlowyError>;
fn application_root_dir(&self) -> Result<PathBuf, FlowyError>;
}
@@ -46,18 +44,18 @@ pub trait AIUserService: Send + Sync + 'static {
pub trait AIExternalService: Send + Sync + 'static {
async fn query_chat_rag_ids(
&self,
parent_view_id: &Uuid,
chat_id: &Uuid,
) -> Result<Vec<Uuid>, FlowyError>;
parent_view_id: &str,
chat_id: &str,
) -> Result<Vec<String>, FlowyError>;
async fn sync_rag_documents(
&self,
workspace_id: &Uuid,
rag_ids: Vec<Uuid>,
rag_metadata_map: HashMap<Uuid, AFCollabMetadata>,
workspace_id: &str,
rag_ids: Vec<String>,
rag_metadata_map: HashMap<String, AFCollabMetadata>,
) -> Result<Vec<AFCollabMetadata>, FlowyError>;
async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError>;
async fn notify_did_send_message(&self, chat_id: &str, message: &str) -> Result<(), FlowyError>;
}
#[derive(Debug, Default)]
@@ -72,7 +70,7 @@ pub struct AIManager {
pub cloud_service_wm: Arc<AICloudServiceMiddleware>,
pub user_service: Arc<dyn AIUserService>,
pub external_service: Arc<dyn AIExternalService>,
chats: Arc<DashMap<Uuid, Arc<Chat>>>,
chats: Arc<DashMap<String, Arc<Chat>>>,
pub local_ai: Arc<LocalAIController>,
pub store_preferences: Arc<KVStorePreferences>,
server_models: Arc<RwLock<ServerModelsCache>>,
@@ -134,11 +132,11 @@ impl AIManager {
Ok(())
}
pub async fn open_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> {
self.chats.entry(*chat_id).or_insert_with(|| {
pub async fn open_chat(&self, chat_id: &str) -> Result<(), FlowyError> {
self.chats.entry(chat_id.to_string()).or_insert_with(|| {
Arc::new(Chat::new(
self.user_service.user_id().unwrap(),
*chat_id,
chat_id.to_string(),
self.user_service.clone(),
self.cloud_service_wm.clone(),
))
@@ -152,7 +150,7 @@ impl AIManager {
let cloud_service_wm = self.cloud_service_wm.clone();
let store_preferences = self.store_preferences.clone();
let external_service = self.external_service.clone();
let chat_id = *chat_id;
let chat_id = chat_id.to_string();
tokio::spawn(async move {
match refresh_chat_setting(
&user_service,
@@ -163,12 +161,7 @@ impl AIManager {
.await
{
Ok(settings) => {
let rag_ids = settings
.rag_ids
.into_iter()
.flat_map(|r| Uuid::from_str(&r).ok())
.collect();
let _ = sync_chat_documents(user_service, external_service, rag_ids).await;
let _ = sync_chat_documents(user_service, external_service, settings.rag_ids).await;
},
Err(err) => {
error!("failed to refresh chat settings: {}", err);
@@ -179,13 +172,13 @@ impl AIManager {
Ok(())
}
pub async fn close_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> {
pub async fn close_chat(&self, chat_id: &str) -> Result<(), FlowyError> {
trace!("close chat: {}", chat_id);
self.local_ai.close_chat(chat_id);
Ok(())
}
pub async fn delete_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> {
pub async fn delete_chat(&self, chat_id: &str) -> Result<(), FlowyError> {
if let Some((_, chat)) = self.chats.remove(chat_id) {
chat.close();
@@ -219,8 +212,8 @@ impl AIManager {
pub async fn create_chat(
&self,
uid: &i64,
parent_view_id: &Uuid,
chat_id: &Uuid,
parent_view_id: &str,
chat_id: &str,
) -> Result<Arc<Chat>, FlowyError> {
let workspace_id = self.user_service.workspace_id()?;
let rag_ids = self
@@ -238,11 +231,11 @@ impl AIManager {
let chat = Arc::new(Chat::new(
self.user_service.user_id()?,
*chat_id,
chat_id.to_string(),
self.user_service.clone(),
self.cloud_service_wm.clone(),
));
self.chats.insert(*chat_id, chat.clone());
self.chats.insert(chat_id.to_string(), chat.clone());
Ok(chat)
}
@@ -251,7 +244,7 @@ impl AIManager {
params: StreamMessageParams,
) -> Result<ChatMessagePB, FlowyError> {
let chat = self.get_or_create_chat_instance(&params.chat_id).await?;
let ai_model = self.get_active_model(&params.chat_id.to_string()).await;
let ai_model = self.get_active_model(&params.chat_id).await;
let question = chat.stream_chat_message(&params, ai_model).await?;
let _ = self
.external_service
@@ -262,7 +255,7 @@ impl AIManager {
pub async fn stream_regenerate_response(
&self,
chat_id: &Uuid,
chat_id: &str,
answer_message_id: i64,
answer_stream_port: i64,
format: Option<PredefinedFormatPB>,
@@ -277,7 +270,7 @@ impl AIManager {
|| {
self
.store_preferences
.get_object::<AIModel>(&ai_available_models_key(&chat_id.to_string()))
.get_object::<AIModel>(&ai_available_models_key(chat_id))
},
|model| Some(model.into()),
);
@@ -287,24 +280,6 @@ impl AIManager {
Ok(())
}
pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
let previous_model = self.local_ai.get_local_ai_setting().chat_model_name;
self.local_ai.update_local_ai_setting(setting).await?;
let current_model = self.local_ai.get_local_ai_setting().chat_model_name;
if previous_model != current_model {
info!(
"[AI Plugin] update global active model, previous: {}, current: {}",
previous_model, current_model
);
let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY);
let model = AIModel::local(current_model, "".to_string());
self.update_selected_model(source_key, model).await?;
}
Ok(())
}
async fn get_workspace_select_model(&self) -> FlowyResult<String> {
let workspace_id = self.user_service.workspace_id()?;
let model = self
@@ -383,10 +358,6 @@ impl AIManager {
}
pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> {
info!(
"[Model Selection] update {} selected model: {:?}",
source, model
);
let source_key = ai_available_models_key(&source);
self
.store_preferences
@@ -447,15 +418,9 @@ impl AIManager {
trace!("[Model Selection]: Available models: {:?}", models);
let mut current_active_local_ai_model = None;
// If user enable local ai, then add local ai model to the list.
if let Some(local_model) = self.local_ai.get_plugin_chat_model() {
let model = AIModel::local(local_model, "".to_string());
current_active_local_ai_model = Some(model.clone());
trace!("[Model Selection] current local ai model: {}", model.name);
models.push(model);
models.push(AIModel::local(local_model, "".to_string()));
}
if models.is_empty() {
@@ -489,16 +454,8 @@ impl AIManager {
user_selected_model = local_ai_model.clone();
}
},
Some(mut model) => {
trace!("[Model Selection] user previous select model: {:?}", model);
if model.is_local {
if let Some(local_ai_model) = &current_active_local_ai_model {
if local_ai_model.name != model.name {
model = local_ai_model.clone();
}
}
}
Some(model) => {
trace!("[Model Selection] user select model: {:?}", model);
user_selected_model = model;
},
}
@@ -527,17 +484,17 @@ impl AIManager {
})
}
pub async fn get_or_create_chat_instance(&self, chat_id: &Uuid) -> Result<Arc<Chat>, FlowyError> {
pub async fn get_or_create_chat_instance(&self, chat_id: &str) -> Result<Arc<Chat>, FlowyError> {
let chat = self.chats.get(chat_id).as_deref().cloned();
match chat {
None => {
let chat = Arc::new(Chat::new(
self.user_service.user_id()?,
*chat_id,
chat_id.to_string(),
self.user_service.clone(),
self.cloud_service_wm.clone(),
));
self.chats.insert(*chat_id, chat.clone());
self.chats.insert(chat_id.to_string(), chat.clone());
Ok(chat)
},
Some(chat) => Ok(chat),
@@ -561,7 +518,7 @@ impl AIManager {
pub async fn load_prev_chat_messages(
&self,
chat_id: &Uuid,
chat_id: &str,
limit: i64,
before_message_id: Option<i64>,
) -> Result<ChatMessageListPB, FlowyError> {
@@ -574,7 +531,7 @@ impl AIManager {
pub async fn load_latest_chat_messages(
&self,
chat_id: &Uuid,
chat_id: &str,
limit: i64,
after_message_id: Option<i64>,
) -> Result<ChatMessageListPB, FlowyError> {
@@ -587,7 +544,7 @@ impl AIManager {
pub async fn get_related_questions(
&self,
chat_id: &Uuid,
chat_id: &str,
message_id: i64,
) -> Result<RepeatedRelatedQuestionPB, FlowyError> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
@@ -597,7 +554,7 @@ impl AIManager {
pub async fn generate_answer(
&self,
chat_id: &Uuid,
chat_id: &str,
question_message_id: i64,
) -> Result<ChatMessagePB, FlowyError> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
@@ -605,19 +562,19 @@ impl AIManager {
Ok(resp)
}
pub async fn stop_stream(&self, chat_id: &Uuid) -> Result<(), FlowyError> {
pub async fn stop_stream(&self, chat_id: &str) -> Result<(), FlowyError> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
chat.stop_stream_message().await;
Ok(())
}
pub async fn chat_with_file(&self, chat_id: &Uuid, file_path: PathBuf) -> FlowyResult<()> {
pub async fn chat_with_file(&self, chat_id: &str, file_path: PathBuf) -> FlowyResult<()> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
chat.index_file(file_path).await?;
Ok(())
}
pub async fn get_rag_ids(&self, chat_id: &Uuid) -> FlowyResult<Vec<String>> {
pub async fn get_rag_ids(&self, chat_id: &str) -> FlowyResult<Vec<String>> {
if let Some(settings) = self
.store_preferences
.get_object::<ChatSettings>(&setting_store_key(chat_id))
@@ -635,7 +592,7 @@ impl AIManager {
Ok(settings.rag_ids)
}
pub async fn update_rag_ids(&self, chat_id: &Uuid, rag_ids: Vec<String>) -> FlowyResult<()> {
pub async fn update_rag_ids(&self, chat_id: &str, rag_ids: Vec<String>) -> FlowyResult<()> {
info!("[Chat] update chat:{} rag ids: {:?}", chat_id, rag_ids);
let workspace_id = self.user_service.workspace_id()?;
let update_setting = UpdateChatParams {
@@ -666,10 +623,6 @@ impl AIManager {
let user_service = self.user_service.clone();
let external_service = self.external_service.clone();
let rag_ids = rag_ids
.into_iter()
.flat_map(|r| Uuid::from_str(&r).ok())
.collect();
sync_chat_documents(user_service, external_service, rag_ids).await?;
Ok(())
}
@@ -678,7 +631,7 @@ impl AIManager {
async fn sync_chat_documents(
user_service: Arc<dyn AIUserService>,
external_service: Arc<dyn AIExternalService>,
rag_ids: Vec<Uuid>,
rag_ids: Vec<String>,
) -> FlowyResult<()> {
if rag_ids.is_empty() {
return Ok(());
@@ -708,7 +661,7 @@ async fn sync_chat_documents(
Ok(())
}
fn save_chat(conn: DBConnection, chat_id: &Uuid) -> FlowyResult<()> {
fn save_chat(conn: DBConnection, chat_id: &str) -> FlowyResult<()> {
let row = ChatTable {
chat_id: chat_id.to_string(),
created_at: timestamp(),
@@ -727,7 +680,7 @@ async fn refresh_chat_setting(
user_service: &Arc<dyn AIUserService>,
cloud_service: &Arc<AICloudServiceMiddleware>,
store_preferences: &Arc<KVStorePreferences>,
chat_id: &Uuid,
chat_id: &str,
) -> FlowyResult<ChatSettings> {
info!("[Chat] refresh chat:{} setting", chat_id);
let workspace_id = user_service.workspace_id()?;
@@ -739,7 +692,7 @@ async fn refresh_chat_setting(
error!("failed to set chat settings: {}", err);
}
chat_notification_builder(chat_id.to_string(), ChatNotification::DidUpdateChatSettings)
chat_notification_builder(chat_id, ChatNotification::DidUpdateChatSettings)
.payload(ChatSettingsPB {
rag_ids: settings.rag_ids.clone(),
})
@@ -748,6 +701,6 @@ async fn refresh_chat_setting(
Ok(settings)
}
fn setting_store_key(chat_id: &Uuid) -> String {
fn setting_store_key(chat_id: &str) -> String {
format!("chat_settings_{}", chat_id)
}
+17 -18
View File
@@ -23,7 +23,6 @@ use std::sync::atomic::{AtomicBool, AtomicI64};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::{error, instrument, trace};
use uuid::Uuid;
enum PrevMessageState {
HasMore,
@@ -32,7 +31,7 @@ enum PrevMessageState {
}
pub struct Chat {
chat_id: Uuid,
chat_id: String,
uid: i64,
user_service: Arc<dyn AIUserService>,
chat_service: Arc<AICloudServiceMiddleware>,
@@ -45,7 +44,7 @@ pub struct Chat {
impl Chat {
pub fn new(
uid: i64,
chat_id: Uuid,
chat_id: String,
user_service: Arc<dyn AIUserService>,
chat_service: Arc<AICloudServiceMiddleware>,
) -> Chat {
@@ -198,13 +197,13 @@ impl Chat {
answer_stream_port: i64,
answer_stream_buffer: Arc<Mutex<StringBuffer>>,
uid: i64,
workspace_id: Uuid,
workspace_id: String,
question_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
) {
let stop_stream = self.stop_stream.clone();
let chat_id = self.chat_id;
let chat_id = self.chat_id.clone();
let cloud_service = self.chat_service.clone();
let user_service = self.user_service.clone();
tokio::spawn(async move {
@@ -255,10 +254,10 @@ impl Chat {
.send(StreamMessage::OnError(err.msg.clone()).to_string())
.await;
let pb = ChatMessageErrorPB {
chat_id: chat_id.to_string(),
chat_id: chat_id.clone(),
error_message: err.to_string(),
};
chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError)
chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError)
.payload(pb)
.send();
return Err(err);
@@ -294,17 +293,17 @@ impl Chat {
}
let pb = ChatMessageErrorPB {
chat_id: chat_id.to_string(),
chat_id: chat_id.clone(),
error_message: err.to_string(),
};
chat_notification_builder(chat_id, ChatNotification::StreamChatMessageError)
chat_notification_builder(&chat_id, ChatNotification::StreamChatMessageError)
.payload(pb)
.send();
return Err(err);
},
}
chat_notification_builder(chat_id, ChatNotification::FinishStreaming).send();
chat_notification_builder(&chat_id, ChatNotification::FinishStreaming).send();
trace!("[Chat] finish streaming");
if answer_stream_buffer.lock().await.is_empty() {
@@ -360,7 +359,7 @@ impl Chat {
has_more: true,
total: 0,
};
chat_notification_builder(self.chat_id, ChatNotification::DidLoadPrevChatMessage)
chat_notification_builder(&self.chat_id, ChatNotification::DidLoadPrevChatMessage)
.payload(pb.clone())
.send();
return Ok(pb);
@@ -433,7 +432,7 @@ impl Chat {
after_message_id
);
let workspace_id = self.user_service.workspace_id()?;
let chat_id = self.chat_id;
let chat_id = self.chat_id.clone();
let cloud_service = self.chat_service.clone();
let user_service = self.user_service.clone();
let uid = self.uid;
@@ -481,11 +480,11 @@ impl Chat {
} else {
*prev_message_state.write().await = PrevMessageState::NoMore;
}
chat_notification_builder(chat_id, ChatNotification::DidLoadPrevChatMessage)
chat_notification_builder(&chat_id, ChatNotification::DidLoadPrevChatMessage)
.payload(pb)
.send();
} else {
chat_notification_builder(chat_id, ChatNotification::DidLoadLatestChatMessage)
chat_notification_builder(&chat_id, ChatNotification::DidLoadLatestChatMessage)
.payload(pb)
.send();
}
@@ -511,7 +510,7 @@ impl Chat {
}
let workspace_id = self.user_service.workspace_id()?;
let chat_id = self.chat_id;
let chat_id = self.chat_id.clone();
let cloud_service = self.chat_service.clone();
let question = cloud_service
@@ -567,7 +566,7 @@ impl Chat {
let conn = self.user_service.sqlite_connection(self.uid)?;
let records = select_chat_messages(
conn,
&self.chat_id.to_string(),
&self.chat_id,
limit,
after_message_id,
before_message_id,
@@ -629,7 +628,7 @@ impl Chat {
fn save_chat_message_disk(
conn: DBConnection,
chat_id: &Uuid,
chat_id: &str,
messages: Vec<ChatMessage>,
) -> FlowyResult<()> {
let records = messages
@@ -684,7 +683,7 @@ impl StringBuffer {
pub(crate) fn save_and_notify_message(
uid: i64,
chat_id: &Uuid,
chat_id: &str,
user_service: &Arc<dyn AIUserService>,
message: ChatMessage,
) -> Result<(), FlowyError> {
+53 -59
View File
@@ -1,7 +1,6 @@
use crate::ai_manager::AIUserService;
use crate::entities::{CompleteTextPB, CompleteTextTaskPB, CompletionTypePB};
use allo_isolate::Isolate;
use std::str::FromStr;
use dashmap::DashMap;
use flowy_ai_pub::cloud::{
@@ -16,8 +15,7 @@ use lib_infra::isolate_stream::IsolateSink;
use crate::stream_message::StreamMessage;
use std::sync::{Arc, Weak};
use tokio::select;
use tracing::{error, info};
use uuid::Uuid;
use tracing::info;
pub struct AICompletion {
tasks: Arc<DashMap<String, tokio::sync::mpsc::Sender<()>>>,
@@ -79,7 +77,7 @@ impl AICompletion {
}
pub struct CompletionTask {
workspace_id: Uuid,
workspace_id: String,
task_id: String,
stop_rx: tokio::sync::mpsc::Receiver<()>,
context: CompleteTextPB,
@@ -89,7 +87,7 @@ pub struct CompletionTask {
impl CompletionTask {
pub fn new(
workspace_id: Uuid,
workspace_id: String,
context: CompleteTextPB,
preferred_model: Option<AIModel>,
cloud_service: Weak<dyn ChatCloudService>,
@@ -124,63 +122,59 @@ impl CompletionTask {
let _ = sink.send("start:".to_string()).await;
let completion_history = Some(self.context.history.iter().map(Into::into).collect());
let format = self.context.format.map(Into::into).unwrap_or_default();
if let Ok(object_id) = Uuid::from_str(&self.context.object_id) {
let params = CompleteTextParams {
text: self.context.text,
completion_type: Some(complete_type),
metadata: Some(CompletionMetadata {
object_id,
workspace_id: Some(self.workspace_id),
rag_ids: Some(self.context.rag_ids),
completion_history,
custom_prompt: self
.context
.custom_prompt
.map(|v| CustomPrompt { system: v }),
}),
format,
};
let params = CompleteTextParams {
text: self.context.text,
completion_type: Some(complete_type),
metadata: Some(CompletionMetadata {
object_id: self.context.object_id,
workspace_id: Some(self.workspace_id.clone()),
rag_ids: Some(self.context.rag_ids),
completion_history,
custom_prompt: self
.context
.custom_prompt
.map(|v| CustomPrompt { system: v }),
}),
format,
};
info!("start completion: {:?}", params);
match cloud_service
.stream_complete(&self.workspace_id, params, self.preferred_model)
.await
{
Ok(mut stream) => loop {
select! {
_ = self.stop_rx.recv() => {
return;
},
result = stream.next() => {
match result {
Some(Ok(data)) => {
match data {
CompletionStreamValue::Answer{ value } => {
let _ = sink.send(format!("data:{}", value)).await;
}
CompletionStreamValue::Comment{ value } => {
let _ = sink.send(format!("comment:{}", value)).await;
}
info!("start completion: {:?}", params);
match cloud_service
.stream_complete(&self.workspace_id, params, self.preferred_model)
.await
{
Ok(mut stream) => loop {
select! {
_ = self.stop_rx.recv() => {
return;
},
result = stream.next() => {
match result {
Some(Ok(data)) => {
match data {
CompletionStreamValue::Answer{ value } => {
let _ = sink.send(format!("data:{}", value)).await;
}
},
Some(Err(error)) => {
handle_error(&mut sink, error).await;
return;
},
None => {
let _ = sink.send(format!("finish:{}", self.task_id)).await;
return;
},
}
CompletionStreamValue::Comment{ value } => {
let _ = sink.send(format!("comment:{}", value)).await;
}
}
},
Some(Err(error)) => {
handle_error(&mut sink, error).await;
return;
},
None => {
let _ = sink.send(format!("finish:{}", self.task_id)).await;
return;
},
}
}
},
Err(error) => {
handle_error(&mut sink, error).await;
},
}
} else {
error!("Invalid uuid: {}", self.context.object_id);
}
}
},
Err(error) => {
handle_error(&mut sink, error).await;
},
}
}
});
+6 -36
View File
@@ -1,6 +1,8 @@
use af_plugin::core::plugin::RunningState;
use std::collections::HashMap;
use crate::local_ai::controller::LocalAISetting;
use crate::local_ai::resource::PendingResource;
use af_plugin::core::plugin::RunningState;
use flowy_ai_pub::cloud::{
AIModel, ChatMessage, ChatMessageMetadata, ChatMessageType, CompletionMessage, LLMModel,
OutputContent, OutputLayout, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion,
@@ -8,8 +10,6 @@ use flowy_ai_pub::cloud::{
};
use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
use lib_infra::validator_fn::required_not_empty_str;
use std::collections::HashMap;
use uuid::Uuid;
use validator::Validate;
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]
@@ -78,7 +78,7 @@ pub struct StreamChatPayloadPB {
#[derive(Default, Debug)]
pub struct StreamMessageParams {
pub chat_id: Uuid,
pub chat_id: String,
pub message: String,
pub message_type: ChatMessageType,
pub answer_stream_port: i64,
@@ -586,7 +586,7 @@ pub struct LocalAIPB {
pub enabled: bool,
#[pb(index = 2, one_of)]
pub lack_of_resource: Option<LackOfAIResourcePB>,
pub lack_of_resource: Option<String>,
#[pb(index = 3)]
pub state: RunningStatePB,
@@ -721,35 +721,5 @@ impl From<LocalAISettingPB> for LocalAISetting {
#[derive(Default, ProtoBuf, Clone, Debug)]
pub struct LackOfAIResourcePB {
#[pb(index = 1)]
pub resource_type: LackOfAIResourceTypePB,
#[pb(index = 2)]
pub missing_model_names: Vec<String>,
}
#[derive(Debug, Default, Clone, ProtoBuf_Enum)]
pub enum LackOfAIResourceTypePB {
#[default]
PluginExecutableNotReady = 0,
OllamaServerNotReady = 1,
MissingModel = 2,
}
impl From<PendingResource> for LackOfAIResourcePB {
fn from(value: PendingResource) -> Self {
match value {
PendingResource::PluginExecutableNotReady => Self {
resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady,
missing_model_names: vec![],
},
PendingResource::OllamaServerNotReady => Self {
resource_type: LackOfAIResourceTypePB::OllamaServerNotReady,
missing_model_names: vec![],
},
PendingResource::MissingModel(model_name) => Self {
resource_type: LackOfAIResourceTypePB::MissingModel,
missing_model_names: vec![model_name],
},
}
}
pub resource_desc: String,
}
+17 -23
View File
@@ -1,3 +1,6 @@
use std::fs;
use std::path::PathBuf;
use crate::ai_manager::{AIManager, GLOBAL_ACTIVE_MODEL_KEY};
use crate::completion::AICompletion;
use crate::entities::*;
@@ -7,12 +10,8 @@ use flowy_ai_pub::cloud::{
};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
use std::fs;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Weak};
use tracing::trace;
use uuid::Uuid;
use validator::Validate;
fn upgrade_ai_manager(ai_manager: AFPluginState<Weak<AIManager>>) -> FlowyResult<Arc<AIManager>> {
@@ -71,7 +70,6 @@ pub(crate) async fn stream_chat_message_handler(
trace!("Stream chat message with metadata: {:?}", metadata);
let chat_id = Uuid::from_str(&chat_id)?;
let params = StreamMessageParams {
chat_id,
message,
@@ -93,12 +91,11 @@ pub(crate) async fn regenerate_response_handler(
ai_manager: AFPluginState<Weak<AIManager>>,
) -> FlowyResult<()> {
let data = data.try_into_inner()?;
let chat_id = Uuid::from_str(&data.chat_id)?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
ai_manager
.stream_regenerate_response(
&chat_id,
&data.chat_id,
data.answer_message_id,
data.answer_stream_port,
data.format,
@@ -150,9 +147,8 @@ pub(crate) async fn load_prev_message_handler(
let data = data.into_inner();
data.validate()?;
let chat_id = Uuid::from_str(&data.chat_id)?;
let messages = ai_manager
.load_prev_chat_messages(&chat_id, data.limit, data.before_message_id)
.load_prev_chat_messages(&data.chat_id, data.limit, data.before_message_id)
.await?;
data_result_ok(messages)
}
@@ -166,9 +162,8 @@ pub(crate) async fn load_next_message_handler(
let data = data.into_inner();
data.validate()?;
let chat_id = Uuid::from_str(&data.chat_id)?;
let messages = ai_manager
.load_latest_chat_messages(&chat_id, data.limit, data.after_message_id)
.load_latest_chat_messages(&data.chat_id, data.limit, data.after_message_id)
.await?;
data_result_ok(messages)
}
@@ -180,9 +175,8 @@ pub(crate) async fn get_related_question_handler(
) -> DataResult<RepeatedRelatedQuestionPB, FlowyError> {
let ai_manager = upgrade_ai_manager(ai_manager)?;
let data = data.into_inner();
let chat_id = Uuid::from_str(&data.chat_id)?;
let messages = ai_manager
.get_related_questions(&chat_id, data.message_id)
.get_related_questions(&data.chat_id, data.message_id)
.await?;
data_result_ok(messages)
}
@@ -194,9 +188,8 @@ pub(crate) async fn get_answer_handler(
) -> DataResult<ChatMessagePB, FlowyError> {
let ai_manager = upgrade_ai_manager(ai_manager)?;
let data = data.into_inner();
let chat_id = Uuid::from_str(&data.chat_id)?;
let message = ai_manager
.generate_answer(&chat_id, data.message_id)
.generate_answer(&data.chat_id, data.message_id)
.await?;
data_result_ok(message)
}
@@ -210,8 +203,7 @@ pub(crate) async fn stop_stream_handler(
data.validate()?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
let chat_id = Uuid::from_str(&data.chat_id)?;
ai_manager.stop_stream(&chat_id).await?;
ai_manager.stop_stream(&data.chat_id).await?;
Ok(())
}
@@ -281,8 +273,7 @@ pub(crate) async fn chat_file_handler(
tracing::debug!("File size: {} bytes", file_size);
let ai_manager = upgrade_ai_manager(ai_manager)?;
let chat_id = Uuid::from_str(&data.chat_id)?;
ai_manager.chat_with_file(&chat_id, file_path).await?;
ai_manager.chat_with_file(&data.chat_id, file_path).await?;
Ok(())
}
@@ -341,7 +332,6 @@ pub(crate) async fn get_chat_settings_handler(
ai_manager: AFPluginState<Weak<AIManager>>,
) -> DataResult<ChatSettingsPB, FlowyError> {
let chat_id = data.try_into_inner()?.value;
let chat_id = Uuid::from_str(&chat_id)?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
let rag_ids = ai_manager.get_rag_ids(&chat_id).await?;
let pb = ChatSettingsPB { rag_ids };
@@ -355,8 +345,9 @@ pub(crate) async fn update_chat_settings_handler(
) -> FlowyResult<()> {
let params = data.try_into_inner()?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
let chat_id = Uuid::from_str(&params.chat_id.value)?;
ai_manager.update_rag_ids(&chat_id, params.rag_ids).await?;
ai_manager
.update_rag_ids(&params.chat_id.value, params.rag_ids)
.await?;
Ok(())
}
@@ -378,6 +369,9 @@ pub(crate) async fn update_local_ai_setting_handler(
) -> Result<(), FlowyError> {
let data = data.try_into_inner()?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
ai_manager.update_local_ai_setting(data.into()).await?;
ai_manager
.local_ai
.update_local_ai_setting(data.into())
.await?;
Ok(())
}
@@ -1,5 +1,5 @@
use crate::ai_manager::AIUserService;
use crate::entities::{LocalAIPB, RunningStatePB};
use crate::entities::{LackOfAIResourcePB, LocalAIPB, RunningStatePB};
use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController};
use crate::notification::{
chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY,
@@ -28,7 +28,6 @@ use std::sync::Arc;
use tokio::select;
use tokio_stream::StreamExt;
use tracing::{debug, error, info, instrument};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LocalAISetting {
@@ -52,7 +51,7 @@ const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v1";
pub struct LocalAIController {
ai_plugin: Arc<OllamaAIPlugin>,
resource: Arc<LocalAIResourceController>,
current_chat_id: ArcSwapOption<Uuid>,
current_chat_id: ArcSwapOption<String>,
store_preferences: Arc<KVStorePreferences>,
user_service: Arc<dyn AIUserService>,
#[allow(dead_code)]
@@ -241,7 +240,7 @@ impl LocalAIController {
Some(self.resource.get_llm_setting().chat_model_name)
}
pub fn open_chat(&self, chat_id: &Uuid) {
pub fn open_chat(&self, chat_id: &str) {
if !self.is_enabled() {
return;
}
@@ -253,7 +252,9 @@ impl LocalAIController {
self.close_chat(current_chat_id);
}
self.current_chat_id.store(Some(Arc::new(*chat_id)));
self
.current_chat_id
.store(Some(Arc::new(chat_id.to_string())));
let chat_id = chat_id.to_string();
let weak_ctrl = Arc::downgrade(&self.ai_plugin);
tokio::spawn(async move {
@@ -265,7 +266,7 @@ impl LocalAIController {
});
}
pub fn close_chat(&self, chat_id: &Uuid) {
pub fn close_chat(&self, chat_id: &str) {
if !self.is_running() {
return;
}
@@ -291,10 +292,8 @@ impl LocalAIController {
setting,
std::thread::current().id()
);
if self.resource.set_llm_setting(setting).await.is_ok() {
self.reload().await?;
}
self.resource.set_llm_setting(setting).await?;
self.reload().await?;
Ok(())
}
@@ -382,7 +381,7 @@ impl LocalAIController {
#[instrument(level = "debug", skip_all)]
pub async fn index_message_metadata(
&self,
chat_id: &Uuid,
chat_id: &str,
metadata_list: &[ChatMessageMetadata],
index_process_sink: &mut (impl Sink<String> + Unpin),
) -> FlowyResult<()> {
@@ -433,7 +432,7 @@ impl LocalAIController {
async fn process_index_file(
&self,
chat_id: &Uuid,
chat_id: &str,
file_path: PathBuf,
index_metadata: &HashMap<String, serde_json::Value>,
index_process_sink: &mut (impl Sink<String> + Unpin),
@@ -455,11 +454,7 @@ impl LocalAIController {
let result = self
.ai_plugin
.embed_file(
&chat_id.to_string(),
file_path,
Some(index_metadata.clone()),
)
.embed_file(chat_id, file_path, Some(index_metadata.clone()))
.await;
match result {
Ok(_) => {
@@ -544,7 +539,9 @@ async fn initialize_ai_plugin(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::LocalAIResourceUpdated,
)
.payload(lack_of_resource)
.payload(LackOfAIResourcePB {
resource_desc: lack_of_resource,
})
.send();
return Ok(());
@@ -619,6 +616,6 @@ impl LLMResourceService for LLMResourceServiceImpl {
}
const APPFLOWY_LOCAL_AI_ENABLED: &str = "appflowy_local_ai_enabled";
fn local_ai_enabled_key(workspace_id: &Uuid) -> String {
fn local_ai_enabled_key(workspace_id: &str) -> String {
format!("{}:{}", APPFLOWY_LOCAL_AI_ENABLED, workspace_id)
}
@@ -47,13 +47,22 @@ pub enum WatchDiskEvent {
Remove,
}
#[derive(Debug, Clone)]
pub enum PendingResource {
PluginExecutableNotReady,
OllamaServerNotReady,
MissingModel(String),
}
impl PendingResource {
pub fn desc(self) -> String {
match self {
PendingResource::PluginExecutableNotReady => "The Local AI app was not installed correctly. Please follow the instructions to install the Local AI application".to_string(),
PendingResource::OllamaServerNotReady => "Ollama is not ready. Please follow the instructions to install Ollama".to_string(),
PendingResource::MissingModel(model) => format!("Cannot find the model: {}. Please use the ollama pull command to install the model", model),
}
}
}
pub struct LocalAIResourceController {
user_service: Arc<dyn AIUserService>,
resource_service: Arc<dyn LLMResourceService>,
@@ -119,10 +128,10 @@ impl LocalAIResourceController {
return false;
}
self
.calculate_pending_resources()
.await
.is_ok_and(|r| r.is_none())
match self.calculate_pending_resources().await {
Ok(res) => res.is_empty(),
Err(_) => false,
}
}
pub async fn get_plugin_download_link(&self) -> FlowyResult<String> {
@@ -138,37 +147,36 @@ impl LocalAIResourceController {
#[instrument(level = "info", skip_all, err)]
pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
self.resource_service.store_setting(setting)?;
if let Some(resource) = self.calculate_pending_resources().await? {
let resource = LackOfAIResourcePB::from(resource);
let mut resources = self.calculate_pending_resources().await?;
if let Some(resource) = resources.pop() {
chat_notification_builder(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::LocalAIResourceUpdated,
)
.payload(resource.clone())
.payload(LackOfAIResourcePB {
resource_desc: resource.desc(),
})
.send();
return Err(FlowyError::local_ai().with_context(format!("{:?}", resource)));
}
Ok(())
}
pub async fn get_lack_of_resource(&self) -> Option<LackOfAIResourcePB> {
self
.calculate_pending_resources()
.await
.ok()?
.map(Into::into)
pub async fn get_lack_of_resource(&self) -> Option<String> {
let mut resources = self.calculate_pending_resources().await.ok()?;
resources.pop().map(|r| r.desc())
}
pub async fn calculate_pending_resources(&self) -> FlowyResult<Option<PendingResource>> {
pub async fn calculate_pending_resources(&self) -> FlowyResult<Vec<PendingResource>> {
let mut resources = vec![];
let app_path = ollama_plugin_path();
if !is_plugin_ready() {
trace!("[LLM Resource] offline app not found: {:?}", app_path);
return Ok(Some(PendingResource::PluginExecutableNotReady));
resources.push(PendingResource::PluginExecutableNotReady);
return Ok(resources);
}
let setting = self.get_llm_setting();
let client = Client::builder().timeout(Duration::from_secs(5)).build()?;
match client.get(&setting.ollama_server_url).send().await {
Ok(resp) if resp.status().is_success() => {
info!(
@@ -181,7 +189,8 @@ impl LocalAIResourceController {
"[LLM Resource] Ollama server is not responding at {}",
setting.ollama_server_url
);
return Ok(Some(PendingResource::OllamaServerNotReady));
resources.push(PendingResource::OllamaServerNotReady);
return Ok(resources);
},
}
@@ -192,22 +201,23 @@ impl LocalAIResourceController {
match client.get(&tags_url).send().await {
Ok(resp) if resp.status().is_success() => {
let tags: TagsResponse = resp.json().await.inspect_err(|e| {
log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}")
let tags: TagsResponse = resp.json().await.map_err(|e| {
log::error!(
"[LLM Resource] Failed to parse /api/tags JSON response: {:?}",
e
);
e
})?;
// Check if each of our required models exists in the list of available models
trace!("[LLM Resource] ollama available models: {:?}", tags.models);
// Check each required model is present in the response.
for required in &required_models {
if !tags
.models
.iter()
.any(|m| m.name == *required || m.name == format!("{}:latest", required))
{
if !tags.models.iter().any(|m| m.name.contains(required)) {
log::trace!(
"[LLM Resource] required model '{}' not found in API response",
required
);
return Ok(Some(PendingResource::MissingModel(required.clone())));
resources.push(PendingResource::MissingModel(required.clone()));
// Optionally, you could continue checking all models rather than returning early.
return Ok(resources);
}
}
},
@@ -216,11 +226,12 @@ impl LocalAIResourceController {
"[LLM Resource] Failed to fetch models from {} (GET /api/tags)",
setting.ollama_server_url
);
return Ok(Some(PendingResource::OllamaServerNotReady));
resources.push(PendingResource::OllamaServerNotReady);
return Ok(resources);
},
}
Ok(None)
Ok(resources)
}
#[instrument(level = "info", skip_all)]
@@ -26,7 +26,6 @@ use serde_json::{json, Value};
use std::path::Path;
use std::sync::{Arc, Weak};
use tracing::{info, trace};
use uuid::Uuid;
pub struct AICloudServiceMiddleware {
cloud_service: Arc<dyn ChatCloudService>,
@@ -56,7 +55,7 @@ impl AICloudServiceMiddleware {
pub async fn index_message_metadata(
&self,
chat_id: &Uuid,
chat_id: &str,
metadata_list: &[ChatMessageMetadata],
index_process_sink: &mut (impl Sink<String> + Unpin),
) -> Result<(), FlowyError> {
@@ -115,9 +114,9 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn create_chat(
&self,
uid: &i64,
workspace_id: &Uuid,
chat_id: &Uuid,
rag_ids: Vec<Uuid>,
workspace_id: &str,
chat_id: &str,
rag_ids: Vec<String>,
) -> Result<(), FlowyError> {
self
.cloud_service
@@ -127,8 +126,8 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn create_question(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message: &str,
message_type: ChatMessageType,
metadata: &[ChatMessageMetadata],
@@ -141,8 +140,8 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn create_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message: &str,
question_id: i64,
metadata: Option<serde_json::Value>,
@@ -155,8 +154,8 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn stream_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
@@ -172,12 +171,7 @@ impl ChatCloudService for AICloudServiceMiddleware {
let row = self.get_message_record(message_id)?;
match self
.local_ai
.stream_question(
&chat_id.to_string(),
&row.content,
Some(json!(format)),
json!({}),
)
.stream_question(chat_id, &row.content, Some(json!(format)), json!({}))
.await
{
Ok(stream) => Ok(QuestionStream::new(stream).boxed()),
@@ -201,17 +195,13 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn get_answer(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
question_message_id: i64,
) -> Result<ChatMessage, FlowyError> {
if self.local_ai.is_running() {
let content = self.get_message_record(question_message_id)?.content;
match self
.local_ai
.ask_question(&chat_id.to_string(), &content)
.await
{
match self.local_ai.ask_question(chat_id, &content).await {
Ok(answer) => {
let message = self
.cloud_service
@@ -234,8 +224,8 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn get_chat_messages(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
offset: MessageCursor,
limit: u64,
) -> Result<RepeatedChatMessage, FlowyError> {
@@ -247,26 +237,26 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn get_question_from_answer_id(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
answer_message_id: i64,
workspace_id: &str,
chat_id: &str,
answer_id: i64,
) -> Result<ChatMessage, FlowyError> {
self
.cloud_service
.get_question_from_answer_id(workspace_id, chat_id, answer_message_id)
.get_question_from_answer_id(workspace_id, chat_id, answer_id)
.await
}
async fn get_related_message(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
message_id: i64,
) -> Result<RepeatedRelatedQuestion, FlowyError> {
if self.local_ai.is_running() {
let questions = self
.local_ai
.get_related_question(&chat_id.to_string())
.get_related_question(chat_id)
.await
.map_err(|err| FlowyError::local_ai().with_context(err))?;
trace!("LocalAI related questions: {:?}", questions);
@@ -290,7 +280,7 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn stream_complete(
&self,
workspace_id: &Uuid,
workspace_id: &str,
params: CompleteTextParams,
ai_model: Option<AIModel>,
) -> Result<StreamComplete, FlowyError> {
@@ -339,15 +329,15 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn embed_file(
&self,
workspace_id: &Uuid,
workspace_id: &str,
file_path: &Path,
chat_id: &Uuid,
chat_id: &str,
metadata: Option<HashMap<String, Value>>,
) -> Result<(), FlowyError> {
if self.local_ai.is_running() {
self
.local_ai
.embed_file(&chat_id.to_string(), file_path.to_path_buf(), metadata)
.embed_file(chat_id, file_path.to_path_buf(), metadata)
.await
.map_err(|err| FlowyError::local_ai().with_context(err))?;
Ok(())
@@ -359,21 +349,21 @@ impl ChatCloudService for AICloudServiceMiddleware {
}
}
async fn get_local_ai_config(&self, workspace_id: &Uuid) -> Result<LocalAIConfig, FlowyError> {
async fn get_local_ai_config(&self, workspace_id: &str) -> Result<LocalAIConfig, FlowyError> {
self.cloud_service.get_local_ai_config(workspace_id).await
}
async fn get_workspace_plan(
&self,
workspace_id: &Uuid,
workspace_id: &str,
) -> Result<Vec<SubscriptionPlan>, FlowyError> {
self.cloud_service.get_workspace_plan(workspace_id).await
}
async fn get_chat_settings(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
) -> Result<ChatSettings, FlowyError> {
self
.cloud_service
@@ -383,8 +373,8 @@ impl ChatCloudService for AICloudServiceMiddleware {
async fn update_chat_settings(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
workspace_id: &str,
chat_id: &str,
params: UpdateChatParams,
) -> Result<(), FlowyError> {
self
@@ -393,11 +383,11 @@ impl ChatCloudService for AICloudServiceMiddleware {
.await
}
async fn get_available_models(&self, workspace_id: &Uuid) -> Result<ModelList, FlowyError> {
async fn get_available_models(&self, workspace_id: &str) -> Result<ModelList, FlowyError> {
self.cloud_service.get_available_models(workspace_id).await
}
async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result<String, FlowyError> {
async fn get_workspace_default_model(&self, workspace_id: &str) -> Result<String, FlowyError> {
self
.cloud_service
.get_workspace_default_model(workspace_id)
@@ -1,6 +1,5 @@
use flowy_derive::ProtoBuf_Enum;
use flowy_notification::NotificationBuilder;
use tracing::trace;
const CHAT_OBSERVABLE_SOURCE: &str = "Chat";
pub const APPFLOWY_AI_NOTIFICATION_KEY: &str = "appflowy_ai_plugin";
@@ -40,12 +39,7 @@ impl std::convert::From<i32> for ChatNotification {
}
}
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) fn chat_notification_builder<T: ToString>(
id: T,
ty: ChatNotification,
) -> NotificationBuilder {
let id = id.to_string();
trace!("chat_notification_builder: id = {id}, ty = {ty:?}");
NotificationBuilder::new(&id, ty, CHAT_OBSERVABLE_SOURCE)
#[tracing::instrument(level = "trace")]
pub(crate) fn chat_notification_builder(id: &str, ty: ChatNotification) -> NotificationBuilder {
NotificationBuilder::new(id, ty, CHAT_OBSERVABLE_SOURCE)
}
+3 -1
View File
@@ -32,6 +32,7 @@ collab = { workspace = true }
#collab = { workspace = true, features = ["verbose_log"] }
diesel.workspace = true
uuid.workspace = true
flowy-storage = { workspace = true }
flowy-storage-pub = { workspace = true }
client-api.workspace = true
@@ -55,7 +56,8 @@ lib-infra = { workspace = true }
serde.workspace = true
serde_json.workspace = true
serde_repr.workspace = true
uuid.workspace = true
futures.workspace = true
walkdir = "2.4.0"
sysinfo = "0.30.5"
semver = { version = "1.0.22", features = ["serde"] }
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more