intermediate
This commit is contained in:
@@ -78,13 +78,13 @@ class TabRepository extends _$TabRepository {
|
||||
|
||||
final tabContentSub =
|
||||
tabContentService.tabContentStream.listen((content) async {
|
||||
await _db.tabDao.updateTab(
|
||||
await _db.tabDao.updateTabContent(
|
||||
content.tabId,
|
||||
isProbablyReaderable: Value(content.isProbablyReaderable),
|
||||
extractedContentMarkdown: Value(content.extractedContentMarkdown),
|
||||
extractedContentPlain: Value(content.extractedContentPlain),
|
||||
fullContentMarkdown: Value(content.fullContentMarkdown),
|
||||
fullContentPlain: Value(content.fullContentPlain),
|
||||
isProbablyReaderable: content.isProbablyReaderable,
|
||||
extractedContentMarkdown: content.extractedContentMarkdown,
|
||||
extractedContentPlain: content.extractedContentPlain,
|
||||
fullContentMarkdown: content.fullContentMarkdown,
|
||||
fullContentPlain: content.fullContentPlain,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'tab.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$tabRepositoryHash() => r'4269add3c89250ab30835055e3567a1e6d5c185a';
|
||||
String _$tabRepositoryHash() => r'b9caf89fe372b79f37c4a3ebff800ccc2d9289d2';
|
||||
|
||||
/// See also [TabRepository].
|
||||
@ProviderFor(TabRepository)
|
||||
|
||||
@@ -26,3 +26,15 @@ class ViewTabsSheet extends Sheet {
|
||||
@override
|
||||
List<Object?> get hashParameters => [];
|
||||
}
|
||||
|
||||
class TabQaChatSheet extends Sheet {
|
||||
final String chatId;
|
||||
|
||||
TabQaChatSheet({required this.chatId});
|
||||
|
||||
@override
|
||||
bool get cacheHash => true;
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [chatId];
|
||||
}
|
||||
|
||||
+213
-133
@@ -1,16 +1,24 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/data/models/web_page_info.dart';
|
||||
import 'package:lensai/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:lensai/features/bangs/presentation/widgets/site_search.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/domain/repositories/chat_metadata.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
|
||||
import 'package:lensai/features/kagi/data/entities/modes.dart';
|
||||
import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder;
|
||||
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
|
||||
@@ -24,12 +32,9 @@ class WebPageDialog extends HookConsumerWidget {
|
||||
final Uri url;
|
||||
final WebPageInfo? precachedInfo;
|
||||
|
||||
final void Function()? onDismiss;
|
||||
|
||||
const WebPageDialog({
|
||||
required this.url,
|
||||
this.precachedInfo,
|
||||
this.onDismiss,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -56,21 +61,19 @@ class WebPageDialog extends HookConsumerWidget {
|
||||
useTextEditingController(text: url.toString());
|
||||
final addressTextFocusNode = useFocusNode();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ModalBarrier(
|
||||
color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
SimpleDialog(
|
||||
titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20.0,
|
||||
vertical: 24.0,
|
||||
),
|
||||
title: WebsiteTitleTile(url, precachedInfo: precachedInfo),
|
||||
return MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Dialog(
|
||||
insetPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 64.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
|
||||
child: WebsiteTitleTile(url, precachedInfo: precachedInfo),
|
||||
),
|
||||
SizedBox(
|
||||
//We need this to stretch the dialog, then padding from dialog is applied
|
||||
width: double.maxFinite,
|
||||
@@ -82,6 +85,7 @@ class WebPageDialog extends HookConsumerWidget {
|
||||
controller: addressTextController,
|
||||
focusNode: addressTextFocusNode,
|
||||
enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Address',
|
||||
),
|
||||
@@ -106,136 +110,212 @@ class WebPageDialog extends HookConsumerWidget {
|
||||
.read(tabSessionProvider(tabId: null).notifier)
|
||||
.loadUrl(url: Uri.tryParse(value)!);
|
||||
|
||||
onDismiss?.call();
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: availableBangsAsync.when(
|
||||
data: (availableBangs) {
|
||||
if (availableBangs.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
Flexible(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: availableBangsAsync.when(
|
||||
data: (availableBangs) {
|
||||
if (availableBangs.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SiteSearch(
|
||||
domain: url.host,
|
||||
availableBangs: availableBangs,
|
||||
return SiteSearch(
|
||||
domain: url.host,
|
||||
availableBangs: availableBangs,
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => FailureWidget(
|
||||
title: 'Could not load bangs',
|
||||
exception: error,
|
||||
),
|
||||
loading: () => SiteSearch(
|
||||
domain: url.host,
|
||||
availableBangs: [
|
||||
BangData(
|
||||
websiteName: 'websiteName',
|
||||
domain: 'domain',
|
||||
trigger: 'trigger',
|
||||
urlTemplate: 'urlTemplate',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (availableBangsAsync.isLoading ||
|
||||
availableBangCount == null ||
|
||||
availableBangCount > 0)
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(MdiIcons.contentCopy),
|
||||
title: const Text('Copy address'),
|
||||
onTap: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: url.toString()),
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
onTap: () async {
|
||||
await ui_helper.launchUrlFeedback(context, url);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_browser),
|
||||
title: const Text('Launch External'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(MdiIcons.tabPlus),
|
||||
title: const Text('Clone tab'),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: url);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: const Text('Share link'),
|
||||
onTap: () async {
|
||||
await Share.shareUri(url);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.mobile_screen_share),
|
||||
title: const Text('Share screenshot'),
|
||||
onTap: () async {
|
||||
final screenshot = await ref
|
||||
.read(selectedTabSessionNotifierProvider)
|
||||
.requestScreenshot();
|
||||
|
||||
if (screenshot != null) {
|
||||
ui.decodeImageFromList(
|
||||
screenshot,
|
||||
(result) async {
|
||||
final png = await result.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
|
||||
if (png != null) {
|
||||
final file = XFile.fromData(
|
||||
png.buffer.asUint8List(),
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[file],
|
||||
subject: precachedInfo?.title,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(KagiTool.assistant.icon),
|
||||
title: const Text('QA Chat'),
|
||||
onTap: () async {
|
||||
final selectedTabId =
|
||||
ref.read(selectedTabStateProvider)?.id;
|
||||
|
||||
if (selectedTabId != null) {
|
||||
final updateResult = await ref
|
||||
.read(
|
||||
chatMetadataRepositoryProvider(
|
||||
selectedTabId)
|
||||
.notifier,
|
||||
)
|
||||
.updateMetadata(
|
||||
ChatMetadata(mainDocumentId: selectedTabId),
|
||||
);
|
||||
|
||||
updateResult.onSuccess((_) {
|
||||
ref
|
||||
.read(
|
||||
bottomSheetControllerProvider.notifier)
|
||||
.show(
|
||||
TabQaChatSheet(chatId: selectedTabId));
|
||||
});
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(KagiTool.summarizer.icon),
|
||||
title: const Text('Summarize'),
|
||||
onTap: () async {
|
||||
final summarizerUrl = uri_builder.summarizerUri(
|
||||
document: SharedUrl(url),
|
||||
mode: SummarizerMode.keyMoments,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: summarizerUrl);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => FailureWidget(
|
||||
title: 'Could not load bangs',
|
||||
exception: error,
|
||||
),
|
||||
loading: () => SiteSearch(
|
||||
domain: url.host,
|
||||
availableBangs: [
|
||||
// BangData(
|
||||
// websiteName: 'websiteName',
|
||||
// domain: 'domain',
|
||||
// trigger: 'trigger',
|
||||
// urlTemplate: 'urlTemplate',
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 16.0),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Close'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (availableBangsAsync.isLoading ||
|
||||
availableBangCount == null ||
|
||||
availableBangCount > 0)
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(MdiIcons.contentCopy),
|
||||
title: const Text('Copy address'),
|
||||
onTap: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: url.toString()),
|
||||
);
|
||||
onDismiss?.call();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
onTap: () async {
|
||||
await ui_helper.launchUrlFeedback(context, url);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_browser),
|
||||
title: const Text('Launch External'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(MdiIcons.tabPlus),
|
||||
title: const Text('Clone tab'),
|
||||
onTap: () async {
|
||||
await ref.read(tabRepositoryProvider.notifier).addTab(url: url);
|
||||
|
||||
onDismiss?.call();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: const Text('Share link'),
|
||||
onTap: () async {
|
||||
await Share.shareUri(url);
|
||||
|
||||
onDismiss?.call();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.mobile_screen_share),
|
||||
title: const Text('Share screenshot'),
|
||||
onTap: () async {
|
||||
final screenshot = await ref
|
||||
.read(selectedTabSessionNotifierProvider)
|
||||
.requestScreenshot();
|
||||
|
||||
if (screenshot != null) {
|
||||
ui.decodeImageFromList(
|
||||
screenshot,
|
||||
(result) async {
|
||||
final png = await result.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
|
||||
if (png != null) {
|
||||
final file = XFile.fromData(
|
||||
png.buffer.asUint8List(),
|
||||
mimeType: 'image/png',
|
||||
);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[file],
|
||||
subject: precachedInfo?.title,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onDismiss?.call();
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(KagiTool.summarizer.icon),
|
||||
title: const Text('Summarize'),
|
||||
onTap: () async {
|
||||
final summarizerUrl = uri_builder.summarizerUri(
|
||||
document: SharedUrl(url),
|
||||
mode: SummarizerMode.keyMoments,
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: summarizerUrl);
|
||||
|
||||
onDismiss?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/core/routing/routes.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_session.dart';
|
||||
@@ -17,18 +19,20 @@ import 'package:lensai/features/geckoview/features/browser/domain/services/engin
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/app_bar_title.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/browser_view.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
|
||||
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/features/find_in_page/presentation/controllers/find_in_page_visibility.dart';
|
||||
import 'package:lensai/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
||||
import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart';
|
||||
import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/chat/presentation/widgets/tab_qa_chat.dart';
|
||||
import 'package:lensai/features/kagi/data/entities/modes.dart';
|
||||
import 'package:lensai/features/user/domain/repositories/settings.dart';
|
||||
import 'package:lensai/presentation/hooks/draggable_scrollable_controller.dart';
|
||||
import 'package:lensai/presentation/hooks/menu_controller.dart';
|
||||
import 'package:lensai/presentation/hooks/overlay_portal_controller.dart';
|
||||
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -52,7 +56,8 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
final selectedTabId =
|
||||
ref.watch(selectedTabStateProvider.select((value) => value?.id));
|
||||
|
||||
final menuController = useMemoized(() => MenuController());
|
||||
final trippleDotMenuController = useMenuController();
|
||||
final tabMenuController = useMenuController();
|
||||
|
||||
final lastBackButtonPress = useRef<DateTime?>(null);
|
||||
|
||||
@@ -120,24 +125,13 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
return (tabState != null)
|
||||
? AppBarTitle(
|
||||
tab: tabState,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.show(
|
||||
WebPageDialog(
|
||||
url: tabState.url,
|
||||
precachedInfo: tabState,
|
||||
onDismiss: ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.dismiss,
|
||||
),
|
||||
);
|
||||
onTap: () async {
|
||||
await context.push(
|
||||
WebPageRoute(
|
||||
url: tabState.url.toString(),
|
||||
).location,
|
||||
extra: tabState,
|
||||
);
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
@@ -261,22 +255,58 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
TabsActionButton(
|
||||
isActive: displayedSheet is ViewTabsSheet,
|
||||
onTap: () {
|
||||
if (displayedSheet case ViewTabsSheet()) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.dismiss();
|
||||
} else {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.show(ViewTabsSheet());
|
||||
}
|
||||
MenuAnchor(
|
||||
controller: tabMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return child!;
|
||||
},
|
||||
menuChildren: [
|
||||
if (selectedTabId != null)
|
||||
MenuItemButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.closeTab(selectedTabId);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.close),
|
||||
child: const Text('Close Tab'),
|
||||
),
|
||||
MenuItemButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: Uri.https('kagi.com'));
|
||||
},
|
||||
leadingIcon: const Icon(Icons.add),
|
||||
child: const Text('Add Tab'),
|
||||
),
|
||||
],
|
||||
child: TabsActionButton(
|
||||
isActive: displayedSheet is ViewTabsSheet,
|
||||
onTap: () {
|
||||
if (displayedSheet case ViewTabsSheet()) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.dismiss();
|
||||
} else {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.show(ViewTabsSheet());
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (tabMenuController.isOpen) {
|
||||
tabMenuController.close();
|
||||
} else {
|
||||
tabMenuController.open();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
MenuAnchor(
|
||||
controller: menuController,
|
||||
controller: trippleDotMenuController,
|
||||
builder: (context, controller, child) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
@@ -487,7 +517,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
await controller.reload();
|
||||
menuController.close();
|
||||
trippleDotMenuController.close();
|
||||
},
|
||||
leadingIcon: const Icon(Icons.refresh),
|
||||
child: const Text('Reload'),
|
||||
@@ -514,7 +544,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
await controller.goBack();
|
||||
menuController.close();
|
||||
trippleDotMenuController.close();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
)
|
||||
@@ -526,7 +556,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
.notifier,
|
||||
)
|
||||
.closeTab(selectedTabId);
|
||||
menuController.close();
|
||||
trippleDotMenuController.close();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
@@ -546,7 +576,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
await controller.goForward();
|
||||
menuController.close();
|
||||
trippleDotMenuController.close();
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
@@ -556,6 +586,15 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
onPressed: () async {
|
||||
final x =
|
||||
await context.push(UserAuthRoute().location);
|
||||
print(x);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.info),
|
||||
child: const Text('Auth'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -744,25 +783,36 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
return false;
|
||||
},
|
||||
child: switch (displayedSheet) {
|
||||
ViewTabsSheet() => DraggableScrollableSheet(
|
||||
key: ValueKey(displayedSheet),
|
||||
expand: false,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: _realtiveSafeArea(context),
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
child: ViewTabsSheetWidget(
|
||||
sheetScrollController: scrollController,
|
||||
onClose: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.dismiss();
|
||||
},
|
||||
),
|
||||
ViewTabsSheet() => HookBuilder(
|
||||
builder: (localContext) {
|
||||
final draggableScrollableController =
|
||||
useDraggableScrollableController();
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
key: ValueKey(displayedSheet),
|
||||
controller: draggableScrollableController,
|
||||
expand: false,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: _realtiveSafeArea(context),
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
child: ViewTabsSheetWidget(
|
||||
sheetScrollController: scrollController,
|
||||
draggableScrollableController:
|
||||
draggableScrollableController,
|
||||
onClose: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider
|
||||
.notifier)
|
||||
.dismiss();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -794,6 +844,60 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
final TabQaChatSheet parameter => HookBuilder(
|
||||
builder: (localContext) {
|
||||
final draggableScrollableController =
|
||||
useDraggableScrollableController();
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
key: ValueKey(displayedSheet),
|
||||
controller: draggableScrollableController,
|
||||
expand: false,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: _realtiveSafeArea(context),
|
||||
builder: (context, scrollController) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
DraggableScrollableHeader(
|
||||
controller: draggableScrollableController,
|
||||
child: Material(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius:
|
||||
BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabQaChat(
|
||||
chatId: parameter.chatId,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:lensai/features/geckoview/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/domain/repositories/document.dart';
|
||||
import 'package:lensai/features/user/domain/repositories/cache.dart';
|
||||
|
||||
class BrowserView extends StatefulHookConsumerWidget {
|
||||
@@ -87,6 +88,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
|
||||
//Initialize and register dependencies
|
||||
ref.listenManual(tabRepositoryProvider, (previous, next) {});
|
||||
ref.listenManual(documentRepositoryProvider, (previous, next) {});
|
||||
|
||||
ref.listenManual(
|
||||
selectionActionServiceProvider,
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DraggableScrollableHeader extends StatelessWidget {
|
||||
final DraggableScrollableController controller;
|
||||
final Widget child;
|
||||
|
||||
const DraggableScrollableHeader({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
// Use the DraggableScrollableSheet's controller
|
||||
controller.jumpTo(
|
||||
min(
|
||||
1,
|
||||
controller.pixelsToSize(
|
||||
controller.pixels - details.delta.dy,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -79,10 +79,10 @@ class CreateTabSheetWidget extends HookConsumerWidget {
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: Icon(KagiTool.search.icon),
|
||||
text: 'Search',
|
||||
),
|
||||
// Tab(
|
||||
// icon: Icon(KagiTool.search.icon),
|
||||
// text: 'Search',
|
||||
// ),
|
||||
Tab(
|
||||
icon: Icon(KagiTool.summarizer.icon),
|
||||
text: 'Summarize',
|
||||
@@ -99,10 +99,10 @@ class CreateTabSheetWidget extends HookConsumerWidget {
|
||||
child: ExpandablePageView(
|
||||
controller: pageController,
|
||||
children: [
|
||||
SearchTab(
|
||||
sharedContent: sharedContent,
|
||||
onSubmit: onSubmit,
|
||||
),
|
||||
// SearchTab(
|
||||
// sharedContent: sharedContent,
|
||||
// onSubmit: onSubmit,
|
||||
// ),
|
||||
SummarizeTab(
|
||||
sharedContent: sharedContent,
|
||||
onSubmit: onSubmit,
|
||||
|
||||
+169
-181
@@ -10,9 +10,10 @@ import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/tab_action.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||
import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
|
||||
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
@@ -22,6 +23,8 @@ import 'package:lensai/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:reorderable_grid/reorderable_grid.dart';
|
||||
|
||||
class _Tab extends HookConsumerWidget {
|
||||
static const headerSize = 124.0;
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
const _Tab({required this.onClose});
|
||||
@@ -148,40 +151,15 @@ class _Tab extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
|
||||
static const headerSize = 124.0;
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
_SliverHeaderDelagate({required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
return _Tab(onClose: onClose);
|
||||
}
|
||||
|
||||
@override
|
||||
double get minExtent => headerSize;
|
||||
|
||||
@override
|
||||
double get maxExtent => headerSize;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
|
||||
false;
|
||||
}
|
||||
|
||||
class ViewTabsSheetWidget extends HookConsumerWidget {
|
||||
final ScrollController sheetScrollController;
|
||||
final DraggableScrollableController draggableScrollableController;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const ViewTabsSheetWidget({
|
||||
required this.onClose,
|
||||
required this.sheetScrollController,
|
||||
required this.draggableScrollableController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -209,181 +187,191 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
|
||||
return Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: sheetScrollController,
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _SliverHeaderDelagate(onClose: onClose),
|
||||
Column(
|
||||
children: [
|
||||
DraggableScrollableHeader(
|
||||
controller: draggableScrollableController,
|
||||
child: _Tab(onClose: onClose),
|
||||
),
|
||||
HookConsumer(
|
||||
builder: (context, ref, child) {
|
||||
final container = ref.watch(selectedContainerProvider);
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
controller: sheetScrollController,
|
||||
slivers: [
|
||||
HookConsumer(
|
||||
builder: (context, ref, child) {
|
||||
final container = ref.watch(selectedContainerProvider);
|
||||
|
||||
final filteredTabs = ref
|
||||
.watch(
|
||||
seamlessFilteredTabsProvider(container).select(
|
||||
(value) => EquatableCollection(value, immutable: true),
|
||||
),
|
||||
)
|
||||
.collection;
|
||||
final filteredTabs = ref
|
||||
.watch(
|
||||
seamlessFilteredTabsProvider(container).select(
|
||||
(value) =>
|
||||
EquatableCollection(value, immutable: true),
|
||||
),
|
||||
)
|
||||
.collection;
|
||||
|
||||
final activeTab = ref.watch(selectedTabProvider);
|
||||
final activeTab = ref.watch(selectedTabProvider);
|
||||
|
||||
final itemHeight = useMemoized(
|
||||
() => _calculateItemHeight(
|
||||
screenWidth: MediaQuery.of(context).size.width,
|
||||
childAspectRatio: 0.75,
|
||||
horizontalPadding: 4.0,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
[MediaQuery.of(context).size.width],
|
||||
);
|
||||
final itemHeight = useMemoized(
|
||||
() => _calculateItemHeight(
|
||||
screenWidth: MediaQuery.of(context).size.width,
|
||||
childAspectRatio: 0.75,
|
||||
horizontalPadding: 4.0,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
[MediaQuery.of(context).size.width],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final index = filteredTabs
|
||||
.indexWhere((webView) => webView == activeTab);
|
||||
useEffect(
|
||||
() {
|
||||
final index = filteredTabs
|
||||
.indexWhere((webView) => webView == activeTab);
|
||||
|
||||
if (index > -1) {
|
||||
final offset = (index ~/ 2) * itemHeight;
|
||||
if (index > -1) {
|
||||
final offset = (index ~/ 2) * itemHeight;
|
||||
|
||||
if (offset != sheetScrollController.offset) {
|
||||
unawaited(
|
||||
sheetScrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (offset != sheetScrollController.offset) {
|
||||
unawaited(
|
||||
sheetScrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[filteredTabs, activeTab],
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[filteredTabs, activeTab],
|
||||
);
|
||||
|
||||
final tabs = useMemoized(
|
||||
() {
|
||||
return filteredTabs
|
||||
.mapIndexed(
|
||||
(index, tabId) =>
|
||||
ReorderableGridDelayedDragStartListener(
|
||||
key: ValueKey(tabId),
|
||||
index: index,
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tab = ref.watch(tabStateProvider(tabId));
|
||||
return (tab != null)
|
||||
? TabPreview(
|
||||
tab: tab,
|
||||
isActive: tabId == activeTab,
|
||||
onTap: () async {
|
||||
if (tabId != activeTab) {
|
||||
//Close first to avoid rebuilds
|
||||
onClose();
|
||||
await ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.selectTab(tab.id);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
onDoubleTap: () {
|
||||
ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.show(
|
||||
TabActionDialog(
|
||||
initialTab: tab,
|
||||
onDismiss: ref
|
||||
final tabs = useMemoized(
|
||||
() {
|
||||
return filteredTabs
|
||||
.mapIndexed(
|
||||
(index, tabId) =>
|
||||
ReorderableGridDelayedDragStartListener(
|
||||
key: ValueKey(tabId),
|
||||
index: index,
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final tab =
|
||||
ref.watch(tabStateProvider(tabId));
|
||||
return (tab != null)
|
||||
? TabPreview(
|
||||
tab: tab,
|
||||
isActive: tabId == activeTab,
|
||||
onTap: () async {
|
||||
if (tabId != activeTab) {
|
||||
//Close first to avoid rebuilds
|
||||
onClose();
|
||||
await ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.dismiss,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.closeTab(tab.id);
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
.selectTab(tab.id);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
onDoubleTap: () {
|
||||
ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.show(
|
||||
TabActionDialog(
|
||||
initialTab: tab,
|
||||
onDismiss: ref
|
||||
.read(
|
||||
overlayDialogControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.dismiss,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(
|
||||
tabRepositoryProvider
|
||||
.notifier,
|
||||
)
|
||||
.closeTab(tab.id);
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
[
|
||||
EquatableCollection(filteredTabs, immutable: true),
|
||||
activeTab,
|
||||
],
|
||||
);
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
sliver: SliverReorderableGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
[
|
||||
EquatableCollection(filteredTabs, immutable: true),
|
||||
activeTab
|
||||
],
|
||||
);
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
final containerRepository =
|
||||
ref.read(containerRepositoryProvider.notifier);
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
sliver: SliverReorderableGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
final containerRepository =
|
||||
ref.read(containerRepositoryProvider.notifier);
|
||||
final tabId = filteredTabs[oldIndex];
|
||||
final containerId = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.containerTabId(tabId);
|
||||
|
||||
final tabId = filteredTabs[oldIndex];
|
||||
final containerId = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.containerTabId(tabId);
|
||||
final String key;
|
||||
if (newIndex <= 0) {
|
||||
key = await containerRepository
|
||||
.getLeadingOrderKey(containerId);
|
||||
} else if (newIndex >= filteredTabs.length - 1) {
|
||||
key = await containerRepository
|
||||
.getTrailingOrderKey(containerId);
|
||||
} else {
|
||||
final orderAfterIndex = newIndex;
|
||||
key =
|
||||
await containerRepository.getOrderKeyAfterTab(
|
||||
filteredTabs[orderAfterIndex],
|
||||
containerId,
|
||||
);
|
||||
}
|
||||
|
||||
final String key;
|
||||
if (newIndex <= 0) {
|
||||
key = await containerRepository
|
||||
.getLeadingOrderKey(containerId);
|
||||
} else if (newIndex >= filteredTabs.length - 1) {
|
||||
key = await containerRepository
|
||||
.getTrailingOrderKey(containerId);
|
||||
} else {
|
||||
final orderAfterIndex = newIndex;
|
||||
key = await containerRepository.getOrderKeyAfterTab(
|
||||
filteredTabs[orderAfterIndex],
|
||||
containerId,
|
||||
);
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.assignOrderKey(tabId, key);
|
||||
await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.assignOrderKey(tabId, key);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: _SliverHeaderDelagate.headerSize + 4,
|
||||
top: _Tab.headerSize + 4,
|
||||
right: 4,
|
||||
),
|
||||
child: FloatingActionButton.small(
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lensai/utils/ui_helper.dart' as ui_helper;
|
||||
import 'package:speech_to_text_google_dialog/speech_to_text_google_dialog.dart';
|
||||
|
||||
class SpeechToTextButton extends StatelessWidget {
|
||||
final Function(dynamic data) onTextReceived;
|
||||
|
||||
const SpeechToTextButton({required this.onTextReceived, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: () async {
|
||||
final isServiceAvailable =
|
||||
await SpeechToTextGoogleDialog.getInstance().showGoogleDialog(
|
||||
onTextReceived: onTextReceived,
|
||||
// locale: "en-US",
|
||||
);
|
||||
|
||||
if (!isServiceAvailable) {
|
||||
if (context.mounted) {
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
'Service is not available',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.mic),
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -5,9 +5,11 @@ import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
|
||||
class TabsActionButton extends HookConsumerWidget {
|
||||
final bool isActive;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const TabsActionButton({
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
this.isActive = false,
|
||||
super.key,
|
||||
});
|
||||
@@ -20,6 +22,7 @@ class TabsActionButton extends HookConsumerWidget {
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
|
||||
+7
-1
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/features/geckoview/domain/entities/readerable_state.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:lensai/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:lensai/features/user/domain/repositories/settings.dart';
|
||||
import 'package:lensai/presentation/widgets/animate_gradient_shader.dart';
|
||||
|
||||
class ReaderButton extends HookConsumerWidget {
|
||||
@@ -14,6 +15,10 @@ class ReaderButton extends HookConsumerWidget {
|
||||
|
||||
final readerChanging = ref.watch(readerableScreenControllerProvider);
|
||||
|
||||
final enableReadability = ref.watch(
|
||||
settingsRepositoryProvider.select((value) => value.enableReadability),
|
||||
);
|
||||
|
||||
final readerabilityState = ref.watch(
|
||||
selectedTabStateProvider.select(
|
||||
(state) => state?.readerableState ?? ReaderableState.$default(),
|
||||
@@ -34,7 +39,8 @@ class ReaderButton extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return Visibility(
|
||||
visible: readerabilityState.readerable,
|
||||
visible: readerabilityState.readerable &&
|
||||
(enableReadability || readerabilityState.active),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 15.0,
|
||||
|
||||
@@ -110,38 +110,25 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateTab(
|
||||
Future<void> updateTabContent(
|
||||
String id, {
|
||||
Value<String?> url = const Value.absent(),
|
||||
Value<String?> title = const Value.absent(),
|
||||
Value<bool> isProbablyReaderable = const Value.absent(),
|
||||
Value<String?> extractedContentMarkdown = const Value.absent(),
|
||||
Value<String?> extractedContentPlain = const Value.absent(),
|
||||
Value<String?> fullContentMarkdown = const Value.absent(),
|
||||
Value<String?> fullContentPlain = const Value.absent(),
|
||||
required bool isProbablyReaderable,
|
||||
required String? extractedContentMarkdown,
|
||||
required String? extractedContentPlain,
|
||||
required String? fullContentMarkdown,
|
||||
required String? fullContentPlain,
|
||||
}) async {
|
||||
final doUpdate = url != const Value.absent() ||
|
||||
title != const Value.absent() ||
|
||||
isProbablyReaderable != const Value.absent() ||
|
||||
extractedContentMarkdown != const Value.absent() ||
|
||||
extractedContentPlain != const Value.absent() ||
|
||||
fullContentMarkdown != const Value.absent() ||
|
||||
fullContentPlain != const Value.absent();
|
||||
final statement = _updateByIdStatement(id);
|
||||
|
||||
if (doUpdate) {
|
||||
final statement = _updateByIdStatement(id);
|
||||
await statement.write(
|
||||
TabCompanion(
|
||||
url: url,
|
||||
title: title,
|
||||
isProbablyReaderable: isProbablyReaderable,
|
||||
extractedContentMarkdown: extractedContentMarkdown,
|
||||
extractedContentPlain: extractedContentPlain,
|
||||
fullContentMarkdown: fullContentMarkdown,
|
||||
fullContentPlain: fullContentPlain,
|
||||
),
|
||||
);
|
||||
}
|
||||
await statement.write(
|
||||
TabCompanion(
|
||||
isProbablyReaderable: Value(isProbablyReaderable),
|
||||
extractedContentMarkdown: Value(extractedContentMarkdown),
|
||||
extractedContentPlain: Value(extractedContentPlain),
|
||||
fullContentMarkdown: Value(fullContentMarkdown),
|
||||
fullContentPlain: Value(fullContentPlain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateTabs(
|
||||
@@ -209,7 +196,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
|
||||
|
||||
if (ftsQuery.isNotEmpty) {
|
||||
return db.queryTabsFullContent(
|
||||
query: db.buildFtsQuery(searchString),
|
||||
query: ftsQuery,
|
||||
snippetLength: snippetLength,
|
||||
beforeMatch: matchPrefix,
|
||||
afterMatch: matchSuffix,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart' show Color, IconData;
|
||||
import 'package:lensai/data/database/converters/color.dart';
|
||||
import 'package:lensai/data/database/converters/icon_data.dart';
|
||||
@@ -7,6 +6,9 @@ import 'package:lensai/features/geckoview/features/tabs/data/database/daos/conta
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/database/daos/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/database/daos/vector.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/database/database.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/models/vector_result.dart';
|
||||
import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart';
|
||||
import 'package:lensai/features/search/domain/fts_tokenizer.dart';
|
||||
import 'package:lensai/features/search/domain/unix_tokenizer.dart';
|
||||
@@ -15,9 +17,11 @@ part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
include: {'database.drift'},
|
||||
daos: [ContainerDao, TabDao],
|
||||
daos: [ContainerDao, TabDao, VectorDao],
|
||||
)
|
||||
class TabDatabase extends _$TabDatabase implements IQueryBuilder {
|
||||
class TabDatabase extends _$TabDatabase with TrigramQueryBuilderMixin {
|
||||
final int embeddingDimensions;
|
||||
|
||||
@override
|
||||
final int schemaVersion = 2;
|
||||
|
||||
@@ -26,61 +30,25 @@ class TabDatabase extends _$TabDatabase implements IQueryBuilder {
|
||||
@override
|
||||
final int ftsMinTokenLength = 3;
|
||||
|
||||
@override
|
||||
String buildFtsQuery(String input) {
|
||||
final ftsQueryBuilder = TrigramQueryBuilder.tokenize(
|
||||
input: input,
|
||||
minTokenLength: ftsMinTokenLength,
|
||||
tokenLimit: ftsTokenLimit,
|
||||
);
|
||||
|
||||
if (ftsQueryBuilder.hasTokens) {
|
||||
return ftsQueryBuilder.build();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String buildLikeQuery(String input) {
|
||||
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
|
||||
input: input,
|
||||
minTokenLength: 1,
|
||||
tokenLimit: 5,
|
||||
);
|
||||
|
||||
return likeQueryBuilder.build();
|
||||
}
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onUpgrade: (m, from, to) async {
|
||||
// disable foreign_keys before migrations
|
||||
await customStatement('PRAGMA foreign_keys = OFF');
|
||||
onCreate: (m) async {
|
||||
final migrator =
|
||||
VectorDatabaseMigrator(dimensions: embeddingDimensions);
|
||||
|
||||
if (from < 2) {
|
||||
await transaction(() async {
|
||||
// await m.dropColumn(tabLink, 'url');
|
||||
// await m.dropColumn(tabLink, 'title');
|
||||
// await m.dropColumn(tabLink, 'screenshot');
|
||||
await m.database.customStatement(migrator.vectorTableDefinition);
|
||||
|
||||
// await m.alterTable(TableMigration(tab));
|
||||
});
|
||||
}
|
||||
|
||||
// Assert that the schema is valid after migrations
|
||||
if (kDebugMode) {
|
||||
final wrongForeignKeys =
|
||||
await customSelect('PRAGMA foreign_key_check').get();
|
||||
assert(
|
||||
wrongForeignKeys.isEmpty,
|
||||
'${wrongForeignKeys.map((e) => e.data)}',
|
||||
);
|
||||
//instead of m.createAll(); we igoner vec0 table
|
||||
for (final entity
|
||||
in allSchemaEntities.where((entity) => entity is! DocumentVec)) {
|
||||
await m.create(entity);
|
||||
}
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON;');
|
||||
await optimizeFtsIndex();
|
||||
},
|
||||
);
|
||||
|
||||
TabDatabase(super.e);
|
||||
TabDatabase(super.e, {required this.embeddingDimensions});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ CREATE TABLE tab (
|
||||
timestamp DATETIME NOT NULL
|
||||
);
|
||||
|
||||
import '../../features/vector_store/data/database/vector_store.drift';
|
||||
|
||||
-- automativcally remove documents and embeddings on delete
|
||||
-- isnert and updates are managed code side
|
||||
CREATE TRIGGER tab_document_delete AFTER DELETE ON tab BEGIN
|
||||
DELETE FROM document WHERE main_document_id = old.id;
|
||||
END;
|
||||
|
||||
CREATE VIRTUAL TABLE tab_fts
|
||||
USING fts5(
|
||||
title,
|
||||
@@ -55,6 +63,9 @@ CREATE TRIGGER tab_after_update AFTER UPDATE ON tab BEGIN
|
||||
VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);
|
||||
END;
|
||||
|
||||
optimizeFtsIndex:
|
||||
INSERT INTO tab_fts(tab_fts) VALUES ('optimize');
|
||||
|
||||
containersWithCount WITH ContainerDataWithCount:
|
||||
SELECT
|
||||
container.*,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
class TabQueryResult {
|
||||
final String id;
|
||||
|
||||
final String title;
|
||||
final String url;
|
||||
final String? title;
|
||||
final String? url;
|
||||
|
||||
final String? extractedContent;
|
||||
final String? fullContent;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:lensai/core/models.dart';
|
||||
import 'package:lensai/data/database/functions/lexo_rank_functions.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -8,13 +11,16 @@ import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||
import 'package:sqlite3_vec/sqlite3_vec.dart';
|
||||
import 'package:universal_io/io.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TabDatabase tabDatabase(Ref ref) {
|
||||
return TabDatabase(
|
||||
final dimensions = ref.watch(embeddingDimensionsProvider);
|
||||
|
||||
final db = TabDatabase(
|
||||
LazyDatabase(() async {
|
||||
// put the database file, called db.sqlite here, into the documents folder
|
||||
// for your app.
|
||||
@@ -33,6 +39,8 @@ TabDatabase tabDatabase(Ref ref) {
|
||||
// Explicitly tell it about the correct temporary directory.
|
||||
sqlite3.tempDirectory = cachebase;
|
||||
|
||||
Sqlite3Vec.ensureExtensionLoaded();
|
||||
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (database) {
|
||||
@@ -40,5 +48,12 @@ TabDatabase tabDatabase(Ref ref) {
|
||||
},
|
||||
);
|
||||
}),
|
||||
embeddingDimensions: dimensions,
|
||||
);
|
||||
|
||||
ref.onDispose(() {
|
||||
unawaited(db.close());
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'providers.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$tabDatabaseHash() => r'1379e222fe4d43c119a89ec89a779ef35fbe1563';
|
||||
String _$tabDatabaseHash() => r'940dd2a1f1df6f2a0b77a1a3e9b8318550b832d2';
|
||||
|
||||
/// See also [tabDatabase].
|
||||
@ProviderFor(tabDatabase)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:exceptions/exceptions.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/data/models/message_types.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/domain/providers.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/domain/repositories/chat_message.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/chat/services/qa_memory_chain.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/domain/repositories/document.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'chat_backend.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
class ChatBackend extends _$ChatBackend {
|
||||
late ChatMetadata? _metadata;
|
||||
|
||||
late ChatMessageRepository _chatRepository;
|
||||
|
||||
late QAMemoryChain _qaMemoryChain;
|
||||
|
||||
Future<void>? _embeddingsUpdate;
|
||||
Future<void> prepareEmbeddings() {
|
||||
if (_embeddingsUpdate != null) {
|
||||
return _embeddingsUpdate!;
|
||||
}
|
||||
|
||||
_embeddingsUpdate =
|
||||
ref.read(documentRepositoryProvider.notifier).updateEmbeddings(
|
||||
mainDocumentId: _metadata?.mainDocumentId,
|
||||
contextId: _metadata?.contextId,
|
||||
);
|
||||
|
||||
_embeddingsUpdate!.whenComplete(() => _embeddingsUpdate = null);
|
||||
|
||||
return _embeddingsUpdate!;
|
||||
}
|
||||
|
||||
Future<Result<void>> processQAMessage(String input) async {
|
||||
//TODO: fix result mess
|
||||
|
||||
final humanMessageResult = await _chatRepository.insertTextMessage(
|
||||
author: MessageAuthor.human,
|
||||
content: input,
|
||||
);
|
||||
|
||||
await prepareEmbeddings();
|
||||
|
||||
return humanMessageResult.flatMapAsync((humanMessage) async {
|
||||
await _chatRepository.setTyping(
|
||||
author: MessageAuthor.ai,
|
||||
typing: true,
|
||||
);
|
||||
|
||||
final result = await _qaMemoryChain.processQuestion(humanMessage.text);
|
||||
await _chatRepository.setTyping(
|
||||
author: MessageAuthor.ai,
|
||||
typing: false,
|
||||
);
|
||||
|
||||
await _chatRepository.insertTextMessage(
|
||||
author: MessageAuthor.ai,
|
||||
content: result.answer,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void build(String chatId) {
|
||||
_metadata = ref.watch(
|
||||
chatMetadataProvider(chatId).select((value) => value.valueOrNull),
|
||||
);
|
||||
|
||||
_chatRepository = ref.watch(chatMessageRepositoryProvider(chatId).notifier);
|
||||
|
||||
_qaMemoryChain = ref.watch(
|
||||
qAMemoryChainProvider(
|
||||
chatId: chatId,
|
||||
mainDocumentId: _metadata?.mainDocumentId,
|
||||
contextId: _metadata?.contextId,
|
||||
).notifier,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_backend.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatBackendHash() => r'befdad9878bf0a1a8a196af5e3006978026eb8ba';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$ChatBackend extends BuildlessAutoDisposeNotifier<void> {
|
||||
late final String chatId;
|
||||
|
||||
void build(
|
||||
String chatId,
|
||||
);
|
||||
}
|
||||
|
||||
/// See also [ChatBackend].
|
||||
@ProviderFor(ChatBackend)
|
||||
const chatBackendProvider = ChatBackendFamily();
|
||||
|
||||
/// See also [ChatBackend].
|
||||
class ChatBackendFamily extends Family<void> {
|
||||
/// See also [ChatBackend].
|
||||
const ChatBackendFamily();
|
||||
|
||||
/// See also [ChatBackend].
|
||||
ChatBackendProvider call(
|
||||
String chatId,
|
||||
) {
|
||||
return ChatBackendProvider(
|
||||
chatId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ChatBackendProvider getProviderOverride(
|
||||
covariant ChatBackendProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.chatId,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'chatBackendProvider';
|
||||
}
|
||||
|
||||
/// See also [ChatBackend].
|
||||
class ChatBackendProvider
|
||||
extends AutoDisposeNotifierProviderImpl<ChatBackend, void> {
|
||||
/// See also [ChatBackend].
|
||||
ChatBackendProvider(
|
||||
String chatId,
|
||||
) : this._internal(
|
||||
() => ChatBackend()..chatId = chatId,
|
||||
from: chatBackendProvider,
|
||||
name: r'chatBackendProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatBackendHash,
|
||||
dependencies: ChatBackendFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ChatBackendFamily._allTransitiveDependencies,
|
||||
chatId: chatId,
|
||||
);
|
||||
|
||||
ChatBackendProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.chatId,
|
||||
}) : super.internal();
|
||||
|
||||
final String chatId;
|
||||
|
||||
@override
|
||||
void runNotifierBuild(
|
||||
covariant ChatBackend notifier,
|
||||
) {
|
||||
return notifier.build(
|
||||
chatId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(ChatBackend Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ChatBackendProvider._internal(
|
||||
() => create()..chatId = chatId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
chatId: chatId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<ChatBackend, void> createElement() {
|
||||
return _ChatBackendProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatBackendProvider && other.chatId == chatId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, chatId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ChatBackendRef on AutoDisposeNotifierProviderRef<void> {
|
||||
/// The parameter `chatId` of this provider.
|
||||
String get chatId;
|
||||
}
|
||||
|
||||
class _ChatBackendProviderElement
|
||||
extends AutoDisposeNotifierProviderElement<ChatBackend, void>
|
||||
with ChatBackendRef {
|
||||
_ChatBackendProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get chatId => (origin as ChatBackendProvider).chatId;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChatTextMessage extends StatelessWidget {
|
||||
final TextMessage message;
|
||||
final int index;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
final double? onlyEmojiFontSize;
|
||||
|
||||
const ChatTextMessage({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.index,
|
||||
this.padding = const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
|
||||
this.onlyEmojiFontSize = 48,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textMessageTheme =
|
||||
context.select((ChatTheme theme) => theme.textMessageTheme);
|
||||
final isSentByMe = context.watch<String>() == message.authorId;
|
||||
final paragraphStyle = isSentByMe
|
||||
? textMessageTheme.sentTextStyle
|
||||
: textMessageTheme.receivedTextStyle;
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: message.isOnlyEmoji == true
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: isSentByMe
|
||||
? textMessageTheme.sentBackgroundColor
|
||||
: textMessageTheme.receivedBackgroundColor,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: MarkdownBody(
|
||||
data: message.text,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: message.isOnlyEmoji == true
|
||||
? paragraphStyle?.copyWith(fontSize: onlyEmojiFontSize)
|
||||
: paragraphStyle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
typedef OnMessageTapCallback = void Function(Message message);
|
||||
typedef OnMessageSendCallback = void Function(String text);
|
||||
typedef OnAttachmentTapCallback = VoidCallback;
|
||||
|
||||
class QaChatInput extends StatefulWidget {
|
||||
final double? left;
|
||||
final double? right;
|
||||
final double? top;
|
||||
final double? bottom;
|
||||
final double? sigmaX;
|
||||
final double? sigmaY;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Widget? attachmentIcon;
|
||||
final Widget? sendIcon;
|
||||
final double? gap;
|
||||
final InputBorder? inputBorder;
|
||||
final bool? filled;
|
||||
final Widget? topWidget;
|
||||
final bool? handleSafeArea;
|
||||
|
||||
const QaChatInput({
|
||||
super.key,
|
||||
this.left = 0,
|
||||
this.right = 0,
|
||||
this.top,
|
||||
this.bottom = 0,
|
||||
this.sigmaX = 20,
|
||||
this.sigmaY = 20,
|
||||
this.padding = const EdgeInsets.all(8.0),
|
||||
this.attachmentIcon = const Icon(Icons.attachment),
|
||||
this.sendIcon = const Icon(Icons.send),
|
||||
this.gap = 8,
|
||||
this.inputBorder = const OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
this.filled = true,
|
||||
this.topWidget,
|
||||
this.handleSafeArea = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QaChatInput> createState() => _QaChatInputState();
|
||||
}
|
||||
|
||||
class _QaChatInputState extends State<QaChatInput> {
|
||||
final GlobalKey _inputKey = GlobalKey();
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateInputHeight());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant QaChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateInputHeight());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomSafeArea = widget.handleSafeArea == true
|
||||
? MediaQuery.of(context).padding.bottom
|
||||
: 0.0;
|
||||
final inputTheme = context.select((ChatTheme theme) => theme.inputTheme);
|
||||
final onAttachmentTap = context.read<OnAttachmentTapCallback?>();
|
||||
|
||||
return Positioned(
|
||||
left: widget.left,
|
||||
right: widget.right,
|
||||
top: widget.top,
|
||||
bottom: widget.bottom,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
// TODO: remove backdrop filter if both are 0
|
||||
sigmaX: widget.sigmaX ?? 0,
|
||||
sigmaY: widget.sigmaY ?? 0,
|
||||
),
|
||||
child: Container(
|
||||
key: _inputKey,
|
||||
color: inputTheme.backgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.topWidget != null) widget.topWidget!,
|
||||
Padding(
|
||||
padding: widget.handleSafeArea == true
|
||||
? (widget.padding
|
||||
?.add(EdgeInsets.only(bottom: bottomSafeArea)) ??
|
||||
EdgeInsets.only(bottom: bottomSafeArea))
|
||||
: (widget.padding ?? EdgeInsets.zero),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.attachmentIcon != null)
|
||||
IconButton(
|
||||
icon: widget.attachmentIcon!,
|
||||
color: inputTheme.hintStyle?.color,
|
||||
onPressed: onAttachmentTap,
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(width: widget.gap),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message',
|
||||
hintStyle: inputTheme.hintStyle,
|
||||
border: widget.inputBorder,
|
||||
filled: widget.filled,
|
||||
fillColor: inputTheme.textFieldColor,
|
||||
hoverColor: Colors.transparent,
|
||||
suffixIcon: SpeechToTextButton(
|
||||
onTextReceived: (data) {
|
||||
_textController.text = data.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
style: inputTheme.textStyle,
|
||||
onSubmitted: _handleSubmitted,
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
SizedBox(width: widget.gap),
|
||||
if (widget.sendIcon != null)
|
||||
IconButton(
|
||||
icon: widget.sendIcon!,
|
||||
color: inputTheme.hintStyle?.color,
|
||||
onPressed: () =>
|
||||
_handleSubmitted(_textController.text),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateInputHeight() {
|
||||
if (!mounted) return;
|
||||
|
||||
final renderBox =
|
||||
_inputKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
context.read<ChatInputHeightNotifier>().updateHeight(
|
||||
// only set real height of the input, ignoring safe area
|
||||
widget.handleSafeArea == true ? height - bottomSafeArea : height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmitted(String text) {
|
||||
if (text.isNotEmpty) {
|
||||
context.read<OnMessageSendCallback?>()?.call(text);
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cross_cache/cross_cache.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/data/models/message_types.dart';
|
||||
import 'package:lensai/features/chat/features/chat_store/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/chat/domain/chat_backend.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/chat/presentation/widgets/chat_text_message.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/chat/presentation/widgets/qa_chat_input.dart';
|
||||
import 'package:lensai/presentation/hooks/on_initialization.dart';
|
||||
|
||||
class TabQaChat extends HookConsumerWidget {
|
||||
final String chatId;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const TabQaChat({required this.chatId, this.scrollController, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chatBackend = ref.watch(chatBackendProvider(chatId).notifier);
|
||||
final chatController = ref.watch(chatControllerProvider(chatId));
|
||||
|
||||
final crossCache = useMemoized(() => CrossCache());
|
||||
final chatScrollController = scrollController ?? useScrollController();
|
||||
|
||||
useOnInitialization(() async {
|
||||
await chatBackend.prepareEmbeddings();
|
||||
});
|
||||
|
||||
return Chat(
|
||||
darkTheme: ChatTheme.dark(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
inputTheme: InputTheme(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
theme: ChatTheme.light(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
builders: Builders(
|
||||
textMessageBuilder: (context, message, index) =>
|
||||
ChatTextMessage(message: message, index: index),
|
||||
customMessageBuilder: (context, message, index) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: const IsTypingIndicator(),
|
||||
),
|
||||
inputBuilder: (context) => const QaChatInput(
|
||||
attachmentIcon: null,
|
||||
),
|
||||
),
|
||||
chatController: chatController,
|
||||
crossCache: crossCache,
|
||||
scrollController: chatScrollController,
|
||||
onMessageSend: (text) async {
|
||||
await chatBackend.processQAMessage(text);
|
||||
},
|
||||
currentUserId: MessageAuthor.human.user.id,
|
||||
resolveUser: (id) => Future.value(
|
||||
MessageAuthor.values
|
||||
.firstWhereOrNull((user) => user.user.id == id)
|
||||
?.user,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:langchain/langchain.dart';
|
||||
import 'package:langchain_openai/langchain_openai.dart';
|
||||
import 'package:lensai/core/models.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/domain/sqlite_vector_store.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'qa_memory_chain.g.dart';
|
||||
|
||||
typedef QAResult = ({String answer, List<Document> docs});
|
||||
|
||||
@Riverpod()
|
||||
class QAMemoryChain extends _$QAMemoryChain {
|
||||
late RetrievalQAChain _retrievalQA;
|
||||
|
||||
@override
|
||||
void build({
|
||||
required String chatId,
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
final chatModel = ref.watch(chatModelProvider);
|
||||
final embeddingsModel = ref.watch(embeddingModelProvider);
|
||||
final db = ref.watch(tabDatabaseProvider);
|
||||
|
||||
final retriever = SqliteVectorStore(
|
||||
db.vectorDao,
|
||||
embeddings: embeddingsModel,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
).asRetriever(
|
||||
defaultOptions: const VectorStoreRetrieverOptions(
|
||||
searchType: VectorStoreSimilaritySearch(k: 3),
|
||||
),
|
||||
);
|
||||
|
||||
final qaChain = OpenAIQAWithSourcesChain(llm: chatModel);
|
||||
final docPrompt = PromptTemplate.fromTemplate(
|
||||
'Content: {page_content}\nSource: {source}',
|
||||
);
|
||||
|
||||
final finalQAChain = StuffDocumentsChain(
|
||||
llmChain: qaChain,
|
||||
documentPrompt: docPrompt,
|
||||
);
|
||||
|
||||
_retrievalQA = RetrievalQAChain(
|
||||
retriever: retriever,
|
||||
combineDocumentsChain: finalQAChain,
|
||||
);
|
||||
}
|
||||
|
||||
Future<QAResult> processQuestion(String input) async {
|
||||
final result = await _retrievalQA(input);
|
||||
final qaResult = result['result'] as QAWithSources;
|
||||
|
||||
return (answer: qaResult.answer, docs: <Document>[]);
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'qa_memory_chain.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$qAMemoryChainHash() => r'd5efc3fc01a6a88c67164d7eefc06412c9ab7c5c';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$QAMemoryChain extends BuildlessAutoDisposeNotifier<void> {
|
||||
late final String chatId;
|
||||
late final String? mainDocumentId;
|
||||
late final String? contextId;
|
||||
|
||||
void build({
|
||||
required String chatId,
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
});
|
||||
}
|
||||
|
||||
/// See also [QAMemoryChain].
|
||||
@ProviderFor(QAMemoryChain)
|
||||
const qAMemoryChainProvider = QAMemoryChainFamily();
|
||||
|
||||
/// See also [QAMemoryChain].
|
||||
class QAMemoryChainFamily extends Family<void> {
|
||||
/// See also [QAMemoryChain].
|
||||
const QAMemoryChainFamily();
|
||||
|
||||
/// See also [QAMemoryChain].
|
||||
QAMemoryChainProvider call({
|
||||
required String chatId,
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
return QAMemoryChainProvider(
|
||||
chatId: chatId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
QAMemoryChainProvider getProviderOverride(
|
||||
covariant QAMemoryChainProvider provider,
|
||||
) {
|
||||
return call(
|
||||
chatId: provider.chatId,
|
||||
mainDocumentId: provider.mainDocumentId,
|
||||
contextId: provider.contextId,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'qAMemoryChainProvider';
|
||||
}
|
||||
|
||||
/// See also [QAMemoryChain].
|
||||
class QAMemoryChainProvider
|
||||
extends AutoDisposeNotifierProviderImpl<QAMemoryChain, void> {
|
||||
/// See also [QAMemoryChain].
|
||||
QAMemoryChainProvider({
|
||||
required String chatId,
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) : this._internal(
|
||||
() => QAMemoryChain()
|
||||
..chatId = chatId
|
||||
..mainDocumentId = mainDocumentId
|
||||
..contextId = contextId,
|
||||
from: qAMemoryChainProvider,
|
||||
name: r'qAMemoryChainProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$qAMemoryChainHash,
|
||||
dependencies: QAMemoryChainFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
QAMemoryChainFamily._allTransitiveDependencies,
|
||||
chatId: chatId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
);
|
||||
|
||||
QAMemoryChainProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.chatId,
|
||||
required this.mainDocumentId,
|
||||
required this.contextId,
|
||||
}) : super.internal();
|
||||
|
||||
final String chatId;
|
||||
final String? mainDocumentId;
|
||||
final String? contextId;
|
||||
|
||||
@override
|
||||
void runNotifierBuild(
|
||||
covariant QAMemoryChain notifier,
|
||||
) {
|
||||
return notifier.build(
|
||||
chatId: chatId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(QAMemoryChain Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: QAMemoryChainProvider._internal(
|
||||
() => create()
|
||||
..chatId = chatId
|
||||
..mainDocumentId = mainDocumentId
|
||||
..contextId = contextId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
chatId: chatId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<QAMemoryChain, void> createElement() {
|
||||
return _QAMemoryChainProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is QAMemoryChainProvider &&
|
||||
other.chatId == chatId &&
|
||||
other.mainDocumentId == mainDocumentId &&
|
||||
other.contextId == contextId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, chatId.hashCode);
|
||||
hash = _SystemHash.combine(hash, mainDocumentId.hashCode);
|
||||
hash = _SystemHash.combine(hash, contextId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin QAMemoryChainRef on AutoDisposeNotifierProviderRef<void> {
|
||||
/// The parameter `chatId` of this provider.
|
||||
String get chatId;
|
||||
|
||||
/// The parameter `mainDocumentId` of this provider.
|
||||
String? get mainDocumentId;
|
||||
|
||||
/// The parameter `contextId` of this provider.
|
||||
String? get contextId;
|
||||
}
|
||||
|
||||
class _QAMemoryChainProviderElement
|
||||
extends AutoDisposeNotifierProviderElement<QAMemoryChain, void>
|
||||
with QAMemoryChainRef {
|
||||
_QAMemoryChainProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get chatId => (origin as QAMemoryChainProvider).chatId;
|
||||
@override
|
||||
String? get mainDocumentId =>
|
||||
(origin as QAMemoryChainProvider).mainDocumentId;
|
||||
@override
|
||||
String? get contextId => (origin as QAMemoryChainProvider).contextId;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:langchain/langchain.dart' as langchain;
|
||||
import 'package:lensai/core/uuid.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/models/vector_result.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/utils/hash.dart';
|
||||
import 'package:lensai/utils/langchain_utils.dart';
|
||||
|
||||
part 'vector.g.dart';
|
||||
|
||||
@DriftAccessor()
|
||||
class VectorDao extends DatabaseAccessor<TabDatabase> with _$VectorDaoMixin {
|
||||
VectorDao(super.attachedDatabase);
|
||||
|
||||
Future<int> deleteDocuments(List<String> ids) {
|
||||
return db.document.deleteWhere((doc) => doc.documentId.isIn(ids));
|
||||
}
|
||||
|
||||
Future<int> deleteDocumentsByMainDocumentId(String mainDocumentId) {
|
||||
return db.document
|
||||
.deleteWhere((doc) => doc.mainDocumentId.equals(mainDocumentId));
|
||||
}
|
||||
|
||||
SingleOrNullSelectable<DocumentData> getDocumentById(String documentId) {
|
||||
return db.document.select()
|
||||
..where((row) => row.documentId.equals(documentId));
|
||||
}
|
||||
|
||||
Selectable<DocumentData> getDocuments({
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
final statement = db.document.select();
|
||||
|
||||
if (mainDocumentId != null) {
|
||||
statement.where((row) => row.mainDocumentId.equals(mainDocumentId));
|
||||
}
|
||||
|
||||
if (contextId != null) {
|
||||
statement.where((row) => row.contextId.equals(contextId));
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
Selectable<DocumentData> getDocumentsWithMissingEmbeddings({
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
return db.missingDocumentEmbeddings(
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertDocuments(
|
||||
List<langchain.Document> documents, {
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
return db.document.insertAll(
|
||||
documents.map((document) {
|
||||
final documentId = document.id ?? uuid.v4();
|
||||
final contentHash = sha2(document.pageContent);
|
||||
|
||||
return DocumentCompanion.insert(
|
||||
documentId: documentId,
|
||||
mainDocumentId: Value.absentIfNull(mainDocumentId),
|
||||
contextId: Value.absentIfNull(contextId),
|
||||
content: document.pageContent,
|
||||
metadata: (document.metadata.isNotEmpty)
|
||||
? Value(jsonEncode(document.metadata))
|
||||
: const Value.absent(),
|
||||
contentHash: contentHash,
|
||||
);
|
||||
}),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertEmbeddings(List<(DocumentData, List<double>)> documents) {
|
||||
return db.documentVec.insertAll(
|
||||
documents.map(
|
||||
(doc) {
|
||||
final (document, vector) = doc;
|
||||
|
||||
return DocumentVecCompanion.insert(
|
||||
id: document.documentId,
|
||||
embedding: serializeVector(vector),
|
||||
mainDocumentId: Value.absentIfNull(document.mainDocumentId),
|
||||
contextId: Value.absentIfNull(document.contextId),
|
||||
contentHash: document.contentHash,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> insertDocumentsWithEmbedings(
|
||||
List<(langchain.Document, List<double>)> documents, {
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) async {
|
||||
final insertedIds = <String>[];
|
||||
|
||||
await db.batch((b) async {
|
||||
final insertableDocuments = <Insertable<DocumentData>>[];
|
||||
final insertableEmbeddings = <Insertable<DocumentVecData>>[];
|
||||
|
||||
for (final (document, vector) in documents) {
|
||||
final documentId = document.id ?? uuid.v4();
|
||||
final contentHash = sha2(document.pageContent);
|
||||
|
||||
insertedIds.add(documentId);
|
||||
|
||||
insertableDocuments.add(
|
||||
DocumentCompanion.insert(
|
||||
documentId: documentId,
|
||||
mainDocumentId: Value.absentIfNull(mainDocumentId),
|
||||
contextId: Value.absentIfNull(contextId),
|
||||
content: document.pageContent,
|
||||
metadata: (document.metadata.isNotEmpty)
|
||||
? Value(jsonEncode(document.metadata))
|
||||
: const Value.absent(),
|
||||
contentHash: contentHash,
|
||||
),
|
||||
);
|
||||
|
||||
insertableEmbeddings.add(
|
||||
DocumentVecCompanion.insert(
|
||||
id: documentId,
|
||||
embedding: serializeVector(vector),
|
||||
mainDocumentId: Value.absentIfNull(mainDocumentId),
|
||||
contextId: Value.absentIfNull(contextId),
|
||||
contentHash: contentHash,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await db.document.insertAll(
|
||||
insertableDocuments,
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
await db.documentVec.insertAll(insertableEmbeddings);
|
||||
});
|
||||
|
||||
return insertedIds;
|
||||
}
|
||||
|
||||
Selectable<VectorResult> vectorSearch({
|
||||
required langchain.VectorStoreSimilaritySearch config,
|
||||
required List<double> searchVectors,
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
assert(config.filter?.isNotEmpty ?? true, 'Filters are unsupported');
|
||||
assert(
|
||||
!(mainDocumentId != null && contextId != null),
|
||||
'Either filter by document or context, not both',
|
||||
);
|
||||
|
||||
return db.queryVectors(
|
||||
searchVectors: serializeVector(searchVectors),
|
||||
k: config.k,
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'vector.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$VectorDaoMixin on DatabaseAccessor<TabDatabase> {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
class VectorDatabaseMigrator {
|
||||
final int dimensions;
|
||||
|
||||
String get vectorTableDefinition => '''
|
||||
CREATE VIRTUAL TABLE document_vec using vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
main_document_id TEXT,
|
||||
context_id TEXT PARTITION KEY,
|
||||
embedding float[$dimensions],
|
||||
content_hash TEXT
|
||||
);
|
||||
''';
|
||||
|
||||
VectorDatabaseMigrator({required this.dimensions});
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/models/vector_result.dart';
|
||||
|
||||
CREATE TABLE document(
|
||||
document_id TEXT NOT NULL PRIMARY KEY,
|
||||
main_document_id TEXT,
|
||||
context_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX document_main_document_id ON document (main_document_id);
|
||||
CREATE INDEX document_context_id ON document (context_id);
|
||||
|
||||
CREATE TABLE document_vec(
|
||||
-- dummy definition of virtual table
|
||||
-- keep in sync with defined table definition
|
||||
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
main_document_id TEXT,
|
||||
context_id TEXT,
|
||||
embedding BLOB NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
|
||||
distance REAL,
|
||||
k INTEGER
|
||||
);
|
||||
|
||||
CREATE TRIGGER document_delete AFTER DELETE ON document BEGIN
|
||||
DELETE FROM document_vec WHERE id = old.document_id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER document_update_delete AFTER UPDATE ON document BEGIN
|
||||
DELETE FROM document_vec WHERE id = new.document_id AND content_hash != new.content_hash;
|
||||
END;
|
||||
|
||||
missingDocumentEmbeddings(
|
||||
:main_document_id AS TEXT OR NULL,
|
||||
:context_id AS TEXT OR NULL
|
||||
):
|
||||
SELECT
|
||||
doc.*
|
||||
FROM document doc
|
||||
WHERE
|
||||
doc.main_document_id IS COALESCE(:main_document_id, doc.main_document_id) AND
|
||||
doc.context_id IS COALESCE(:context_id, doc.context_id) AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM document_vec vec
|
||||
WHERE vec.id = doc.document_id
|
||||
);
|
||||
|
||||
queryVectors(
|
||||
:searchVectors AS BLOB,
|
||||
:main_document_id AS TEXT OR NULL,
|
||||
:context_id AS TEXT OR NULL
|
||||
) WITH VectorResult:
|
||||
SELECT
|
||||
vec.id,
|
||||
doc.main_document_id,
|
||||
doc.context_id,
|
||||
doc.content,
|
||||
doc.metadata,
|
||||
vec.distance
|
||||
FROM document_vec vec
|
||||
INNER JOIN document doc ON doc.document_id = vec.id
|
||||
WHERE
|
||||
vec.embedding MATCH :searchVectors AND
|
||||
vec.k = :k AND
|
||||
vec.main_document_id IS COALESCE(:main_document_id, vec.main_document_id) AND
|
||||
vec.context_id IS COALESCE(:context_id, vec.context_id)
|
||||
ORDER BY vec.distance;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:langchain/langchain.dart';
|
||||
|
||||
class VectorResult {
|
||||
final String id;
|
||||
final String? mainDocumentId;
|
||||
final String? contextId;
|
||||
final String content;
|
||||
final Map<String, dynamic> metadata;
|
||||
final double distance;
|
||||
|
||||
VectorResult({
|
||||
required this.id,
|
||||
required this.mainDocumentId,
|
||||
required this.contextId,
|
||||
required this.content,
|
||||
required String? metadata,
|
||||
required double? distance,
|
||||
}) : metadata = (metadata != null)
|
||||
? jsonDecode(metadata) as Map<String, dynamic>
|
||||
: const {},
|
||||
distance = distance!;
|
||||
|
||||
Document toDocument() {
|
||||
return Document(
|
||||
id: id,
|
||||
pageContent: content,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:exceptions/exceptions.dart';
|
||||
import 'package:langchain/langchain.dart' as langchain;
|
||||
import 'package:langchain/langchain.dart';
|
||||
import 'package:langchain_openai/langchain_openai.dart';
|
||||
import 'package:lensai/core/models.dart';
|
||||
import 'package:lensai/features/geckoview/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/database/daos/vector.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/utils/markdown_document_splitter.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'document.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
class DocumentRepository extends _$DocumentRepository {
|
||||
late VectorDao _vectorDao;
|
||||
late OpenAIEmbeddings _embeddings;
|
||||
|
||||
Future<void> _insertMarkdownDocumentsSplitted(
|
||||
List<langchain.Document> originalDocuments, {
|
||||
List<(String, String)> headersToSplitOn = const [
|
||||
('#', 'h1'),
|
||||
('##', 'h2'),
|
||||
('###', 'h3'),
|
||||
('####', 'h4'),
|
||||
('#####', 'h5'),
|
||||
('######', 'h6'),
|
||||
],
|
||||
int chunkSize = 748,
|
||||
int chunkOverlap = 150,
|
||||
}) {
|
||||
return _vectorDao.transaction(() async {
|
||||
for (final doc in originalDocuments) {
|
||||
final splitted = splitMarkdownDocument(
|
||||
doc,
|
||||
headersToSplitOn: headersToSplitOn,
|
||||
chunkSize: chunkSize,
|
||||
chunkOverlap: chunkOverlap,
|
||||
);
|
||||
|
||||
final splittedWithSource = (splitted != null)
|
||||
? (
|
||||
mainDocumentId: splitted.mainDocumentId,
|
||||
parts: splitted.parts
|
||||
.map(
|
||||
(part) => part.copyWith(
|
||||
metadata: {
|
||||
...part.metadata,
|
||||
'source': part.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
)
|
||||
: null;
|
||||
|
||||
await _vectorDao.insertDocuments(
|
||||
splittedWithSource?.parts ?? [doc],
|
||||
mainDocumentId: splittedWithSource?.mainDocumentId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result> updateEmbeddings({
|
||||
String? mainDocumentId,
|
||||
String? contextId,
|
||||
}) {
|
||||
return Result.fromAsync(() async {
|
||||
final missing = await _vectorDao
|
||||
.getDocumentsWithMissingEmbeddings(
|
||||
mainDocumentId: mainDocumentId,
|
||||
contextId: contextId,
|
||||
)
|
||||
.get();
|
||||
|
||||
final documentEmbeddings = await _embeddings.embedDocuments(
|
||||
missing
|
||||
.map((doc) => langchain.Document(pageContent: doc.content))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
return _vectorDao.insertEmbeddings(
|
||||
missing.mapIndexed((i, doc) => (doc, documentEmbeddings[i])).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
_vectorDao = ref.watch(tabDatabaseProvider).vectorDao;
|
||||
_embeddings = ref.watch(embeddingModelProvider);
|
||||
|
||||
final tabContentService = ref.watch(tabContentServiceProvider);
|
||||
|
||||
final tabContentSub =
|
||||
tabContentService.tabContentStream.listen((content) async {
|
||||
final bestContent = content.isProbablyReaderable
|
||||
? content.extractedContentMarkdown
|
||||
: content.fullContentMarkdown;
|
||||
|
||||
if (bestContent != null) {
|
||||
await _insertMarkdownDocumentsSplitted(
|
||||
[Document(id: content.tabId, pageContent: bestContent)],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
unawaited(tabContentSub.cancel());
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'document.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$documentRepositoryHash() =>
|
||||
r'0c93116e0f9887d452b7c493dd4da45e5bf0c2d4';
|
||||
|
||||
/// See also [DocumentRepository].
|
||||
@ProviderFor(DocumentRepository)
|
||||
final documentRepositoryProvider =
|
||||
AutoDisposeNotifierProvider<DocumentRepository, void>.internal(
|
||||
DocumentRepository.new,
|
||||
name: r'documentRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$documentRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$DocumentRepository = AutoDisposeNotifier<void>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:langchain/langchain.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/database/daos/vector.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/data/models/vector_result.dart';
|
||||
|
||||
class SqliteVectorStore extends VectorStore {
|
||||
final VectorDao _dao;
|
||||
|
||||
final String? mainDocumentId;
|
||||
final String? contextId;
|
||||
|
||||
SqliteVectorStore(
|
||||
this._dao, {
|
||||
required super.embeddings,
|
||||
this.mainDocumentId,
|
||||
this.contextId,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<String>> addVectors({
|
||||
required List<Document> documents,
|
||||
required List<List<double>> vectors,
|
||||
}) {
|
||||
return _dao.insertDocumentsWithEmbedings(
|
||||
documents.mapIndexed((i, doc) => (doc, vectors[i])).toList(),
|
||||
contextId: contextId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required List<String> ids}) {
|
||||
return _dao.deleteDocuments(ids);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<(Document, double)>> similaritySearchByVectorWithScores({
|
||||
required List<double> embedding,
|
||||
VectorStoreSimilaritySearch config = const VectorStoreSimilaritySearch(),
|
||||
}) async {
|
||||
Iterable<VectorResult> results = await _dao
|
||||
.vectorSearch(
|
||||
config: config,
|
||||
searchVectors: embedding,
|
||||
contextId: contextId,
|
||||
mainDocumentId: mainDocumentId,
|
||||
)
|
||||
.get();
|
||||
|
||||
if (config.scoreThreshold != null) {
|
||||
results = results.where(
|
||||
(result) => result.distance >= config.scoreThreshold!,
|
||||
);
|
||||
}
|
||||
|
||||
return results
|
||||
.map((result) => (result.toDocument(), result.distance))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:lensai/core/uuid.dart';
|
||||
import 'package:uuid/data.dart';
|
||||
import 'package:uuid/parsing.dart';
|
||||
import 'package:uuid/rng.dart';
|
||||
|
||||
final _rng = CryptoRNG();
|
||||
|
||||
class DocumentUuid {
|
||||
final Uint8List _baseBytes;
|
||||
|
||||
DocumentUuid([Uint8List? baseBytes])
|
||||
: _baseBytes = baseBytes ?? _rng.generate();
|
||||
|
||||
factory DocumentUuid.fromUuid(String uuid) {
|
||||
return DocumentUuid(UuidParsing.parseAsByteList(uuid));
|
||||
}
|
||||
|
||||
String getDocumentPartUuid(int sequence) {
|
||||
final bytes = Uint8List.fromList(_baseBytes);
|
||||
bytes.buffer.asByteData().setInt16(14, sequence);
|
||||
|
||||
return uuid.v8g(config: V8GenericOptions(bytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
String sha2(String input) {
|
||||
return sha256.convert(utf8.encode(input)).toString();
|
||||
}
|
||||
|
||||
Future<String> sha2Isolated(String input) async {
|
||||
if (input.length < 1048576) {
|
||||
// Less than 1MB
|
||||
return sha2(input);
|
||||
} else {
|
||||
return await compute(sha2, input);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:langchain/langchain.dart';
|
||||
import 'package:lensai/core/uuid.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/features/vector_store/utils/document_uuid.dart';
|
||||
|
||||
typedef DocumentParts = ({String mainDocumentId, List<Document> parts});
|
||||
|
||||
List<Document> mergeShortDocuments(
|
||||
List<Document> documents,
|
||||
int maxLength, {
|
||||
String separator = '\n',
|
||||
required Document Function(Document a, Document b) doMerge,
|
||||
int Function(String) lengthFunction = TextSplitter.defaultLengthFunction,
|
||||
}) {
|
||||
final result = <Document>[];
|
||||
|
||||
Document? current;
|
||||
for (final doc in documents) {
|
||||
if (current == null) {
|
||||
current = doc;
|
||||
} else if (lengthFunction(current.pageContent) +
|
||||
lengthFunction(doc.pageContent) <=
|
||||
maxLength) {
|
||||
current = doMerge(current, doc);
|
||||
} else {
|
||||
result.add(current);
|
||||
current = doc;
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null) {
|
||||
result.add(current);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
List<String> _headerValueList(dynamic value) {
|
||||
return switch (value) {
|
||||
String _ => [value],
|
||||
List<String> _ => value,
|
||||
_ => throw Exception('Unsupported type')
|
||||
};
|
||||
}
|
||||
|
||||
DocumentParts? splitMarkdownDocument(
|
||||
Document originalDoc, {
|
||||
required List<(String, String)> headersToSplitOn,
|
||||
required int chunkSize,
|
||||
required int chunkOverlap,
|
||||
}) {
|
||||
final markdownHeaderSplitter = MarkdownHeaderTextSplitter(
|
||||
stripHeaders: false,
|
||||
headersToSplitOn: headersToSplitOn,
|
||||
);
|
||||
|
||||
final markdownTextSplitter = MarkdownTextSplitter(
|
||||
chunkSize: chunkSize,
|
||||
chunkOverlap: chunkOverlap,
|
||||
);
|
||||
|
||||
final headerChunks =
|
||||
markdownHeaderSplitter.splitText(originalDoc.pageContent);
|
||||
final docChunks = markdownTextSplitter.splitDocuments(headerChunks);
|
||||
|
||||
if (docChunks.length == 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final headerKeys = headersToSplitOn.map((header) => header.$2).toSet();
|
||||
final mergedDocChunks = mergeShortDocuments(
|
||||
docChunks,
|
||||
chunkSize,
|
||||
doMerge: (a, b) {
|
||||
//Disallow merging into headers of the same
|
||||
final intersectingHeaders = a.metadata.keys
|
||||
.toSet()
|
||||
.intersection(b.metadata.keys.toSet())
|
||||
.intersection(headerKeys);
|
||||
|
||||
return Document(
|
||||
id: a.id ?? b.id,
|
||||
pageContent: '${a.pageContent}\n${b.pageContent}',
|
||||
metadata: {
|
||||
...a.metadata,
|
||||
...b.metadata,
|
||||
for (final header in intersectingHeaders)
|
||||
header: {
|
||||
..._headerValueList(a.metadata[header]),
|
||||
..._headerValueList(b.metadata[header]),
|
||||
}.toList(),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final mainDocumentId = originalDoc.id ?? uuid.v4();
|
||||
final documentIdFactory = DocumentUuid.fromUuid(mainDocumentId);
|
||||
|
||||
return (
|
||||
mainDocumentId: mainDocumentId,
|
||||
parts: mergedDocChunks
|
||||
.mapIndexed(
|
||||
(i, doc) => Document(
|
||||
id: documentIdFactory.getDocumentPartUuid(i),
|
||||
pageContent: doc.pageContent,
|
||||
metadata: mergeMaps(originalDoc.metadata, doc.metadata),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
@@ -122,30 +122,6 @@ class ContainerListScreen extends HookConsumerWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Containers'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final initialColor =
|
||||
await ref.read(unusedRandomContainerColorProvider.future);
|
||||
|
||||
if (context.mounted) {
|
||||
final result = await showDialog<ContainerResult?>(
|
||||
context: context,
|
||||
builder: (context) => ContainerDialog.create(
|
||||
initialColor: initialColor,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.addContainer(name: result.name, color: result.color);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: HookConsumer(
|
||||
builder: (context, ref, child) {
|
||||
@@ -206,6 +182,28 @@ class ContainerListScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
final initialColor =
|
||||
await ref.read(unusedRandomContainerColorProvider.future);
|
||||
|
||||
if (context.mounted) {
|
||||
final result = await showDialog<ContainerResult?>(
|
||||
context: context,
|
||||
builder: (context) => ContainerDialog.create(
|
||||
initialColor: initialColor,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.addContainer(name: result.name, color: result.color);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user