Compare commits

..
Author SHA1 Message Date
Mathias Mogensen 111989c96c fix: popover onOpen not triggered by manual show 2024-07-31 02:55:22 +02:00
Mathias Mogensen fd3f850d63 feat: add uploaded at & improvements 2024-07-31 02:40:25 +02:00
Mathias Mogensen 61d30fad6c chore: merge branch 'upstream/main' into feat/upload-file-in-document 2024-07-31 01:56:50 +02:00
Mathias Mogensen 3c66fe3164 feat: upload file in document 2024-07-31 01:56:31 +02:00
Lucas.Xu 81532d014e chore: use light haptic feedback (#5838)
* chore: use light haptic feedback

* chore: optimize tabbar & side action animation
2024-07-30 19:53:11 +08:00
Nathan.fooo 735a09b333 chore: remove local ai (#5837)
* chore: remove local ai

* chore: flutter analyze
2024-07-30 19:44:08 +08:00
nathan d52a04a9e3 chore: update chat input hint 2024-07-30 17:40:58 +08:00
Lucas.Xu a14ced458c chore: bump version 0.6.6 (#5833) 2024-07-30 17:37:28 +08:00
Nathan.fooo 5250a151c8 feat: Chat app download (#5835)
* chore: downlaod chat app

* chore: download app

* chore: disable local ai in billing

* chore: remove watcher

* chore: flutter analyzer
2024-07-30 17:32:30 +08:00
Mathias Mogensen d5a5a64fcf fix: photo gallery improvements + launch review fixes (#5830)
* fix: photo gallery improvements

* fix: improve when to show billing/plan pages

* feat: add grid photo gallery layout

* fix: close inline actions menu
2024-07-30 15:28:40 +08:00
Lucas.Xu 175c90e379 chore: update collab 93ec9a38722d5ed0628e6d03a161e2de172967f3 (#5832) 2024-07-30 15:28:21 +08:00
77 changed files with 3221 additions and 882 deletions

No files matched your search

+8
View File
@@ -1,4 +1,12 @@
# Release Notes
## Version 0.6.6 - 30/07/2024
### New Features
- Upgrade your workspace to a premium plan to unlock more features and storage.
- Image galleries and drag-and-drop image support in documents.
### Bug Fixes
- Fix minor UI issues on Desktop and Mobile.
## Version 0.6.5 - 24/07/2024
### New Features
- Publish a Database to the Web
@@ -8,9 +8,10 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
@@ -36,7 +36,7 @@ class _AnimatedGestureDetectorState extends State<AnimatedGestureDetector> {
onTapUp: (details) {
setState(() => scale = 1.0);
HapticFeedbackType.vibrate.call();
HapticFeedbackType.light.call();
widget.onTapUp();
},
@@ -167,15 +167,16 @@ class _Pages extends StatelessWidget {
onSelected: context.pushView,
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
final actions = [
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
];
return buildEndActionPane(
context,
[
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
],
actions,
spaceType: spaceType,
spaceRatio: 4,
spaceRatio: actions.length == 1 ? 3 : 4,
);
},
),
@@ -23,10 +23,12 @@ class MobileSpaceTabBar extends StatelessWidget {
final labelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 16.0,
height: 20 / 16,
);
final unselectedLabelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w400,
fontSize: 15.0,
height: 20 / 15,
);
return Container(
@@ -52,7 +52,7 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
);
},
updateState: (aiState) {
if (aiState.enabled) {
if (aiState.pluginState.state == RunningStatePB.Running) {
emit(const ChatInputState(aiType: _LocalAI()));
} else {
emit(const ChatInputState(aiType: _AppFlowyAI()));
@@ -4,14 +4,17 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
@@ -19,15 +22,18 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
const _excludeFromDropTarget = [
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
FileBlockKeys.type,
];
class DocumentPage extends StatefulWidget {
@@ -79,46 +85,65 @@ class _DocumentPageState extends State<DocumentPage>
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
return ChangeNotifierProvider(
// Due to how DropTarget works, there is no way to differentiate if an overlay is
// blocking the target visibly, so when we have an overlay with a drop target,
// we should disable the drop target for the Editor, until it is closed.
//
// See FileBlockComponent for sample use.
//
// Relates to:
// - https://github.com/MixinNetwork/flutter-plugins/issues/2
// - https://github.com/MixinNetwork/flutter-plugins/issues/331
//
create: (_) => EditorDropManagerState(),
child: MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return FlowyErrorPage.message(
error.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
final editorState = state.editorState;
this.editorState = editorState;
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return FlowyErrorPage.message(
error.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
);
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: _onNotificationAction,
child: Consumer<EditorDropManagerState>(
builder: (context, dropState, _) =>
_buildEditorPage(context, state, dropState),
),
);
}
if (state.forceClose) {
widget.onDeleted();
return const SizedBox.shrink();
}
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: _onNotificationAction,
child: _buildEditorPage(context, state),
);
},
},
),
),
);
}
Widget _buildEditorPage(BuildContext context, DocumentState state) {
Widget _buildEditorPage(
BuildContext context,
DocumentState state,
EditorDropManagerState dropState,
) {
final Widget child;
if (PlatformExtension.isMobile) {
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) {
@@ -136,6 +161,7 @@ class _DocumentPageState extends State<DocumentPage>
);
} else {
child = DropTarget(
enable: dropState.isDropEnabled,
onDragExited: (_) =>
state.editorState!.selectionService.removeDropTarget(),
onDragUpdated: (details) {
@@ -172,9 +198,27 @@ class _DocumentPageState extends State<DocumentPage>
}
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
final List<XFile> imageFiles = [];
final List<XFile> otherfiles = [];
for (final file in details.files) {
if (file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(file.name)) {
imageFiles.add(file);
} else {
otherfiles.add(file);
}
}
await editorState!.dropImages(
data.dropTarget!,
details.files,
imageFiles,
widget.view.id,
isLocalMode,
);
await editorState!.dropFiles(
data.dropTarget!,
otherfiles,
widget.view.id,
isLocalMode,
);
@@ -249,6 +249,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
imageUrl: imageUrl,
),
),
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
),
@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';
class EditorDropManagerState extends ChangeNotifier {
final Set<String> _draggedTypes = {};
void add(String type) {
_draggedTypes.add(type);
notifyListeners();
}
void remove(String type) {
_draggedTypes.remove(type);
notifyListeners();
}
bool get isDropEnabled => _draggedTypes.isEmpty;
}
@@ -421,6 +421,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
autoGeneratorMenuItem,
dateMenuItem,
multiImageMenuItem,
fileMenuItem,
];
}
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
@@ -8,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
@@ -53,7 +54,6 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
// try to paste the content in order, if any of them is failed, then try the next one
if (inAppJson != null && inAppJson.isNotEmpty) {
debugPrint('paste in app json: $inAppJson');
await editorState.deleteSelectionIfNeeded();
if (await editorState.pasteInAppJson(inAppJson)) {
return;
@@ -0,0 +1,40 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
extension PasteFromFile on EditorState {
Future<void> dropFiles(
Node dropNode,
List<XFile> files,
String documentId,
bool isLocalMode,
) async {
for (final file in files) {
String? path;
FileUrlType? type;
if (isLocalMode) {
path = await saveFileToLocalStorage(file.path);
type = FileUrlType.local;
} else {
(path, _) = await saveFileToCloudStorage(file.path, documentId);
type = FileUrlType.cloud;
}
if (path == null) {
continue;
}
final t = transaction
..insertNode(
dropNode.path,
fileNode(
url: path,
type: type,
name: file.name,
),
);
await apply(t);
}
}
}
@@ -0,0 +1,2 @@
export './file_block_component.dart';
export './file_selection_menu.dart';
@@ -0,0 +1,487 @@
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'file_block_menu.dart';
import 'file_upload_menu.dart';
class FileBlockKeys {
const FileBlockKeys._();
static const String type = 'file';
/// The src of the file.
///
/// The value is a String.
/// It can be a url for a network file or a local file path.
///
static const String url = 'url';
/// The name of the file.
///
/// The value is a String.
///
static const String name = 'name';
/// The type of the url.
///
/// The value is a FileUrlType enum.
///
static const String urlType = 'url_type';
/// The date of the file upload.
///
/// The value is a timestamp in ms.
///
static const String uploadedAt = 'uploaded_at';
/// The user who uploaded the file.
///
/// The value is a String, in form of user id.
///
static const String uploadedBy = 'uploaded_by';
}
enum FileUrlType {
local,
network,
cloud;
static FileUrlType fromIntValue(int value) {
switch (value) {
case 0:
return FileUrlType.local;
case 1:
return FileUrlType.network;
case 2:
return FileUrlType.cloud;
default:
throw UnimplementedError();
}
}
int toIntValue() {
switch (this) {
case FileUrlType.local:
return 0;
case FileUrlType.network:
return 1;
case FileUrlType.cloud:
return 2;
}
}
}
Node fileNode({
required String url,
FileUrlType type = FileUrlType.local,
String? name,
}) {
return Node(
type: FileBlockKeys.type,
attributes: {
FileBlockKeys.url: url,
FileBlockKeys.urlType: type.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
},
);
}
class FileBlockComponentBuilder extends BlockComponentBuilder {
FileBlockComponentBuilder({super.configuration});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return FileBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
);
}
@override
bool validate(Node node) => node.delta == null && node.children.isEmpty;
}
class FileBlockComponent extends BlockComponentStatefulWidget {
const FileBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<FileBlockComponent> createState() => FileBlockComponentState();
}
class FileBlockComponentState extends State<FileBlockComponent>
with SelectableMixin, BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
late EditorDropManagerState dropManagerState =
context.read<EditorDropManagerState>();
final fileKey = GlobalKey();
final showActionsNotifier = ValueNotifier<bool>(false);
final controller = PopoverController();
final menuController = PopoverController();
late final editorState = Provider.of<EditorState>(context, listen: false);
bool alwaysShowMenu = false;
bool isDragging = false;
bool isHovering = false;
@override
void didChangeDependencies() {
dropManagerState = context.read<EditorDropManagerState>();
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final url = node.attributes[FileBlockKeys.url];
Widget child = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) {
setState(() => isHovering = true);
showActionsNotifier.value = true;
},
onExit: (_) {
setState(() => isHovering = false);
if (!alwaysShowMenu) {
showActionsNotifier.value = false;
}
},
opaque: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: url != null && url.isNotEmpty
? () => afLaunchUrlString(url)
: () {
controller.show();
dropManagerState.add(FileBlockKeys.type);
},
child: DecoratedBox(
decoration: BoxDecoration(
color: isHovering
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: isDragging
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: SizedBox(
height: 52,
child: Row(
children: [
const HSpace(10),
const Icon(Icons.upload_file_outlined),
const HSpace(10),
..._buildTrailing(context),
],
),
),
),
),
);
if (PlatformExtension.isDesktopOrWeb) {
if (url == null || url.isEmpty) {
child = DropTarget(
onDragEntered: (_) {
if (dropManagerState.isDropEnabled) {
setState(() => isDragging = true);
}
},
onDragExited: (_) {
if (dropManagerState.isDropEnabled) {
setState(() => isDragging = false);
}
},
onDragDone: (details) {
if (dropManagerState.isDropEnabled) {
insertFileFromLocal(details.files.first.path);
}
},
child: AppFlowyPopover(
controller: controller,
direction: PopoverDirection.bottomWithCenterAligned,
constraints: const BoxConstraints(
maxWidth: 480,
maxHeight: 340,
minHeight: 80,
),
clickHandler: PopoverClickHandler.gestureDetector,
onOpen: () => dropManagerState.add(FileBlockKeys.type),
onClose: () => dropManagerState.remove(FileBlockKeys.type),
popupBuilder: (_) => FileUploadMenu(
onInsertLocalFile: insertFileFromLocal,
onInsertNetworkFile: insertNetworkFile,
),
child: child,
),
);
}
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: Padding(key: fileKey, padding: padding, child: child),
);
} else {
child = Padding(key: fileKey, padding: padding, child: child);
}
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: child,
);
}
if (!PlatformExtension.isDesktopOrWeb) {
// show a fixed menu on mobile
child = MobileBlockActionButtons(
showThreeDots: false,
node: node,
editorState: editorState,
child: child,
);
}
return child;
}
List<Widget> _buildTrailing(BuildContext context) {
if (node.attributes[FileBlockKeys.url]?.isNotEmpty == true) {
final name = node.attributes[FileBlockKeys.name] as String;
return [
Expanded(
child: FlowyText(
name,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(8),
if (PlatformExtension.isDesktopOrWeb) ...[
ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (_, value, __) {
final url = node.attributes[FileBlockKeys.url];
if (!value || url == null || url.isEmpty) {
return const SizedBox.shrink();
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: menuController.show,
child: AppFlowyPopover(
controller: menuController,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithRightAligned,
onClose: () {
setState(
() {
alwaysShowMenu = false;
showActionsNotifier.value = false;
},
);
},
popupBuilder: (_) {
alwaysShowMenu = true;
return FileBlockMenu(
controller: menuController,
node: node,
editorState: editorState,
);
},
child: const FlowyHover(
resetHoverOnRebuild: false,
child: Padding(
padding: EdgeInsets.all(4),
child: FlowySvg(
FlowySvgs.three_dots_s,
),
),
),
),
);
},
),
const HSpace(8),
],
];
} else {
return [
Flexible(
child: FlowyText(
isDragging
? LocaleKeys.document_plugins_file_placeholderDragging.tr()
: LocaleKeys.document_plugins_file_placeholderText.tr(),
overflow: TextOverflow.ellipsis,
),
),
];
}
}
Future<void> insertFileFromLocal(String path) async {
final documentBloc = context.read<DocumentBloc>();
final isLocalMode = documentBloc.isLocalMode;
final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud;
String? url;
String? errorMsg;
if (isLocalMode) {
url = await saveFileToLocalStorage(path);
} else {
final result =
await saveFileToCloudStorage(path, documentBloc.documentId);
url = result.$1;
errorMsg = result.$2;
}
if (errorMsg != null && mounted) {
return showSnackBarMessage(context, errorMsg);
}
// Remove the file block from the drop state manager
dropManagerState.remove(FileBlockKeys.type);
final name = Uri.tryParse(path)?.pathSegments.last ?? url;
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
FileBlockKeys.url: url,
FileBlockKeys.urlType: urlType.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
});
await editorState.apply(transaction);
}
Future<void> insertNetworkFile(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
context,
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
);
}
// Remove the file block from the drop state manager
dropManagerState.remove(FileBlockKeys.type);
final name = Uri.tryParse(url)?.pathSegments.last ?? url;
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
FileBlockKeys.url: url,
FileBlockKeys.urlType: FileUrlType.network.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
});
await editorState.apply(transaction);
}
@override
Position start() => Position(path: widget.node.path);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.cover;
@override
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final renderBox = fileKey.currentContext?.findRenderObject();
if (renderBox is RenderBox) {
return Offset.zero & renderBox.size;
}
return Rect.zero;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final rects = getRectsInSelection(Selection.collapsed(position));
return rects.firstOrNull;
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final renderBox = fileKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && renderBox is RenderBox) {
return [
renderBox.localToGlobal(Offset.zero, ancestor: parentBox) &
renderBox.size,
];
}
return [Offset.zero & _renderBox!.size];
}
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
Offset localToGlobal(
Offset offset, {
bool shiftWithBaseOffset = false,
}) =>
_renderBox!.localToGlobal(offset);
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FileBlockMenu extends StatefulWidget {
const FileBlockMenu({
super.key,
required this.controller,
required this.node,
required this.editorState,
});
final PopoverController controller;
final Node node;
final EditorState editorState;
@override
State<FileBlockMenu> createState() => _FileBlockMenuState();
}
class _FileBlockMenuState extends State<FileBlockMenu> {
final nameController = TextEditingController();
final errorMessage = ValueNotifier<String?>(null);
BuildContext? renameContext;
@override
void initState() {
super.initState();
nameController.text = widget.node.attributes[FileBlockKeys.name] ?? '';
nameController.selection = TextSelection(
baseOffset: 0,
extentOffset: nameController.text.length,
);
}
@override
void dispose() {
errorMessage.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final uploadedAtInMS =
widget.node.attributes[FileBlockKeys.uploadedAt] as int?;
final uploadedAt = uploadedAtInMS != null
? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS)
: null;
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
final urlType =
FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
HoverButton(
itemHeight: 20,
leftIcon: const FlowySvg(FlowySvgs.edit_s),
name: LocaleKeys.document_plugins_file_renameFile_title.tr(),
onTap: () {
widget.controller.close();
showCustomConfirmDialog(
context: context,
title: LocaleKeys.document_plugins_file_renameFile_title.tr(),
description:
LocaleKeys.document_plugins_file_renameFile_description.tr(),
closeOnConfirm: false,
builder: (context) {
renameContext = context;
return _RenameTextField(
nameController: nameController,
errorMessage: errorMessage,
onSubmitted: _saveName,
);
},
confirmLabel: LocaleKeys.button_save.tr(),
onConfirm: _saveName,
);
},
),
const VSpace(4),
HoverButton(
itemHeight: 20,
leftIcon: const FlowySvg(FlowySvgs.delete_s),
name: LocaleKeys.button_delete.tr(),
onTap: () {
final transaction = widget.editorState.transaction
..deleteNode(widget.node);
widget.editorState.apply(transaction);
widget.controller.close();
},
),
if (uploadedAt != null) ...[
const Divider(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FlowyText.regular(
[FileUrlType.cloud, FileUrlType.local].contains(urlType)
? LocaleKeys.document_plugins_file_uploadedAt.tr(
args: [dateFormat.formatDate(uploadedAt, false)],
)
: LocaleKeys.document_plugins_file_linkedAt.tr(
args: [dateFormat.formatDate(uploadedAt, false)],
),
fontSize: 14,
maxLines: 2,
color: Theme.of(context).hintColor,
),
),
const VSpace(2),
],
],
);
}
void _saveName() {
if (nameController.text.isEmpty) {
errorMessage.value =
LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr();
return;
}
final attributes = widget.node.attributes;
attributes[FileBlockKeys.name] = nameController.text;
final transaction = widget.editorState.transaction
..updateNode(widget.node, attributes);
widget.editorState.apply(transaction);
if (renameContext != null) {
Navigator.of(renameContext!).pop();
}
}
}
class _RenameTextField extends StatefulWidget {
const _RenameTextField({
required this.nameController,
required this.errorMessage,
required this.onSubmitted,
});
final TextEditingController nameController;
final ValueNotifier<String?> errorMessage;
final VoidCallback onSubmitted;
@override
State<_RenameTextField> createState() => _RenameTextFieldState();
}
class _RenameTextFieldState extends State<_RenameTextField> {
@override
void initState() {
super.initState();
widget.errorMessage.addListener(_setState);
}
@override
void dispose() {
widget.errorMessage.removeListener(_setState);
widget.nameController.dispose();
super.dispose();
}
void _setState() {
if (mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyTextField(
controller: widget.nameController,
onSubmitted: (_) => widget.onSubmitted(),
),
if (widget.errorMessage.value != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: FlowyText(
widget.errorMessage.value!,
color: Theme.of(context).colorScheme.error,
),
),
],
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
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';
final fileMenuItem = SelectionMenuItem(
getName: () => LocaleKeys.document_plugins_file_name.tr(),
icon: (_, isSelected, style) => SelectionMenuIconWidget(
icon: Icons.file_present_outlined,
isSelected: isSelected,
style: style,
),
keywords: ['file', 'pdf', 'zip', 'archive', 'upload'],
handler: (editorState, _, __) async => editorState.insertEmptyFileBlock(),
);
extension InsertFile on EditorState {
Future<void> insertEmptyFileBlock() async {
final selection = this.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final file = fileNode(url: '');
final transaction = this.transaction;
// if the current node is empty paragraph, replace it with the file node
if (node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false)) {
transaction
..insertNode(node.path, file)
..deleteNode(node);
} else {
transaction.insertNode(node.path.next, file);
}
transaction.afterSelection =
Selection.collapsed(Position(path: node.path.next));
return apply(transaction);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
class FileUploadMenu extends StatefulWidget {
const FileUploadMenu({
super.key,
required this.onInsertLocalFile,
required this.onInsertNetworkFile,
});
final void Function(String path) onInsertLocalFile;
final void Function(String url) onInsertNetworkFile;
@override
State<FileUploadMenu> createState() => _FileUploadMenuState();
}
class _FileUploadMenuState extends State<FileUploadMenu> {
int currentTab = 0;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar(
onTap: (value) => setState(() {
currentTab = value;
}),
isScrollable: true,
padding: EdgeInsets.zero,
overlayColor: WidgetStatePropertyAll(
PlatformExtension.isDesktop
? Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
tabs: [
_Tab(
title: LocaleKeys.document_plugins_file_uploadTab.tr(),
),
_Tab(
title: LocaleKeys.document_plugins_file_networkTab.tr(),
),
],
),
const Divider(height: 4),
if (currentTab == 0) ...[
_FileUploadLocal(
onFilePicked: (path) {
if (path != null) {
widget.onInsertLocalFile(path);
}
},
),
] else ...[
_FileUploadNetwork(onSubmit: widget.onInsertNetworkFile),
],
],
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 12.0,
right: 12.0,
bottom: 8.0,
top: PlatformExtension.isMobile ? 0 : 8.0,
),
child: FlowyText(title),
);
}
}
class _FileUploadLocal extends StatefulWidget {
const _FileUploadLocal({required this.onFilePicked});
final void Function(String?) onFilePicked;
@override
State<_FileUploadLocal> createState() => _FileUploadLocalState();
}
class _FileUploadLocalState extends State<_FileUploadLocal> {
bool isDragging = false;
@override
Widget build(BuildContext context) {
final constraints =
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
return Padding(
padding: const EdgeInsets.all(4),
child: DropTarget(
onDragEntered: (_) => setState(() => isDragging = true),
onDragExited: (_) => setState(() => isDragging = false),
onDragDone: (details) => widget.onFilePicked(details.files.first.path),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _uploadFile(context),
child: FlowyHover(
resetHoverOnRebuild: false,
isSelected: () => isDragging,
style: HoverStyle(
borderRadius: BorderRadius.circular(12),
),
child: Container(
padding: const EdgeInsets.all(8),
constraints: constraints,
child: DottedBorder(
dashPattern: const [3, 3],
radius: const Radius.circular(8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 32,
),
borderType: BorderType.RRect,
color: isDragging
? Theme.of(context).colorScheme.primary
: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isDragging) ...[
const VSpace(13.5),
FlowyText(
LocaleKeys.document_plugins_file_dropFileToUpload
.tr(),
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
const VSpace(13.5),
] else ...[
FlowyText(
LocaleKeys.document_plugins_file_fileUploadHint
.tr(),
fontSize: 16,
maxLines: 2,
textAlign: TextAlign.center,
lineHeight: 1.5,
),
],
],
),
),
),
),
),
),
),
),
);
}
Future<void> _uploadFile(BuildContext context) async {
final result = await getIt<FilePickerService>().pickFiles(dialogTitle: '');
widget.onFilePicked(result?.files.first.path);
}
}
class _FileUploadNetwork extends StatefulWidget {
const _FileUploadNetwork({required this.onSubmit});
final void Function(String url) onSubmit;
@override
State<_FileUploadNetwork> createState() => _FileUploadNetworkState();
}
class _FileUploadNetworkState extends State<_FileUploadNetwork> {
bool isUrlValid = true;
String inputText = '';
@override
Widget build(BuildContext context) {
final constraints =
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
return Container(
padding: const EdgeInsets.all(8),
constraints: constraints,
alignment: Alignment.center,
child: Column(
children: [
FlowyTextField(
hintText: LocaleKeys.document_plugins_file_networkHint.tr(),
onChanged: (value) => inputText = value,
onEditingComplete: submit,
),
if (!isUrlValid) ...[
const VSpace(8),
FlowyText(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: Theme.of(context).colorScheme.error,
),
],
const VSpace(8),
SizedBox(
width: 160,
child: FlowyButton(
showDefaultBoxDecorationOnMobile: true,
margin: const EdgeInsets.all(8.0),
text: FlowyText(
LocaleKeys.document_plugins_file_networkAction.tr(),
textAlign: TextAlign.center,
),
onTap: submit,
),
),
],
),
);
}
void submit() {
if (checkUrlValidity(inputText)) {
return widget.onSubmit(inputText);
}
setState(() => isUrlValid = false);
}
bool checkUrlValidity(String url) => hrefRegex.hasMatch(url);
}
@@ -0,0 +1,65 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_extension.dart';
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
import 'package:appflowy_backend/dispatch/error.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:path/path.dart' as p;
Future<String?> saveFileToLocalStorage(String localFilePath) async {
final path = await getIt<ApplicationDataStorage>().getPath();
final filePath = p.join(path, 'files');
try {
// create the directory if not exists
final directory = Directory(filePath);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
final copyToPath = p.join(
filePath,
'${uuid()}${p.extension(localFilePath)}',
);
await File(localFilePath).copy(
copyToPath,
);
return copyToPath;
} catch (e) {
Log.error('cannot save file', e);
return null;
}
}
Future<(String? path, String? errorMessage)> saveFileToCloudStorage(
String localFilePath,
String documentId,
) async {
final size = localFilePath.fileSize;
if (size == null || size > 10 * 1024 * 1024) {
// 10MB
return (
null,
LocaleKeys.document_plugins_file_fileTooBigError.tr(),
);
}
final documentService = DocumentService();
Log.debug("Uploading file from local path: $localFilePath");
final result = await documentService.uploadFile(
localFilePath: localFilePath,
documentId: documentId,
);
return result.fold(
(s) => (s.url, null),
(err) {
if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
}
return (null, err.msg);
},
);
}
@@ -0,0 +1,42 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flowy_infra/size.dart';
@visibleForTesting
class ImageRender extends StatelessWidget {
const ImageRender({
super.key,
required this.image,
this.userProfile,
this.fit = BoxFit.cover,
this.borderRadius = Corners.s6Border,
});
final ImageBlockData image;
final UserProfilePB? userProfile;
final BoxFit fit;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final child = switch (image.type) {
CustomImageType.internal || CustomImageType.external => FlowyNetworkImage(
url: image.url,
userProfilePB: userProfile,
fit: fit,
),
CustomImageType.local => Image.file(File(image.url), fit: fit),
};
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: borderRadius),
child: child,
);
}
}
@@ -7,13 +7,13 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
import 'package:collection/collection.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -25,25 +25,10 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:provider/provider.dart';
import '../image_render.dart';
const _thumbnailItemSize = 100.0;
abstract class ImageBlockMultiLayout extends StatefulWidget {
const ImageBlockMultiLayout({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
}
class ImageBrowserLayout extends ImageBlockMultiLayout {
const ImageBrowserLayout({
super.key,
@@ -97,115 +82,118 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
(constraints.maxWidth / (_thumbnailItemSize + 4)).floor();
final items = widget.images.take(maxItems).toList();
return Wrap(
children: items.mapIndexed((index, image) {
final isLast = items.last == image;
final amountLeft = widget.images.length - items.length;
if (isLast && amountLeft > 0) {
return Center(
child: Wrap(
children: items.mapIndexed((index, image) {
final isLast = items.last == image;
final amountLeft = widget.images.length - items.length;
if (isLast && amountLeft > 0) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _openInteractiveViewer(
context,
maxItems - 1,
),
child: Container(
width: _thumbnailItemSize,
height: _thumbnailItemSize,
padding: const EdgeInsets.all(2),
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: Corners.s8Border,
border: Border.all(
width: 2,
color: Theme.of(context).dividerColor,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: Corners.s6Border,
image: image.type == CustomImageType.local
? DecorationImage(
image: FileImage(File(image.url)),
fit: BoxFit.cover,
opacity: 0.5,
)
: null,
),
child: Stack(
children: [
if (image.type != CustomImageType.local)
Positioned.fill(
child: Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
borderRadius: Corners.s6Border,
),
child: FlowyNetworkImage(
url: image.url,
userProfilePB: _userProfile,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
),
child: Center(
child: FlowyText(
'+$amountLeft',
color: AFThemeExtension.of(context)
.strongText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _openInteractiveViewer(
context,
maxItems - 1,
),
child: Container(
width: _thumbnailItemSize,
height: _thumbnailItemSize,
padding: const EdgeInsets.all(2),
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: Corners.s8Border,
border: Border.all(
width: 2,
color: Theme.of(context).dividerColor,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: Corners.s6Border,
image: image.type == CustomImageType.local
? DecorationImage(
image: FileImage(File(image.url)),
fit: BoxFit.cover,
opacity: 0.5,
)
: null,
),
child: Stack(
children: [
if (image.type != CustomImageType.local)
Positioned.fill(
child: Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
borderRadius: Corners.s6Border,
),
child: FlowyNetworkImage(
url: image.url,
userProfilePB: _userProfile,
),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
),
child: Center(
child: FlowyText(
'+$amountLeft',
color: AFThemeExtension.of(context)
.strongText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
onTap: () => widget.onIndexChanged(index),
child: ThumbnailItem(
images: widget.images,
index: index,
selectedIndex: widget.indexNotifier.value,
userProfile: _userProfile,
onDeleted: () async {
final transaction =
widget.editorState.transaction;
final images = widget.images.toList();
images.removeAt(index);
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
images.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout: widget.node
.attributes[MultiImageBlockKeys.layout],
},
);
await widget.editorState.apply(transaction);
widget.onIndexChanged(
widget.indexNotifier.value > 0
? widget.indexNotifier.value - 1
: 0,
);
},
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onIndexChanged(index),
child: ThumbnailItem(
images: widget.images,
index: index,
selectedIndex: widget.indexNotifier.value,
userProfile: _userProfile,
onDeleted: () async {
final transaction = widget.editorState.transaction;
final images = widget.images.toList();
images.removeAt(index);
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
images.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout: widget.node
.attributes[MultiImageBlockKeys.layout],
},
);
await widget.editorState.apply(transaction);
widget.onIndexChanged(
widget.indexNotifier.value > 0
? widget.indexNotifier.value - 1
: 0,
);
},
),
),
);
}).toList(),
}).toList(),
),
);
},
),
@@ -324,7 +312,8 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await widget.editorState.apply(transaction);
@@ -417,35 +406,3 @@ class _ThumbnailItemState extends State<ThumbnailItem> {
);
}
}
@visibleForTesting
class ImageRender extends StatelessWidget {
const ImageRender({
super.key,
required this.image,
this.userProfile,
this.fit = BoxFit.cover,
});
final ImageBlockData image;
final UserProfilePB? userProfile;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final child = switch (image.type) {
CustomImageType.internal || CustomImageType.external => FlowyNetworkImage(
url: image.url,
userProfilePB: userProfile,
fit: fit,
),
CustomImageType.local => Image.file(File(image.url), fit: fit),
};
return Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(borderRadius: Corners.s6Border),
child: child,
);
}
}
@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:collection/collection.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:provider/provider.dart';
class ImageGridLayout extends ImageBlockMultiLayout {
const ImageGridLayout({
super.key,
required super.node,
required super.editorState,
required super.images,
required super.indexNotifier,
required super.isLocalMode,
});
@override
State<ImageGridLayout> createState() => _ImageGridLayoutState();
}
class _ImageGridLayoutState extends State<ImageGridLayout> {
@override
Widget build(BuildContext context) {
return StaggeredGridBuilder(
images: widget.images,
onImageDoubleTapped: (index) {
_openInteractiveViewer(context, index);
},
);
}
void _openInteractiveViewer(BuildContext context, int index) => showDialog(
context: context,
builder: (_) => InteractiveImageViewer(
userProfile: context.read<DocumentBloc>().state.userProfilePB,
imageProvider: AFBlockImageProvider(
images: widget.images,
initialIndex: index,
onDeleteImage: (index) async {
final transaction = widget.editorState.transaction;
final newImages = widget.images.toList();
newImages.removeAt(index);
if (newImages.isNotEmpty) {
transaction.updateNode(
widget.node,
{
MultiImageBlockKeys.images:
newImages.map((e) => e.toJson()).toList(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
},
);
} else {
transaction.deleteNode(widget.node);
}
await widget.editorState.apply(transaction);
},
),
),
);
}
/// Draws a staggered grid of images, where the pattern is based
/// on the amount of images to fill the grid at all times.
///
/// They will be alternating depending on the current index of the images, such that
/// the layout is reversed in odd segments.
///
/// If there are 4 images in the last segment, this layout will be used:
/// ┌─────┐┌─┐┌─┐
/// │ │└─┘└─┘
/// │ │┌────┐
/// └─────┘└────┘
///
/// If there are 3 images in the last segment, this layout will be used:
/// ┌─────┐┌────┐
/// │ │└────┘
/// │ │┌────┐
/// └─────┘└────┘
///
/// If there are 2 images in the last segment, this layout will be used:
/// ┌─────┐┌─────┐
/// │ ││ │
/// └─────┘└─────┘
///
/// If there is 1 image in the last segment, this layout will be used:
/// ┌──────────┐
/// │ │
/// └──────────┘
class StaggeredGridBuilder extends StatefulWidget {
const StaggeredGridBuilder({
super.key,
required this.images,
required this.onImageDoubleTapped,
});
final List<ImageBlockData> images;
final void Function(int) onImageDoubleTapped;
@override
State<StaggeredGridBuilder> createState() => _StaggeredGridBuilderState();
}
class _StaggeredGridBuilderState extends State<StaggeredGridBuilder> {
late final UserProfilePB? _userProfile;
final List<List<ImageBlockData>> _splitImages = [];
@override
void initState() {
super.initState();
_userProfile = context.read<DocumentBloc>().state.userProfilePB;
for (int i = 0; i < widget.images.length; i += 4) {
final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length;
_splitImages.add(widget.images.sublist(i, end));
}
}
@override
void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) {
if (widget.images.length != oldWidget.images.length) {
_splitImages.clear();
for (int i = 0; i < widget.images.length; i += 4) {
final end =
(i + 4 < widget.images.length) ? i + 4 : widget.images.length;
_splitImages.add(widget.images.sublist(i, end));
}
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return StaggeredGrid.count(
crossAxisCount: 4,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
children:
_splitImages.indexed.map(_buildTilesForImages).flattened.toList(),
);
}
List<Widget> _buildTilesForImages((int, List<ImageBlockData>) data) {
final index = data.$1;
final images = data.$2;
final isReversed = index.isOdd;
if (images.length == 4) {
return [
StaggeredGridTile.count(
crossAxisCellCount: isReversed ? 1 : 2,
mainAxisCellCount: isReversed ? 1 : 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 1,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: isReversed ? 2 : 1,
mainAxisCellCount: isReversed ? 2 : 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 2;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[2],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 3;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[3],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else if (images.length == 3) {
return [
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: isReversed ? 1 : 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: isReversed ? 2 : 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 1,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 2;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[2],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else if (images.length == 2) {
return [
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
StaggeredGridTile.count(
crossAxisCellCount: 2,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4 + 1;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[1],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
} else {
return [
StaggeredGridTile.count(
crossAxisCellCount: 4,
mainAxisCellCount: 2,
child: GestureDetector(
onDoubleTap: () {
final imageIndex = index * 4;
widget.onImageDoubleTapped(imageIndex);
},
child: ImageRender(
image: images[0],
userProfile: _userProfile,
borderRadius: BorderRadius.zero,
),
),
),
];
}
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
abstract class ImageBlockMultiLayout extends StatefulWidget {
const ImageBlockMultiLayout({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
}
class ImageLayoutRender extends StatelessWidget {
const ImageLayoutRender({
super.key,
required this.node,
required this.editorState,
required this.images,
required this.indexNotifier,
required this.isLocalMode,
required this.onIndexChanged,
});
final Node node;
final EditorState editorState;
final List<ImageBlockData> images;
final ValueNotifier<int> indexNotifier;
final bool isLocalMode;
final void Function(int) onIndexChanged;
@override
Widget build(BuildContext context) {
final layout = _getLayout();
return _buildLayout(layout);
}
MultiImageLayout _getLayout() {
return MultiImageLayout.fromIntValue(
node.attributes[MultiImageBlockKeys.layout] ?? 0,
);
}
Widget _buildLayout(MultiImageLayout layout) {
switch (layout) {
case MultiImageLayout.grid:
return ImageGridLayout(
node: node,
editorState: editorState,
images: images,
indexNotifier: indexNotifier,
isLocalMode: isLocalMode,
);
case MultiImageLayout.browser:
default:
return ImageBrowserLayout(
node: node,
editorState: editorState,
images: images,
indexNotifier: indexNotifier,
isLocalMode: isLocalMode,
onIndexChanged: onIndexChanged,
);
}
}
}
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:provider/provider.dart';
const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey';
@@ -109,6 +112,38 @@ class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
bool alwaysShowMenu = false;
static const _interceptorKey = 'multi-image-block-interceptor';
late final interceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => _isTapInBounds(details.globalPosition),
canPanStart: (details) => _isTapInBounds(details.globalPosition),
);
@override
void initState() {
super.initState();
editorState.selectionService.registerGestureInterceptor(interceptor);
}
@override
void dispose() {
editorState.selectionService.unregisterGestureInterceptor(_interceptorKey);
super.dispose();
}
bool _isTapInBounds(Offset offset) {
if (_renderBox == null) {
// We shouldn't block any actions if the render box is not available.
// This has the potential to break taps on the editor completely if we
// accidentally return false here.
return true;
}
final localPosition = _renderBox!.globalToLocal(offset);
return !_renderBox!.paintBounds.contains(localPosition);
}
@override
Widget build(BuildContext context) {
final data = MultiImageData.fromJson(
@@ -127,7 +162,7 @@ class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
node: node,
);
} else {
child = ImageBrowserLayout(
child = ImageLayoutRender(
node: node,
images: data.images,
editorState: editorState,
@@ -302,17 +337,14 @@ class MultiImageData {
enum MultiImageLayout {
browser,
masonry,
grid;
int toIntValue() {
switch (this) {
case MultiImageLayout.browser:
return 0;
case MultiImageLayout.masonry:
return 1;
case MultiImageLayout.grid:
return 2;
return 1;
}
}
@@ -321,11 +353,19 @@ enum MultiImageLayout {
case 0:
return MultiImageLayout.browser;
case 1:
return MultiImageLayout.masonry;
case 2:
return MultiImageLayout.grid;
default:
throw UnimplementedError();
}
}
String get label => switch (this) {
browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(),
grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(),
};
FlowySvgData get icon => switch (this) {
browser => FlowySvgs.photo_layout_browser_s,
grid => FlowySvgs.photo_layout_grid_s,
};
}
@@ -21,6 +21,8 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:http/http.dart';
@@ -57,6 +59,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
);
final PopoverController controller = PopoverController();
final PopoverController layoutController = PopoverController();
late List<ImageBlockData> images;
late final EditorState editorState;
@@ -73,6 +76,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
void dispose() {
allowMenuClose();
controller.close();
layoutController.close();
super.dispose();
}
@@ -88,6 +92,9 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final layout = MultiImageLayout.fromIntValue(
widget.node.attributes[MultiImageBlockKeys.layout] ?? 0,
);
return Container(
height: 32,
decoration: BoxDecoration(
@@ -104,11 +111,6 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
AppFlowyPopover(
controller: controller,
direction: PopoverDirection.bottomWithRightAligned,
@@ -141,9 +143,57 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
onTap: () {},
),
),
const HSpace(4),
AppFlowyPopover(
controller: layoutController,
onClose: allowMenuClose,
direction: PopoverDirection.bottomWithRightAligned,
offset: const Offset(0, 10),
constraints: const BoxConstraints(
maxHeight: 300,
maxWidth: 300,
),
popupBuilder: (context) {
preventMenuClose();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_LayoutSelector(
selectedLayout: layout,
onSelected: (layout) {
allowMenuClose();
layoutController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images:
widget.node.attributes[MultiImageBlockKeys.images],
MultiImageBlockKeys.layout: layout.toIntValue(),
});
editorState.apply(transaction);
},
),
],
);
},
child: MenuBlockButton(
tooltip: LocaleKeys
.document_plugins_photoGallery_changeLayoutTooltip
.tr(),
iconData: FlowySvgs.edit_layout_s,
onTap: () {},
),
),
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
// disable the copy link button if the image is hosted on appflowy cloud
// because the url needs the verification token to be accessible
if (!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[
if (layout == MultiImageLayout.browser &&
!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copyLink.tr(),
@@ -153,7 +203,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
],
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip
.tr(),
iconData: FlowySvgs.delete_s,
onTap: deleteImage,
),
@@ -202,8 +253,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@@ -242,12 +293,12 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
return;
}
newImages.addAll(images);
final imagesJson = newImages.map((image) => image.toJson()).toList();
final imagesJson =
[...images, ...newImages].map((i) => i.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@@ -310,8 +361,8 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
final imagesJson = newImages.map((image) => image.toJson()).toList();
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout],
});
await editorState.apply(transaction);
@@ -330,3 +381,58 @@ class _Divider extends StatelessWidget {
);
}
}
class _LayoutSelector extends StatelessWidget {
const _LayoutSelector({
required this.selectedLayout,
required this.onSelected,
});
final MultiImageLayout selectedLayout;
final Function(MultiImageLayout) onSelected;
@override
Widget build(BuildContext context) {
return SeparatedRow(
separatorBuilder: () => const HSpace(6),
mainAxisSize: MainAxisSize.min,
children: MultiImageLayout.values
.map(
(layout) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => onSelected(layout),
child: Container(
height: 80,
width: 80,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
width: 2,
color: selectedLayout == layout
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
borderRadius: Corners.s8Border,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowySvg(
layout.icon,
color: AFThemeExtension.of(context).strongText,
size: const Size.square(24),
),
const VSpace(6),
FlowyText(layout.label),
],
),
),
),
),
)
.toList(),
);
}
}
@@ -222,8 +222,9 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images: imagesJson,
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout] ??
MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
@@ -282,8 +283,9 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
transaction.updateNode(widget.node, {
MultiImageBlockKeys.images:
images.map((image) => image.toJson()).toList(),
// Default to Browser layout
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
MultiImageBlockKeys.layout:
widget.node.attributes[MultiImageBlockKeys.layout] ??
MultiImageLayout.browser.toIntValue(),
});
await editorState.apply(transaction);
}
@@ -53,3 +53,4 @@ export 'table/table_option_action.dart';
export 'todo_list/todo_list_icon.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart';
export 'file/file_block.dart';
@@ -49,7 +49,7 @@ extension _StartWithsSort on List<InlineActionsResult> {
);
}
const _invalidSearchesAmount = 20;
const _invalidSearchesAmount = 10;
class InlineActionsHandler extends StatefulWidget {
const InlineActionsHandler({
@@ -81,8 +81,6 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
final _scrollController = ScrollController();
Timer? _debounce;
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
late int startOffset;
@@ -90,8 +88,7 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
String _search = '';
set search(String search) {
_search = search;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 200), _doSearch);
_doSearch();
}
Future<void> _doSearch() async {
@@ -109,10 +106,13 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
: 0;
if (invalidCounter >= _invalidSearchesAmount) {
widget.onDismiss();
// Workaround to bring focus back to editor
await widget.editorState
.updateSelectionWithReason(widget.editorState.selection);
return widget.onDismiss();
return;
}
_resetSelection();
@@ -143,7 +143,6 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
void dispose() {
_scrollController.dispose();
_focusNode.dispose();
_debounce?.cancel();
super.dispose();
}
@@ -208,11 +208,6 @@ void _resolveFolderDeps(GetIt getIt) {
),
);
// Settings
getIt.registerFactoryParam<SettingsDialogBloc, UserProfilePB, void>(
(user, _) => SettingsDialogBloc(user),
);
// User
getIt.registerFactoryParam<SettingsUserViewBloc, UserProfilePB, void>(
(user, _) => SettingsUserViewBloc(user),
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl;
part 'download_offline_ai_app_bloc.freezed.dart';
class DownloadOfflineAIBloc
extends Bloc<DownloadOfflineAIEvent, DownloadOfflineAIState> {
DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) {
on<DownloadOfflineAIEvent>(_handleEvent);
}
Future<void> _handleEvent(
DownloadOfflineAIEvent event,
Emitter<DownloadOfflineAIState> emit,
) async {
await event.when(
started: () async {
final result = await ChatEventGetOfflineAIAppLink().send();
await result.fold(
(app) async {
await launchUrl(Uri.parse(app.link));
},
(err) {},
);
},
);
}
}
@freezed
class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent {
const factory DownloadOfflineAIEvent.started() = _Started;
}
@freezed
class DownloadOfflineAIState with _$DownloadOfflineAIState {
const factory DownloadOfflineAIState() = _DownloadOfflineAIState;
}
@@ -20,7 +20,7 @@ class LocalAIChatSettingBloc
listener.start(
stateCallback: (newState) {
if (!isClosed) {
add(LocalAIChatSettingEvent.updateLLMRunningState(newState.state));
add(LocalAIChatSettingEvent.updatePluginState(newState));
}
},
);
@@ -46,14 +46,14 @@ class LocalAIChatSettingBloc
modelInfo: modelInfo,
models: modelInfo.models,
selectedLLMModel: modelInfo.selectedModel,
fetchModelInfoState: const LoadingState.finish(),
aiModelProgress: const AIModelProgress.finish(),
),
);
},
(err) {
emit(
state.copyWith(
fetchModelInfoState: LoadingState.finish(error: err),
aiModelProgress: AIModelProgress.finish(error: err),
),
);
},
@@ -68,7 +68,7 @@ class LocalAIChatSettingBloc
emit(
state.copyWith(
selectedLLMModel: llmModel,
localAIInfo: LocalAIProgress.showDownload(
progressIndicator: LocalAIProgress.showDownload(
llmResource,
llmModel,
),
@@ -80,7 +80,7 @@ class LocalAIChatSettingBloc
state.copyWith(
selectedLLMModel: llmModel,
selectLLMState: const LoadingState.finish(),
localAIInfo: const LocalAIProgress.checkPluginState(),
progressIndicator: const LocalAIProgress.checkPluginState(),
),
);
}
@@ -106,7 +106,7 @@ class LocalAIChatSettingBloc
if (llmResource.pendingResources.isEmpty) {
emit(
state.copyWith(
localAIInfo: const LocalAIProgress.checkPluginState(),
progressIndicator: const LocalAIProgress.checkPluginState(),
),
);
} else {
@@ -115,7 +115,7 @@ class LocalAIChatSettingBloc
if (llmResource.isDownloading) {
emit(
state.copyWith(
localAIInfo:
progressIndicator:
LocalAIProgress.startDownloading(state.selectedLLMModel!),
selectLLMState: const LoadingState.finish(),
),
@@ -124,7 +124,7 @@ class LocalAIChatSettingBloc
} else {
emit(
state.copyWith(
localAIInfo: LocalAIProgress.showDownload(
progressIndicator: LocalAIProgress.showDownload(
llmResource,
state.selectedLLMModel!,
),
@@ -138,7 +138,7 @@ class LocalAIChatSettingBloc
startDownloadModel: (LLMModelPB llmModel) {
emit(
state.copyWith(
localAIInfo: LocalAIProgress.startDownloading(llmModel),
progressIndicator: LocalAIProgress.startDownloading(llmModel),
selectLLMState: const LoadingState.finish(),
),
);
@@ -149,19 +149,39 @@ class LocalAIChatSettingBloc
},
finishDownload: () async {
emit(
state.copyWith(localAIInfo: const LocalAIProgress.finishDownload()),
state.copyWith(
progressIndicator: const LocalAIProgress.finishDownload(),
),
);
},
updateLLMRunningState: (RunningStatePB newRunningState) {
if (newRunningState == RunningStatePB.Stopped) {
updatePluginState: (LocalAIPluginStatePB pluginState) {
if (pluginState.offlineAiReady) {
ChatEventRefreshLocalAIModelInfo().send().then((result) {
if (!isClosed) {
add(LocalAIChatSettingEvent.didLoadModelInfo(result));
}
});
if (pluginState.state == RunningStatePB.Stopped) {
emit(
state.copyWith(
runningState: pluginState.state,
progressIndicator: const LocalAIProgress.checkPluginState(),
),
);
} else {
emit(
state.copyWith(
runningState: pluginState.state,
),
);
}
} else {
emit(
state.copyWith(
runningState: newRunningState,
localAIInfo: const LocalAIProgress.checkPluginState(),
progressIndicator: const LocalAIProgress.startOfflineAIApp(),
),
);
} else {
emit(state.copyWith(runningState: newRunningState));
}
},
);
@@ -183,10 +203,21 @@ class LocalAIChatSettingBloc
/// Handles the event to fetch local AI settings when the application starts.
Future<void> _handleStarted() async {
final result = await ChatEventRefreshLocalAIModelInfo().send();
if (!isClosed) {
add(LocalAIChatSettingEvent.didLoadModelInfo(result));
}
final result = await ChatEventGetLocalAIPluginState().send();
result.fold(
(pluginState) async {
if (!isClosed) {
add(LocalAIChatSettingEvent.updatePluginState(pluginState));
if (pluginState.offlineAiReady) {
final result = await ChatEventRefreshLocalAIModelInfo().send();
if (!isClosed) {
add(LocalAIChatSettingEvent.didLoadModelInfo(result));
}
}
}
},
(err) => Log.error(err.toString()),
);
}
@override
@@ -214,9 +245,9 @@ class LocalAIChatSettingEvent with _$LocalAIChatSettingEvent {
const factory LocalAIChatSettingEvent.cancelDownload() = _CancelDownload;
const factory LocalAIChatSettingEvent.finishDownload() = _FinishDownload;
const factory LocalAIChatSettingEvent.updateLLMRunningState(
RunningStatePB newRunningState,
) = _RunningState;
const factory LocalAIChatSettingEvent.updatePluginState(
LocalAIPluginStatePB pluginState,
) = _PluginState;
}
@freezed
@@ -224,29 +255,16 @@ class LocalAIChatSettingState with _$LocalAIChatSettingState {
const factory LocalAIChatSettingState({
LLMModelInfoPB? modelInfo,
LLMModelPB? selectedLLMModel,
LocalAIProgress? localAIInfo,
@Default(LoadingState.loading()) LoadingState fetchModelInfoState,
LocalAIProgress? progressIndicator,
@Default(AIModelProgress.init()) AIModelProgress aiModelProgress,
@Default(LoadingState.loading()) LoadingState selectLLMState,
@Default([]) List<LLMModelPB> models,
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
}) = _LocalAIChatSettingState;
}
// @freezed
// class LocalChatAIStateIndicator with _$LocalChatAIStateIndicator {
// // when start downloading the model
// const factory LocalChatAIStateIndicator.error(FlowyError error) = _OnError;
// const factory LocalChatAIStateIndicator.ready(bool isEnabled) = _Ready;
// }
@freezed
class LocalAIProgress with _$LocalAIProgress {
// when user select a new model, it will call requestDownload
const factory LocalAIProgress.requestDownloadInfo(
LocalModelResourcePB llmResource,
LLMModelPB llmModel,
) = _RequestDownload;
// when user comes back to the setting page, it will auto detect current llm state
const factory LocalAIProgress.showDownload(
LocalModelResourcePB llmResource,
@@ -257,5 +275,13 @@ class LocalAIProgress with _$LocalAIProgress {
const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) =
_Downloading;
const factory LocalAIProgress.finishDownload() = _Finish;
const factory LocalAIProgress.checkPluginState() = _PluginState;
const factory LocalAIProgress.checkPluginState() = _CheckPluginState;
const factory LocalAIProgress.startOfflineAIApp() = _StartOfflineAIApp;
}
@freezed
class AIModelProgress with _$AIModelProgress {
const factory AIModelProgress.init() = _AIModelProgressInit;
const factory AIModelProgress.loading() = _AIModelDownloading;
const factory AIModelProgress.finish({FlowyError? error}) = _AIModelFinish;
}
@@ -7,6 +7,7 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl;
part 'plugin_state_bloc.freezed.dart';
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
@@ -53,19 +54,31 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
);
},
updateState: (LocalAIPluginStatePB pluginState) {
switch (pluginState.state) {
case RunningStatePB.Connecting:
emit(
const PluginStateState(action: PluginStateAction.loadingPlugin()),
);
case RunningStatePB.Running:
emit(const PluginStateState(action: PluginStateAction.ready()));
break;
default:
emit(
state.copyWith(action: const PluginStateAction.restart()),
);
break;
// if the offline ai is not started, ask user to start it
if (pluginState.offlineAiReady) {
// Chech state of the plugin
switch (pluginState.state) {
case RunningStatePB.Connecting:
emit(
const PluginStateState(
action: PluginStateAction.loadingPlugin(),
),
);
case RunningStatePB.Running:
emit(const PluginStateState(action: PluginStateAction.ready()));
break;
default:
emit(
state.copyWith(action: const PluginStateAction.restartPlugin()),
);
break;
}
} else {
emit(
const PluginStateState(
action: PluginStateAction.startAIOfflineApp(),
),
);
}
},
restartLocalAI: () async {
@@ -83,6 +96,15 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
(err) => Log.error(err.toString()),
);
},
downloadOfflineAIApp: () async {
final result = await ChatEventGetOfflineAIAppLink().send();
await result.fold(
(app) async {
await launchUrl(Uri.parse(app.link));
},
(err) {},
);
},
);
}
}
@@ -95,6 +117,7 @@ class PluginStateEvent with _$PluginStateEvent {
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
const factory PluginStateEvent.openModelDirectory() =
_OpenModelStorageDirectory;
const factory PluginStateEvent.downloadOfflineAIApp() = _DownloadOfflineAIApp;
}
@freezed
@@ -109,5 +132,6 @@ class PluginStateAction with _$PluginStateAction {
const factory PluginStateAction.init() = _Init;
const factory PluginStateAction.loadingPlugin() = _LoadingPlugin;
const factory PluginStateAction.ready() = _Ready;
const factory PluginStateAction.restart() = _Restart;
const factory PluginStateAction.restartPlugin() = _RestartPlugin;
const factory PluginStateAction.startAIOfflineApp() = _StartAIOfflineApp;
}
@@ -43,6 +43,9 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
emit(state.copyWith(userProfile: userProfile));
},
toggleAISearch: () {
emit(
state.copyWith(enableSearchIndexing: !state.enableSearchIndexing),
);
_updateUserWorkspaceSetting(
disableSearchIndexing:
!(state.aiSettings?.disableSearchIndexing ?? false),
@@ -1,7 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:appflowy/user/application/user_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -27,7 +32,8 @@ enum SettingsPage {
class SettingsDialogBloc
extends Bloc<SettingsDialogEvent, SettingsDialogState> {
SettingsDialogBloc(
this.userProfile, {
this.userProfile,
this.workspaceMember, {
SettingsPage? initPage,
}) : _userListener = UserListener(userProfile: userProfile),
super(SettingsDialogState.initial(userProfile, initPage)) {
@@ -35,6 +41,7 @@ class SettingsDialogBloc
}
final UserProfilePB userProfile;
final WorkspaceMemberPB? workspaceMember;
final UserListener _userListener;
@override
@@ -49,6 +56,12 @@ class SettingsDialogBloc
await event.when(
initial: () async {
_userListener.start(onProfileUpdated: _profileUpdated);
final isBillingEnabled =
await _isBillingEnabled(userProfile, workspaceMember);
if (isBillingEnabled) {
emit(state.copyWith(isBillingEnabled: true));
}
},
didReceiveUserProfile: (UserProfilePB newUserProfile) {
emit(state.copyWith(userProfile: newUserProfile));
@@ -70,6 +83,42 @@ class SettingsDialogBloc
(err) => Log.error(err),
);
}
Future<bool> _isBillingEnabled(
UserProfilePB userProfile, [
WorkspaceMemberPB? member,
]) async {
if ([
AuthenticatorPB.Local,
AuthenticatorPB.Supabase,
].contains(userProfile.authenticator)) {
return false;
}
if (member == null || member.role != AFRolePB.Owner) {
return false;
}
if (kDebugMode) {
return true;
}
final result = await UserEventGetCloudConfig().send();
return result.fold(
(cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
return whiteList.contains(cloudSetting.serverUrl);
},
(err) {
Log.error("Failed to get cloud config: $err");
return false;
},
);
}
}
@freezed
@@ -87,6 +136,7 @@ class SettingsDialogState with _$SettingsDialogState {
const factory SettingsDialogState({
required UserProfilePB userProfile,
required SettingsPage page,
required bool isBillingEnabled,
}) = _SettingsDialogState;
factory SettingsDialogState.initial(
@@ -96,5 +146,6 @@ class SettingsDialogState with _$SettingsDialogState {
SettingsDialogState(
userProfile: userProfile,
page: page ?? SettingsPage.account,
isBillingEnabled: false,
);
}
@@ -1,3 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
@@ -18,9 +22,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SpacePermissionSwitch extends StatefulWidget {
@@ -292,13 +293,17 @@ class ConfirmPopup extends StatefulWidget {
required this.title,
required this.description,
required this.onConfirm,
this.onCancel,
this.confirmLabel,
this.confirmButtonColor,
this.child,
this.closeOnAction = true,
});
final String title;
final String description;
final VoidCallback onConfirm;
final VoidCallback? onCancel;
final Color? confirmButtonColor;
final ConfirmPopupStyle style;
@@ -309,6 +314,18 @@ class ConfirmPopup extends StatefulWidget {
///
final String? confirmLabel;
/// Allows to add a child to the popup.
///
/// This is useful when you want to add more content to the popup.
/// The child will be placed below the description.
///
final Widget? child;
/// Decides whether the popup should be closed when the confirm button is clicked.
/// Defaults to true.
///
final bool closeOnAction;
@override
State<ConfirmPopup> createState() => _ConfirmPopupState();
}
@@ -337,9 +354,13 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
const VSpace(6.0),
const VSpace(6),
_buildDescription(),
const VSpace(20.0),
if (widget.child != null) ...[
const VSpace(12),
widget.child!,
],
const VSpace(20),
_buildStyledButton(context),
],
),
@@ -384,7 +405,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
return SpaceOkButton(
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
if (widget.closeOnAction) {
Navigator.of(context).pop();
}
},
confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(),
confirmButtonColor: widget.confirmButtonColor ??
@@ -392,10 +415,15 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
);
case ConfirmPopupStyle.cancelAndOk:
return SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onCancel: () {
widget.onCancel?.call();
Navigator.of(context).pop();
},
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
if (widget.closeOnAction) {
Navigator.of(context).pop();
}
},
confirmButtonName:
widget.confirmLabel ?? LocaleKeys.space_delete.tr(),
@@ -38,20 +38,23 @@ class InitLocalAIIndicator extends StatelessWidget {
],
);
case RunningStatePB.Running:
return Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
),
],
return SizedBox(
height: 30,
child: Row(
children: [
const HSpace(8),
const FlowySvg(
FlowySvgs.download_success_s,
color: Color(0xFF2E7D32),
),
const HSpace(6),
FlowyText(
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
fontSize: 11,
color: const Color(0xFF1E4620),
),
],
),
);
case RunningStatePB.Stopped:
return Row(
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart';
@@ -67,53 +68,53 @@ class LocalAIChatSetting extends StatelessWidget {
tapBodyToExpand: false,
tapHeaderToExpand: false,
),
header: const LocalAIChatSettingHeader(),
header: const SizedBox.shrink(),
collapsed: const SizedBox.shrink(),
expanded: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
// child: _LocalLLMInfoWidget(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: FlowyText.medium(
LocaleKeys.settings_aiPage_keys_llmModel.tr(),
fontSize: 14,
),
),
const Spacer(),
BlocBuilder<LocalAIChatSettingBloc,
LocalAIChatSettingState>(
builder: (context, state) {
return state.fetchModelInfoState.when(
loading: () => Expanded(
child: Row(
children: [
Flexible(
child: FlowyText(
LocaleKeys
.settings_aiPage_keys_fetchLocalModel
.tr(),
),
),
const Spacer(),
const CircularProgressIndicator.adaptive(),
],
BlocBuilder<LocalAIChatSettingBloc, LocalAIChatSettingState>(
builder: (context, state) {
// If the progress indicator is startOfflineAIApp, then don't show the LLM model.
if (state.progressIndicator ==
const LocalAIProgress.startOfflineAIApp()) {
return const SizedBox.shrink();
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: FlowyText.medium(
LocaleKeys.settings_aiPage_keys_llmModel.tr(),
fontSize: 14,
),
),
finish: (err) {
return (err == null)
const Spacer(),
state.aiModelProgress.when(
init: () => const SizedBox.shrink(),
loading: () {
return const Expanded(
child: Row(
children: [
Spacer(),
CircularProgressIndicator.adaptive(),
],
),
);
},
finish: (err) => (err == null)
? const _SelectLocalModelDropdownMenu()
: const SizedBox.shrink();
},
);
},
),
],
: const SizedBox.shrink(),
),
],
);
}
},
),
const IntrinsicHeight(child: _LocalLLMInfoWidget()),
const IntrinsicHeight(child: _LocalAIStateWidget()),
],
),
),
@@ -200,8 +201,8 @@ class _SelectLocalModelDropdownMenu extends StatelessWidget {
}
}
class _LocalLLMInfoWidget extends StatelessWidget {
const _LocalLLMInfoWidget();
class _LocalAIStateWidget extends StatelessWidget {
const _LocalAIStateWidget();
@override
Widget build(BuildContext context) {
@@ -210,15 +211,8 @@ class _LocalLLMInfoWidget extends StatelessWidget {
final error = errorFromState(state);
if (error == null) {
// If the error is null, handle selected llm model.
if (state.localAIInfo != null) {
final child = state.localAIInfo!.when(
requestDownloadInfo: (
LocalModelResourcePB llmResource,
LLMModelPB llmModel,
) {
_showDownloadDialog(context, llmResource, llmModel);
return const SizedBox.shrink();
},
if (state.progressIndicator != null) {
final child = state.progressIndicator!.when(
showDownload: (
LocalModelResourcePB llmResource,
LLMModelPB llmModel,
@@ -241,6 +235,13 @@ class _LocalLLMInfoWidget extends StatelessWidget {
},
finishDownload: () => const InitLocalAIIndicator(),
checkPluginState: () => const PluginStateIndicator(),
startOfflineAIApp: () => OpenOrDownloadOfflineAIApp(
onRetry: () {
context
.read<LocalAIChatSettingBloc>()
.add(const LocalAIChatSettingEvent.refreshAISetting());
},
),
);
return Padding(
@@ -266,44 +267,11 @@ class _LocalLLMInfoWidget extends StatelessWidget {
);
}
void _showDownloadDialog(
BuildContext context,
LocalModelResourcePB llmResource,
LLMModelPB llmModel,
) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) {
return _LLMModelDownloadDialog(
llmResource: llmResource,
onOkPressed: () {
context.read<LocalAIChatSettingBloc>().add(
LocalAIChatSettingEvent.startDownloadModel(
llmModel,
),
);
},
onCancelPressed: () {
context.read<LocalAIChatSettingBloc>().add(
const LocalAIChatSettingEvent.cancelDownload(),
);
},
);
},
);
},
debugLabel: 'localModel.download',
);
}
FlowyError? errorFromState(LocalAIChatSettingState state) {
final err = state.fetchModelInfoState.when(
final err = state.aiModelProgress.when(
loading: () => null,
finish: (err) => err,
init: () {},
);
if (err == null) {
@@ -317,39 +285,48 @@ class _LocalLLMInfoWidget extends StatelessWidget {
}
}
class _LLMModelDownloadDialog extends StatelessWidget {
const _LLMModelDownloadDialog({
required this.llmResource,
required this.onOkPressed,
required this.onCancelPressed,
});
final LocalModelResourcePB llmResource;
final VoidCallback onOkPressed;
final VoidCallback onCancelPressed;
void _showDownloadDialog(
BuildContext context,
LocalModelResourcePB llmResource,
LLMModelPB llmModel,
) {
if (llmResource.pendingResources.isEmpty) {
return;
}
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr(
final res = llmResource.pendingResources.first;
String desc = "";
switch (res.resType) {
case PendingResourceTypePB.AIModel:
desc = LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr(
args: [
llmResource.pendingResources[0].name,
llmResource.pendingResources[0].fileSize,
],
),
message: llmResource.pendingResources[0].fileSize == 0
? ""
: LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr(
args: [
llmResource.pendingResources[0].name,
llmResource.pendingResources[0].fileSize.toString(),
],
),
okTitle: LocaleKeys.button_confirm.tr(),
cancelTitle: LocaleKeys.button_cancel.tr(),
onOkPressed: onOkPressed,
onCancelPressed: onCancelPressed,
titleUpperCase: false,
);
);
break;
case PendingResourceTypePB.OfflineApp:
desc = LocaleKeys.settings_aiPage_keys_downloadAppFlowyOfflineAI.tr();
break;
}
showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr(
args: [res.name],
),
description: desc,
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () => context.read<LocalAIChatSettingBloc>().add(
LocalAIChatSettingEvent.startDownloadModel(
llmModel,
),
),
onCancel: () => context.read<LocalAIChatSettingBloc>().add(
const LocalAIChatSettingEvent.cancelDownload(),
),
);
}
class _ShowDownloadIndicator extends StatelessWidget {
@@ -381,29 +358,7 @@ class _ShowDownloadIndicator extends StatelessWidget {
color: Color(0xFF005483),
),
onTap: () {
showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) {
return _LLMModelDownloadDialog(
llmResource: llmResource,
onOkPressed: () {
context.read<LocalAIChatSettingBloc>().add(
LocalAIChatSettingEvent.startDownloadModel(
llmModel,
),
);
},
onCancelPressed: () {
context.read<LocalAIChatSettingBloc>().add(
const LocalAIChatSettingEvent
.cancelDownload(),
);
},
);
},
);
_showDownloadDialog(context, llmResource, llmModel);
},
),
),
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
@@ -7,9 +10,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LocalAISetting extends StatefulWidget {
@@ -108,20 +108,18 @@ class LocalAISettingHeader extends StatelessWidget {
value: isEnabled,
onChanged: (value) {
if (isEnabled) {
showDialog(
showConfirmDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) {
return _ToggleLocalAIDialog(
onOkPressed: () {
context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle());
},
onCancelPressed: () {},
);
},
title: LocaleKeys
.settings_aiPage_keys_disableLocalAITitle
.tr(),
description: LocaleKeys
.settings_aiPage_keys_disableLocalAIDescription
.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () => context
.read<LocalAIToggleBloc>()
.add(const LocalAIToggleEvent.toggle()),
);
} else {
context
@@ -138,24 +136,3 @@ class LocalAISettingHeader extends StatelessWidget {
);
}
}
class _ToggleLocalAIDialog extends StatelessWidget {
const _ToggleLocalAIDialog({
required this.onOkPressed,
required this.onCancelPressed,
});
final VoidCallback onOkPressed;
final VoidCallback onCancelPressed;
@override
Widget build(BuildContext context) {
return NavigatorOkCancelDialog(
message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(),
okTitle: LocaleKeys.button_confirm.tr(),
cancelTitle: LocaleKeys.button_cancel.tr(),
onOkPressed: onOkPressed,
onCancelPressed: onCancelPressed,
titleUpperCase: false,
);
}
}
@@ -1,10 +1,14 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart';
import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,8 +25,15 @@ class PluginStateIndicator extends StatelessWidget {
return state.action.when(
init: () => const _InitPlugin(),
ready: () => const _LocalAIReadyToUse(),
restart: () => const _ReloadButton(),
restartPlugin: () => const _ReloadButton(),
loadingPlugin: () => const _InitPlugin(),
startAIOfflineApp: () => OpenOrDownloadOfflineAIApp(
onRetry: () {
context
.read<PluginStateBloc>()
.add(const PluginStateEvent.started());
},
),
);
},
),
@@ -35,9 +46,15 @@ class _InitPlugin extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 20,
child: CircularProgressIndicator.adaptive(),
return Row(
children: [
FlowyText(LocaleKeys.settings_aiPage_keys_localAIStart.tr()),
const Spacer(),
const SizedBox(
height: 20,
child: CircularProgressIndicator.adaptive(),
),
],
);
}
}
@@ -124,3 +141,106 @@ class _LocalAIReadyToUse extends StatelessWidget {
);
}
}
class OpenOrDownloadOfflineAIApp extends StatelessWidget {
const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DownloadOfflineAIBloc(),
child: BlocBuilder<DownloadOfflineAIBloc, DownloadOfflineAIState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
maxLines: 3,
textAlign: TextAlign.left,
text: TextSpan(
children: <TextSpan>[
TextSpan(
text:
"${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
height: 1.5,
),
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString(
"https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline",
),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
TextSpan(
text:
"${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
height: 1.5,
),
recognizer: TapGestureRecognizer()
..onTap =
() => context.read<DownloadOfflineAIBloc>().add(
const DownloadOfflineAIEvent.started(),
),
),
TextSpan(
text:
" ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(height: 1.5),
),
],
),
),
const SizedBox(
height: 6,
), // Replaced VSpace with SizedBox for simplicity
SizedBox(
height: 30,
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(horizontal: 12),
text: FlowyText(
LocaleKeys.settings_aiPage_keys_activeOfflineAI.tr(),
),
onTap: onRetry,
),
),
],
);
},
),
);
}
}
@@ -7,7 +7,6 @@ import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/m
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@@ -53,11 +52,12 @@ class SettingsAIView extends StatelessWidget {
];
children.add(const _AISearchToggle(value: false));
children.add(
_LocalAIOnBoarding(
workspaceId: userProfile.workspaceId,
),
);
// TODO(nathan): enable local ai
// children.add(
// _LocalAIOnBoarding(
// workspaceId: userProfile.workspaceId,
// ),
// );
return SettingsBody(
title: LocaleKeys.settings_aiPage_title.tr(),
@@ -114,6 +114,7 @@ class _AISearchToggle extends StatelessWidget {
}
}
// ignore: unused_element
class _LocalAIOnBoarding extends StatelessWidget {
const _LocalAIOnBoarding({required this.workspaceId});
final String workspaceId;
@@ -129,7 +130,7 @@ class _LocalAIOnBoarding extends StatelessWidget {
child: BlocBuilder<LocalAIOnBoardingBloc, LocalAIOnBoardingState>(
builder: (context, state) {
// Show the local AI settings if the user has purchased the AI Local plan
if (kDebugMode || state.isPurchaseAILocal) {
if (state.isPurchaseAILocal) {
return const LocalAISetting();
} else {
// Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/util/int64_extension.dart';
@@ -214,23 +212,24 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
// Currently, the AI Local tile is only available on macOS
// TODO(nathan): enable windows and linux
if (Platform.isMacOS)
_AITile(
plan: SubscriptionPlanPB.AiLocal,
label: LocaleKeys
.settings_billingPage_addons_aiOnDevice_label
.tr(),
description: LocaleKeys
.settings_billingPage_addons_aiOnDevice_description,
activeDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_activeDescription,
canceledDescription: LocaleKeys
.settings_billingPage_addons_aiOnDevice_canceledDescription,
subscriptionInfo:
state.subscriptionInfo.addOns.firstWhereOrNull(
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
),
),
// TODO(nathan): enable local ai
// if (Platform.isMacOS)
// _AITile(
// plan: SubscriptionPlanPB.AiLocal,
// label: LocaleKeys
// .settings_billingPage_addons_aiOnDevice_label
// .tr(),
// description: LocaleKeys
// .settings_billingPage_addons_aiOnDevice_description,
// activeDescription: LocaleKeys
// .settings_billingPage_addons_aiOnDevice_activeDescription,
// canceledDescription: LocaleKeys
// .settings_billingPage_addons_aiOnDevice_canceledDescription,
// subscriptionInfo:
// state.subscriptionInfo.addOns.firstWhereOrNull(
// (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
// ),
// ),
],
),
],
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -141,43 +139,44 @@ class _SettingsPlanViewState extends State<SettingsPlanView> {
// Currently, the AI Local tile is only available on macOS
// TODO(nathan): enable windows and linux
if (Platform.isMacOS)
Flexible(
child: _AddOnBox(
title: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_title
.tr(),
description: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_description
.tr(),
price: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_price
.tr(
args: [
SubscriptionPlanPB.AiLocal.priceAnnualBilling,
],
),
priceInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
.tr(),
billingInfo: LocaleKeys
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
.tr(
args: [
SubscriptionPlanPB.AiLocal.priceMonthBilling,
],
),
buttonText: state.subscriptionInfo.hasAIOnDevice
? LocaleKeys
.settings_planPage_planUsage_addons_activeLabel
.tr()
: LocaleKeys
.settings_planPage_planUsage_addons_addLabel
.tr(),
isActive: state.subscriptionInfo.hasAIOnDevice,
plan: SubscriptionPlanPB.AiLocal,
),
),
// TODO(nathan): enable local ai
// if (Platform.isMacOS)
// Flexible(
// child: _AddOnBox(
// title: LocaleKeys
// .settings_planPage_planUsage_addons_aiOnDevice_title
// .tr(),
// description: LocaleKeys
// .settings_planPage_planUsage_addons_aiOnDevice_description
// .tr(),
// price: LocaleKeys
// .settings_planPage_planUsage_addons_aiOnDevice_price
// .tr(
// args: [
// SubscriptionPlanPB.AiLocal.priceAnnualBilling,
// ],
// ),
// priceInfo: LocaleKeys
// .settings_planPage_planUsage_addons_aiOnDevice_priceInfo
// .tr(),
// billingInfo: LocaleKeys
// .settings_planPage_planUsage_addons_aiOnDevice_billingInfo
// .tr(
// args: [
// SubscriptionPlanPB.AiLocal.priceMonthBilling,
// ],
// ),
// buttonText: state.subscriptionInfo.hasAIOnDevice
// ? LocaleKeys
// .settings_planPage_planUsage_addons_activeLabel
// .tr()
// : LocaleKeys
// .settings_planPage_planUsage_addons_addLabel
// .tr(),
// isActive: state.subscriptionInfo.hasAIOnDevice,
// plan: SubscriptionPlanPB.AiLocal,
// ),
// ),
],
),
],
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart';
@@ -25,7 +25,7 @@ class SettingsDialog extends StatelessWidget {
required this.dismissDialog,
required this.didLogout,
required this.restartApp,
this.initPage,
this.initPage,
}) : super(key: ValueKey(user.id));
final VoidCallback dismissDialog;
@@ -39,6 +39,7 @@ class SettingsDialog extends StatelessWidget {
return BlocProvider<SettingsDialogBloc>(
create: (context) => SettingsDialogBloc(
user,
context.read<UserWorkspaceBloc>().state.currentWorkspaceMember,
initPage: initPage,
)..add(const SettingsDialogEvent.initial()),
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
@@ -60,6 +61,7 @@ class SettingsDialog extends StatelessWidget {
.add(SettingsDialogEvent.setSelectedPage(index)),
currentPage:
context.read<SettingsDialogBloc>().state.page,
isBillingEnabled: state.isBillingEnabled,
member: context
.read<UserWorkspaceBloc>()
.state
@@ -1,4 +1,3 @@
import 'package:appflowy_backend/log.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@@ -13,7 +12,9 @@ import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_bu
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -355,25 +356,25 @@ class BillingGateGuard extends StatelessWidget {
Future<bool> isBillingEnabled() async {
final result = await UserEventGetCloudConfig().send();
return result.fold((cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
return result.fold(
(cloudSetting) {
final whiteList = [
"https://beta.appflowy.cloud",
"https://test.appflowy.cloud",
];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
if (whiteList.contains(cloudSetting.serverUrl)) {
return true;
} else {
Log.warn(
"Billing is not enabled for this server:${cloudSetting.serverUrl}",
);
final isWhiteListed = whiteList.contains(cloudSetting.serverUrl);
if (!isWhiteListed) {
Log.warn("Billing is not enabled for server ${cloudSetting.serverUrl}");
}
return isWhiteListed;
},
(err) {
Log.error("Failed to get cloud config: $err");
return false;
}
}, (err) {
Log.error("Failed to get cloud config: $err");
return false;
});
},
);
}
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
@@ -17,12 +16,14 @@ class SettingsMenu extends StatelessWidget {
required this.changeSelectedPage,
required this.currentPage,
required this.userProfile,
required this.isBillingEnabled,
this.member,
});
final Function changeSelectedPage;
final SettingsPage currentPage;
final UserProfilePB userProfile;
final bool isBillingEnabled;
final WorkspaceMemberPB? member;
@override
@@ -112,11 +113,7 @@ class SettingsMenu extends StatelessWidget {
),
changeSelectedPage: changeSelectedPage,
),
if (FeatureFlag.planBilling.isOn &&
userProfile.authenticator ==
AuthenticatorPB.AppFlowyCloud &&
member != null &&
member!.role.isOwner) ...[
if (FeatureFlag.planBilling.isOn && isBillingEnabled) ...[
SettingsMenuElement(
page: SettingsPage.plan,
selectedPage: currentPage,
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
@@ -11,7 +13,6 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:toastification/toastification.dart';
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
@@ -418,6 +419,7 @@ Future<void> showConfirmDialog({
required String title,
required String description,
VoidCallback? onConfirm,
VoidCallback? onCancel,
String? confirmLabel,
ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk,
}) {
@@ -434,6 +436,7 @@ Future<void> showConfirmDialog({
title: title,
description: description,
onConfirm: () => onConfirm?.call(),
onCancel: () => onCancel?.call(),
confirmLabel: confirmLabel,
style: style,
),
@@ -471,3 +474,37 @@ Future<void> showCancelAndConfirmDialog({
},
);
}
Future<void> showCustomConfirmDialog({
required BuildContext context,
required String title,
required String description,
required Widget Function(BuildContext) builder,
VoidCallback? onConfirm,
String? confirmLabel,
ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk,
bool closeOnConfirm = true,
}) {
return showDialog(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 440,
child: ConfirmPopup(
title: title,
description: description,
onConfirm: () => onConfirm?.call(),
confirmLabel: confirmLabel,
style: style,
closeOnAction: closeOnConfirm,
child: builder(context),
),
),
);
},
);
}
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@@ -14,7 +16,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
@@ -190,7 +191,7 @@ class InteractiveImageToolbar extends StatelessWidget {
tooltip: LocaleKeys
.document_imageBlock_interactiveViewer_toolbar_closeViewer
.tr(),
icon: FlowySvgs.close_s,
icon: FlowySvgs.close_viewer_s,
onTap: () => Navigator.of(context).pop(),
),
],
@@ -291,11 +292,12 @@ class _ToolbarItem extends StatelessWidget {
isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
width: 32,
height: 32,
padding: const EdgeInsets.all(8),
child: FlowySvg(
icon,
size: const Size.square(16),
color: isDisabled ? Colors.grey : Colors.white,
),
),
@@ -110,10 +110,14 @@ class _InteractiveImageViewerState extends State<InteractiveImageViewer> {
child: SizedBox(
height: size.height,
width: size.width,
child: widget.imageProvider.renderImage(
context,
currentIndex,
userProfile,
child: GestureDetector(
// We can consider adding zoom behavior instead in a later iteration
onDoubleTap: () => Navigator.of(context).pop(),
child: widget.imageProvider.renderImage(
context,
currentIndex,
userProfile,
),
),
),
),
+8
View File
@@ -806,6 +806,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_staggered_grid_view:
dependency: "direct main"
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_sticky_header:
dependency: transitive
description:
+2
View File
@@ -149,6 +149,8 @@ dependencies:
desktop_drop: ^0.4.4
cross_file: ^0.3.4+1
flutter_staggered_grid_view: ^0.7.0
# Window Manager for MacOS and Linux
window_manager: ^0.3.9
+22 -22
View File
@@ -172,7 +172,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
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=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -206,7 +206,7 @@ dependencies = [
[[package]]
name = "appflowy-local-ai"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"appflowy-plugin",
@@ -225,7 +225,7 @@ dependencies = [
[[package]]
name = "appflowy-plugin"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"cfg-if",
@@ -826,7 +826,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"again",
"anyhow",
@@ -876,7 +876,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -888,7 +888,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"futures-channel",
"futures-util",
@@ -962,7 +962,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -986,7 +986,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -1016,7 +1016,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"bytes",
@@ -1055,7 +1055,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"chrono",
@@ -1093,7 +1093,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-stream",
@@ -1132,7 +1132,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bincode",
@@ -1157,7 +1157,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"async-trait",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -1421,7 +1421,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa 1.0.6",
"phf 0.8.0",
"phf 0.11.2",
"smallvec",
]
@@ -1532,7 +1532,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -3038,7 +3038,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"futures-util",
@@ -3055,7 +3055,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -6031,7 +6031,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
+10 -10
View File
@@ -53,7 +53,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0062c950677f7f633f5b7edabc827a35d3bc92c3" }
[dependencies]
serde_json.workspace = true
@@ -116,17 +116,17 @@ custom-protocol = ["tauri/custom-protocol"]
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
# Working directory: frontend
# To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
+22 -22
View File
@@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
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=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -197,7 +197,7 @@ dependencies = [
[[package]]
name = "appflowy-local-ai"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"appflowy-plugin",
@@ -216,7 +216,7 @@ dependencies = [
[[package]]
name = "appflowy-plugin"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"cfg-if",
@@ -800,7 +800,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"again",
"anyhow",
@@ -850,7 +850,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -862,7 +862,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"futures-channel",
"futures-util",
@@ -945,7 +945,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -969,7 +969,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -999,7 +999,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -1019,7 +1019,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"bytes",
@@ -1038,7 +1038,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"chrono",
@@ -1076,7 +1076,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-stream",
@@ -1115,7 +1115,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bincode",
@@ -1140,7 +1140,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"async-trait",
@@ -1157,7 +1157,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -1411,7 +1411,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa 1.0.10",
"phf 0.8.0",
"phf 0.11.2",
"smallvec",
]
@@ -1522,7 +1522,7 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -3105,7 +3105,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"futures-util",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -3559,7 +3559,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -6095,7 +6095,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=c2a839ba8bf9ead44679eb08f3a9680467b767ca#c2a839ba8bf9ead44679eb08f3a9680467b767ca"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
+10 -10
View File
@@ -52,7 +52,7 @@ collab-user = { version = "0.2" }
# Run the script:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "c2a839ba8bf9ead44679eb08f3a9680467b767ca" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0062c950677f7f633f5b7edabc827a35d3bc92c3" }
[dependencies]
serde_json.workspace = true
@@ -116,18 +116,18 @@ custom-protocol = ["tauri/custom-protocol"]
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
# Working directory: frontend
# To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="11.6765" y="3.27295" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(45 11.6765 3.27295)" fill="#333333"/>
<rect x="12.7271" y="11.6766" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(135 12.7271 11.6766)" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M215.74-528Q186-528 165-549.5 144-571 144-600v-144q0-29.7 21.18-50.85Q186.35-816 216.09-816h144.17Q390-816 411-794.85q21 21.15 21 50.85v144q0 29-21.18 50.5-21.17 21.5-50.91 21.5H215.74Zm0 384Q186-144 165-165.18q-21-21.17-21-50.91v-144.17Q144-390 165.18-411q21.17-21 50.91-21h144.17Q390-432 411-410.82q21 21.17 21 50.91v144.17Q432-186 410.82-165q-21.17 21-50.91 21H215.74ZM600-528q-29 0-50.5-21.5T528-600v-144q0-29.7 21.5-50.85Q571-816 600-816h144q29.7 0 50.85 21.15Q816-773.7 816-744v144q0 29-21.15 50.5T744-528H600Zm0 384q-29 0-50.5-21.18-21.5-21.17-21.5-50.91v-144.17Q528-390 549.5-411q21.5-21 50.5-21h144q29.7 0 50.85 21.18Q816-389.65 816-359.91v144.17Q816-186 794.85-165 773.7-144 744-144H600ZM216-600h144v-144H216v144Zm384 0h144v-144H600v144Zm0 384h144v-144H600v144Zm-384 0h144v-144H216v144Zm384-384Zm0 240Zm-240 0Zm0-240Z"/></svg>

After

Width:  |  Height:  |  Size: 952 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M96-384v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T219-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T117-333.15Q96-354.3 96-384Zm264 144q-33 0-52.5-19.5T288-312v-336q0-33 19.5-52.5T360-720h240q33 0 52.5 19.5T672-648v336q0 33-19.5 52.5T600-240H360Zm360-144v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T843-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T741-333.15Q720-354.3 720-384Zm-360 72h240v-336H360v336Zm120-168Z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M216-144q-29 0-50.5-21.5T144-216v-528q0-29.7 21.5-50.85Q187-816 216-816h528q29.7 0 50.85 21.15Q816-773.7 816-744v528q0 29-21.15 50.5T744-144H216Zm0-72h228v-528H216v528Zm300 0h228v-264H516v264Zm0-336h228v-192H516v192Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

+36 -3
View File
@@ -655,6 +655,7 @@
"llmModel": "Language Model",
"llmModelType": "Language Model Type",
"downloadLLMPrompt": "Download {}",
"downloadAppFlowyOfflineAI": "Downloading AI offline package will enable AI to run on your device. Do you want to continue?",
"downloadLLMPromptDetail": "Downloading {} local model will take up to {} of storage. Do you want to continue?",
"downloadAIModelButton": "Download AI model",
"downloadingModel": "Downloading",
@@ -664,9 +665,17 @@
"localAIStopped": "Local AI stopped",
"failToLoadLocalAI": "Failed to start local AI",
"restartLocalAI": "Restart Local AI",
"disableLocalAIDialog": "Do you want to disable local AI?",
"disableLocalAITitle": "Disable local AI",
"disableLocalAIDescription": "Do you want to disable local AI?",
"localAIToggleTitle": "Toggle to enable or disable local AI",
"fetchLocalModel": "Fetch local model configuration",
"offlineAIInstruction1": "Follow the",
"offlineAIInstruction2": "instruction",
"offlineAIInstruction3": "to enable offline AI.",
"offlineAIDownload1": "If you have not downloaded the AppFlowy AI, please",
"offlineAIDownload2": "download",
"offlineAIDownload3": "it first",
"activeOfflineAI": "Active",
"downloadOfflineAI": "Download",
"openModelDirectory": "Open folder"
}
},
@@ -1505,7 +1514,11 @@
"photoKeyword": "photo",
"photoBrowserKeyword": "photo browser",
"galleryKeyword": "gallery",
"addImageTooltip": "Add image"
"addImageTooltip": "Add image",
"changeLayoutTooltip": "Change layout",
"browserLayout": "Browser",
"gridLayout": "Grid",
"deleteBlockTooltip": "Delete whole gallery"
},
"math": {
"copiedToPasteBoard": "The math equation has been copied to the clipboard"
@@ -1550,6 +1563,26 @@
"invalidVideoUrl": "The source URL is not supported yet.",
"invalidVideoUrlYouTube": "YouTube is not supported yet.",
"supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264"
},
"file": {
"name": "File",
"uploadTab": "Upload",
"networkTab": "Integrate link",
"placeholderText": "Click or drag and drop to upload a file",
"placeholderDragging": "Drop the file to upload",
"dropFileToUpload": "Drop the file to upload",
"fileUploadHint": "Drag and drop a file here\nor click to select a file.",
"networkHint": "Enter a link to a file",
"networkUrlInvalid": "Invalid URL, please correct the URL and try again",
"networkAction": "Embed file link",
"fileTooBigError": "File size is too big, please upload a file with size less than 10MB",
"renameFile": {
"title": "Rename file",
"description": "Enter the new name for this file",
"nameEmptyError": "File name cannot be left empty."
},
"uploadedAt": "Uploaded on {}",
"linkedAt": "Link added on {}"
}
},
"outlineBlock": {
+211 -101
View File
@@ -163,7 +163,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "app-error"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
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=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -197,7 +197,7 @@ dependencies = [
[[package]]
name = "appflowy-local-ai"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"appflowy-plugin",
@@ -216,7 +216,7 @@ dependencies = [
[[package]]
name = "appflowy-plugin"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f3b678e36f22012b241f8e2f3cb811be2da245c0#f3b678e36f22012b241f8e2f3cb811be2da245c0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec#8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec"
dependencies = [
"anyhow",
"cfg-if",
@@ -598,9 +598,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "bytes"
version = "1.6.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
dependencies = [
"serde",
]
@@ -718,7 +718,7 @@ dependencies = [
[[package]]
name = "client-api"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"again",
"anyhow",
@@ -768,7 +768,7 @@ dependencies = [
[[package]]
name = "client-api-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"collab-entity",
"collab-rt-entity",
@@ -780,7 +780,7 @@ dependencies = [
[[package]]
name = "client-websocket"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"futures-channel",
"futures-util",
@@ -823,7 +823,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -847,7 +847,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-trait",
@@ -877,7 +877,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -897,7 +897,7 @@ dependencies = [
[[package]]
name = "collab-entity"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"bytes",
@@ -916,7 +916,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"chrono",
@@ -954,7 +954,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"async-stream",
@@ -993,7 +993,7 @@ dependencies = [
[[package]]
name = "collab-rt-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bincode",
@@ -1018,7 +1018,7 @@ dependencies = [
[[package]]
name = "collab-rt-protocol"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"async-trait",
@@ -1035,7 +1035,7 @@ dependencies = [
[[package]]
name = "collab-user"
version = "0.2.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=1c0611e#1c0611e293ff29eb798fd881a6155b7576cf37f9"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=93ec9a38722d5ed0628e6d03a161e2de172967f3#93ec9a38722d5ed0628e6d03a161e2de172967f3"
dependencies = [
"anyhow",
"collab",
@@ -1356,7 +1356,7 @@ checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
[[package]]
name = "database-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -1525,10 +1525,10 @@ dependencies = [
]
[[package]]
name = "dotenvy"
version = "0.15.7"
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "downcast-rs"
@@ -1624,7 +1624,7 @@ dependencies = [
"collab-entity",
"collab-folder",
"collab-plugins",
"dotenvy",
"dotenv",
"flowy-chat",
"flowy-chat-pub",
"flowy-core",
@@ -1655,7 +1655,7 @@ dependencies = [
"serde",
"serde_json",
"strum",
"tempfile",
"tempdir",
"thread-id",
"tokio",
"tokio-postgres",
@@ -1739,6 +1739,18 @@ dependencies = [
"getrandom 0.2.10",
]
[[package]]
name = "filetime"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.4.1",
"windows-sys 0.52.0",
]
[[package]]
name = "finl_unicode"
version = "1.2.0"
@@ -1781,7 +1793,7 @@ dependencies = [
"base64 0.21.5",
"bytes",
"dashmap",
"dotenvy",
"dotenv",
"flowy-chat-pub",
"flowy-codegen",
"flowy-derive",
@@ -1794,6 +1806,7 @@ dependencies = [
"lib-infra",
"log",
"md5",
"notify",
"parking_lot 0.12.1",
"protobuf",
"reqwest",
@@ -2227,7 +2240,7 @@ dependencies = [
"collab-entity",
"collab-folder",
"collab-plugins",
"dotenvy",
"dotenv",
"flowy-chat-pub",
"flowy-database-pub",
"flowy-document-pub",
@@ -2457,6 +2470,21 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "funty"
version = "2.0.0"
@@ -2699,7 +2727,7 @@ dependencies = [
[[package]]
name = "gotrue"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"futures-util",
@@ -2716,7 +2744,7 @@ dependencies = [
[[package]]
name = "gotrue-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -2729,9 +2757,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.26"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
dependencies = [
"bytes",
"fnv",
@@ -2739,7 +2767,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http",
"indexmap 2.1.0",
"indexmap 1.9.3",
"slab",
"tokio",
"tokio-util",
@@ -3081,7 +3109,7 @@ dependencies = [
[[package]]
name = "infra"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"bytes",
@@ -3092,6 +3120,26 @@ dependencies = [
"tracing",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.3"
@@ -3169,12 +3217,32 @@ checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378"
dependencies = [
"base64 0.21.5",
"pem",
"ring 0.16.20",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "kqueue"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
@@ -3572,11 +3640,12 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.11"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0"
dependencies = [
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
@@ -3636,6 +3705,25 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.4.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "ntapi"
version = "0.4.1"
@@ -3734,9 +3822,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.66"
version = "0.10.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671"
dependencies = [
"bitflags 2.4.0",
"cfg-if",
@@ -3766,18 +3854,18 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "300.3.1+3.3.1"
version = "300.1.5+3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91"
checksum = "559068e4c12950d7dcaa1857a61725c0d38d4fc03ff8e070ab31a75d6e316491"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.103"
version = "0.9.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
checksum = "c1665caf8ab2dc9aef43d1c0023bd904633a6a05cb30b0ad59bec2ae986e57a7"
dependencies = [
"cc",
"libc",
@@ -4500,6 +4588,19 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand"
version = "0.7.3"
@@ -4545,6 +4646,21 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -4611,6 +4727,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "redox_syscall"
version = "0.1.57"
@@ -4694,6 +4819,15 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi",
]
[[package]]
name = "rend"
version = "0.4.0"
@@ -4761,27 +4895,12 @@ dependencies = [
"cc",
"libc",
"once_cell",
"spin 0.5.2",
"untrusted 0.7.1",
"spin",
"untrusted",
"web-sys",
"winapi",
]
[[package]]
name = "ring"
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.10",
"libc",
"spin 0.9.8",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.42"
@@ -4882,12 +5001,12 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.21.12"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8"
dependencies = [
"log",
"ring 0.17.8",
"ring",
"rustls-webpki",
"sct",
]
@@ -4903,12 +5022,12 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.101.7"
version = "0.101.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d"
dependencies = [
"ring 0.17.8",
"untrusted 0.9.0",
"ring",
"untrusted",
]
[[package]]
@@ -5011,8 +5130,8 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
dependencies = [
"ring 0.16.20",
"untrusted 0.7.1",
"ring",
"untrusted",
]
[[package]]
@@ -5205,7 +5324,7 @@ dependencies = [
[[package]]
name = "shared-entity"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=762e558d72b11c01d78d407dbebb7c15099ecc55#762e558d72b11c01d78d407dbebb7c15099ecc55"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=0062c950677f7f633f5b7edabc827a35d3bc92c3#0062c950677f7f633f5b7edabc827a35d3bc92c3"
dependencies = [
"anyhow",
"app-error",
@@ -5228,9 +5347,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380"
[[package]]
name = "signal-hook-registry"
@@ -5360,12 +5479,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -5677,10 +5790,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.10.1"
name = "tempdir"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
dependencies = [
"rand 0.4.6",
"remove_dir_all",
]
[[package]]
name = "tempfile"
version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67"
dependencies = [
"cfg-if",
"fastrand",
@@ -6355,9 +6478,9 @@ dependencies = [
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa"
[[package]]
name = "untrusted"
@@ -6365,12 +6488,6 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.0"
@@ -6497,12 +6614,6 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.90"
@@ -6627,12 +6738,11 @@ dependencies = [
[[package]]
name = "whoami"
version = "1.5.1"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9"
checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50"
dependencies = [
"redox_syscall 0.4.1",
"wasite",
"wasm-bindgen",
"web-sys",
]
@@ -6884,18 +6994,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.7.35"
version = "0.7.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
version = "0.7.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f"
dependencies = [
"proc-macro2",
"quote",
+12 -11
View File
@@ -43,6 +43,7 @@ flowy-ast = { workspace = true, path = "build-tool/flowy-ast" }
flowy-codegen = { workspace = true, path = "build-tool/flowy-codegen" }
flowy-derive = { workspace = true, path = "build-tool/flowy-derive" }
flowy-core = { workspace = true, path = "flowy-core" }
dart-ffi = { workspace = true, path = "dart-ffi" }
flowy-user = { workspace = true, path = "flowy-user" }
flowy-user-pub = { workspace = true, path = "flowy-user-pub" }
flowy-sqlite = { workspace = true, path = "flowy-sqlite" }
@@ -98,8 +99,8 @@ zip = "2.1.3"
# 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 = "762e558d72b11c01d78d407dbebb7c15099ecc55" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "762e558d72b11c01d78d407dbebb7c15099ecc55" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0062c950677f7f633f5b7edabc827a35d3bc92c3" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "0062c950677f7f633f5b7edabc827a35d3bc92c3" }
[profile.dev]
opt-level = 0
@@ -134,17 +135,17 @@ rocksdb = { git = "https://github.com/LucasXu0/rust-rocksdb", rev = "21cf4a23ec1
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "1c0611e" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "93ec9a38722d5ed0628e6d03a161e2de172967f3" }
# Working directory: frontend
# To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f3b678e36f22012b241f8e2f3cb811be2da245c0" }
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8ef7d3e4c38fbf92ff9b3630fe79017e95a496ec" }
@@ -49,6 +49,8 @@ rand = { version = "0.8.5", features = [] }
strum = "0.25.0"
[dev-dependencies]
dotenv = "0.15.0"
tempdir = "0.3.7"
uuid.workspace = true
assert-json-diff = "2.0.2"
tokio-postgres = { version = "0.7.8" }
@@ -57,11 +59,9 @@ zip.workspace = true
walkdir = "2.5.0"
futures = "0.3.30"
flowy-chat-pub = { workspace = true }
dotenvy = "0.15.7"
tempfile = "3.10.1"
[features]
default = ["supabase_cloud_test"]
dart = ["flowy-core/dart"]
supabase_cloud_test = []
single_thread = []
single_thread = []
@@ -34,7 +34,7 @@ use flowy_user_pub::cloud::UserCloudService;
use flowy_user_pub::entities::Authenticator;
pub fn get_supabase_config() -> Option<SupabaseConfiguration> {
dotenvy::from_path(".env.ci").ok()?;
dotenv::from_path(".env.ci").ok()?;
SupabaseConfiguration::from_env().ok()
}
@@ -80,13 +80,13 @@ pub async fn receive_with_timeout<T>(mut receiver: Receiver<T>, duration: Durati
}
pub fn get_supabase_ci_config() -> Option<SupabaseConfiguration> {
dotenvy::from_filename("./.env.ci").ok()?;
dotenv::from_filename("./.env.ci").ok()?;
SupabaseConfiguration::from_env().ok()
}
#[allow(dead_code)]
pub fn get_supabase_dev_config() -> Option<SupabaseConfiguration> {
dotenvy::from_filename("./.env.dev").ok()?;
dotenv::from_filename("./.env.dev").ok()?;
SupabaseConfiguration::from_env().ok()
}
@@ -1,6 +1,6 @@
use bytes::Bytes;
pub use client_api::entity::ai_dto::{
AppFlowyAIPlugin, CompletionType, LLMModel, LocalAIConfig, ModelInfo, RelatedQuestion,
AppFlowyOfflineAI, CompletionType, LLMModel, LocalAIConfig, ModelInfo, RelatedQuestion,
RepeatedRelatedQuestion, StringOrMessage,
};
pub use client_api::entity::{
+4 -1
View File
@@ -44,11 +44,14 @@ md5 = "0.7.0"
zip = { workspace = true, features = ["deflate"] }
zip-extensions = "0.8.0"
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
notify = "6.1.1"
[dev-dependencies]
dotenv = "0.15.0"
uuid.workspace = true
tracing-subscriber = { version = "0.3.17", features = ["registry", "env-filter", "ansi", "json"] }
simsimd = "4.4.0"
dotenvy = "0.15.7"
[build-dependencies]
flowy-codegen.workspace = true
@@ -21,7 +21,7 @@ pub trait ChatUserService: Send + Sync + 'static {
fn device_id(&self) -> Result<String, FlowyError>;
fn workspace_id(&self) -> Result<String, FlowyError>;
fn sqlite_connection(&self, uid: i64) -> Result<DBConnection, FlowyError>;
fn user_data_dir(&self) -> Result<PathBuf, FlowyError>;
fn data_root_dir(&self) -> Result<PathBuf, FlowyError>;
}
pub struct ChatManager {
+30 -1
View File
@@ -1,6 +1,7 @@
use crate::local_ai::local_llm_chat::LLMModelInfo;
use appflowy_plugin::core::plugin::RunningState;
use crate::local_ai::local_llm_resource::PendingResource;
use flowy_chat_pub::cloud::{
ChatMessage, LLMModel, RelatedQuestion, RepeatedChatMessage, RepeatedRelatedQuestion,
};
@@ -360,16 +361,38 @@ pub struct PendingResourcePB {
pub name: String,
#[pb(index = 2)]
pub file_size: i64,
pub file_size: String,
#[pb(index = 3)]
pub requirements: String,
#[pb(index = 4)]
pub res_type: PendingResourceTypePB,
}
#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)]
pub enum PendingResourceTypePB {
#[default]
OfflineApp = 0,
AIModel = 1,
}
impl From<PendingResource> for PendingResourceTypePB {
fn from(value: PendingResource) -> Self {
match value {
PendingResource::OfflineApp { .. } => PendingResourceTypePB::OfflineApp,
PendingResource::ModelInfoRes { .. } => PendingResourceTypePB::AIModel,
}
}
}
#[derive(Default, ProtoBuf, Clone, Debug)]
pub struct LocalAIPluginStatePB {
#[pb(index = 1)]
pub state: RunningStatePB,
#[pb(index = 2)]
pub offline_ai_ready: bool,
}
#[derive(Debug, Default, Clone, ProtoBuf_Enum, PartialEq, Eq, Copy)]
@@ -416,3 +439,9 @@ pub struct LocalModelStoragePB {
#[pb(index = 1)]
pub file_path: String,
}
#[derive(Default, ProtoBuf, Clone, Debug)]
pub struct OfflineAIPB {
#[pb(index = 1)]
pub link: String,
}
@@ -374,3 +374,22 @@ pub(crate) async fn get_model_storage_directory_handler(
.get_model_storage_directory()?;
data_result_ok(LocalModelStoragePB { file_path })
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn get_offline_app_handler(
chat_manager: AFPluginState<Weak<ChatManager>>,
) -> DataResult<OfflineAIPB, FlowyError> {
let chat_manager = upgrade_chat_manager(chat_manager)?;
let (tx, rx) = oneshot::channel::<Result<String, FlowyError>>();
tokio::spawn(async move {
let link = chat_manager
.local_ai_controller
.get_offline_ai_app_download_link()
.await?;
let _ = tx.send(Ok(link));
Ok::<_, FlowyError>(())
});
let link = rx.await??;
data_result_ok(OfflineAIPB { link })
}
@@ -57,6 +57,7 @@ pub fn init(chat_manager: Weak<ChatManager>) -> AFPlugin {
ChatEvent::GetModelStorageDirectory,
get_model_storage_directory_handler,
)
.event(ChatEvent::GetOfflineAIAppLink, get_offline_app_handler)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Display, Hash, ProtoBuf_Enum, Flowy_Event)]
@@ -133,4 +134,7 @@ pub enum ChatEvent {
#[event(output = "LocalModelStoragePB")]
GetModelStorageDirectory = 21,
#[event(output = "OfflineAIPB")]
GetOfflineAIAppLink = 22,
}
@@ -6,23 +6,22 @@ use anyhow::Error;
use appflowy_local_ai::chat_plugin::{AIPluginConfig, LocalChatLLMChat};
use appflowy_plugin::manager::PluginManager;
use appflowy_plugin::util::is_apple_silicon;
use flowy_chat_pub::cloud::{AppFlowyAIPlugin, ChatCloudService, LLMModel, LocalAIConfig};
use flowy_chat_pub::cloud::{AppFlowyOfflineAI, ChatCloudService, LLMModel, LocalAIConfig};
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite::kv::KVStorePreferences;
use futures::Sink;
use lib_infra::async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use parking_lot::Mutex;
use std::sync::Arc;
use tokio_stream::StreamExt;
use tracing::{debug, error, info, trace};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LLMSetting {
pub plugin: AppFlowyAIPlugin,
pub app: AppFlowyOfflineAI,
pub llm_model: LLMModel,
}
@@ -59,22 +58,6 @@ impl LocalAIController {
cloud_service: Arc<dyn ChatCloudService>,
) -> Self {
let llm_chat = Arc::new(LocalChatLLMChat::new(plugin_manager));
let mut rx = llm_chat.subscribe_running_state();
let _weak_store_preferences = Arc::downgrade(&store_preferences);
tokio::spawn(async move {
while let Some(state) = rx.next().await {
info!("[AI Plugin] state: {:?}", state);
let new_state = RunningStatePB::from(state);
make_notification(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::UpdateChatPluginState,
)
.payload(LocalAIPluginStatePB { state: new_state })
.send();
}
});
let res_impl = LLMResourceServiceImpl {
user_service: user_service.clone(),
cloud_service,
@@ -85,6 +68,24 @@ impl LocalAIController {
let llm_res = Arc::new(LLMResourceController::new(user_service, res_impl, tx));
let current_chat_id = Mutex::new(None);
let mut running_state_rx = llm_chat.subscribe_running_state();
let offline_ai_ready = llm_res.is_offline_ai_ready();
tokio::spawn(async move {
while let Some(state) = running_state_rx.next().await {
info!("[AI Plugin] state: {:?}", state);
let new_state = RunningStatePB::from(state);
make_notification(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::UpdateChatPluginState,
)
.payload(LocalAIPluginStatePB {
state: new_state,
offline_ai_ready,
})
.send();
}
});
let this = Self {
llm_chat,
llm_res,
@@ -195,11 +196,6 @@ impl LocalAIController {
return Err(FlowyError::local_ai_unavailable());
}
let llm_chat = self.llm_chat.clone();
match llm_chat.destroy_chat_plugin().await {
Ok(_) => info!("[AI Plugin] destroy plugin successfully"),
Err(err) => error!("[AI Plugin] failed to destroy plugin: {:?}", err),
}
let state = self.llm_res.use_local_llm(llm_id)?;
// Re-initialize the plugin if the setting is updated and ready to use
if self.llm_res.is_resource_ready() {
@@ -230,9 +226,11 @@ impl LocalAIController {
}
pub fn get_chat_plugin_state(&self) -> LocalAIPluginStatePB {
let offline_ai_ready = self.llm_res.is_offline_ai_ready();
let state = self.llm_chat.get_plugin_running_state();
LocalAIPluginStatePB {
state: RunningStatePB::from(state),
offline_ai_ready,
}
}
@@ -252,6 +250,10 @@ impl LocalAIController {
.map(|path| path.to_string_lossy().to_string())
}
pub async fn get_offline_ai_app_download_link(&self) -> FlowyResult<String> {
self.llm_res.get_offline_ai_app_download_link().await
}
pub async fn toggle_local_ai(&self) -> FlowyResult<bool> {
let enabled = !self
.store_preferences
@@ -317,6 +319,7 @@ fn initialize_chat_plugin(
ret: Option<tokio::sync::oneshot::Sender<()>>,
) -> FlowyResult<()> {
let llm_chat = llm_chat.clone();
tokio::spawn(async move {
trace!("[AI Plugin] config: {:?}", chat_config);
if is_apple_silicon().await.unwrap_or(false) {
@@ -1,24 +1,25 @@
use crate::chat_manager::ChatUserService;
use crate::entities::{LocalModelResourcePB, PendingResourcePB};
use crate::entities::{LocalModelResourcePB, PendingResourcePB, PendingResourceTypePB};
use crate::local_ai::local_llm_chat::{LLMModelInfo, LLMSetting};
use crate::local_ai::model_request::download_model;
use appflowy_local_ai::chat_plugin::AIPluginConfig;
use flowy_chat_pub::cloud::{LLMModel, LocalAIConfig, ModelInfo};
use flowy_error::{FlowyError, FlowyResult};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use futures::Sink;
use futures_util::SinkExt;
use lib_infra::async_trait::async_trait;
use parking_lot::RwLock;
use appflowy_local_ai::plugin_request::download_plugin;
use lib_infra::util::{get_operating_system, OperatingSystem};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
use crate::local_ai::watch::{watch_path, WatchContext};
use tokio::fs::{self};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, instrument, trace, warn};
use zip_extensions::zip_extract;
#[async_trait]
pub trait LLMResourceService: Send + Sync + 'static {
@@ -29,12 +30,17 @@ pub trait LLMResourceService: Send + Sync + 'static {
fn is_rag_enabled(&self) -> bool;
}
const PLUGIN_DIR: &str = "plugin";
const LLM_MODEL_DIR: &str = "models";
const DOWNLOAD_FINISH: &str = "finish";
#[derive(Debug, Clone)]
pub enum WatchDiskEvent {
Create,
Remove,
}
pub enum PendingResource {
PluginRes,
OfflineApp,
ModelInfoRes(Vec<ModelInfo>),
}
#[derive(Clone)]
@@ -62,6 +68,9 @@ pub struct LLMResourceController {
ai_config: RwLock<Option<LocalAIConfig>>,
download_task: Arc<RwLock<Option<DownloadTask>>>,
resource_notify: tokio::sync::mpsc::Sender<()>,
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
offline_app_disk_watch: RwLock<Option<WatchContext>>,
offline_app_state_sender: tokio::sync::broadcast::Sender<WatchDiskEvent>,
}
impl LLMResourceController {
@@ -70,6 +79,7 @@ impl LLMResourceController {
resource_service: impl LLMResourceService,
resource_notify: tokio::sync::mpsc::Sender<()>,
) -> Self {
let (offline_app_ready_sender, _) = tokio::sync::broadcast::channel(1);
let llm_setting = RwLock::new(resource_service.retrieve_setting());
Self {
user_service,
@@ -78,6 +88,43 @@ impl LLMResourceController {
ai_config: Default::default(),
download_task: Default::default(),
resource_notify,
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
offline_app_disk_watch: Default::default(),
offline_app_state_sender: offline_app_ready_sender,
}
}
#[allow(dead_code)]
pub fn subscribe_offline_app_state(&self) -> tokio::sync::broadcast::Receiver<WatchDiskEvent> {
self.offline_app_state_sender.subscribe()
}
fn set_llm_setting(&self, llm_setting: LLMSetting) {
let offline_app_path = self.offline_app_path(&llm_setting.app.ai_plugin_name);
*self.llm_setting.write() = Some(llm_setting);
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
{
let is_diff = self
.offline_app_disk_watch
.read()
.as_ref()
.map(|watch_context| watch_context.path == offline_app_path)
.unwrap_or(true);
// If the offline app path is different from the current watch path, update the watch path.
if is_diff {
if let Ok((watcher, mut rx)) = watch_path(offline_app_path) {
let offline_app_ready_sender = self.offline_app_state_sender.clone();
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
info!("Offline app file changed: {:?}", event);
let _ = offline_app_ready_sender.send(event);
}
});
self.offline_app_disk_watch.write().replace(watcher);
}
}
}
}
@@ -89,6 +136,24 @@ impl LLMResourceController {
}
}
pub fn is_offline_ai_ready(&self) -> bool {
match self.llm_setting.read().as_ref() {
None => {
trace!("[LLM Resource] No local ai setting found");
false
},
Some(setting) => {
let path = self.offline_app_path(&setting.app.ai_plugin_name);
path.exists()
},
}
}
pub async fn get_offline_ai_app_download_link(&self) -> FlowyResult<String> {
let ai_config = self.fetch_ai_config().await?;
Ok(ai_config.plugin.url)
}
/// Retrieves model information and updates the current model settings.
#[instrument(level = "debug", skip_all, err)]
pub async fn refresh_llm_resource(&self) -> FlowyResult<LLMModelInfo> {
@@ -101,10 +166,10 @@ impl LLMResourceController {
let selected_model = self.select_model(&ai_config)?;
let llm_setting = LLMSetting {
plugin: ai_config.plugin.clone(),
app: ai_config.plugin.clone(),
llm_model: selected_model.clone(),
};
self.llm_setting.write().replace(llm_setting.clone());
self.set_llm_setting(llm_setting.clone());
self.resource_service.store_setting(llm_setting)?;
Ok(LLMModelInfo {
@@ -130,12 +195,12 @@ impl LLMResourceController {
.ok_or_else(|| FlowyError::local_ai().with_context("No local ai config found"))?;
let llm_setting = LLMSetting {
plugin: package,
app: package,
llm_model: llm_config.clone(),
};
trace!("[LLM Resource] Selected AI setting: {:?}", llm_setting);
*self.llm_setting.write() = Some(llm_setting.clone());
self.set_llm_setting(llm_setting.clone());
self.resource_service.store_setting(llm_setting)?;
self.get_local_llm_state()
}
@@ -157,17 +222,19 @@ impl LLMResourceController {
let pending_resources: Vec<_> = pending_resources
.into_iter()
.flat_map(|res| match res {
PendingResource::PluginRes => vec![PendingResourcePB {
PendingResource::OfflineApp => vec![PendingResourcePB {
name: "AppFlowy Plugin".to_string(),
file_size: 0,
file_size: "0 GB".to_string(),
requirements: "".to_string(),
res_type: PendingResourceTypePB::OfflineApp,
}],
PendingResource::ModelInfoRes(model_infos) => model_infos
.into_iter()
.map(|model_info| PendingResourcePB {
name: model_info.name,
file_size: model_info.file_size,
file_size: bytes_to_readable_format(model_info.file_size as u64),
requirements: model_info.requirements,
res_type: PendingResourceTypePB::AIModel,
})
.collect::<Vec<_>>(),
})
@@ -189,11 +256,10 @@ impl LLMResourceController {
None => Err(FlowyError::local_ai().with_context("Can't find any llm config")),
Some(llm_setting) => {
let mut resources = vec![];
let plugin_path = self.plugin_path(&llm_setting.plugin.etag)?;
let plugin_path = self.offline_app_path(&llm_setting.app.ai_plugin_name);
if !plugin_path.exists() {
trace!("[LLM Resource] Plugin file not found: {:?}", plugin_path);
resources.push(PendingResource::PluginRes);
trace!("[LLM Resource] offline plugin not found: {:?}", plugin_path);
resources.push(PendingResource::OfflineApp);
}
let chat_model = self.model_path(&llm_setting.llm_model.chat_model.file_name)?;
@@ -271,12 +337,12 @@ impl LLMResourceController {
*self.download_task.write() = Some(download_task.clone());
progress_notify(download_task.tx.subscribe());
let plugin_dir = self.user_plugin_folder()?;
if !plugin_dir.exists() {
fs::create_dir_all(&plugin_dir).await.map_err(|err| {
FlowyError::local_ai().with_context(format!("Failed to create plugin dir: {:?}", err))
})?;
}
// let plugin_dir = self.user_plugin_folder()?;
// if !plugin_dir.exists() {
// fs::create_dir_all(&plugin_dir).await.map_err(|err| {
// FlowyError::local_ai().with_context(format!("Failed to create plugin dir: {:?}", err))
// })?;
// }
let model_dir = self.user_model_folder()?;
if !model_dir.exists() {
@@ -286,42 +352,42 @@ impl LLMResourceController {
}
tokio::spawn(async move {
let plugin_file_etag_dir = plugin_dir.join(&llm_setting.plugin.etag);
// let plugin_file_etag_dir = plugin_dir.join(&llm_setting.app.etag);
// We use the ETag as the identifier for the plugin file. If a file with the given ETag
// already exists, skip downloading it.
if !plugin_file_etag_dir.exists() {
let plugin_progress_tx = download_task.tx.clone();
info!(
"[LLM Resource] Downloading plugin: {:?}",
llm_setting.plugin.etag
);
let file_name = format!("{}.zip", llm_setting.plugin.etag);
let zip_plugin_file = download_plugin(
&llm_setting.plugin.url,
&plugin_dir,
&file_name,
Some(download_task.cancel_token.clone()),
Some(Arc::new(move |downloaded, total_size| {
let progress = (downloaded as f64 / total_size as f64).clamp(0.0, 1.0);
let _ = plugin_progress_tx.send(format!("plugin:progress:{}", progress));
})),
Some(Duration::from_millis(100)),
)
.await?;
// unzip file
info!(
"[LLM Resource] unzip {:?} to {:?}",
zip_plugin_file, plugin_file_etag_dir
);
zip_extract(&zip_plugin_file, &plugin_file_etag_dir)?;
// delete zip file
info!("[LLM Resource] Delete zip file: {:?}", file_name);
if let Err(err) = fs::remove_file(&zip_plugin_file).await {
error!("Failed to delete zip file: {:?}", err);
}
}
// if !plugin_file_etag_dir.exists() {
// let plugin_progress_tx = download_task.tx.clone();
// info!(
// "[LLM Resource] Downloading plugin: {:?}",
// llm_setting.app.etag
// );
// let file_name = format!("{}.zip", llm_setting.app.etag);
// let zip_plugin_file = download_plugin(
// &llm_setting.app.url,
// &plugin_dir,
// &file_name,
// Some(download_task.cancel_token.clone()),
// Some(Arc::new(move |downloaded, total_size| {
// let progress = (downloaded as f64 / total_size as f64).clamp(0.0, 1.0);
// let _ = plugin_progress_tx.send(format!("plugin:progress:{}", progress));
// })),
// Some(Duration::from_millis(100)),
// )
// .await?;
//
// // unzip file
// info!(
// "[LLM Resource] unzip {:?} to {:?}",
// zip_plugin_file, plugin_file_etag_dir
// );
// zip_extract(&zip_plugin_file, &plugin_file_etag_dir)?;
//
// // delete zip file
// info!("[LLM Resource] Delete zip file: {:?}", file_name);
// if let Err(err) = fs::remove_file(&zip_plugin_file).await {
// error!("Failed to delete zip file: {:?}", err);
// }
// }
// After download the plugin, start downloading models
let chat_model_file = (
@@ -391,7 +457,7 @@ impl LLMResourceController {
Ok(())
}
#[instrument(level = "debug", skip_all, err)]
#[instrument(level = "info", skip_all, err)]
pub fn get_chat_config(&self, rag_enabled: bool) -> FlowyResult<AIPluginConfig> {
if !self.is_resource_ready() {
return Err(FlowyError::local_ai().with_context("Local AI resources are not ready"));
@@ -405,9 +471,25 @@ impl LLMResourceController {
.ok_or_else(|| FlowyError::local_ai().with_context("No local llm setting found"))?;
let model_dir = self.user_model_folder()?;
let bin_path = self
.plugin_path(&llm_setting.plugin.etag)?
.join(llm_setting.plugin.name);
let bin_path = match get_operating_system() {
OperatingSystem::MacOS => {
let path = self.offline_app_path(&llm_setting.app.ai_plugin_name);
if !path.exists() {
return Err(FlowyError::new(
ErrorCode::AIOfflineNotInstalled,
format!("AppFlowy Offline not installed at path: {:?}", path),
));
}
path
},
_ => {
return Err(
FlowyError::local_ai_unavailable()
.with_context("Local AI not available on current platform"),
);
},
};
let chat_model_path = model_dir.join(&llm_setting.llm_model.chat_model.file_name);
let mut config = AIPluginConfig::new(bin_path, chat_model_path)?;
@@ -474,16 +556,12 @@ impl LLMResourceController {
Ok(selected_model)
}
fn user_plugin_folder(&self) -> FlowyResult<PathBuf> {
self.resource_dir().map(|dir| dir.join(PLUGIN_DIR))
}
pub(crate) fn user_model_folder(&self) -> FlowyResult<PathBuf> {
self.resource_dir().map(|dir| dir.join(LLM_MODEL_DIR))
}
fn plugin_path(&self, etag: &str) -> FlowyResult<PathBuf> {
self.user_plugin_folder().map(|dir| dir.join(etag))
pub(crate) fn offline_app_path(&self, plugin_name: &str) -> PathBuf {
PathBuf::from(format!("/usr/local/bin/{}", plugin_name))
}
fn model_path(&self, model_file_name: &str) -> FlowyResult<PathBuf> {
@@ -493,7 +571,19 @@ impl LLMResourceController {
}
pub(crate) fn resource_dir(&self) -> FlowyResult<PathBuf> {
let user_data_dir = self.user_service.user_data_dir()?;
Ok(user_data_dir.join("llm"))
let user_data_dir = self.user_service.data_root_dir()?;
Ok(user_data_dir.join("ai"))
}
}
fn bytes_to_readable_format(bytes: u64) -> String {
const BYTES_IN_GIGABYTE: u64 = 1024 * 1024 * 1024;
const BYTES_IN_MEGABYTE: u64 = 1024 * 1024;
if bytes >= BYTES_IN_GIGABYTE {
let gigabytes = (bytes as f64) / (BYTES_IN_GIGABYTE as f64);
format!("{:.1} GB", gigabytes)
} else {
let megabytes = (bytes as f64) / (BYTES_IN_MEGABYTE as f64);
format!("{:.2} MB", megabytes)
}
}
@@ -1,3 +1,6 @@
pub mod local_llm_chat;
pub mod local_llm_resource;
mod model_request;
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub mod watch;
@@ -0,0 +1,38 @@
use crate::local_ai::local_llm_resource::WatchDiskEvent;
use flowy_error::{FlowyError, FlowyResult};
use notify::{Event, RecursiveMode, Watcher};
use std::path::PathBuf;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
use tracing::error;
pub struct WatchContext {
#[allow(dead_code)]
watcher: notify::RecommendedWatcher,
pub path: PathBuf,
}
pub fn watch_path(path: PathBuf) -> FlowyResult<(WatchContext, UnboundedReceiver<WatchDiskEvent>)> {
let (tx, rx) = unbounded_channel();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
Ok(event) => match event.kind {
notify::EventKind::Create(_) => {
if let Err(err) = tx.send(WatchDiskEvent::Create) {
error!("watch send error: {:?}", err)
}
},
notify::EventKind::Remove(_) => {
if let Err(err) = tx.send(WatchDiskEvent::Remove) {
error!("watch send error: {:?}", err)
}
},
_ => {},
},
Err(e) => error!("watch error: {:?}", e),
})
.map_err(|err| FlowyError::internal().with_context(err))?;
watcher
.watch(&path, RecursiveMode::Recursive)
.map_err(|err| FlowyError::internal().with_context(err))?;
Ok((WatchContext { watcher, path }, rx))
}
@@ -52,7 +52,9 @@ impl ChatUserService for ChatUserServiceImpl {
self.upgrade_user()?.get_sqlite_connection(uid)
}
fn user_data_dir(&self) -> Result<PathBuf, FlowyError> {
self.upgrade_user()?.get_user_data_dir()
fn data_root_dir(&self) -> Result<PathBuf, FlowyError> {
Ok(PathBuf::from(
self.upgrade_user()?.get_application_root_dir(),
))
}
}
@@ -301,6 +301,9 @@ pub enum ErrorCode {
#[error("Unsupported file format")]
UnsupportedFileFormat = 104,
#[error("AI offline not started")]
AIOfflineNotInstalled = 105,
}
impl ErrorCode {
+1 -1
View File
@@ -66,9 +66,9 @@ features = [
[dev-dependencies]
uuid.workspace = true
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
dotenv = "0.15.0"
assert-json-diff = "2.0.2"
serde_json.workspace = true
dotenvy = "0.15.7"
[features]
enable_supabase = ["collab-plugins/postgres_plugin"]
@@ -22,7 +22,7 @@ use crate::setup_log;
/// - `GOTRUE_ADMIN_EMAIL=admin@example.com`
/// - `GOTRUE_ADMIN_PASSWORD=password`
pub fn get_af_cloud_config() -> Option<AFCloudConfiguration> {
dotenvy::from_filename("./.env.ci").ok()?;
dotenv::from_filename("./.env.ci").ok()?;
setup_log();
AFCloudConfiguration::from_env().ok()
}
@@ -23,14 +23,14 @@ use lib_infra::future::FutureResult;
use crate::setup_log;
pub fn get_supabase_ci_config() -> Option<SupabaseConfiguration> {
dotenvy::from_filename("./.env.ci").ok()?;
dotenv::from_filename("./.env.ci").ok()?;
setup_log();
SupabaseConfiguration::from_env().ok()
}
#[allow(dead_code)]
pub fn get_supabase_dev_config() -> Option<SupabaseConfiguration> {
dotenvy::from_filename("./.env.dev").ok()?;
dotenv::from_filename("./.env.dev").ok()?;
setup_log();
SupabaseConfiguration::from_env().ok()
}