Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9701536760 | ||
|
|
c11904c2e9 | ||
|
|
19b1c84ea3 | ||
|
|
e5f30b000c | ||
|
|
e64462ca9a | ||
|
|
570de3e1a5 | ||
|
|
df82c99755 | ||
|
|
6a02679963 | ||
|
|
8dcd4c51f0 | ||
|
|
3a26570de9 | ||
|
|
76710ea244 | ||
|
|
1bcda3944f | ||
|
|
3d8e6ebd72 |
No files matched your search
+9
-1
@@ -1,5 +1,13 @@
|
||||
# Release Notes
|
||||
# Release Notes
|
||||
## Version 0.7.5 - 25/11/2024
|
||||
### Bug Fixes
|
||||
- Improved chat response parsing
|
||||
- Fixed toggle list icon direction for RTL mode
|
||||
- Fixed cross blocks formatting not reflecting in float toolbar
|
||||
- Fixed unable to click inside the toggle list to create a new paragraph
|
||||
- Fixed open file error 50 on macOS
|
||||
- Fixed upload file exceed limit error
|
||||
|
||||
## Version 0.7.4 - 19/11/2024
|
||||
### New Features
|
||||
- Support uploading WebP and BMP images
|
||||
|
||||
@@ -42,10 +42,12 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
## User Installation
|
||||
|
||||
- [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases)
|
||||
- Other channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Other
|
||||
channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
|
||||
- Available on
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is not supported
|
||||
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
|
||||
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
|
||||
not supported
|
||||
- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
|
||||
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
|
||||
|
||||
@@ -61,15 +63,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
|
||||
|
||||
## Getting Started with development
|
||||
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific development instructions
|
||||
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific
|
||||
development instructions
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap)
|
||||
- [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12)
|
||||
|
||||
If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
If you'd like to propose a feature, submit a feature
|
||||
request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
|
||||
If you'd like to report a bug, submit a bug
|
||||
report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
|
||||
|
||||
## **Releases**
|
||||
|
||||
@@ -77,16 +82,24 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) for details.
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make
|
||||
are **greatly appreciated**. Please look
|
||||
at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy)
|
||||
for details.
|
||||
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
|
||||
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
|
||||
the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
|
||||
us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
|
||||
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
|
||||
|
||||
## Translations 🌎🗺
|
||||
|
||||
[](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge)
|
||||
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or run `npx inlang machine translate` to add missing translations.
|
||||
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use
|
||||
the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or
|
||||
run `npx inlang machine translate` to add missing translations.
|
||||
|
||||
## Join the community to build AppFlowy together
|
||||
|
||||
@@ -96,16 +109,30 @@ To add translations, you can manually edit the JSON translation files in `/front
|
||||
|
||||
## Why Are We Building This?
|
||||
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints.
|
||||
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and
|
||||
functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations.
|
||||
These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative
|
||||
workplace management tools also have their constraints.
|
||||
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to come up with a one-size fits all solution in such a fragmented market.
|
||||
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have
|
||||
led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates
|
||||
from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a
|
||||
proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to
|
||||
come up with a one-size fits all solution in such a fragmented market.
|
||||
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well.
|
||||
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up,
|
||||
in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is
|
||||
a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention
|
||||
the speed and native experience. The same may apply to individual users as well.
|
||||
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well.
|
||||
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs
|
||||
well.
|
||||
|
||||
- To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability.
|
||||
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to
|
||||
enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy
|
||||
your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term
|
||||
maintainability.
|
||||
|
||||
We decided to achieve this mission by upholding the three most fundamental values:
|
||||
|
||||
@@ -113,16 +140,20 @@ We decided to achieve this mission by upholding the three most fundamental value
|
||||
- Reliable native experience
|
||||
- Community-driven extensibility
|
||||
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority
|
||||
doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the
|
||||
knowledge and wheels of making complex workplace management tools while enabling people and businesses to create
|
||||
beautiful things on their own by equipping them with a versatile toolbox of building blocks.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information.
|
||||
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for
|
||||
more information.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Special thanks to these amazing projects which help power AppFlowy.IO:
|
||||
|
||||
- [flutter-quill](https://github.com/singerdmx/flutter-quill)
|
||||
- [cargo-make](https://github.com/sagiegurari/cargo-make)
|
||||
- [contrib.rocks](https://contrib.rocks)
|
||||
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
|
||||
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
APPFLOWY_VERSION = "0.7.4"
|
||||
APPFLOWY_VERSION = "0.7.5"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/constants.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AI Writer:', () {
|
||||
testWidgets('the ai writer transaction should only apply in memory',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
const pageName = 'Document';
|
||||
await tester.createNewPageInSpace(
|
||||
spaceName: Constants.generalSpaceName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
pageName: pageName,
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
|
||||
);
|
||||
expect(find.byType(AutoCompletionBlockComponent), findsOneWidget);
|
||||
|
||||
// switch to another page
|
||||
await tester.openPage(Constants.gettingStartedPageName);
|
||||
// switch back to the page
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// expect the ai writer block is not in the document
|
||||
expect(find.byType(AutoCompletionBlockComponent), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'document_ai_writer_test.dart' as document_ai_writer_test;
|
||||
import 'document_copy_link_to_block_test.dart'
|
||||
as document_copy_link_to_block_test;
|
||||
import 'document_option_actions_test.dart' as document_option_actions_test;
|
||||
@@ -11,4 +12,5 @@ void main() {
|
||||
document_option_actions_test.main();
|
||||
document_copy_link_to_block_test.main();
|
||||
document_publish_test.main();
|
||||
document_ai_writer_test.main();
|
||||
}
|
||||
-1
@@ -26,7 +26,6 @@ import '../../shared/util.dart';
|
||||
|
||||
const _testImageUrls = [
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640',
|
||||
'https://mathiasbynens.be/demo/animated-webp-supported.webp',
|
||||
'https://www.easygifanimator.net/images/samples/eglite.gif',
|
||||
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
|
||||
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
@@ -146,7 +146,7 @@ void main() {
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
|
||||
offset: 100,
|
||||
offset: 80,
|
||||
);
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
|
||||
+21
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -263,5 +265,24 @@ void main() {
|
||||
expect(node.attributes[ToggleListBlockKeys.level], 3);
|
||||
expect(node.delta!.toPlainText(), 'Hello');
|
||||
});
|
||||
|
||||
testWidgets('click the toggle list to create a new paragraph',
|
||||
(tester) async {
|
||||
await prepareToggleHeadingBlock(tester, '> # Hello');
|
||||
final emptyHintText = find.text(
|
||||
LocaleKeys.document_plugins_emptyToggleHeading.tr(
|
||||
args: ['1'],
|
||||
),
|
||||
);
|
||||
expect(emptyHintText, findsOneWidget);
|
||||
|
||||
await tester.tapButton(emptyHintText);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// check the new paragraph is created
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final node = editorState.getNodeAtPath([0, 0])!;
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
});
|
||||
});
|
||||
}
|
||||
+4
-2
@@ -9,10 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -863,6 +863,8 @@ class _SelectOptionListState extends State<_SelectOptionList> {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
// disable the inner scroll physics, so the outer ListView can scroll
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: widget.selectOptions
|
||||
.mapIndexed(
|
||||
(index, option) => _SelectOptionTile(
|
||||
|
||||
+47
-38
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
@@ -22,42 +20,27 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}) : super(
|
||||
ChatAIMessageState.initial(
|
||||
message,
|
||||
messageReferenceSource(refSourceJsonString),
|
||||
parseMetadata(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
_dispatch();
|
||||
|
||||
if (state.stream != null) {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (sources) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveSources(sources));
|
||||
}
|
||||
},
|
||||
);
|
||||
_startListening();
|
||||
|
||||
if (state.stream!.error != null) {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
});
|
||||
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
|
||||
}
|
||||
if (state.stream!.aiLimitReached) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatAIMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
@@ -116,10 +99,12 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveSources: (List<ChatMessageRefSource> sources) {
|
||||
receiveMetadata: (metadata) {
|
||||
Log.debug("AI Steps: ${metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -128,8 +113,30 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
void _startListening() {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (metadata) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveMetadata(metadata));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -139,8 +146,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.retry() = _Retry;
|
||||
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
|
||||
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
|
||||
const factory ChatAIMessageEvent.receiveSources(
|
||||
List<ChatMessageRefSource> sources,
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
}
|
||||
|
||||
@@ -151,17 +158,19 @@ class ChatAIMessageState with _$ChatAIMessageState {
|
||||
required String text,
|
||||
required MessageState messageState,
|
||||
required List<ChatMessageRefSource> sources,
|
||||
required AIChatProgress? progress,
|
||||
}) = _ChatAIMessageState;
|
||||
|
||||
factory ChatAIMessageState.initial(
|
||||
dynamic text,
|
||||
List<ChatMessageRefSource> sources,
|
||||
MetadataCollection metadata,
|
||||
) {
|
||||
return ChatAIMessageState(
|
||||
text: text is String ? text : "",
|
||||
stream: text is AnswerStream ? text : null,
|
||||
messageState: const MessageState.ready(),
|
||||
sources: sources,
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/util/int64_extension.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_backend/protobuf/flowy-error/code.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_listener.dart';
|
||||
import 'chat_message_service.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
part 'chat_bloc.freezed.dart';
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required ViewPB view,
|
||||
required UserProfilePB userProfile,
|
||||
}) : listener = ChatMessageListener(chatId: view.id),
|
||||
chatId = view.id,
|
||||
super(
|
||||
ChatState.initial(view, userProfile),
|
||||
) {
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_init();
|
||||
}
|
||||
|
||||
final ChatMessageListener listener;
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
|
||||
final ChatController chatController;
|
||||
|
||||
/// The last streaming message id
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
ChatMessagePB? lastSentMessage;
|
||||
|
||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||
///
|
||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||
@@ -51,11 +54,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
/// is 3 (AI response).
|
||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||
|
||||
bool isLoadingPreviousMessages = false;
|
||||
bool hasMorePreviousMessages = true;
|
||||
AnswerStream? answerStream;
|
||||
int numSendMessage = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
@@ -64,195 +70,161 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
on<ChatEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initialLoad: () {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
limit: Int64(10),
|
||||
);
|
||||
AIEventLoadNextMessage(payload).send().then(
|
||||
(result) {
|
||||
result.fold((list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to load messages: $err");
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
// Loading messages
|
||||
startLoadingPrevMessage: () async {
|
||||
Int64? beforeMessageId;
|
||||
final oldestMessage = _getOldestMessage();
|
||||
if (oldestMessage != null) {
|
||||
try {
|
||||
beforeMessageId = Int64.parseInt(oldestMessage.id);
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
|
||||
);
|
||||
}
|
||||
}
|
||||
_loadPrevMessage(beforeMessageId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingPreviousStatus: const ChatLoadingState.loading(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _permanentMessages();
|
||||
final uniqueMessages = {...allMessages, ...messages}.toList();
|
||||
|
||||
uniqueMessages.insertAll(0, onetimeMessages);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
hasMorePrevMessage: hasMore,
|
||||
),
|
||||
);
|
||||
},
|
||||
didLoadLatestMessages: (List<Message> messages) {
|
||||
final onetimeMessages = _getOnetimeMessages();
|
||||
final allMessages = _permanentMessages();
|
||||
final uniqueMessages = {...allMessages, ...messages}.toList();
|
||||
uniqueMessages.insertAll(0, onetimeMessages);
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// streaming message
|
||||
finishAnswerStreaming: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
streamingState: const StreamingState.done(),
|
||||
acceptRelatedQuestion: true,
|
||||
canSendMessage:
|
||||
state.sendingState == const SendMessageState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didUpdateAnswerStream: (AnswerStream stream) {
|
||||
emit(state.copyWith(answerStream: stream));
|
||||
},
|
||||
stopStream: () async {
|
||||
if (state.answerStream == null) {
|
||||
return;
|
||||
didLoadLatestMessages: (List<Message> messages) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
final allMessages = _permanentMessages();
|
||||
if (state.streamingState != const StreamingState.done()) {
|
||||
// If the streaming is not started, remove the message from the list
|
||||
if (!state.answerStream!.hasStarted) {
|
||||
allMessages.removeWhere(
|
||||
(element) => element.id == answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// when stop stream, we will set the answer stream to null. Which means the streaming
|
||||
// is finished or canceled.
|
||||
if (state.loadingState.isLoading) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
answerStream: null,
|
||||
streamingState: const StreamingState.done(),
|
||||
),
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
}
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final allMessages = _permanentMessages();
|
||||
// remove message with the same id
|
||||
allMessages.removeWhere((element) => element.id == message.id);
|
||||
allMessages.insert(0, message);
|
||||
loadPreviousMessages: () {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldestMessage = _getOldestMessage();
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
},
|
||||
didLoadPreviousMessages: (messages, hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
},
|
||||
didFinishAnswerStream: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
),
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
);
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
final allMessages = _permanentMessages();
|
||||
allMessages.insert(0, message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
streamingState: const StreamingState.streaming(),
|
||||
canSendMessage: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
sendMessage: (String message, Map<String, dynamic>? metadata) async {
|
||||
unawaited(_startStreamingMessage(message, metadata, emit));
|
||||
final allMessages = _permanentMessages();
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: null,
|
||||
messages: allMessages,
|
||||
relatedQuestions: [],
|
||||
acceptRelatedQuestion: false,
|
||||
sendingState: const SendMessageState.sending(),
|
||||
canSendMessage: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: (ChatMessagePB message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: message,
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage:
|
||||
state.streamingState == const StreamingState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: _permanentMessages()..removeAt(0),
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
// related question
|
||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
|
||||
didReceiveRelatedQuestions: (List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allMessages = _permanentMessages();
|
||||
final message = CustomMessage(
|
||||
metadata: OnetimeShotType.relatedQuestion.toMap(),
|
||||
final metatdata = OnetimeShotType.relatedQuestion.toMap();
|
||||
metatdata['questions'] = questions;
|
||||
|
||||
final message = TextMessage(
|
||||
text: '',
|
||||
metadata: metatdata,
|
||||
author: const User(id: systemUserId),
|
||||
showStatus: false,
|
||||
id: systemUserId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
allMessages.insert(0, message);
|
||||
|
||||
chatController.insert(message);
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final oldMessage = chatController.messages
|
||||
.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
},
|
||||
sendMessage: (
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
numSendMessage += 1;
|
||||
|
||||
final relatedQuestionMessages = chatController.messages.where(
|
||||
(message) {
|
||||
return onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion;
|
||||
},
|
||||
).toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
|
||||
_startStreamingMessage(message, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
relatedQuestions: questions,
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearRelatedQuestions: () {
|
||||
finishSending: (ChatMessagePB message) {
|
||||
lastSentMessage = message;
|
||||
emit(
|
||||
state.copyWith(
|
||||
relatedQuestions: [],
|
||||
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
stopStream: () async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tell backend to stop
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
|
||||
// allow user input
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
|
||||
// no need to remove old message if stream has started already
|
||||
if (answerStream!.hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
// set answer stream to null
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
chatController.insert(message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -264,29 +236,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
chatMessageCallback: (pb) {
|
||||
if (!isClosed) {
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = "";
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
if (!isClosed) {
|
||||
Log.error("chat error: ${err.errorMessage}");
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
}
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
@@ -301,65 +275,74 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||
}
|
||||
},
|
||||
finishStreamingCallback: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
// The answer strema will bet set to null after the streaming is finished or canceled.
|
||||
// so if the answer stream is null, we will not get related question.
|
||||
if (state.lastSentMessage != null && state.answerStream != null) {
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: state.lastSentMessage!.messageId,
|
||||
);
|
||||
// When user message was sent to the server, we start gettting related question
|
||||
AIEventGetRelatedQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(list) {
|
||||
if (state.acceptRelatedQuestion) {
|
||||
add(ChatEvent.didReceiveRelatedQuestion(list.items));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get related question: $err");
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
finishStreamingCallback: () async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
|
||||
// The answer stream will bet set to null after the streaming has
|
||||
// finished, got cancelled, or errored. In this case, don't retrieve
|
||||
// related questions.
|
||||
if (answerStream == null || lastSentMessage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
// when previous numSendMessage is not equal to current numSendMessage, it means that the user
|
||||
// has sent a new message. So we don't need to get related questions.
|
||||
final preNumSendMessage = numSendMessage;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed && preNumSendMessage == numSendMessage) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the list of messages that are not include one-time messages.
|
||||
List<Message> _permanentMessages() {
|
||||
final allMessages = state.messages.where((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return allMessages;
|
||||
void _init() async {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
await AIEventLoadNextMessage(payload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to load messages: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
List<Message> _getOnetimeMessages() {
|
||||
final messages = state.messages.where((element) {
|
||||
return (element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
|
||||
return messages;
|
||||
bool _isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
/// get the last message that is not a one-time message
|
||||
Message? _getOldestMessage() {
|
||||
// get the last message that is not a one-time message
|
||||
final message = state.messages.lastWhereOrNull((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
});
|
||||
return message;
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
void _loadPrevMessage(Int64? beforeMessageId) {
|
||||
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||
final payload = LoadPrevChatMessagePB(
|
||||
chatId: state.view.id,
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
beforeMessageId: beforeMessageId,
|
||||
);
|
||||
@@ -369,43 +352,36 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
Future<void> _startStreamingMessage(
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
if (state.answerStream != null) {
|
||||
await state.answerStream?.dispose();
|
||||
}
|
||||
await answerStream?.dispose();
|
||||
|
||||
final answerStream = AnswerStream();
|
||||
answerStream = AnswerStream();
|
||||
final questionStream = QuestionStream();
|
||||
add(ChatEvent.didUpdateAnswerStream(answerStream));
|
||||
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: state.view.id,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream.nativePort),
|
||||
metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
|
||||
// add a streaming question message
|
||||
final questionStreamMessage = _createQuestionStreamMessage(
|
||||
questionStream,
|
||||
metadata,
|
||||
);
|
||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||
|
||||
// Stream message to the server
|
||||
final result = await AIEventStreamMessage(payload).send();
|
||||
result.fold(
|
||||
(ChatMessagePB question) {
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
|
||||
// stream the question to the server
|
||||
await AIEventStreamMessage(payload).send().fold(
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
add(ChatEvent.finishSending(question));
|
||||
|
||||
// final message = _createTextMessage(question);
|
||||
// add(ChatEvent.receiveMessage(message));
|
||||
|
||||
final streamAnswer =
|
||||
_createAnswerStreamMessage(answerStream, question.messageId);
|
||||
_createAnswerStreamMessage(answerStream!, question.messageId);
|
||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||
}
|
||||
},
|
||||
@@ -417,11 +393,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
metadata[sendMessageErrorKey] = err.msg;
|
||||
}
|
||||
|
||||
final error = CustomMessage(
|
||||
final error = TextMessage(
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
showStatus: false,
|
||||
id: systemUserId,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
add(const ChatEvent.failedSending());
|
||||
@@ -446,8 +423,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
"chatId": chatId,
|
||||
},
|
||||
id: streamMessageId,
|
||||
showStatus: false,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
createdAt: DateTime.now(),
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
@@ -457,24 +433,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final timestamp = now.millisecondsSinceEpoch;
|
||||
questionStreamMessageId = timestamp.toString();
|
||||
final Map<String, dynamic> metadata = {};
|
||||
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
|
||||
// if (sentMetadata != null) {
|
||||
// metadata[messageMetadataJsonStringKey] = sentMetadata;
|
||||
// }
|
||||
final Map<String, dynamic> metadata = {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||
};
|
||||
|
||||
metadata["$QuestionStream"] = stream;
|
||||
metadata["chatId"] = chatId;
|
||||
metadata[messageChatFileListKey] =
|
||||
chatFilesFromMessageMetadata(sentMetadata);
|
||||
return TextMessage(
|
||||
author: User(id: state.userProfile.id.toString()),
|
||||
author: User(id: userId),
|
||||
metadata: metadata,
|
||||
id: questionStreamMessageId,
|
||||
showStatus: false,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
@@ -491,8 +462,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toInt() * 1000,
|
||||
showStatus: false,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: message.metadata,
|
||||
},
|
||||
@@ -502,8 +472,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
@freezed
|
||||
class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
|
||||
|
||||
// send message
|
||||
const factory ChatEvent.sendMessage({
|
||||
required String message,
|
||||
@@ -513,14 +481,14 @@ class ChatEvent with _$ChatEvent {
|
||||
_FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
|
||||
// receive message
|
||||
// receive message
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
||||
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
|
||||
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
|
||||
// loading messages
|
||||
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadPreviousMessages(
|
||||
List<Message> messages,
|
||||
bool hasMore,
|
||||
@@ -528,56 +496,24 @@ class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
||||
List<RelatedQuestionPB> questions,
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestions(
|
||||
List<String> questions,
|
||||
) = _DidReceiveRelatedQueston;
|
||||
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
|
||||
|
||||
const factory ChatEvent.didUpdateAnswerStream(
|
||||
AnswerStream stream,
|
||||
) = _DidUpdateAnswerStream;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required ViewPB view,
|
||||
required List<Message> messages,
|
||||
required UserProfilePB userProfile,
|
||||
// When opening the chat, the initial loading status will be set as loading.
|
||||
//After the initial loading is done, the status will be set as finished.
|
||||
required ChatLoadingState initialLoadingStatus,
|
||||
// When loading previous messages, the status will be set as loading.
|
||||
// After the loading is done, the status will be set as finished.
|
||||
required ChatLoadingState loadingPreviousStatus,
|
||||
// When sending a user message, the status will be set as loading.
|
||||
// After the message is sent, the status will be set as finished.
|
||||
required StreamingState streamingState,
|
||||
required SendMessageState sendingState,
|
||||
// Indicate whether there are more previous messages to load.
|
||||
required bool hasMorePrevMessage,
|
||||
// The related questions that are received after the user message is sent.
|
||||
required List<RelatedQuestionPB> relatedQuestions,
|
||||
@Default(false) bool acceptRelatedQuestion,
|
||||
// The last user message that is sent to the server.
|
||||
ChatMessagePB? lastSentMessage,
|
||||
AnswerStream? answerStream,
|
||||
@Default(true) bool canSendMessage,
|
||||
required ChatLoadingState loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
|
||||
ChatState(
|
||||
view: view,
|
||||
messages: [],
|
||||
userProfile: userProfile,
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
streamingState: const StreamingState.done(),
|
||||
sendingState: const SendMessageState.done(),
|
||||
hasMorePrevMessage: true,
|
||||
relatedQuestions: [],
|
||||
factory ChatState.initial() => const ChatState(
|
||||
loadingState: ChatLoadingState.loading(),
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,25 @@ class ChatMessageRefSource {
|
||||
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class StreamingState with _$StreamingState {
|
||||
const factory StreamingState.streaming() = _Streaming;
|
||||
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
|
||||
@JsonSerializable()
|
||||
class AIChatProgress {
|
||||
AIChatProgress({
|
||||
required this.step,
|
||||
});
|
||||
|
||||
factory AIChatProgress.fromJson(Map<String, dynamic> json) =>
|
||||
_$AIChatProgressFromJson(json);
|
||||
|
||||
final String step;
|
||||
|
||||
Map<String, dynamic> toJson() => _$AIChatProgressToJson(this);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SendMessageState with _$SendMessageState {
|
||||
const factory SendMessageState.sending() = _Sending;
|
||||
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
|
||||
enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
awaitingAnswer,
|
||||
streamingAnswer,
|
||||
}
|
||||
|
||||
class ChatFile extends Equatable {
|
||||
|
||||
+78
-55
@@ -7,7 +7,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
/// Indicate file source from appflowy document
|
||||
@@ -66,78 +66,101 @@ ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
|
||||
return ChatFile.fromFilePath(filePath);
|
||||
}
|
||||
|
||||
List<ChatMessageRefSource> messageReferenceSource(String? s) {
|
||||
if (s == null || s.isEmpty || s == "null") {
|
||||
return [];
|
||||
class MetadataCollection {
|
||||
MetadataCollection({
|
||||
required this.sources,
|
||||
this.progress,
|
||||
});
|
||||
final List<ChatMessageRefSource> sources;
|
||||
final AIChatProgress? progress;
|
||||
}
|
||||
|
||||
MetadataCollection parseMetadata(String? s) {
|
||||
if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
|
||||
final List<ChatMessageRefSource> metadata = [];
|
||||
try {
|
||||
final metadataJson = jsonDecode(s);
|
||||
if (metadataJson == null) {
|
||||
Log.warn("metadata is null");
|
||||
return [];
|
||||
}
|
||||
// [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}]
|
||||
AIChatProgress? progress;
|
||||
|
||||
if (metadataJson is Map<String, dynamic>) {
|
||||
if (metadataJson.isNotEmpty) {
|
||||
metadata.add(ChatMessageRefSource.fromJson(metadataJson));
|
||||
}
|
||||
} else if (metadataJson is List) {
|
||||
metadata.addAll(
|
||||
metadataJson.map(
|
||||
(e) => ChatMessageRefSource.fromJson(e as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.error("Invalid metadata: $metadataJson");
|
||||
try {
|
||||
final dynamic decodedJson = jsonDecode(s);
|
||||
if (decodedJson == null) {
|
||||
return MetadataCollection(sources: []);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Failed to parse metadata: $e");
|
||||
|
||||
void processMap(Map<String, dynamic> map) {
|
||||
if (map.containsKey("step") && map["step"] != null) {
|
||||
progress = AIChatProgress.fromJson(map);
|
||||
} else if (map.containsKey("id") && map["id"] != null) {
|
||||
metadata.add(ChatMessageRefSource.fromJson(map));
|
||||
} else {
|
||||
Log.info("Unsupported metadata format: $map");
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedJson is Map<String, dynamic>) {
|
||||
processMap(decodedJson);
|
||||
} else if (decodedJson is List) {
|
||||
for (final element in decodedJson) {
|
||||
if (element is Map<String, dynamic>) {
|
||||
processMap(element);
|
||||
} else {
|
||||
Log.error("Invalid metadata element: $element");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.error("Invalid metadata format: $decodedJson");
|
||||
}
|
||||
} catch (e, stacktrace) {
|
||||
Log.error("Failed to parse metadata: $e, input: $s");
|
||||
Log.debug(stacktrace.toString());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return MetadataCollection(sources: metadata, progress: progress);
|
||||
}
|
||||
|
||||
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||
Map<String, dynamic>? map,
|
||||
) async {
|
||||
if (map == null) return [];
|
||||
|
||||
final List<ChatMessageMetaPB> metadata = [];
|
||||
if (map != null) {
|
||||
for (final entry in map.entries) {
|
||||
if (entry.value is ViewActionPage) {
|
||||
if (entry.value.page is ViewPB) {
|
||||
final view = entry.value.page as ViewPB;
|
||||
if (view.layout.isDocumentView) {
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
final result = await DocumentEventGetDocumentText(payload).send();
|
||||
result.fold((pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
Log.error('Failed to get document text: $err');
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (entry.value is ChatFile) {
|
||||
|
||||
for (final value in map.values) {
|
||||
switch (value) {
|
||||
case ViewActionPage(view: final view) when view.layout.isDocumentView:
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
await DocumentEventGetDocumentText(payload).send().fold(
|
||||
(pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) => Log.error('Failed to get document text: $err'),
|
||||
);
|
||||
break;
|
||||
case ChatFile(
|
||||
filePath: final filePath,
|
||||
fileName: final fileName,
|
||||
fileType: final fileType,
|
||||
):
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: nanoid(8),
|
||||
name: entry.value.fileName,
|
||||
data: entry.value.filePath,
|
||||
dataType: entry.value.fileType,
|
||||
source: entry.value.filePath,
|
||||
name: fileName,
|
||||
data: filePath,
|
||||
dataType: fileType,
|
||||
source: filePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
|
||||
class AnswerStream {
|
||||
@@ -25,9 +24,10 @@ class AnswerStream {
|
||||
} else if (event.startsWith("metadata:")) {
|
||||
if (_onMetadata != null) {
|
||||
final s = event.substring(9);
|
||||
_onMetadata!(messageReferenceSource(s));
|
||||
_onMetadata!(parseMetadata(s));
|
||||
}
|
||||
} else if (event == "AI_RESPONSE_LIMIT") {
|
||||
_aiLimitReached = true;
|
||||
if (_onAIResponseLimit != null) {
|
||||
_onAIResponseLimit!();
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class AnswerStream {
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
bool _hasStarted = false;
|
||||
bool _aiLimitReached = false;
|
||||
String? _error;
|
||||
String _text = "";
|
||||
|
||||
@@ -59,11 +60,12 @@ class AnswerStream {
|
||||
void Function()? _onEnd;
|
||||
void Function(String error)? _onError;
|
||||
void Function()? _onAIResponseLimit;
|
||||
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
|
||||
void Function(MetadataCollection metadataCollection)? _onMetadata;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
String? get error => _error;
|
||||
bool get aiLimitReached => _aiLimitReached;
|
||||
String get text => _text;
|
||||
|
||||
Future<void> dispose() async {
|
||||
@@ -78,7 +80,7 @@ class AnswerStream {
|
||||
void Function()? onEnd,
|
||||
void Function(String error)? onError,
|
||||
void Function()? onAIResponseLimit,
|
||||
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
|
||||
+1
-5
@@ -9,11 +9,7 @@ class ChatUserMessageBloc
|
||||
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
||||
ChatUserMessageBloc({
|
||||
required dynamic message,
|
||||
}) : super(
|
||||
ChatUserMessageState.initial(
|
||||
message,
|
||||
),
|
||||
) {
|
||||
}) : super(ChatUserMessageState.initial(message)) {
|
||||
on<ChatUserMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_message_service.dart';
|
||||
|
||||
@@ -9,27 +9,27 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
hide ChatAnimatedListReversed;
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_side_panel_bloc.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_side_panel.dart';
|
||||
import 'presentation/chat_theme.dart';
|
||||
import 'presentation/chat_user_invalid_message.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
import 'presentation/message/user_text_message.dart';
|
||||
import 'presentation/scroll_to_bottom.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
const AIChatPage({
|
||||
@@ -59,9 +59,9 @@ class AIChatPage extends StatelessWidget {
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
chatId: view.id,
|
||||
userId: userProfile.id.toString(),
|
||||
),
|
||||
),
|
||||
|
||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||
@@ -113,7 +113,7 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Center(
|
||||
child: buildChatWidget()
|
||||
child: buildChatWidget(context)
|
||||
.constrained(
|
||||
maxWidth: 784,
|
||||
)
|
||||
@@ -148,7 +148,7 @@ class _ChatContentPage extends StatelessWidget {
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(),
|
||||
child: buildChatWidget(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -167,187 +167,207 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatWidget() {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) => state.initialLoadingStatus.isFinish
|
||||
? Chat(
|
||||
messages: state.messages,
|
||||
dateHeaderBuilder: (_) => const SizedBox.shrink(),
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: _buildBottom(context),
|
||||
user: types.User(id: userProfile.id.toString()),
|
||||
theme: _buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus.isFinish) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: TextFieldTapRegion(
|
||||
child: ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question)),
|
||||
Widget buildChatWidget(BuildContext context) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return state.loadingState.when(
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
},
|
||||
finish: (_) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Chat(
|
||||
chatController: chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
builders: Builders(
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: _buildTextMessage,
|
||||
chatMessageBuilder: _buildChatMessage,
|
||||
scrollToBottomBuilder: _buildScrollToBottom,
|
||||
chatAnimatedListBuilder: _buildChatAnimatedList,
|
||||
),
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildTextMessage(context, textMessage, state),
|
||||
customMessageBuilder: (message, {required messageWidth}) {
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||
return ChatInvalidUserMessage(
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: state.relatedQuestions,
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
bloc
|
||||
..add(ChatEvent.sendMessage(message: question))
|
||||
..add(const ChatEvent.clearRelatedQuestions());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) =>
|
||||
_buildBubble(context, message, child),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
_buildInput(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextMessage(
|
||||
BuildContext context,
|
||||
TextMessage message,
|
||||
ChatState state,
|
||||
) {
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageWidget(
|
||||
key: ValueKey(message.id),
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
);
|
||||
} else if (isOtherUserMessage(message)) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageWidget(
|
||||
key: ValueKey(message.id),
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
);
|
||||
} else {
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
final messageType = onetimeMessageTypeFromMeta(
|
||||
message.metadata,
|
||||
);
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
key: ValueKey(message.id),
|
||||
selector: (state) {
|
||||
final messages = state.messages.where((e) {
|
||||
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
|
||||
if (oneTimeMessageType == null) {
|
||||
return true;
|
||||
}
|
||||
if (oneTimeMessageType
|
||||
case OnetimeShotType.relatedQuestion ||
|
||||
OnetimeShotType.sendingMessage ||
|
||||
OnetimeShotType.invalidSendMesssage) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return messages.isEmpty ? false : messages.first.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
);
|
||||
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||
return ChatInvalidUserMessage(
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBubble(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Widget child,
|
||||
) {
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
child: child,
|
||||
child: ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
),
|
||||
);
|
||||
} else if (isOtherUserMessage(message)) {
|
||||
}
|
||||
|
||||
if (isOtherUserMessage(message)) {
|
||||
final stream = message.metadata?["$QuestionStream"];
|
||||
return ChatUserMessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
child: child,
|
||||
child: ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: stream is QuestionStream ? stream : message.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// The bubble is rendered in the child already
|
||||
return child;
|
||||
}
|
||||
|
||||
final stream = message.metadata?["$AnswerStream"];
|
||||
final questionId = message.metadata?[messageQuestionIdKey];
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
key: ValueKey(message.id),
|
||||
selector: (state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages.where((e) {
|
||||
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
|
||||
if (oneTimeMessageType == null) {
|
||||
return true;
|
||||
}
|
||||
if (oneTimeMessageType
|
||||
case OnetimeShotType.relatedQuestion ||
|
||||
OnetimeShotType.sendingMessage ||
|
||||
OnetimeShotType.invalidSendMesssage) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return messages.isEmpty ? false : messages.last.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
message: message,
|
||||
stream: stream is AnswerStream ? stream : null,
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) {
|
||||
context
|
||||
.read<ChatSidePanelBloc>()
|
||||
.add(ChatSidePanelEvent.selectedMetadata(metadata));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottom(BuildContext context) {
|
||||
Widget _buildChatMessage(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return ChatMessage(
|
||||
message: message,
|
||||
animation: animation,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollToBottom(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatAnimatedList(
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
ChatItem itemBuilder,
|
||||
) {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
if (bloc.chatController.messages.isEmpty) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
bloc.add(ChatEvent.sendMessage(message: question));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ChatAnimatedListReversed(
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
onLoadPreviousMessages: () {
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(BuildContext context) {
|
||||
return Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
selector: (state) {
|
||||
return state.promptResponseState == PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
indicateFocus: true,
|
||||
onSubmitted: (message) {
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -358,11 +378,11 @@ class _ChatContentPage extends StatelessWidget {
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (message) {
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -375,11 +395,4 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AFDefaultChatTheme _buildTheme(BuildContext context) {
|
||||
return AFDefaultChatTheme(
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/chat_input_height_notifier.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
|
||||
class ChatAnimatedListReversed extends StatefulWidget {
|
||||
const ChatAnimatedListReversed({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.itemBuilder,
|
||||
this.insertAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.removeAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
|
||||
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
|
||||
this.bottomPadding = 8,
|
||||
this.onLoadPreviousMessages,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ChatItem itemBuilder;
|
||||
final Duration insertAnimationDuration;
|
||||
final Duration removeAnimationDuration;
|
||||
final Duration scrollToEndAnimationDuration;
|
||||
final Duration scrollToBottomAppearanceDelay;
|
||||
final double? bottomPadding;
|
||||
final VoidCallback? onLoadPreviousMessages;
|
||||
|
||||
@override
|
||||
ChatAnimatedListReversedState createState() =>
|
||||
ChatAnimatedListReversedState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late ChatController _chatController;
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
|
||||
late AnimationController _scrollToBottomController;
|
||||
late Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
String _lastInsertedMessageId = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatController = Provider.of<ChatController>(context, listen: false);
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when inserting a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
_onInserted(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
event.index != null,
|
||||
'Index must be provided when removing a message.',
|
||||
);
|
||||
assert(
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
for (var i = updates.length - 1; i >= 0; i--) {
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.forward) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.maxScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
reverse: true,
|
||||
controller: widget.scrollController,
|
||||
slivers: <Widget>[
|
||||
Consumer<ChatInputHeightNotifier>(
|
||||
builder: (context, heightNotifier, child) {
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: heightNotifier.height + (widget.bottomPadding ?? 0),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[
|
||||
max(_chatController.messages.length - 1 - index, 0)];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initialScrollToEnd() async {
|
||||
// Delay the scroll to the end animation so new message is painted, otherwise
|
||||
// maxScrollExtent is not yet updated and the animation might not work.
|
||||
await Future.delayed(widget.insertAnimationDuration);
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _subsequentScrollToEnd(Message data) async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// In this case we only want to scroll to the bottom if user has not scrolled up
|
||||
// or if the message is sent by the current user.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// we need an additional jump to the end. Sometimes Flutter
|
||||
// will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// very end even with this, so this is something that needs to be
|
||||
// addressed by the Flutter team.
|
||||
//
|
||||
// Additionally here we have a check for the message id, because
|
||||
// if new message arrives in the meantime it will trigger another
|
||||
// scroll to the end animation, making this logic redundant.
|
||||
if (data.id == _lastInsertedMessageId &&
|
||||
widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent &&
|
||||
(user.id == data.author.id || !_userHasScrolled)) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToEnd(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// We need this condition because if scroll view is not yet scrollable,
|
||||
// we want to wait for the insert animation to finish before scrolling to the end.
|
||||
if (widget.scrollController.position.maxScrollExtent == 0) {
|
||||
// Scroll view is not yet scrollable, scroll to the end if
|
||||
// new message makes it scrollable.
|
||||
_initialScrollToEnd();
|
||||
} else {
|
||||
_subsequentScrollToEnd(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController
|
||||
.jumpTo(widget.scrollController.position.minScrollExtent);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
widget.scrollController.jumpTo(
|
||||
widget.scrollController.position.minScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (!_isScrollingToBottom) {
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
if (widget.scrollController.offset >
|
||||
widget.scrollController.position.minScrollExtent) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status == AnimationStatus.completed) {
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onInserted(final int position, final Message data) {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// There is a scroll notification listener the controls the `_userHasScrolled` variable.
|
||||
// However, when a new message is sent by the current user we want to
|
||||
// set `_userHasScrolled` to false so that the scroll animation is triggered.
|
||||
//
|
||||
// Also, if for some reason `_userHasScrolled` is true and the user is not at the bottom of the list,
|
||||
// set `_userHasScrolled` to false so that the scroll animation is triggered.
|
||||
if (user.id == data.author.id ||
|
||||
(_userHasScrolled == true &&
|
||||
widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent)) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
position,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
_lastInsertedMessageId = data.id;
|
||||
|
||||
if (user.id == data.author.id && position == _oldList.length) {
|
||||
_scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -12,8 +12,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:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'ai_prompt_buttons.dart';
|
||||
@@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.indicateFocus,
|
||||
this.options = const InputOptions(),
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
@@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
|
||||
final String chatId;
|
||||
final bool indicateFocus;
|
||||
final InputOptions options;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(types.PartialText) onSubmitted;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
|
||||
@override
|
||||
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||
@@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = InputTextFieldController()
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
|
||||
_inputFocusNode = FocusNode(
|
||||
@@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
@@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSubmitted(partialText);
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
|
||||
+3
-11
@@ -12,8 +12,6 @@ import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_span.dart';
|
||||
@@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget {
|
||||
const MobileAIPromptInput({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
this.options = const InputOptions(),
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final InputOptions options;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(types.PartialText) onSubmitted;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
|
||||
@override
|
||||
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
||||
@@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = InputTextFieldController()
|
||||
_textController = TextEditingController()
|
||||
..addListener(_handleTextControllerChange);
|
||||
|
||||
_inputFocusNode = FocusNode();
|
||||
@@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSubmitted(partialText);
|
||||
widget.onSubmitted(trimmedText, metadata);
|
||||
}
|
||||
|
||||
void _handleTextControllerChange() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.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_animate/flutter_animate.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// An animated generating indicator for an AI response
|
||||
class ChatAILoading extends StatelessWidget {
|
||||
@@ -23,9 +25,14 @@ class ChatAILoading extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_generatingResponse.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
return FlowyText(
|
||||
state.progress?.step ??
|
||||
LocaleKeys.chat_generatingResponse.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
buildDot(const Color(0xFF9327FF))
|
||||
|
||||
+4
-5
@@ -1,6 +1,5 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.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/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -16,7 +15,7 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
});
|
||||
|
||||
final Function(String) onQuestionSelected;
|
||||
final List<RelatedQuestionPB> relatedQuestions;
|
||||
final List<String> relatedQuestions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -57,14 +56,14 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final RelatedQuestionPB question;
|
||||
final String question;
|
||||
final Function(String) onQuestionSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyButton(
|
||||
text: FlowyText(
|
||||
question.content,
|
||||
question,
|
||||
lineHeight: 1.4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -76,7 +75,7 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: const Size.square(16.0),
|
||||
),
|
||||
onTap: () => onQuestionSelected(question.content),
|
||||
onTap: () => onQuestionSelected(question),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
|
||||
/// Default chat theme which extends [ChatTheme].
|
||||
@immutable
|
||||
class AFDefaultChatTheme extends ChatTheme {
|
||||
const AFDefaultChatTheme({
|
||||
required super.primaryColor,
|
||||
required super.secondaryColor,
|
||||
}) : super(
|
||||
backgroundColor: Colors.transparent,
|
||||
// TODO: think how to offset the default 12 pixels set by chat package
|
||||
bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
messageMaxWidth: double.infinity,
|
||||
// unused
|
||||
dateDividerMargin: EdgeInsets.zero,
|
||||
dateDividerTextStyle: const TextStyle(),
|
||||
attachmentButtonIcon: null,
|
||||
attachmentButtonMargin: null,
|
||||
deliveredIcon: null,
|
||||
documentIcon: null,
|
||||
emptyChatPlaceholderTextStyle: const TextStyle(),
|
||||
errorColor: error,
|
||||
errorIcon: null,
|
||||
inputBackgroundColor: neutral0,
|
||||
inputSurfaceTintColor: neutral0,
|
||||
inputElevation: 0,
|
||||
inputBorderRadius: BorderRadius.zero,
|
||||
inputContainerDecoration: null,
|
||||
inputMargin: EdgeInsets.zero,
|
||||
inputPadding: EdgeInsets.zero,
|
||||
inputTextColor: neutral7,
|
||||
inputTextCursorColor: null,
|
||||
inputTextDecoration: const InputDecoration(),
|
||||
inputTextStyle: const TextStyle(),
|
||||
messageBorderRadius: 0,
|
||||
messageInsetsHorizontal: 0,
|
||||
messageInsetsVertical: 0,
|
||||
receivedEmojiMessageTextStyle: const TextStyle(),
|
||||
receivedMessageBodyBoldTextStyle: null,
|
||||
receivedMessageBodyCodeTextStyle: null,
|
||||
receivedMessageBodyLinkTextStyle: null,
|
||||
receivedMessageBodyTextStyle: const TextStyle(),
|
||||
receivedMessageDocumentIconColor: primary,
|
||||
receivedMessageCaptionTextStyle: const TextStyle(),
|
||||
receivedMessageLinkDescriptionTextStyle: const TextStyle(),
|
||||
receivedMessageLinkTitleTextStyle: const TextStyle(),
|
||||
seenIcon: null,
|
||||
sendButtonIcon: null,
|
||||
sendButtonMargin: null,
|
||||
sendingIcon: null,
|
||||
sentEmojiMessageTextStyle: const TextStyle(),
|
||||
sentMessageBodyBoldTextStyle: null,
|
||||
sentMessageBodyCodeTextStyle: null,
|
||||
sentMessageBodyLinkTextStyle: null,
|
||||
sentMessageBodyTextStyle: const TextStyle(),
|
||||
sentMessageCaptionTextStyle: const TextStyle(),
|
||||
sentMessageDocumentIconColor: neutral7,
|
||||
sentMessageLinkDescriptionTextStyle: const TextStyle(),
|
||||
sentMessageLinkTitleTextStyle: const TextStyle(),
|
||||
statusIconPadding: EdgeInsets.zero,
|
||||
systemMessageTheme: const SystemMessageTheme(
|
||||
margin: EdgeInsets.zero,
|
||||
textStyle: TextStyle(),
|
||||
),
|
||||
typingIndicatorTheme: const TypingIndicatorTheme(
|
||||
animatedCirclesColor: neutral1,
|
||||
animatedCircleSize: 0.0,
|
||||
bubbleBorder: BorderRadius.zero,
|
||||
bubbleColor: neutral7,
|
||||
countAvatarColor: primary,
|
||||
countTextColor: secondary,
|
||||
multipleUserTextStyle: TextStyle(),
|
||||
),
|
||||
unreadHeaderTheme: const UnreadHeaderTheme(
|
||||
color: secondary,
|
||||
textStyle: TextStyle(),
|
||||
),
|
||||
userAvatarImageBackgroundColor: Colors.transparent,
|
||||
userAvatarNameColors: colors,
|
||||
userAvatarTextStyle: const TextStyle(),
|
||||
userNameTextStyle: const TextStyle(),
|
||||
highlightMessageColor: null,
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatInvalidUserMessage extends StatelessWidget {
|
||||
|
||||
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class AIChatUILayout {
|
||||
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
|
||||
|
||||
static EdgeInsets safeAreaInsets(BuildContext context) {
|
||||
final query = MediaQuery.of(context);
|
||||
return UniversalPlatform.isMobile
|
||||
|
||||
+1
-2
@@ -14,7 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
@@ -341,7 +341,6 @@ class CopyButton extends StatelessWidget {
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
|
||||
+6
-2
@@ -13,7 +13,7 @@ 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';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'ai_message_bubble.dart';
|
||||
@@ -77,7 +77,11 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
},
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
? ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: const ChatAILoading(),
|
||||
)
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ 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 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ChatUserMessageBubble extends StatelessWidget {
|
||||
|
||||
+6
-15
@@ -3,7 +3,7 @@ import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
class ChatUserMessageWidget extends StatelessWidget {
|
||||
const ChatUserMessageWidget({
|
||||
@@ -22,20 +22,11 @@ class ChatUserMessageWidget extends StatelessWidget {
|
||||
..add(const ChatUserMessageEvent.initial()),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextMessageText(
|
||||
text: state.text,
|
||||
),
|
||||
),
|
||||
if (!state.messageState.isFinish) ...[
|
||||
const HSpace(6),
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
],
|
||||
],
|
||||
return Opacity(
|
||||
opacity: state.messageState.isFinish ? 1.0 : 0.8,
|
||||
child: TextMessageText(
|
||||
text: state.text,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16));
|
||||
|
||||
class CustomScrollToBottom extends StatelessWidget {
|
||||
const CustomScrollToBottom({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
|
||||
return Positioned(
|
||||
bottom: 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: ScaleTransition(
|
||||
scale: animation,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: _borderRadius,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 8,
|
||||
color: isLightMode
|
||||
? const Color(0x0F1F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.06),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 8,
|
||||
color: isLightMode
|
||||
? const Color(0x141F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.08),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: _borderRadius,
|
||||
color: Colors.transparent,
|
||||
borderOnForeground: false,
|
||||
child: InkWell(
|
||||
overlayColor: WidgetStateProperty.all(
|
||||
AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
borderRadius: _borderRadius,
|
||||
onTap: onPressed,
|
||||
child: const SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.ai_scroll_to_bottom_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,10 +246,16 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
(event) async {
|
||||
final time = event.$1;
|
||||
final transaction = event.$2;
|
||||
final options = event.$3;
|
||||
if (time != TransactionTime.before) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.inMemoryUpdate) {
|
||||
Log.info('skip transaction for in-memory update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.debug(
|
||||
'[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}',
|
||||
|
||||
@@ -163,7 +163,11 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
return Provider(
|
||||
create: (_) {
|
||||
final context = SharedEditorContext();
|
||||
if (widget.view.name.isEmpty) {
|
||||
final children = editorState.document.root.children;
|
||||
final firstDelta = children.firstOrNull?.delta;
|
||||
final isEmptyDocument =
|
||||
children.length == 1 && (firstDelta == null || firstDelta.isEmpty);
|
||||
if (widget.view.name.isEmpty && isEmptyDocument) {
|
||||
context.requestCoverTitleFocus = true;
|
||||
}
|
||||
return context;
|
||||
|
||||
@@ -404,8 +404,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
}
|
||||
|
||||
List<SelectionMenuItem> _customSlashMenuItems() {
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
return [
|
||||
aiWriterSlashMenuItem,
|
||||
if (!isLocalMode) aiWriterSlashMenuItem,
|
||||
textSlashMenuItem,
|
||||
heading1SlashMenuItem,
|
||||
heading2SlashMenuItem,
|
||||
|
||||
+122
-27
@@ -1,59 +1,154 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
enum TextRobotInputType {
|
||||
character,
|
||||
word,
|
||||
sentence,
|
||||
}
|
||||
|
||||
class TextRobot {
|
||||
const TextRobot({
|
||||
TextRobot({
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final Lock lock = Lock();
|
||||
|
||||
/// This function is used to insert text in a synchronized way
|
||||
///
|
||||
/// It is suitable for inserting text in a loop.
|
||||
Future<void> autoInsertTextSync(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.word,
|
||||
Duration delay = const Duration(milliseconds: 10),
|
||||
String separator = '\n',
|
||||
}) async {
|
||||
await lock.synchronized(() async {
|
||||
await autoInsertText(
|
||||
text,
|
||||
inputType: inputType,
|
||||
delay: delay,
|
||||
separator: separator,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// This function is used to insert text in an asynchronous way
|
||||
///
|
||||
/// It is suitable for inserting a long paragraph or a long sentence.
|
||||
Future<void> autoInsertText(
|
||||
String text, {
|
||||
TextRobotInputType inputType = TextRobotInputType.word,
|
||||
Duration delay = const Duration(milliseconds: 10),
|
||||
String separator = '\n',
|
||||
}) async {
|
||||
if (text == '\n') {
|
||||
return editorState.insertNewLine();
|
||||
if (text == separator) {
|
||||
await insertNewParagraph(delay);
|
||||
return;
|
||||
}
|
||||
final lines = text.split('\n');
|
||||
final lines = _splitText(text, separator);
|
||||
for (final line in lines) {
|
||||
if (line.isEmpty) {
|
||||
await editorState.insertNewLine();
|
||||
await insertNewParagraph(delay);
|
||||
continue;
|
||||
}
|
||||
switch (inputType) {
|
||||
case TextRobotInputType.character:
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
iterator.currentAsString,
|
||||
);
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
await insertCharacter(line, delay);
|
||||
break;
|
||||
case TextRobotInputType.word:
|
||||
final words = line.split(' ');
|
||||
if (words.length == 1 ||
|
||||
(words.length == 2 &&
|
||||
(words.first.isEmpty || words.last.isEmpty))) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
line,
|
||||
);
|
||||
} else {
|
||||
for (final word in words.map((e) => '$e ')) {
|
||||
await editorState.insertTextAtCurrentSelection(
|
||||
word,
|
||||
);
|
||||
}
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
await insertWord(line, delay);
|
||||
break;
|
||||
case TextRobotInputType.sentence:
|
||||
await insertSentence(line, delay);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertCharacter(String line, Duration delay) async {
|
||||
final iterator = line.runes.iterator;
|
||||
while (iterator.moveNext()) {
|
||||
await insertText(iterator.currentAsString, delay);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertWord(String line, Duration delay) async {
|
||||
final words = line.split(' ');
|
||||
if (words.length == 1 ||
|
||||
(words.length == 2 && (words.first.isEmpty || words.last.isEmpty))) {
|
||||
await insertText(line, delay);
|
||||
} else {
|
||||
for (final word in words.map((e) => '$e ')) {
|
||||
await insertText(word, delay);
|
||||
}
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
|
||||
Future<void> insertSentence(String line, Duration delay) async {
|
||||
await insertText(line, delay);
|
||||
}
|
||||
|
||||
Future<void> insertNewParagraph(Duration delay) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final next = selection.end.path.next;
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(
|
||||
next,
|
||||
paragraphNode(),
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(path: next),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
debugPrint(
|
||||
'AI insertNewParagraph: path: ${editorState.selection!.end.path}, index: ${editorState.selection!.endIndex}',
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
Future<void> insertText(String text, Duration delay) async {
|
||||
final selection = editorState.selection;
|
||||
debugPrint(
|
||||
'AI insertText: get selection, path: ${selection!.end.path}, index: ${selection.endIndex}',
|
||||
);
|
||||
if (!selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(node, selection.endIndex, text);
|
||||
await editorState.apply(transaction);
|
||||
await Future.delayed(delay);
|
||||
|
||||
debugPrint(
|
||||
'AI insertText: path: ${selection.end.path}, index: ${selection.endIndex}, text: "$text"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _splitText(String text, String separator) {
|
||||
final parts = text.split(RegExp(separator));
|
||||
final result = <String>[];
|
||||
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result.add(parts[i]);
|
||||
// Only add empty string if it's not the last part and the next part is not empty
|
||||
if (i < parts.length - 1 && parts[i + 1].isNotEmpty) {
|
||||
result.add('');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+1
-2
@@ -323,8 +323,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
FileUrlType urlType,
|
||||
String url,
|
||||
) async {
|
||||
if ([FileUrlType.cloud, FileUrlType.network].contains(urlType) ||
|
||||
UniversalPlatform.isDesktopOrWeb) {
|
||||
if ([FileUrlType.cloud, FileUrlType.network].contains(urlType)) {
|
||||
await afLaunchUrlString(url);
|
||||
} else {
|
||||
final result = await OpenFilex.open(url);
|
||||
|
||||
+13
@@ -163,6 +163,10 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
bool _shouldFocus(ViewPB view, ViewState? state) {
|
||||
final name = state?.view.name ?? view.name;
|
||||
|
||||
if (editorState.document.root.children.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the view's name is empty, focus on the title
|
||||
if (name.isEmpty) {
|
||||
return true;
|
||||
@@ -180,6 +184,15 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (titleFocusNode.hasFocus) {
|
||||
// if the document is empty, disable the keyboard service
|
||||
final children = editorState.document.root.children;
|
||||
final firstDelta = children.firstOrNull?.delta;
|
||||
final isEmptyDocument =
|
||||
children.length == 1 && (firstDelta == null || firstDelta.isEmpty);
|
||||
if (!isEmptyDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorState.selection != null) {
|
||||
Log.info('cover title got focus, clear the editor selection');
|
||||
editorState.selection = null;
|
||||
|
||||
+15
-5
@@ -194,7 +194,7 @@ class _AutoCompletionBlockComponentState
|
||||
final transaction = editorState.transaction..deleteNode(widget.node);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(recordUndo: false),
|
||||
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
}
|
||||
@@ -229,8 +229,10 @@ class _AutoCompletionBlockComponentState
|
||||
}
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await textRobot.autoInsertText(
|
||||
await textRobot.autoInsertTextSync(
|
||||
text,
|
||||
separator: r'\n\n',
|
||||
inputType: TextRobotInputType.sentence,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
},
|
||||
@@ -316,8 +318,10 @@ class _AutoCompletionBlockComponentState
|
||||
await _makeSurePreviousNodeIsEmptyParagraphNode();
|
||||
},
|
||||
onProcess: (text) async {
|
||||
await textRobot.autoInsertText(
|
||||
await textRobot.autoInsertTextSync(
|
||||
text,
|
||||
inputType: TextRobotInputType.sentence,
|
||||
separator: r'\n\n',
|
||||
delay: Duration.zero,
|
||||
);
|
||||
},
|
||||
@@ -394,12 +398,13 @@ class _AutoCompletionBlockComponentState
|
||||
|
||||
Future<void> _makeSurePreviousNodeIsEmptyParagraphNode() async {
|
||||
// make sure the previous node is a empty paragraph node without any styles.
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
final previous = widget.node.previous;
|
||||
final Selection selection;
|
||||
if (previous == null ||
|
||||
previous.type != ParagraphBlockKeys.type ||
|
||||
(previous.delta?.toPlainText().isNotEmpty ?? false)) {
|
||||
final transaction = editorState.transaction;
|
||||
selection = Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
@@ -408,17 +413,22 @@ class _AutoCompletionBlockComponentState
|
||||
widget.node.path,
|
||||
paragraphNode(),
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
} else {
|
||||
selection = Selection.single(
|
||||
path: previous.path,
|
||||
startOffset: 0,
|
||||
);
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
AutoCompletionBlockKeys.startSelection: selection.toJson(),
|
||||
});
|
||||
transaction.afterSelection = selection;
|
||||
await editorState.apply(transaction);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(inMemoryUpdate: true),
|
||||
);
|
||||
}
|
||||
|
||||
void _subscribeSelectionGesture() {
|
||||
|
||||
+1
-2
@@ -88,8 +88,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
|
||||
|
||||
@override
|
||||
late EditorState editorState = context.read<EditorState>();
|
||||
late Stream<(TransactionTime, Transaction)> stream =
|
||||
editorState.transactionStream;
|
||||
late Stream<EditorTransactionValue> stream = editorState.transactionStream;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+26
-8
@@ -465,6 +465,7 @@ SelectionMenuItem outlineSlashMenuItem = SelectionMenuItem(
|
||||
editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
|
||||
// math equation
|
||||
SelectionMenuItem mathEquationSlashMenuItem = SelectionMenuItem.node(
|
||||
getName: () => LocaleKeys.document_slashMenu_name_mathEquation.tr(),
|
||||
@@ -541,20 +542,37 @@ SelectionMenuItem emojiSlashMenuItem = SelectionMenuItem(
|
||||
);
|
||||
|
||||
// auto generate menu item
|
||||
SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem.node(
|
||||
getName: () => LocaleKeys.document_slashMenu_name_aiWriter.tr(),
|
||||
nameBuilder: _slashMenuItemNameBuilder,
|
||||
iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget(
|
||||
SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem(
|
||||
getName: LocaleKeys.document_slashMenu_name_aiWriter.tr,
|
||||
icon: (editorState, isSelected, style) => SelectableSvgWidget(
|
||||
data: FlowySvgs.slash_menu_icon_ai_writer_s,
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: ['ai', 'openai', 'writer', 'ai writer', 'autogenerator'],
|
||||
nodeBuilder: (editorState, _) {
|
||||
final node = autoCompletionNode(start: editorState.selection!);
|
||||
return node;
|
||||
handler: (editorState, menuService, context) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
final newNode = autoCompletionNode(start: selection);
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
//default insert after
|
||||
final path = node.path.next;
|
||||
transaction
|
||||
..insertNode(path, newNode)
|
||||
..afterSelection = null;
|
||||
editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(inMemoryUpdate: true),
|
||||
);
|
||||
},
|
||||
replace: (_, node) => false,
|
||||
);
|
||||
|
||||
// table menu item
|
||||
|
||||
+121
-34
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -211,25 +211,7 @@ class _ToggleListBlockComponentWidgetState
|
||||
BuildContext context, {
|
||||
bool withBackgroundColor = false,
|
||||
}) {
|
||||
final textDirection = calculateTextDirection(
|
||||
layoutDirection: Directionality.maybeOf(context),
|
||||
);
|
||||
|
||||
Widget child = Container(
|
||||
width: double.infinity,
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
textDirection: textDirection,
|
||||
children: [
|
||||
_buildExpandIcon(),
|
||||
Flexible(
|
||||
child: _buildRichText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Widget child = _buildToggleBlock();
|
||||
|
||||
child = BlockSelectionContainer(
|
||||
node: node,
|
||||
@@ -265,6 +247,60 @@ class _ToggleListBlockComponentWidgetState
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildToggleBlock() {
|
||||
final textDirection = calculateTextDirection(
|
||||
layoutDirection: Directionality.maybeOf(context),
|
||||
);
|
||||
final crossAxisAlignment = textDirection == TextDirection.ltr
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.end;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
alignment: alignment,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
textDirection: textDirection,
|
||||
children: [
|
||||
_buildExpandIcon(),
|
||||
Flexible(
|
||||
child: _buildRichText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
// if the toggle block is collapsed or it contains children, don't show the
|
||||
// placeholder.
|
||||
if (collapsed || node.children.isNotEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: indentPadding,
|
||||
child: FlowyButton(
|
||||
text: FlowyText(
|
||||
buildPlaceholderText(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: UniversalPlatform.isDesktopOrWeb ? 16.0 : 14.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3.0, vertical: 8),
|
||||
onTap: onAddContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRichText() {
|
||||
final textDirection = calculateTextDirection(
|
||||
layoutDirection: Directionality.maybeOf(context),
|
||||
@@ -304,6 +340,9 @@ class _ToggleListBlockComponentWidgetState
|
||||
|
||||
Widget _buildExpandIcon() {
|
||||
double buttonHeight = UniversalPlatform.isDesktop ? 22.0 : 26.0;
|
||||
final textDirection = calculateTextDirection(
|
||||
layoutDirection: Directionality.maybeOf(context),
|
||||
);
|
||||
|
||||
if (level != null) {
|
||||
// top padding * 2 + button height = height of the heading text
|
||||
@@ -316,18 +355,46 @@ class _ToggleListBlockComponentWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 26,
|
||||
minHeight: buttonHeight,
|
||||
),
|
||||
child: FlowyIconButton(
|
||||
width: 20.0,
|
||||
onPressed: onCollapsed,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: AnimatedRotation(
|
||||
turns: collapsed ? 0.0 : 0.25,
|
||||
final turns = switch (textDirection) {
|
||||
TextDirection.ltr => collapsed ? 0.0 : 0.25,
|
||||
TextDirection.rtl => collapsed ? -0.5 : -0.75,
|
||||
};
|
||||
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 26,
|
||||
minHeight: buttonHeight,
|
||||
),
|
||||
child: FlowyIconButton(
|
||||
width: 20.0,
|
||||
onPressed: onCollapsed,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: AnimatedRotation(
|
||||
turns: collapsed ? 0.0 : 0.25,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const Icon(
|
||||
Icons.arrow_right,
|
||||
size: 18.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 26,
|
||||
minHeight: buttonHeight,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsets.all(2.0),
|
||||
useIntrinsicWidth: true,
|
||||
onTap: onCollapsed,
|
||||
text: AnimatedRotation(
|
||||
turns: turns,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const Icon(
|
||||
Icons.arrow_right,
|
||||
@@ -335,8 +402,8 @@ class _ToggleListBlockComponentWidgetState
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onCollapsed() async {
|
||||
@@ -347,4 +414,24 @@ class _ToggleListBlockComponentWidgetState
|
||||
transaction.afterSelection = editorState.selection;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> onAddContent() async {
|
||||
final transaction = editorState.transaction;
|
||||
final path = node.path.child(0);
|
||||
transaction.insertNode(
|
||||
path,
|
||||
paragraphNode(),
|
||||
);
|
||||
transaction.afterSelection = Selection.collapsed(Position(path: path));
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
String buildPlaceholderText() {
|
||||
if (level != null) {
|
||||
return LocaleKeys.document_plugins_emptyToggleHeading.tr(
|
||||
args: [level.toString()],
|
||||
);
|
||||
}
|
||||
return LocaleKeys.document_plugins_emptyToggleList.tr();
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -40,7 +40,7 @@ class EditorTransactionService extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EditorTransactionServiceState extends State<EditorTransactionService> {
|
||||
StreamSubscription<(TransactionTime, Transaction)>? transactionSubscription;
|
||||
StreamSubscription<EditorTransactionValue>? transactionSubscription;
|
||||
|
||||
bool isUndoRedo = false;
|
||||
bool isPaste = false;
|
||||
@@ -131,8 +131,11 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
|
||||
return matchingNodes;
|
||||
}
|
||||
|
||||
void onEditorTransaction((TransactionTime, Transaction) event) {
|
||||
if (event.$1 == TransactionTime.before) {
|
||||
void onEditorTransaction(EditorTransactionValue event) {
|
||||
final time = event.$1;
|
||||
final transaction = event.$2;
|
||||
|
||||
if (time == TransactionTime.before) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,7 +148,7 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
|
||||
handler.type: handler.livesInDelta ? <MentionBlockData>[] : <Node>[],
|
||||
};
|
||||
|
||||
for (final op in event.$2.operations) {
|
||||
for (final op in transaction.operations) {
|
||||
if (op is InsertOperation) {
|
||||
for (final n in op.nodes) {
|
||||
for (final handler in _transactionHandlers) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:appflowy/startup/startup.dart';
|
||||
|
||||
class WindowSizeManager {
|
||||
static const double minWindowHeight = 640.0;
|
||||
static const double minWindowWidth = 960.0;
|
||||
static const double minWindowWidth = 640.0;
|
||||
// Preventing failed assertion due to Texture Descriptor Validation
|
||||
static const double maxWindowHeight = 8192.0;
|
||||
static const double maxWindowWidth = 8192.0;
|
||||
@@ -35,7 +35,10 @@ class WindowSizeManager {
|
||||
|
||||
Future<Size> getSize() async {
|
||||
final defaultWindowSize = jsonEncode(
|
||||
{WindowSizeManager.height: minWindowHeight, WindowSizeManager.width: minWindowWidth},
|
||||
{
|
||||
WindowSizeManager.height: minWindowHeight,
|
||||
WindowSizeManager.width: minWindowWidth,
|
||||
},
|
||||
);
|
||||
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
|
||||
final size = json.decode(
|
||||
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/shared/af_role_pb_extension.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -44,6 +45,10 @@ class WorkspaceMemberBloc
|
||||
(e) => [],
|
||||
);
|
||||
final myRole = _getMyRole(members);
|
||||
|
||||
if (myRole.isOwner) {
|
||||
unawaited(_fetchWorkspaceSubscriptionInfo());
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
members: members,
|
||||
@@ -215,8 +220,6 @@ class WorkspaceMemberBloc
|
||||
_workspaceId = '';
|
||||
});
|
||||
}
|
||||
|
||||
unawaited(_fetchWorkspaceSubscriptionInfo());
|
||||
}
|
||||
|
||||
// We fetch workspace subscription info lazily as it's not needed in the first
|
||||
|
||||
@@ -61,8 +61,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: ea81e3c
|
||||
resolved-ref: ea81e3c1647344aff45970c39556902ffad4373d
|
||||
ref: "5b3878d"
|
||||
resolved-ref: "5b3878dcc5876ae7a329b308ff82763f02cf8c5f"
|
||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||
source: git
|
||||
version: "4.0.0"
|
||||
@@ -70,8 +70,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/appflowy_editor_plugins"
|
||||
ref: "27c898d"
|
||||
resolved-ref: "27c898d1343f52d80444a0f469b8ee403606cf36"
|
||||
ref: "3f82111"
|
||||
resolved-ref: "3f82111f958b0ac9f06aa80fd19a629f3a649ec0"
|
||||
url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git"
|
||||
source: git
|
||||
version: "0.0.6"
|
||||
@@ -386,6 +386,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.2"
|
||||
cross_cache:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_cache
|
||||
sha256: ed30348320a7fefe4195c26cfcbabc76b7108ce3d364c4dd7c1b1c681a4cfe28
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
cross_file:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -459,13 +467,29 @@ packages:
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
diffutil_dart:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: diffutil_dart
|
||||
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
dotted_border:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -692,8 +716,16 @@ packages:
|
||||
url: "https://github.com/LucasXu0/flutter_cache_manager.git"
|
||||
source: git
|
||||
version: "3.3.1"
|
||||
flutter_chat_types:
|
||||
flutter_chat_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_chat_core
|
||||
sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
flutter_chat_types:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_chat_types
|
||||
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
|
||||
@@ -704,10 +736,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_chat_ui
|
||||
sha256: "168a4231464ad00a17ea5f0813f1b58393bdd4035683ea4dc37bbe26be62891e"
|
||||
sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.15"
|
||||
version: "2.0.0-dev.1"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -767,14 +799,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
flutter_parsed_text:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_parsed_text
|
||||
sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1523,14 +1547,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
photo_view:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: photo_view
|
||||
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.0"
|
||||
pixel_snap:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an
|
||||
your data. The best open source alternative to Notion.
|
||||
publish_to: "none"
|
||||
|
||||
version: 0.7.4
|
||||
version: 0.7.5
|
||||
|
||||
environment:
|
||||
flutter: ">=3.22.0"
|
||||
@@ -45,6 +45,7 @@ dependencies:
|
||||
# Desktop Drop uses Cross File (XFile) data type
|
||||
desktop_drop: ^0.4.4
|
||||
device_info_plus:
|
||||
diffutil_dart: ^4.0.1
|
||||
dotted_border: ^2.0.0+3
|
||||
easy_localization: ^3.0.2
|
||||
envied: ^0.5.2
|
||||
@@ -66,8 +67,8 @@ dependencies:
|
||||
flutter_animate: ^4.5.0
|
||||
flutter_bloc: ^8.1.3
|
||||
flutter_cache_manager: ^3.3.1
|
||||
flutter_chat_types: ^3.6.2
|
||||
flutter_chat_ui: ^1.6.13
|
||||
flutter_chat_core: ^0.0.2
|
||||
flutter_chat_ui: 2.0.0-dev.1
|
||||
flutter_emoji_mart:
|
||||
git:
|
||||
url: https://github.com/LucasXu0/emoji_mart.git
|
||||
@@ -171,13 +172,13 @@ dependency_overrides:
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
ref: "ea81e3c"
|
||||
ref: "5b3878d"
|
||||
|
||||
appflowy_editor_plugins:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git
|
||||
path: "packages/appflowy_editor_plugins"
|
||||
ref: "27c898d"
|
||||
ref: "3f82111"
|
||||
|
||||
sheet:
|
||||
git:
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('text robot:', () {
|
||||
setUpAll(() {
|
||||
Log.shared.disableLog = true;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
Log.shared.disableLog = false;
|
||||
});
|
||||
|
||||
test('auto insert text with sentence mode (1)', () async {
|
||||
final editorState = EditorState.blank();
|
||||
editorState.selection = Selection.collapsed(Position(path: [0]));
|
||||
final textRobot = TextRobot(
|
||||
editorState: editorState,
|
||||
);
|
||||
for (final text in _sample1) {
|
||||
await textRobot.autoInsertTextSync(
|
||||
text,
|
||||
separator: r'\n\n',
|
||||
inputType: TextRobotInputType.sentence,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText();
|
||||
final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText();
|
||||
final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText();
|
||||
|
||||
expect(
|
||||
p1,
|
||||
'In a quaint village nestled between rolling hills, a young girl named Elara discovered a hidden garden. She stumbled upon it while chasing a mischievous rabbit through a narrow, winding path. ',
|
||||
);
|
||||
expect(
|
||||
p2,
|
||||
'The garden was a vibrant oasis, brimming with colorful flowers and whispering trees. Elara felt an inexplicable connection to the place, as if it held secrets from a forgotten time. ',
|
||||
);
|
||||
expect(
|
||||
p3,
|
||||
'Determined to uncover its mysteries, she visited daily, unraveling tales of ancient magic and wisdom. The garden transformed her spirit, teaching her the importance of harmony and the beauty of nature\'s wonders.',
|
||||
);
|
||||
});
|
||||
|
||||
test('auto insert text with sentence mode (2)', () async {
|
||||
final editorState = EditorState.blank();
|
||||
editorState.selection = Selection.collapsed(Position(path: [0]));
|
||||
final textRobot = TextRobot(
|
||||
editorState: editorState,
|
||||
);
|
||||
|
||||
var breakCount = 0;
|
||||
for (final text in _sample2) {
|
||||
if (text.contains('\n\n')) {
|
||||
breakCount++;
|
||||
}
|
||||
await textRobot.autoInsertTextSync(
|
||||
text,
|
||||
separator: r'\n\n',
|
||||
inputType: TextRobotInputType.sentence,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
final len = editorState.document.root.children.length;
|
||||
expect(len, breakCount + 1);
|
||||
expect(len, 7);
|
||||
|
||||
final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText();
|
||||
final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText();
|
||||
final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText();
|
||||
final p4 = editorState.document.nodeAtPath([3])!.delta!.toPlainText();
|
||||
final p5 = editorState.document.nodeAtPath([4])!.delta!.toPlainText();
|
||||
final p6 = editorState.document.nodeAtPath([5])!.delta!.toPlainText();
|
||||
final p7 = editorState.document.nodeAtPath([6])!.delta!.toPlainText();
|
||||
|
||||
expect(
|
||||
p1,
|
||||
'Once upon a time in the small, whimsical village of Greenhollow, nestled between rolling hills and lush forests, there lived a young girl named Elara. Unlike the other villagers, Elara had a unique gift: she could communicate with animals. This extraordinary ability made her both a beloved and mysterious figure in Greenhollow.',
|
||||
);
|
||||
expect(
|
||||
p2,
|
||||
'One crisp autumn morning, as golden leaves danced in the breeze, Elara heard a distressed call from the forest. Following the sound, she discovered a young fox trapped in a hunter\'s snare. With gentle hands and a calming voice, she freed the frightened creature, who introduced himself as Rufus. Grateful for her help, Rufus promised to assist Elara whenever she needed.',
|
||||
);
|
||||
expect(
|
||||
p3,
|
||||
'Word of Elara\'s kindness spread among the forest animals, and soon she found herself surrounded by a diverse group of animal friends, from wise old owls to playful otters. Together, they shared stories, solved problems, and looked out for one another.',
|
||||
);
|
||||
expect(
|
||||
p4,
|
||||
'One day, the village faced an unexpected threat: a severe drought that threatened their crops and water supply. The villagers grew anxious, unsure of how to cope with the impending scarcity. Elara, determined to help, turned to her animal friends for guidance.',
|
||||
);
|
||||
expect(
|
||||
p5,
|
||||
'The animals led Elara to a hidden spring deep within the forest, a source of fresh water unknown to the villagers. With Rufus\'s clever planning and the otters\' help in directing the flow, they managed to channel the spring water to the village, saving the crops and quenching the villagers\' thirst.',
|
||||
);
|
||||
expect(
|
||||
p6,
|
||||
'Grateful and amazed, the villagers hailed Elara as a hero. They came to understand the importance of living harmoniously with nature and the wonders that could be achieved through kindness and cooperation.',
|
||||
);
|
||||
expect(
|
||||
p7,
|
||||
'From that day on, Greenhollow thrived as a community where humans and animals lived together in harmony, cherishing the bonds that Elara had helped forge. And whenever challenges arose, the villagers knew they could rely on Elara and her extraordinary friends to guide them through, ensuring that the spirit of unity and compassion always prevailed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final _sample1 = [
|
||||
"In",
|
||||
" a quaint",
|
||||
" village",
|
||||
" nestled",
|
||||
" between",
|
||||
" rolling",
|
||||
" hills",
|
||||
",",
|
||||
" a",
|
||||
" young",
|
||||
" girl",
|
||||
" named",
|
||||
" El",
|
||||
"ara discovered",
|
||||
" a hidden",
|
||||
" garden",
|
||||
".",
|
||||
" She stumbled",
|
||||
" upon",
|
||||
" it",
|
||||
" while",
|
||||
" chasing",
|
||||
" a",
|
||||
" misch",
|
||||
"iev",
|
||||
"ous rabbit",
|
||||
" through",
|
||||
" a",
|
||||
" narrow,",
|
||||
" winding path",
|
||||
".",
|
||||
" \n\n",
|
||||
"The",
|
||||
" garden",
|
||||
" was",
|
||||
" a",
|
||||
" vibrant",
|
||||
" oasis",
|
||||
",",
|
||||
" br",
|
||||
"imming with",
|
||||
" colorful",
|
||||
" flowers",
|
||||
" and whisper",
|
||||
"ing",
|
||||
" trees",
|
||||
".",
|
||||
" El",
|
||||
"ara",
|
||||
" felt",
|
||||
" an inexp",
|
||||
"licable",
|
||||
" connection",
|
||||
" to",
|
||||
" the",
|
||||
" place,",
|
||||
" as",
|
||||
" if",
|
||||
" it held",
|
||||
" secrets",
|
||||
" from",
|
||||
" a",
|
||||
" forgotten",
|
||||
" time",
|
||||
".",
|
||||
" \n\n",
|
||||
"Determ",
|
||||
"ined to",
|
||||
" uncover",
|
||||
" its",
|
||||
" mysteries",
|
||||
",",
|
||||
" she",
|
||||
" visited",
|
||||
" daily,",
|
||||
" unravel",
|
||||
"ing",
|
||||
" tales",
|
||||
" of",
|
||||
" ancient",
|
||||
" magic",
|
||||
" and",
|
||||
" wisdom",
|
||||
".",
|
||||
" The",
|
||||
" garden transformed",
|
||||
" her",
|
||||
" spirit",
|
||||
", teaching",
|
||||
" her the",
|
||||
" importance of harmony and",
|
||||
" the",
|
||||
" beauty",
|
||||
" of",
|
||||
" nature",
|
||||
"'s wonders.",
|
||||
];
|
||||
|
||||
final _sample2 = [
|
||||
"Once",
|
||||
" upon",
|
||||
" a",
|
||||
" time",
|
||||
" in",
|
||||
" the small",
|
||||
",",
|
||||
" whimsical",
|
||||
" village",
|
||||
" of",
|
||||
" Green",
|
||||
"h",
|
||||
"ollow",
|
||||
",",
|
||||
" nestled",
|
||||
" between",
|
||||
" rolling hills",
|
||||
" and",
|
||||
" lush",
|
||||
" forests",
|
||||
",",
|
||||
" there",
|
||||
" lived",
|
||||
" a young",
|
||||
" girl",
|
||||
" named",
|
||||
" Elara.",
|
||||
" Unlike the",
|
||||
" other",
|
||||
" villagers",
|
||||
",",
|
||||
" El",
|
||||
"ara",
|
||||
" had",
|
||||
" a unique",
|
||||
" gift",
|
||||
":",
|
||||
" she could",
|
||||
" communicate",
|
||||
" with",
|
||||
" animals",
|
||||
".",
|
||||
" This",
|
||||
" extraordinary",
|
||||
" ability",
|
||||
" made",
|
||||
" her both a",
|
||||
" beloved",
|
||||
" and",
|
||||
" mysterious",
|
||||
" figure",
|
||||
" in",
|
||||
" Green",
|
||||
"h",
|
||||
"ollow",
|
||||
".\n\n",
|
||||
"One",
|
||||
" crisp",
|
||||
" autumn",
|
||||
" morning,",
|
||||
" as",
|
||||
" golden",
|
||||
" leaves",
|
||||
" danced",
|
||||
" in",
|
||||
" the",
|
||||
" breeze",
|
||||
", El",
|
||||
"ara heard",
|
||||
" a distressed",
|
||||
" call",
|
||||
" from",
|
||||
" the",
|
||||
" forest",
|
||||
".",
|
||||
" Following",
|
||||
" the",
|
||||
" sound",
|
||||
",",
|
||||
" she",
|
||||
" discovered",
|
||||
" a",
|
||||
" young",
|
||||
" fox",
|
||||
" trapped",
|
||||
" in",
|
||||
" a",
|
||||
" hunter's",
|
||||
" snare",
|
||||
".",
|
||||
" With",
|
||||
" gentle",
|
||||
" hands",
|
||||
" and",
|
||||
" a",
|
||||
" calming",
|
||||
" voice",
|
||||
",",
|
||||
" she",
|
||||
" freed",
|
||||
" the",
|
||||
" frightened",
|
||||
" creature",
|
||||
", who",
|
||||
" introduced",
|
||||
" himself",
|
||||
" as Ruf",
|
||||
"us.",
|
||||
" Gr",
|
||||
"ateful",
|
||||
" for",
|
||||
" her",
|
||||
" help",
|
||||
",",
|
||||
" Rufus promised",
|
||||
" to assist",
|
||||
" Elara",
|
||||
" whenever",
|
||||
" she",
|
||||
" needed.\n\n",
|
||||
"Word",
|
||||
" of",
|
||||
" Elara",
|
||||
"'s kindness",
|
||||
" spread among",
|
||||
" the forest",
|
||||
" animals",
|
||||
",",
|
||||
" and soon",
|
||||
" she",
|
||||
" found",
|
||||
" herself",
|
||||
" surrounded",
|
||||
" by",
|
||||
" a",
|
||||
" diverse",
|
||||
" group",
|
||||
" of",
|
||||
" animal",
|
||||
" friends",
|
||||
",",
|
||||
" from",
|
||||
" wise",
|
||||
" old ow",
|
||||
"ls to playful",
|
||||
" ot",
|
||||
"ters.",
|
||||
" Together,",
|
||||
" they",
|
||||
" shared stories",
|
||||
",",
|
||||
" solved problems",
|
||||
",",
|
||||
" and",
|
||||
" looked",
|
||||
" out",
|
||||
" for",
|
||||
" one",
|
||||
" another",
|
||||
".\n\n",
|
||||
"One",
|
||||
" day",
|
||||
", the village faced",
|
||||
" an unexpected",
|
||||
" threat",
|
||||
":",
|
||||
" a",
|
||||
" severe",
|
||||
" drought",
|
||||
" that",
|
||||
" threatened",
|
||||
" their",
|
||||
" crops",
|
||||
" and",
|
||||
" water supply",
|
||||
".",
|
||||
" The",
|
||||
" villagers",
|
||||
" grew",
|
||||
" anxious",
|
||||
",",
|
||||
" unsure",
|
||||
" of",
|
||||
" how to",
|
||||
" cope",
|
||||
" with",
|
||||
" the",
|
||||
" impending",
|
||||
" scarcity",
|
||||
".",
|
||||
" El",
|
||||
"ara",
|
||||
",",
|
||||
" determined",
|
||||
" to",
|
||||
" help",
|
||||
",",
|
||||
" turned",
|
||||
" to her",
|
||||
" animal friends",
|
||||
" for",
|
||||
" guidance",
|
||||
".\n\nThe",
|
||||
" animals",
|
||||
" led",
|
||||
" El",
|
||||
"ara",
|
||||
" to",
|
||||
" a",
|
||||
" hidden",
|
||||
" spring",
|
||||
" deep",
|
||||
" within",
|
||||
" the forest,",
|
||||
" a source",
|
||||
" of",
|
||||
" fresh",
|
||||
" water unknown",
|
||||
" to the",
|
||||
" villagers",
|
||||
".",
|
||||
" With",
|
||||
" Ruf",
|
||||
"us's",
|
||||
" clever planning",
|
||||
" and the",
|
||||
" ot",
|
||||
"ters",
|
||||
"'",
|
||||
" help",
|
||||
" in directing",
|
||||
" the",
|
||||
" flow",
|
||||
",",
|
||||
" they",
|
||||
" managed",
|
||||
" to",
|
||||
" channel the",
|
||||
" spring",
|
||||
" water",
|
||||
" to",
|
||||
" the",
|
||||
" village,",
|
||||
" saving the",
|
||||
" crops",
|
||||
" and",
|
||||
" quenching",
|
||||
" the",
|
||||
" villagers",
|
||||
"'",
|
||||
" thirst",
|
||||
".\n\n",
|
||||
"Gr",
|
||||
"ateful and",
|
||||
" amazed,",
|
||||
" the",
|
||||
" villagers",
|
||||
" hailed El",
|
||||
"ara as",
|
||||
" a",
|
||||
" hero",
|
||||
".",
|
||||
" They",
|
||||
" came",
|
||||
" to",
|
||||
" understand the",
|
||||
" importance",
|
||||
" of living",
|
||||
" harmon",
|
||||
"iously",
|
||||
" with",
|
||||
" nature",
|
||||
" and",
|
||||
" the",
|
||||
" wonders",
|
||||
" that",
|
||||
" could",
|
||||
" be",
|
||||
" achieved",
|
||||
" through kindness",
|
||||
" and cooperation",
|
||||
".\n\nFrom",
|
||||
" that day",
|
||||
" on",
|
||||
",",
|
||||
" Greenh",
|
||||
"ollow",
|
||||
" thr",
|
||||
"ived",
|
||||
" as",
|
||||
" a",
|
||||
" community",
|
||||
" where",
|
||||
" humans",
|
||||
" and",
|
||||
" animals",
|
||||
" lived together",
|
||||
" in",
|
||||
" harmony",
|
||||
",",
|
||||
" cher",
|
||||
"ishing",
|
||||
" the",
|
||||
" bonds that",
|
||||
" El",
|
||||
"ara",
|
||||
" had",
|
||||
" helped",
|
||||
" forge",
|
||||
".",
|
||||
" And whenever",
|
||||
" challenges arose",
|
||||
", the",
|
||||
" villagers",
|
||||
" knew",
|
||||
" they",
|
||||
" could",
|
||||
" rely on",
|
||||
" El",
|
||||
"ara and",
|
||||
" her",
|
||||
" extraordinary",
|
||||
" friends",
|
||||
" to",
|
||||
" guide them",
|
||||
" through",
|
||||
",",
|
||||
" ensuring",
|
||||
" that",
|
||||
" the",
|
||||
" spirit",
|
||||
" of",
|
||||
" unity",
|
||||
" and",
|
||||
" compassion",
|
||||
" always prevailed.",
|
||||
];
|
||||
+12
-12
@@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
[[package]]
|
||||
name = "app-error"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -192,7 +192,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "appflowy-ai-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -888,7 +888,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"again",
|
||||
"anyhow",
|
||||
@@ -943,7 +943,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"collab-entity",
|
||||
"collab-rt-entity",
|
||||
@@ -956,7 +956,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-websocket"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -1256,7 +1256,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -1281,7 +1281,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1678,7 +1678,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
|
||||
[[package]]
|
||||
name = "database-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -3266,7 +3266,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures-util",
|
||||
@@ -3283,7 +3283,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -3715,7 +3715,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "infra"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "shared-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
|
||||
@@ -59,7 +59,7 @@ collab-importer = { version = "0.1" }
|
||||
# Run the script:
|
||||
# scripts/tool/update_client_api_rev.sh new_rev_id
|
||||
# ⚠️⚠️⚠️️
|
||||
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e9c374bfe2440d53a58aee21dca7c7af048fff85" }
|
||||
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0ec12c5f2fdd1bce0a0457eafb9963532b5208b3" }
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
+12
-12
@@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
[[package]]
|
||||
name = "app-error"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -183,7 +183,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "appflowy-ai-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -877,7 +877,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"again",
|
||||
"anyhow",
|
||||
@@ -932,7 +932,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"collab-entity",
|
||||
"collab-rt-entity",
|
||||
@@ -945,7 +945,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-websocket"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -1279,7 +1279,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1683,7 +1683,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
|
||||
[[package]]
|
||||
name = "database-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -3348,7 +3348,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures-util",
|
||||
@@ -3365,7 +3365,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -3802,7 +3802,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "infra"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -6480,7 +6480,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "shared-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
|
||||
@@ -58,7 +58,7 @@ collab-importer = { version = "0.1" }
|
||||
# Run the script:
|
||||
# scripts/tool/update_client_api_rev.sh new_rev_id
|
||||
# ⚠️⚠️⚠️️
|
||||
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e9c374bfe2440d53a58aee21dca7c7af048fff85" }
|
||||
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0ec12c5f2fdd1bce0a0457eafb9963532b5208b3" }
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3.75V16.25M10 16.25L14.6875 11.5625M10 16.25L5.3125 11.5625" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -1704,6 +1704,8 @@
|
||||
"insertDate": "Insert date",
|
||||
"emoji": "Emoji",
|
||||
"toggleList": "Toggle list",
|
||||
"emptyToggleHeading": "Empty toggle heading {}. Click to add content",
|
||||
"emptyToggleList": "Empty toggle list. Click to add content",
|
||||
"quoteList": "Quote list",
|
||||
"numberedList": "Numbered list",
|
||||
"bulletedList": "Bulleted list",
|
||||
@@ -2865,4 +2867,4 @@
|
||||
"unfavorite": "Unfavorite",
|
||||
"favoriteDisabledHint": "Cannot favorite this view"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+12
-12
@@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
[[package]]
|
||||
name = "app-error"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -183,7 +183,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "appflowy-ai-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -780,7 +780,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"again",
|
||||
"anyhow",
|
||||
@@ -835,7 +835,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-api-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"collab-entity",
|
||||
"collab-rt-entity",
|
||||
@@ -848,7 +848,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "client-websocket"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -1117,7 +1117,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
@@ -1142,7 +1142,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-rt-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1537,7 +1537,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
|
||||
[[package]]
|
||||
name = "database-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -2980,7 +2980,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures-util",
|
||||
@@ -2997,7 +2997,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gotrue-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
@@ -3359,7 +3359,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "infra"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "shared-entity"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=e9c374bfe2440d53a58aee21dca7c7af048fff85#e9c374bfe2440d53a58aee21dca7c7af048fff85"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0ec12c5f2fdd1bce0a0457eafb9963532b5208b3#0ec12c5f2fdd1bce0a0457eafb9963532b5208b3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app-error",
|
||||
|
||||
@@ -106,8 +106,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 = "e9c374bfe2440d53a58aee21dca7c7af048fff85" }
|
||||
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "e9c374bfe2440d53a58aee21dca7c7af048fff85" }
|
||||
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0ec12c5f2fdd1bce0a0457eafb9963532b5208b3" }
|
||||
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0ec12c5f2fdd1bce0a0457eafb9963532b5208b3" }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
@@ -4,6 +4,13 @@ use collab::preclude::Collab;
|
||||
use collab_document::blocks::DocumentData;
|
||||
use collab_document::document::Document;
|
||||
use collab_entity::CollabType;
|
||||
use flowy_core::config::AppFlowyCoreConfig;
|
||||
use flowy_core::AppFlowyCore;
|
||||
use flowy_notification::register_notification_sender;
|
||||
use flowy_server::AppFlowyServer;
|
||||
use flowy_user::entities::AuthenticatorPB;
|
||||
use flowy_user::errors::FlowyError;
|
||||
use lib_dispatch::runtime::AFPluginRuntime;
|
||||
use nanoid::nanoid;
|
||||
use semver::Version;
|
||||
use std::env::temp_dir;
|
||||
@@ -15,14 +22,6 @@ use tokio::select;
|
||||
use tokio::task::LocalSet;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use flowy_core::config::AppFlowyCoreConfig;
|
||||
use flowy_core::AppFlowyCore;
|
||||
use flowy_notification::register_notification_sender;
|
||||
use flowy_server::AppFlowyServer;
|
||||
use flowy_user::entities::AuthenticatorPB;
|
||||
use flowy_user::errors::FlowyError;
|
||||
use lib_dispatch::runtime::AFPluginRuntime;
|
||||
|
||||
use crate::user_event::TestNotificationSender;
|
||||
|
||||
mod chat_event;
|
||||
@@ -43,6 +42,8 @@ pub struct EventIntegrationTest {
|
||||
local_set: Arc<LocalSet>,
|
||||
}
|
||||
|
||||
pub const SINGLE_FILE_UPLOAD_SIZE: usize = 15 * 1024 * 1024;
|
||||
|
||||
impl EventIntegrationTest {
|
||||
pub async fn new() -> Self {
|
||||
Self::new_with_name(nanoid!(6)).await
|
||||
@@ -76,7 +77,7 @@ impl EventIntegrationTest {
|
||||
pub async fn new_with_user_data_path(path_buf: PathBuf, name: String) -> Self {
|
||||
let path = path_buf.to_str().unwrap().to_string();
|
||||
let device_id = uuid::Uuid::new_v4().to_string();
|
||||
let config = AppFlowyCoreConfig::new(
|
||||
let mut config = AppFlowyCoreConfig::new(
|
||||
Version::new(0, 7, 0),
|
||||
path.clone(),
|
||||
path,
|
||||
@@ -92,6 +93,10 @@ impl EventIntegrationTest {
|
||||
// "lib_dispatch".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
if let Some(cloud_config) = config.cloud_config.as_mut() {
|
||||
cloud_config.maximum_upload_file_size_in_bytes = Some(SINGLE_FILE_UPLOAD_SIZE as u64);
|
||||
}
|
||||
Self::new_with_config(config).await
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,7 @@ pub async fn use_localhost_af_cloud() {
|
||||
base_url,
|
||||
ws_base_url,
|
||||
gotrue_url,
|
||||
maximum_upload_file_size_in_bytes: None,
|
||||
}
|
||||
.write_env();
|
||||
std::env::set_var("GOTRUE_ADMIN_EMAIL", "admin@example.com");
|
||||
|
||||
+67
-4
@@ -1,6 +1,6 @@
|
||||
use crate::document::generate_random_bytes;
|
||||
use event_integration_test::user_event::use_localhost_af_cloud;
|
||||
use event_integration_test::EventIntegrationTest;
|
||||
use event_integration_test::{EventIntegrationTest, SINGLE_FILE_UPLOAD_SIZE};
|
||||
use flowy_storage_pub::storage::FileUploadState;
|
||||
use lib_infra::util::md5;
|
||||
use std::env::temp_dir;
|
||||
@@ -24,7 +24,7 @@ async fn af_cloud_upload_big_file_test() {
|
||||
let (created_upload, rx) = test
|
||||
.storage_manager
|
||||
.storage_service
|
||||
.create_upload(&workspace_id, parent_dir, &file_path, false)
|
||||
.create_upload(&workspace_id, parent_dir, &file_path)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -85,14 +85,14 @@ async fn af_cloud_upload_6_files_test() {
|
||||
|
||||
let mut created_uploads = vec![];
|
||||
let mut receivers = vec![];
|
||||
for file_size in [1, 2, 5, 8, 12, 20] {
|
||||
for file_size in [1, 2, 5, 8, 10, 12] {
|
||||
let file_path = generate_file_with_bytes_len(file_size * 1024 * 1024)
|
||||
.await
|
||||
.0;
|
||||
let (created_upload, rx) = test
|
||||
.storage_manager
|
||||
.storage_service
|
||||
.create_upload(&workspace_id, "temp_test", &file_path, false)
|
||||
.create_upload(&workspace_id, "temp_test", &file_path)
|
||||
.await
|
||||
.unwrap();
|
||||
receivers.push(rx.unwrap());
|
||||
@@ -132,6 +132,69 @@ async fn af_cloud_upload_6_files_test() {
|
||||
assert_eq!(uploads.lock().await.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn af_cloud_delete_upload_file_test() {
|
||||
use_localhost_af_cloud().await;
|
||||
let test = EventIntegrationTest::new().await;
|
||||
test.af_cloud_sign_up().await;
|
||||
let workspace_id = test.get_current_workspace().await.id;
|
||||
|
||||
// Pause sync
|
||||
test.storage_manager.update_network_reachable(false);
|
||||
let mut created_uploads = vec![];
|
||||
let mut receivers = vec![];
|
||||
|
||||
// file that exceeds limit will be deleted automatically
|
||||
let exceed_file_limit_size = SINGLE_FILE_UPLOAD_SIZE + 1;
|
||||
for file_size in [8 * 1024 * 1024, exceed_file_limit_size, 10 * 1024 * 1024] {
|
||||
let file_path = generate_file_with_bytes_len(file_size).await.0;
|
||||
let (created_upload, rx) = test
|
||||
.storage_manager
|
||||
.storage_service
|
||||
.create_upload(&workspace_id, "temp_test", &file_path)
|
||||
.await
|
||||
.unwrap();
|
||||
receivers.push(rx.unwrap());
|
||||
created_uploads.push(created_upload);
|
||||
let _ = fs::remove_file(file_path).await;
|
||||
}
|
||||
|
||||
test
|
||||
.storage_manager
|
||||
.storage_service
|
||||
.delete_object(created_uploads[0].clone().url)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut handles = vec![];
|
||||
// start sync
|
||||
test.storage_manager.update_network_reachable(true);
|
||||
|
||||
for mut receiver in receivers {
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Ok(Ok(value)) = timeout(Duration::from_secs(60), receiver.recv()).await {
|
||||
if let FileUploadState::Finished { file_id } = value {
|
||||
println!("file_id: {} was uploaded", file_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// join all handles
|
||||
futures::future::join_all(handles).await;
|
||||
let tasks = test.storage_manager.get_all_tasks().await.unwrap();
|
||||
assert!(tasks.is_empty());
|
||||
|
||||
let state = test
|
||||
.storage_manager
|
||||
.get_file_state(&created_uploads[2].file_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(state, FileUploadState::Finished { .. }));
|
||||
}
|
||||
|
||||
async fn generate_file_with_bytes_len(len: usize) -> (String, Vec<u8>) {
|
||||
let data = generate_random_bytes(len);
|
||||
let file_dir = temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
|
||||
@@ -27,7 +27,7 @@ pub struct AppFlowyCoreConfig {
|
||||
/// the origin_application_path.
|
||||
pub application_path: String,
|
||||
pub(crate) log_filter: String,
|
||||
cloud_config: Option<AFCloudConfiguration>,
|
||||
pub cloud_config: Option<AFCloudConfiguration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AppFlowyCoreConfig {
|
||||
|
||||
@@ -11,7 +11,6 @@ use flowy_server::af_cloud::define::ServerUser;
|
||||
use flowy_server::af_cloud::AppFlowyCloudServer;
|
||||
use flowy_server::local_server::{LocalServer, LocalServerDB};
|
||||
use flowy_server::{AppFlowyEncryption, AppFlowyServer, EncryptionImpl};
|
||||
use flowy_server_pub::af_cloud_config::AFCloudConfiguration;
|
||||
use flowy_server_pub::AuthenticatorType;
|
||||
use flowy_sqlite::kv::KVStorePreferences;
|
||||
use flowy_user_pub::entities::*;
|
||||
@@ -123,7 +122,9 @@ impl ServerProvider {
|
||||
Ok::<Arc<dyn AppFlowyServer>, FlowyError>(server)
|
||||
},
|
||||
Server::AppFlowyCloud => {
|
||||
let config = AFCloudConfiguration::from_env()?;
|
||||
let config = self.config.cloud_config.clone().ok_or_else(|| {
|
||||
FlowyError::internal().with_context("AppFlowyCloud configuration is missing")
|
||||
})?;
|
||||
let server = Arc::new(AppFlowyCloudServer::new(
|
||||
config,
|
||||
self.user_enable_sync.load(Ordering::Acquire),
|
||||
|
||||
@@ -106,11 +106,12 @@ impl StorageCloudService for ServerProvider {
|
||||
parent_dir: &str,
|
||||
file_id: &str,
|
||||
content_type: &str,
|
||||
file_size: u64,
|
||||
) -> Result<CreateUploadResponse, FlowyError> {
|
||||
let server = self.get_server();
|
||||
let storage = server?.file_storage().ok_or(FlowyError::internal())?;
|
||||
let server = self.get_server()?;
|
||||
let storage = server.file_storage().ok_or(FlowyError::internal())?;
|
||||
storage
|
||||
.create_upload(workspace_id, parent_dir, file_id, content_type)
|
||||
.create_upload(workspace_id, parent_dir, file_id, content_type, file_size)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use client_api::entity::billing_dto::SubscriptionPlan;
|
||||
use tracing::{event, trace};
|
||||
use tracing::{event, info};
|
||||
|
||||
use collab_entity::CollabType;
|
||||
use collab_integrate::collab_builder::AppFlowyCollabBuilder;
|
||||
@@ -204,7 +204,7 @@ impl UserStatusCallback for UserStatusCallbackImpl {
|
||||
}
|
||||
|
||||
fn did_update_network(&self, reachable: bool) {
|
||||
trace!("Notify did update network: reachable: {}", reachable);
|
||||
info!("Notify did update network: reachable: {}", reachable);
|
||||
self.collab_builder.update_network(reachable);
|
||||
self.storage_manager.update_network_reachable(reachable);
|
||||
}
|
||||
|
||||
@@ -490,10 +490,10 @@ pub(crate) async fn delete_file_handler(
|
||||
) -> FlowyResult<()> {
|
||||
let DownloadFilePB {
|
||||
url,
|
||||
local_file_path,
|
||||
local_file_path: _,
|
||||
} = params.try_into_inner()?;
|
||||
let manager = upgrade_document(manager)?;
|
||||
manager.delete_file(local_file_path, url).await
|
||||
manager.delete_file(url).await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_awareness_local_state_handler(
|
||||
|
||||
@@ -417,7 +417,7 @@ impl DocumentManager {
|
||||
) -> FlowyResult<CreatedUpload> {
|
||||
let storage_service = self.storage_service_upgrade()?;
|
||||
let upload = storage_service
|
||||
.create_upload(&workspace_id, document_id, local_file_path, false)
|
||||
.create_upload(&workspace_id, document_id, local_file_path)
|
||||
.await?
|
||||
.0;
|
||||
Ok(upload)
|
||||
@@ -429,9 +429,9 @@ impl DocumentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_file(&self, local_file_path: String, url: String) -> FlowyResult<()> {
|
||||
pub async fn delete_file(&self, url: String) -> FlowyResult<()> {
|
||||
let storage_service = self.storage_service_upgrade()?;
|
||||
storage_service.delete_object(url, local_file_path)?;
|
||||
storage_service.delete_object(url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ pub struct DocumentTestFileStorageService;
|
||||
|
||||
#[async_trait]
|
||||
impl StorageService for DocumentTestFileStorageService {
|
||||
fn delete_object(&self, _url: String, _local_file_path: String) -> FlowyResult<()> {
|
||||
async fn delete_object(&self, _url: String) -> FlowyResult<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -199,7 +199,6 @@ impl StorageService for DocumentTestFileStorageService {
|
||||
_workspace_id: &str,
|
||||
_parent_dir: &str,
|
||||
_local_file_path: &str,
|
||||
_upload_immediately: bool,
|
||||
) -> Result<(CreatedUpload, Option<FileProgressReceiver>), flowy_error::FlowyError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct AFCloudConfiguration {
|
||||
pub base_url: String,
|
||||
pub ws_base_url: String,
|
||||
pub gotrue_url: String,
|
||||
#[serde(default)]
|
||||
pub maximum_upload_file_size_in_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl Display for AFCloudConfiguration {
|
||||
@@ -57,6 +59,7 @@ impl AFCloudConfiguration {
|
||||
base_url,
|
||||
ws_base_url,
|
||||
gotrue_url,
|
||||
maximum_upload_file_size_in_bytes: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
use crate::af_cloud::AFServer;
|
||||
use client_api::entity::{CompleteUploadRequest, CreateUploadRequest};
|
||||
use flowy_error::FlowyError;
|
||||
use flowy_error::{ErrorCode, FlowyError};
|
||||
use flowy_storage_pub::cloud::{ObjectIdentity, ObjectValue, StorageCloudService};
|
||||
use flowy_storage_pub::storage::{CompletedPartRequest, CreateUploadResponse, UploadPartResponse};
|
||||
use lib_infra::async_trait::async_trait;
|
||||
|
||||
pub struct AFCloudFileStorageServiceImpl<T>(pub T);
|
||||
pub struct AFCloudFileStorageServiceImpl<T> {
|
||||
pub client: T,
|
||||
|
||||
/// Only use in debug mode
|
||||
pub maximum_upload_file_size_in_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl<T> AFCloudFileStorageServiceImpl<T> {
|
||||
pub fn new(client: T) -> Self {
|
||||
Self(client)
|
||||
pub fn new(client: T, maximum_upload_file_size_in_bytes: Option<u64>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
maximum_upload_file_size_in_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,25 +29,25 @@ where
|
||||
async fn get_object_url(&self, object_id: ObjectIdentity) -> Result<String, FlowyError> {
|
||||
let file_name = format!("{}.{}", object_id.file_id, object_id.ext);
|
||||
let url = self
|
||||
.0
|
||||
.client
|
||||
.try_get_client()?
|
||||
.get_blob_url(&object_id.workspace_id, &file_name);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn put_object(&self, url: String, file: ObjectValue) -> Result<(), FlowyError> {
|
||||
let client = self.0.try_get_client()?;
|
||||
let client = self.client.try_get_client()?;
|
||||
client.put_blob(&url, file.raw, &file.mime).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_object(&self, url: &str) -> Result<(), FlowyError> {
|
||||
self.0.try_get_client()?.delete_blob(url).await?;
|
||||
self.client.try_get_client()?.delete_blob(url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_object(&self, url: String) -> Result<ObjectValue, FlowyError> {
|
||||
let (mime, raw) = self.0.try_get_client()?.get_blob(&url).await?;
|
||||
let (mime, raw) = self.client.try_get_client()?.get_blob(&url).await?;
|
||||
Ok(ObjectValue {
|
||||
raw: raw.into(),
|
||||
mime,
|
||||
@@ -53,14 +61,14 @@ where
|
||||
file_id: &str,
|
||||
) -> Result<String, FlowyError> {
|
||||
let url = self
|
||||
.0
|
||||
.client
|
||||
.try_get_client()?
|
||||
.get_blob_url_v1(workspace_id, parent_dir, file_id);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)> {
|
||||
let value = self.0.try_get_client().ok()?.parse_blob_url_v1(url)?;
|
||||
let value = self.client.try_get_client().ok()?.parse_blob_url_v1(url)?;
|
||||
Some(value)
|
||||
}
|
||||
|
||||
@@ -70,17 +78,31 @@ where
|
||||
parent_dir: &str,
|
||||
file_id: &str,
|
||||
content_type: &str,
|
||||
file_size: u64,
|
||||
) -> Result<CreateUploadResponse, FlowyError> {
|
||||
let parent_dir = parent_dir.to_string();
|
||||
let content_type = content_type.to_string();
|
||||
let file_id = file_id.to_string();
|
||||
let try_get_client = self.0.try_get_client();
|
||||
let try_get_client = self.client.try_get_client();
|
||||
let client = try_get_client?;
|
||||
let req = CreateUploadRequest {
|
||||
file_id,
|
||||
parent_dir,
|
||||
content_type,
|
||||
file_size: Some(file_size),
|
||||
};
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
if let Some(maximum_upload_size) = self.maximum_upload_file_size_in_bytes {
|
||||
if file_size > maximum_upload_size {
|
||||
return Err(FlowyError::new(
|
||||
ErrorCode::SingleUploadLimitExceeded,
|
||||
"File size exceeds the maximum limit",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = client.create_upload(workspace_id, req).await?;
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -94,7 +116,7 @@ where
|
||||
part_number: i32,
|
||||
body: Vec<u8>,
|
||||
) -> Result<UploadPartResponse, FlowyError> {
|
||||
let try_get_client = self.0.try_get_client();
|
||||
let try_get_client = self.client.try_get_client();
|
||||
let client = try_get_client?;
|
||||
let resp = client
|
||||
.upload_part(
|
||||
@@ -121,7 +143,7 @@ where
|
||||
let parent_dir = parent_dir.to_string();
|
||||
let upload_id = upload_id.to_string();
|
||||
let file_id = file_id.to_string();
|
||||
let try_get_client = self.0.try_get_client();
|
||||
let try_get_client = self.client.try_get_client();
|
||||
let client = try_get_client?;
|
||||
let request = CompleteUploadRequest {
|
||||
file_id,
|
||||
|
||||
@@ -270,7 +270,10 @@ impl AppFlowyServer for AppFlowyCloudServer {
|
||||
let client = AFServerImpl {
|
||||
client: self.get_client(),
|
||||
};
|
||||
Some(Arc::new(AFCloudFileStorageServiceImpl::new(client)))
|
||||
Some(Arc::new(AFCloudFileStorageServiceImpl::new(
|
||||
client,
|
||||
self.config.maximum_upload_file_size_in_bytes,
|
||||
)))
|
||||
}
|
||||
|
||||
fn search_service(&self) -> Option<Arc<dyn SearchCloudService>> {
|
||||
|
||||
@@ -137,7 +137,7 @@ mod tests {
|
||||
file_path.push("test_small_file");
|
||||
|
||||
let mut file = File::create(&file_path).await.unwrap();
|
||||
file.write_all(&vec![0; 1 * 1024 * 1024]).await.unwrap(); // 1 MB
|
||||
file.write_all(&vec![0; 1024 * 1024]).await.unwrap(); // 1 MB
|
||||
file.flush().await.unwrap();
|
||||
|
||||
// Create ChunkedBytes instance
|
||||
@@ -148,7 +148,7 @@ mod tests {
|
||||
// Validate total chunks and read the data
|
||||
assert_eq!(chunked_bytes.total_chunks(), 1); // Only 1 chunk due to file size
|
||||
let chunk = chunked_bytes.next_chunk().await.unwrap().unwrap();
|
||||
assert_eq!(chunk.len(), 1 * 1024 * 1024); // The full 1 MB
|
||||
assert_eq!(chunk.len(), 1024 * 1024); // The full 1 MB
|
||||
|
||||
// Ensure no more chunks are available
|
||||
assert!(chunked_bytes.next_chunk().await.is_none());
|
||||
@@ -237,7 +237,7 @@ mod tests {
|
||||
|
||||
// Read the second chunk
|
||||
let chunk2 = chunked_bytes.next_chunk().await.unwrap().unwrap();
|
||||
assert_eq!(chunk2.len(), 1 * 1024 * 1024); // Partial chunk
|
||||
assert_eq!(chunk2.len(), 1024 * 1024); // Partial chunk
|
||||
|
||||
// Ensure no more chunks are available
|
||||
assert!(chunked_bytes.next_chunk().await.is_none());
|
||||
|
||||
@@ -52,6 +52,7 @@ pub trait StorageCloudService: Send + Sync {
|
||||
file_id: &str,
|
||||
) -> FlowyResult<String>;
|
||||
|
||||
/// Return workspace_id, parent_dir, file_id
|
||||
async fn parse_object_url_v1(&self, url: &str) -> Option<(String, String, String)>;
|
||||
|
||||
async fn create_upload(
|
||||
@@ -60,6 +61,7 @@ pub trait StorageCloudService: Send + Sync {
|
||||
parent_dir: &str,
|
||||
file_id: &str,
|
||||
content_type: &str,
|
||||
file_size: u64,
|
||||
) -> Result<CreateUploadResponse, FlowyError>;
|
||||
|
||||
async fn upload_part(
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::sync::broadcast;
|
||||
|
||||
#[async_trait]
|
||||
pub trait StorageService: Send + Sync {
|
||||
fn delete_object(&self, url: String, local_file_path: String) -> FlowyResult<()>;
|
||||
async fn delete_object(&self, url: String) -> FlowyResult<()>;
|
||||
|
||||
fn download_object(&self, url: String, local_file_path: String) -> FlowyResult<()>;
|
||||
|
||||
@@ -18,7 +18,6 @@ pub trait StorageService: Send + Sync {
|
||||
workspace_id: &str,
|
||||
parent_dir: &str,
|
||||
local_file_path: &str,
|
||||
upload_immediately: bool,
|
||||
) -> Result<(CreatedUpload, Option<FileProgressReceiver>), FlowyError>;
|
||||
|
||||
async fn start_upload(&self, record: &BoxAny) -> Result<(), FlowyError>;
|
||||
@@ -127,6 +126,7 @@ impl ProgressNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CreatedUpload {
|
||||
pub url: String,
|
||||
pub file_id: String,
|
||||
|
||||
@@ -2,9 +2,10 @@ use crate::entities::FileStatePB;
|
||||
use crate::file_cache::FileTempStorage;
|
||||
use crate::notification::{make_notification, StorageNotification};
|
||||
use crate::sqlite_sql::{
|
||||
batch_select_upload_file, delete_all_upload_parts, delete_upload_file, insert_upload_file,
|
||||
insert_upload_part, is_upload_completed, select_upload_file, select_upload_parts,
|
||||
update_upload_file_completed, update_upload_file_upload_id, UploadFilePartTable, UploadFileTable,
|
||||
batch_select_upload_file, delete_all_upload_parts, delete_upload_file,
|
||||
delete_upload_file_by_file_id, insert_upload_file, insert_upload_part, is_upload_completed,
|
||||
is_upload_exist, select_upload_file, select_upload_parts, update_upload_file_completed,
|
||||
update_upload_file_upload_id, UploadFilePartTable, UploadFileTable,
|
||||
};
|
||||
use crate::uploader::{FileUploader, FileUploaderRunner, Signal, UploadTask, UploadTaskQueue};
|
||||
use allo_isolate::Isolate;
|
||||
@@ -25,7 +26,6 @@ use lib_infra::util::timestamp;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::{debug, error, info, instrument, trace};
|
||||
@@ -91,8 +91,6 @@ impl StorageManager {
|
||||
let weak_uploader = Arc::downgrade(&uploader);
|
||||
let cloned_user_service = user_service.clone();
|
||||
tokio::spawn(async move {
|
||||
// Start uploading after 20 seconds
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
if let Some(uploader) = weak_uploader.upgrade() {
|
||||
if let Err(err) = prepare_upload_task(uploader, cloned_user_service).await {
|
||||
error!("prepare upload task failed: {}", err);
|
||||
@@ -172,8 +170,12 @@ impl StorageManager {
|
||||
Some(FileStatePB { file_id, is_finish })
|
||||
}
|
||||
|
||||
pub async fn initialize(&self, _workspace_id: &str) {
|
||||
pub async fn initialize(&self, workspace_id: &str) {
|
||||
self.enable_storage_write_access();
|
||||
|
||||
if let Err(err) = prepare_upload_task(self.uploader.clone(), self.user_service.clone()).await {
|
||||
error!("prepare {} upload task failed: {}", workspace_id, err);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_network_reachable(&self, reachable: bool) {
|
||||
@@ -205,33 +207,42 @@ impl StorageManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns None if the file with given file_id is not exist
|
||||
/// When delete a file, the progress notifier for given file_id will be deleted too
|
||||
pub async fn get_file_state(&self, file_id: &str) -> Option<FileUploadState> {
|
||||
self
|
||||
.progress_notifiers
|
||||
.get(file_id)
|
||||
.and_then(|notifier| notifier.value().current_value.clone())
|
||||
}
|
||||
|
||||
pub async fn get_all_tasks(&self) -> FlowyResult<Vec<UploadTask>> {
|
||||
let tasks = self.uploader.all_tasks().await;
|
||||
Ok(tasks)
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_upload_task(
|
||||
uploader: Arc<FileUploader>,
|
||||
user_service: Arc<dyn StorageUserService>,
|
||||
) -> FlowyResult<()> {
|
||||
let uid = user_service.user_id()?;
|
||||
let conn = user_service.sqlite_connection(uid)?;
|
||||
let upload_files = batch_select_upload_file(conn, 100, false)?;
|
||||
let tasks = upload_files
|
||||
.into_iter()
|
||||
.map(|upload_file| UploadTask::BackgroundTask {
|
||||
workspace_id: upload_file.workspace_id,
|
||||
file_id: upload_file.file_id,
|
||||
parent_dir: upload_file.parent_dir,
|
||||
created_at: upload_file.created_at,
|
||||
retry_count: 0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
info!("[File] prepare upload task: {}", tasks.len());
|
||||
uploader.queue_tasks(tasks).await;
|
||||
if let Ok(uid) = user_service.user_id() {
|
||||
let workspace_id = user_service.workspace_id()?;
|
||||
let conn = user_service.sqlite_connection(uid)?;
|
||||
let upload_files = batch_select_upload_file(conn, &workspace_id, 100, false)?;
|
||||
let tasks = upload_files
|
||||
.into_iter()
|
||||
.map(|upload_file| UploadTask::BackgroundTask {
|
||||
workspace_id: upload_file.workspace_id,
|
||||
file_id: upload_file.file_id,
|
||||
parent_dir: upload_file.parent_dir,
|
||||
created_at: upload_file.created_at,
|
||||
retry_count: 0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
info!("[File] prepare upload task: {}", tasks.len());
|
||||
uploader.queue_tasks(tasks).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -247,24 +258,52 @@ pub struct StorageServiceImpl {
|
||||
|
||||
#[async_trait]
|
||||
impl StorageService for StorageServiceImpl {
|
||||
fn delete_object(&self, url: String, local_file_path: String) -> FlowyResult<()> {
|
||||
let cloud_service = self.cloud_service.clone();
|
||||
tokio::spawn(async move {
|
||||
match tokio::fs::remove_file(&local_file_path).await {
|
||||
Ok(_) => {
|
||||
debug!("[File] deleted file from local disk: {}", local_file_path)
|
||||
async fn delete_object(&self, url: String) -> FlowyResult<()> {
|
||||
if let Some((workspace_id, parent_dir, file_id)) =
|
||||
self.cloud_service.parse_object_url_v1(&url).await
|
||||
{
|
||||
info!(
|
||||
"[File] delete object: workspace: {}, parent_dir: {}, file_id: {}",
|
||||
workspace_id, parent_dir, file_id
|
||||
);
|
||||
|
||||
self
|
||||
.task_queue
|
||||
.remove_task(&workspace_id, &parent_dir, &file_id)
|
||||
.await;
|
||||
|
||||
trace!("[File] delete progress notifier: {}", file_id);
|
||||
self.progress_notifiers.remove(&file_id);
|
||||
match delete_upload_file_by_file_id(
|
||||
self
|
||||
.user_service
|
||||
.sqlite_connection(self.user_service.user_id()?)?,
|
||||
&workspace_id,
|
||||
&parent_dir,
|
||||
&file_id,
|
||||
) {
|
||||
Ok(Some(file)) => {
|
||||
let file_path = file.local_file_path;
|
||||
match tokio::fs::remove_file(&file_path).await {
|
||||
Ok(_) => debug!("[File] deleted file from local disk: {}", file_path),
|
||||
Err(err) => {
|
||||
error!("[File] delete file at {} failed: {}", file_path, err);
|
||||
},
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
info!(
|
||||
"[File]: can not find file record for url: {} when delete",
|
||||
url
|
||||
);
|
||||
},
|
||||
Err(err) => {
|
||||
error!("[File] delete file at {} failed: {}", local_file_path, err);
|
||||
error!("[File] delete upload file failed: {}", err);
|
||||
},
|
||||
}
|
||||
if let Err(e) = cloud_service.delete_object(&url).await {
|
||||
// TODO: add WAL to log the delete operation.
|
||||
// keep a list of files to be deleted, and retry later
|
||||
error!("[File] delete file failed: {}", e);
|
||||
}
|
||||
debug!("[File] deleted file from cloud: {}", url);
|
||||
});
|
||||
}
|
||||
|
||||
let _ = self.cloud_service.delete_object(&url).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -301,7 +340,6 @@ impl StorageService for StorageServiceImpl {
|
||||
workspace_id: &str,
|
||||
parent_dir: &str,
|
||||
file_path: &str,
|
||||
upload_immediately: bool,
|
||||
) -> Result<(CreatedUpload, Option<FileProgressReceiver>), FlowyError> {
|
||||
if workspace_id.is_empty() {
|
||||
return Err(FlowyError::internal().with_context("workspace id is empty"));
|
||||
@@ -354,28 +392,18 @@ impl StorageService for StorageServiceImpl {
|
||||
match insert_upload_file(conn, &record) {
|
||||
Ok(_) => {
|
||||
// 3. generate url for given file
|
||||
if upload_immediately {
|
||||
self
|
||||
.task_queue
|
||||
.queue_task(UploadTask::ImmediateTask {
|
||||
local_file_path,
|
||||
record,
|
||||
retry_count: 3,
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
self
|
||||
.task_queue
|
||||
.queue_task(UploadTask::Task {
|
||||
local_file_path,
|
||||
record,
|
||||
retry_count: 0,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self
|
||||
.task_queue
|
||||
.queue_task(UploadTask::Task {
|
||||
local_file_path,
|
||||
record,
|
||||
retry_count: 3,
|
||||
})
|
||||
.await;
|
||||
|
||||
let notifier = ProgressNotifier::new(file_id.to_string());
|
||||
let receiver = notifier.subscribe();
|
||||
trace!("[File] create upload progress notifier: {}", file_id);
|
||||
self
|
||||
.progress_notifiers
|
||||
.insert(file_id.to_string(), notifier);
|
||||
@@ -397,14 +425,21 @@ impl StorageService for StorageServiceImpl {
|
||||
FlowyError::internal().with_context("failed to downcast record to UploadFileTable")
|
||||
})?;
|
||||
|
||||
start_upload(
|
||||
&self.cloud_service,
|
||||
&self.user_service,
|
||||
&self.temp_storage,
|
||||
file_record,
|
||||
self.global_notifier.clone(),
|
||||
)
|
||||
.await?;
|
||||
// If the file is already uploaded, skip the upload process
|
||||
if !is_upload_exist(
|
||||
self
|
||||
.user_service
|
||||
.sqlite_connection(self.user_service.user_id()?)?,
|
||||
&file_record.upload_id,
|
||||
)? {
|
||||
info!(
|
||||
"[File] skip upload, {} was deleted",
|
||||
file_record.local_file_path
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
start_upload(self, file_record).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -421,16 +456,12 @@ impl StorageService for StorageServiceImpl {
|
||||
.sqlite_connection(self.user_service.user_id()?)?;
|
||||
|
||||
if let Some(upload_file) = select_upload_file(&mut conn, workspace_id, parent_dir, file_id)? {
|
||||
resume_upload(
|
||||
&self.cloud_service,
|
||||
&self.user_service,
|
||||
&self.temp_storage,
|
||||
upload_file,
|
||||
self.global_notifier.clone(),
|
||||
)
|
||||
.await?;
|
||||
resume_upload(self, upload_file).await?;
|
||||
} else {
|
||||
error!("[File] resume upload failed: record not found");
|
||||
error!(
|
||||
"[File] resume upload failed: can not found {}:{}",
|
||||
parent_dir, file_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -474,7 +505,7 @@ async fn create_upload_record(
|
||||
|
||||
// Calculate the total number of chunks
|
||||
let num_chunk = calculate_offsets(file_size, MIN_CHUNK_SIZE).len();
|
||||
let content_type = mime_guess::from_path(&file_path)
|
||||
let content_type = mime_guess::from_path(file_path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
let file_id = FileId::from_path(&file_path.to_path_buf()).await?;
|
||||
@@ -496,12 +527,14 @@ async fn create_upload_record(
|
||||
|
||||
#[instrument(level = "debug", skip_all, err)]
|
||||
async fn start_upload(
|
||||
cloud_service: &Arc<dyn StorageCloudService>,
|
||||
user_service: &Arc<dyn StorageUserService>,
|
||||
temp_storage: &Arc<FileTempStorage>,
|
||||
storage_service: &StorageServiceImpl,
|
||||
upload_file: &UploadFileTable,
|
||||
global_notifier: GlobalNotifier,
|
||||
) -> FlowyResult<()> {
|
||||
let temp_storage = &storage_service.temp_storage;
|
||||
let user_service = &storage_service.user_service;
|
||||
let global_notifier = storage_service.global_notifier.clone();
|
||||
let cloud_service = &storage_service.cloud_service;
|
||||
|
||||
// 4. gather existing completed parts
|
||||
let mut conn = user_service.sqlite_connection(user_service.user_id()?)?;
|
||||
let mut completed_parts = select_upload_parts(&mut conn, &upload_file.upload_id)
|
||||
@@ -523,6 +556,10 @@ async fn start_upload(
|
||||
}
|
||||
}
|
||||
}
|
||||
let file_size = file_path
|
||||
.metadata()
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut chunked_bytes =
|
||||
ChunkedBytes::from_file(&upload_file.local_file_path, MIN_CHUNK_SIZE).await?;
|
||||
@@ -559,10 +596,20 @@ async fn start_upload(
|
||||
&upload_file.parent_dir,
|
||||
&upload_file.file_id,
|
||||
&upload_file.content_type,
|
||||
file_size,
|
||||
)
|
||||
.await;
|
||||
|
||||
let file_url = cloud_service
|
||||
.get_object_url_v1(
|
||||
&upload_file.workspace_id,
|
||||
&upload_file.parent_dir,
|
||||
&upload_file.file_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = create_upload_resp_result.as_ref() {
|
||||
handle_upload_error(user_service, &err, &upload_file.upload_id);
|
||||
handle_upload_error(storage_service, err, &file_url).await;
|
||||
}
|
||||
let create_upload_resp = create_upload_resp_result?;
|
||||
|
||||
@@ -602,13 +649,6 @@ async fn start_upload(
|
||||
chunk_bytes.len() / 1000,
|
||||
);
|
||||
|
||||
let file_url = cloud_service
|
||||
.get_object_url_v1(
|
||||
&upload_file.workspace_id,
|
||||
&upload_file.parent_dir,
|
||||
&upload_file.file_id,
|
||||
)
|
||||
.await?;
|
||||
// start uploading parts
|
||||
match upload_part(
|
||||
cloud_service,
|
||||
@@ -633,8 +673,11 @@ async fn start_upload(
|
||||
if progress_value >= 0.9 {
|
||||
progress_value = 0.9;
|
||||
}
|
||||
let progress =
|
||||
FileProgress::new_progress(file_url, upload_file.file_id.clone(), progress_value);
|
||||
let progress = FileProgress::new_progress(
|
||||
file_url.clone(),
|
||||
upload_file.file_id.clone(),
|
||||
progress_value,
|
||||
);
|
||||
trace!("[File] upload progress: {}", progress);
|
||||
|
||||
if let Err(err) = global_notifier.send(progress) {
|
||||
@@ -652,9 +695,9 @@ async fn start_upload(
|
||||
"[File] {} failed to upload part: {}",
|
||||
upload_file.file_id, err
|
||||
);
|
||||
handle_upload_error(user_service, &err, &upload_file.upload_id);
|
||||
handle_upload_error(storage_service, &err, &file_url).await;
|
||||
if let Err(err) = global_notifier.send(FileProgress::new_error(
|
||||
file_url,
|
||||
file_url.clone(),
|
||||
upload_file.file_id.clone(),
|
||||
err.msg.clone(),
|
||||
)) {
|
||||
@@ -686,17 +729,17 @@ async fn start_upload(
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = complete_upload_result {
|
||||
handle_upload_error(user_service, &err, &upload_file.upload_id);
|
||||
handle_upload_error(storage_service, &err, &file_url).await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_upload_error(
|
||||
user_service: &Arc<dyn StorageUserService>,
|
||||
async fn handle_upload_error(
|
||||
storage_service: &StorageServiceImpl,
|
||||
err: &FlowyError,
|
||||
upload_id: &str,
|
||||
file_url: &str,
|
||||
) {
|
||||
if err.is_file_limit_exceeded() {
|
||||
make_notification(StorageNotification::FileStorageLimitExceeded)
|
||||
@@ -705,13 +748,9 @@ fn handle_upload_error(
|
||||
}
|
||||
|
||||
if err.is_single_file_limit_exceeded() {
|
||||
info!("[File] file exceed limit:{}", upload_id);
|
||||
if let Ok(user_id) = user_service.user_id() {
|
||||
if let Ok(db_conn) = user_service.sqlite_connection(user_id) {
|
||||
if let Err(err) = delete_upload_file(db_conn, upload_id) {
|
||||
error!("[File] delete upload file:{} error:{}", upload_id, err);
|
||||
}
|
||||
}
|
||||
info!("[File] file exceed limit:{}", file_url);
|
||||
if let Err(err) = storage_service.delete_object(file_url.to_string()).await {
|
||||
error!("[File] delete upload file:{} error:{}", file_url, err);
|
||||
}
|
||||
|
||||
make_notification(StorageNotification::SingleFileLimitExceeded)
|
||||
@@ -722,11 +761,8 @@ fn handle_upload_error(
|
||||
|
||||
#[instrument(level = "debug", skip_all, err)]
|
||||
async fn resume_upload(
|
||||
cloud_service: &Arc<dyn StorageCloudService>,
|
||||
user_service: &Arc<dyn StorageUserService>,
|
||||
temp_storage: &Arc<FileTempStorage>,
|
||||
storage_service: &StorageServiceImpl,
|
||||
upload_file: UploadFileTable,
|
||||
global_notifier: GlobalNotifier,
|
||||
) -> FlowyResult<()> {
|
||||
trace!(
|
||||
"[File] resume upload for workspace: {}, parent_dir: {}, file_id: {}, local_file_path:{}",
|
||||
@@ -736,14 +772,7 @@ async fn resume_upload(
|
||||
upload_file.local_file_path
|
||||
);
|
||||
|
||||
start_upload(
|
||||
cloud_service,
|
||||
user_service,
|
||||
temp_storage,
|
||||
&upload_file,
|
||||
global_notifier,
|
||||
)
|
||||
.await?;
|
||||
start_upload(storage_service, &upload_file).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -848,8 +877,8 @@ async fn complete_upload(
|
||||
error!("[File] send global notifier failed: {}", send_err);
|
||||
}
|
||||
|
||||
let conn = user_service.sqlite_connection(user_service.user_id()?)?;
|
||||
if let Err(err) = delete_all_upload_parts(conn, &upload_file.upload_id) {
|
||||
let mut conn = user_service.sqlite_connection(user_service.user_id()?)?;
|
||||
if let Err(err) = delete_all_upload_parts(&mut conn, &upload_file.upload_id) {
|
||||
error!("[File] delete all upload parts failed: {}", err);
|
||||
}
|
||||
return Err(err);
|
||||
|
||||
@@ -6,7 +6,7 @@ use flowy_sqlite::{
|
||||
diesel, AsChangeset, BoolExpressionMethods, DBConnection, ExpressionMethods, Identifiable,
|
||||
Insertable, OptionalExtension, QueryDsl, Queryable, RunQueryDsl, SqliteConnection,
|
||||
};
|
||||
use tracing::warn;
|
||||
use tracing::{trace, warn};
|
||||
|
||||
#[derive(Queryable, Insertable, AsChangeset, Identifiable, Debug, Clone)]
|
||||
#[diesel(table_name = upload_file_table)]
|
||||
@@ -55,6 +55,7 @@ pub fn insert_upload_file(
|
||||
mut conn: DBConnection,
|
||||
upload_file: &UploadFileTable,
|
||||
) -> FlowyResult<()> {
|
||||
trace!("[File]: insert upload file: {:?}", upload_file);
|
||||
match diesel::insert_into(upload_file_table::table)
|
||||
.values(upload_file)
|
||||
.execute(&mut *conn)
|
||||
@@ -97,6 +98,14 @@ pub fn update_upload_file_completed(mut conn: DBConnection, upload_id: &str) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_upload_exist(mut conn: DBConnection, upload_id: &str) -> FlowyResult<bool> {
|
||||
let result = upload_file_table::dsl::upload_file_table
|
||||
.filter(upload_file_table::upload_id.eq(upload_id))
|
||||
.first::<UploadFileTable>(&mut *conn)
|
||||
.optional()?;
|
||||
Ok(result.is_some())
|
||||
}
|
||||
|
||||
pub fn is_upload_completed(
|
||||
conn: &mut SqliteConnection,
|
||||
workspace_id: &str,
|
||||
@@ -116,28 +125,65 @@ pub fn is_upload_completed(
|
||||
Ok(result.is_some())
|
||||
}
|
||||
|
||||
/// Delete upload file and its parts
|
||||
pub fn delete_upload_file(mut conn: DBConnection, upload_id: &str) -> FlowyResult<()> {
|
||||
conn.immediate_transaction(|conn| {
|
||||
diesel::delete(
|
||||
upload_file_table::dsl::upload_file_table.filter(upload_file_table::upload_id.eq(upload_id)),
|
||||
)
|
||||
.execute(&mut *conn)?;
|
||||
|
||||
if let Err(err) = diesel::delete(
|
||||
upload_file_part::dsl::upload_file_part.filter(upload_file_part::upload_id.eq(upload_id)),
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
{
|
||||
warn!("Failed to delete upload parts: {:?}", err)
|
||||
}
|
||||
|
||||
_delete_upload_file(upload_id, conn)?;
|
||||
Ok::<_, FlowyError>(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_upload_parts(mut conn: DBConnection, upload_id: &str) -> FlowyResult<()> {
|
||||
pub fn delete_upload_file_by_file_id(
|
||||
mut conn: DBConnection,
|
||||
workspace_id: &str,
|
||||
parent_dir: &str,
|
||||
file_id: &str,
|
||||
) -> FlowyResult<Option<UploadFileTable>> {
|
||||
let file = conn.immediate_transaction(|conn| {
|
||||
let file = select_upload_file(&mut *conn, workspace_id, parent_dir, file_id)?;
|
||||
|
||||
if let Some(file) = &file {
|
||||
// when the upload is not started, the upload id will be empty
|
||||
if file.upload_id.is_empty() {
|
||||
diesel::delete(
|
||||
upload_file_table::dsl::upload_file_table.filter(
|
||||
upload_file_table::workspace_id
|
||||
.eq(workspace_id)
|
||||
.and(upload_file_table::parent_dir.eq(parent_dir))
|
||||
.and(upload_file_table::file_id.eq(file_id)),
|
||||
),
|
||||
)
|
||||
.execute(&mut *conn)?;
|
||||
} else {
|
||||
_delete_upload_file(&file.upload_id, &mut *conn)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, FlowyError>(file)
|
||||
})?;
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn _delete_upload_file(upload_id: &str, conn: &mut SqliteConnection) -> Result<(), FlowyError> {
|
||||
if upload_id.is_empty() {
|
||||
warn!("[File]: upload_id is empty when delete upload file");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
trace!("[File]: delete upload file: {}", upload_id);
|
||||
diesel::delete(
|
||||
upload_file_table::dsl::upload_file_table.filter(upload_file_table::upload_id.eq(upload_id)),
|
||||
)
|
||||
.execute(&mut *conn)?;
|
||||
if let Err(err) = delete_all_upload_parts(&mut *conn, upload_id) {
|
||||
warn!("Failed to delete upload parts: {:?}", err)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_upload_parts(conn: &mut SqliteConnection, upload_id: &str) -> FlowyResult<()> {
|
||||
diesel::delete(
|
||||
upload_file_part::dsl::upload_file_part.filter(upload_file_part::upload_id.eq(upload_id)),
|
||||
)
|
||||
@@ -179,10 +225,12 @@ pub fn select_upload_parts(
|
||||
|
||||
pub fn batch_select_upload_file(
|
||||
mut conn: DBConnection,
|
||||
workspace_id: &str,
|
||||
limit: i32,
|
||||
is_finish: bool,
|
||||
) -> FlowyResult<Vec<UploadFileTable>> {
|
||||
let results = upload_file_table::dsl::upload_file_table
|
||||
.filter(upload_file_table::workspace_id.eq(workspace_id))
|
||||
.filter(upload_file_table::is_finish.eq(is_finish))
|
||||
.order(upload_file_table::created_at.desc())
|
||||
.limit(limit.into())
|
||||
|
||||
@@ -35,6 +35,24 @@ impl UploadTaskQueue {
|
||||
self.tasks.write().await.push(task);
|
||||
let _ = self.notifier.send_replace(Signal::Proceed);
|
||||
}
|
||||
|
||||
pub async fn remove_task(&self, workspace_id: &str, parent_dir: &str, file_id: &str) {
|
||||
let mut tasks = self.tasks.write().await;
|
||||
|
||||
tasks.retain(|task| match task {
|
||||
UploadTask::BackgroundTask {
|
||||
workspace_id: w_id,
|
||||
parent_dir: p_dir,
|
||||
file_id: f_id,
|
||||
..
|
||||
} => !(w_id == workspace_id && p_dir == parent_dir && f_id == file_id),
|
||||
UploadTask::Task { record, .. } => {
|
||||
!(record.workspace_id == workspace_id
|
||||
&& record.parent_dir == parent_dir
|
||||
&& record.file_id == file_id)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileUploader {
|
||||
@@ -43,7 +61,7 @@ pub struct FileUploader {
|
||||
max_uploads: u8,
|
||||
current_uploads: AtomicU8,
|
||||
pause_sync: AtomicBool,
|
||||
has_exceeded_limit: Arc<AtomicBool>,
|
||||
disable_upload: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for FileUploader {
|
||||
@@ -64,10 +82,15 @@ impl FileUploader {
|
||||
max_uploads: 3,
|
||||
current_uploads: Default::default(),
|
||||
pause_sync: Default::default(),
|
||||
has_exceeded_limit: is_exceed_limit,
|
||||
disable_upload: is_exceed_limit,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn all_tasks(&self) -> Vec<UploadTask> {
|
||||
let tasks = self.queue.tasks.read().await;
|
||||
tasks.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn queue_tasks(&self, tasks: Vec<UploadTask>) {
|
||||
let mut queue_lock = self.queue.tasks.write().await;
|
||||
for task in tasks {
|
||||
@@ -84,14 +107,14 @@ impl FileUploader {
|
||||
|
||||
pub fn disable_storage_write(&self) {
|
||||
self
|
||||
.has_exceeded_limit
|
||||
.disable_upload
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
self.pause();
|
||||
}
|
||||
|
||||
pub fn enable_storage_write(&self) {
|
||||
self
|
||||
.has_exceeded_limit
|
||||
.disable_upload
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
self.resume();
|
||||
}
|
||||
@@ -100,6 +123,7 @@ impl FileUploader {
|
||||
self
|
||||
.pause_sync
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
trace!("[File] Uploader resumed");
|
||||
let _ = self.queue.notifier.send(Signal::ProceedAfterSecs(3));
|
||||
}
|
||||
|
||||
@@ -130,7 +154,7 @@ impl FileUploader {
|
||||
}
|
||||
|
||||
if self
|
||||
.has_exceeded_limit
|
||||
.disable_upload
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
// If the storage limitation is enabled, do not proceed.
|
||||
@@ -152,12 +176,7 @@ impl FileUploader {
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
match task {
|
||||
UploadTask::ImmediateTask {
|
||||
local_file_path,
|
||||
record,
|
||||
mut retry_count,
|
||||
}
|
||||
| UploadTask::Task {
|
||||
UploadTask::Task {
|
||||
local_file_path,
|
||||
record,
|
||||
mut retry_count,
|
||||
@@ -234,6 +253,9 @@ pub struct FileUploaderRunner;
|
||||
|
||||
impl FileUploaderRunner {
|
||||
pub async fn run(weak_uploader: Weak<FileUploader>, mut notifier: watch::Receiver<Signal>) {
|
||||
// Start uploading after 20 seconds
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
|
||||
loop {
|
||||
// stops the runner if the notifier was closed.
|
||||
if notifier.changed().await.is_err() {
|
||||
@@ -272,12 +294,8 @@ impl FileUploaderRunner {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum UploadTask {
|
||||
ImmediateTask {
|
||||
local_file_path: String,
|
||||
record: UploadFileTable,
|
||||
retry_count: u8,
|
||||
},
|
||||
Task {
|
||||
local_file_path: String,
|
||||
record: UploadFileTable,
|
||||
@@ -295,7 +313,6 @@ pub enum UploadTask {
|
||||
impl UploadTask {
|
||||
pub fn retry_count(&self) -> u8 {
|
||||
match self {
|
||||
UploadTask::ImmediateTask { retry_count, .. } => *retry_count,
|
||||
UploadTask::Task { retry_count, .. } => *retry_count,
|
||||
UploadTask::BackgroundTask { retry_count, .. } => *retry_count,
|
||||
}
|
||||
@@ -307,7 +324,6 @@ impl Display for UploadTask {
|
||||
match self {
|
||||
UploadTask::Task { record, .. } => write!(f, "Task: {}", record.file_id),
|
||||
UploadTask::BackgroundTask { file_id, .. } => write!(f, "BackgroundTask: {}", file_id),
|
||||
UploadTask::ImmediateTask { record, .. } => write!(f, "Immediate Task: {}", record.file_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,9 +333,6 @@ impl Eq for UploadTask {}
|
||||
impl PartialEq for UploadTask {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::ImmediateTask { record: lhs, .. }, Self::ImmediateTask { record: rhs, .. }) => {
|
||||
lhs.local_file_path == rhs.local_file_path
|
||||
},
|
||||
(Self::Task { record: lhs, .. }, Self::Task { record: rhs, .. }) => {
|
||||
lhs.local_file_path == rhs.local_file_path
|
||||
},
|
||||
@@ -349,11 +362,6 @@ impl PartialOrd for UploadTask {
|
||||
impl Ord for UploadTask {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (self, other) {
|
||||
(Self::ImmediateTask { record: lhs, .. }, Self::ImmediateTask { record: rhs, .. }) => {
|
||||
lhs.created_at.cmp(&rhs.created_at)
|
||||
},
|
||||
(_, Self::ImmediateTask { .. }) => Ordering::Less,
|
||||
(Self::ImmediateTask { .. }, _) => Ordering::Greater,
|
||||
(Self::Task { record: lhs, .. }, Self::Task { record: rhs, .. }) => {
|
||||
lhs.created_at.cmp(&rhs.created_at)
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ async fn test_insert_new_upload() {
|
||||
|
||||
// select
|
||||
let conn = db.get_connection().unwrap();
|
||||
let records = batch_select_upload_file(conn, 100, false).unwrap();
|
||||
let records = batch_select_upload_file(conn, &workspace_id, 100, false).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 5);
|
||||
// compare the upload id order is the same as upload_ids
|
||||
@@ -55,7 +55,7 @@ async fn test_insert_new_upload() {
|
||||
}
|
||||
|
||||
let conn = db.get_connection().unwrap();
|
||||
let records = batch_select_upload_file(conn, 100, false).unwrap();
|
||||
let records = batch_select_upload_file(conn, &workspace_id, 100, false).unwrap();
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user