From 47151a2ba76564f83d95354dfc1b3ef3ecb3cfab Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 24 Jul 2025 23:47:34 +0200 Subject: [PATCH] feat: tab suggestions --- .../geckoview/domain/providers/tab_state.dart | 4 +- .../domain/providers/tab_state.g.dart | 2 +- .../features/browser/domain/providers.dart | 33 +++ .../features/browser/domain/providers.g.dart | 128 ++++++++++ .../controllers/tab_suggestions.dart | 21 ++ .../controllers/tab_suggestions.g.dart | 27 +++ .../widgets/sheets/view_tabs.dart | 148 ++++++++---- .../presentation/widgets/tab_preview.dart | 95 +++++++- .../features/tabs/domain/providers.dart | 8 +- .../features/tabs/domain/providers.g.dart | 132 +++++++++- .../domain/repositories/container_topic.dart | 80 ------ .../domain/repositories/gecko_inference.dart | 228 ++++++++++++++++++ ...er_topic.g.dart => gecko_inference.g.dart} | 153 +++++++++++- .../controllers/container_topic.dart | 4 +- .../controllers/container_topic.g.dart | 2 +- .../presentation/screens/container_edit.dart | 2 +- .../presentation/widgets/container_title.dart | 2 +- .../tabs/utils/cosine_similarity.dart | 31 +++ .../tabs/utils/embedding_text_processing.dart | 34 +++ .../features/tabs/utils/nearest_neighbor.dart | 54 +++++ app/pubspec.yaml | 2 + app/test/text_processing_test.dart | 99 ++++++++ .../assets/extensions/ml_engine/api/ml.js | 29 ++- .../assets/extensions/ml_engine/background.js | 18 +- .../assets/extensions/ml_engine/schema.json | 18 +- .../api/GeckoMlApiImpl.kt | 38 ++- .../pigeons/Gecko.g.kt | 29 ++- .../lib/src/domain/services/gecko_ml.dart | 14 +- .../lib/src/pigeons/gecko.g.dart | 34 ++- .../pigeons/gecko.dart | 4 +- 30 files changed, 1293 insertions(+), 180 deletions(-) create mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart delete mode 100644 app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart create mode 100644 app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart rename app/lib/features/geckoview/features/tabs/domain/repositories/{container_topic.g.dart => gecko_inference.g.dart} (50%) create mode 100644 app/lib/features/geckoview/features/tabs/utils/cosine_similarity.dart create mode 100644 app/lib/features/geckoview/features/tabs/utils/embedding_text_processing.dart create mode 100644 app/lib/features/geckoview/features/tabs/utils/nearest_neighbor.dart create mode 100644 app/test/text_processing_test.dart diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 78111690..1fa2cb78 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -16,7 +16,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; import 'package:weblibre/features/geckoview/utils/image_helper.dart'; part 'tab_state.g.dart'; @@ -43,7 +43,7 @@ class TabStates extends _$TabStates { if (newState.isFinishedLoading) { ref - .read(containerTopicRepositoryProvider.notifier) + .read(geckoInferenceRepositoryProvider.notifier) .markInitialLoadComplete(); } } diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index 78c4b3bd..0c71e5d0 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -193,7 +193,7 @@ final selectedTabContainerIdProvider = Provider>.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SelectedTabContainerIdRef = ProviderRef>; -String _$tabStatesHash() => r'ec7e0905d77b2f82491c5b5af0d2ce1351a564f4'; +String _$tabStatesHash() => r'66cff6a7b36328ee23b89fdd53046987b346bfcd'; /// See also [TabStates]. @ProviderFor(TabStates) diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index d3ed6579..19c70547 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; @@ -11,6 +12,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_ import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; part 'providers.g.dart'; @@ -88,6 +90,37 @@ EquatableValue> availableTabStates( }); } +@Riverpod() +EquatableValue> suggestedTabEntities( + Ref ref, + String? containerId, +) { + final excludedTabIds = ref.watch( + containerTabIdsProvider( + // ignore: provider_parameters + ContainerFilterById(containerId: containerId), + ).select((value) => EquatableValue(value.valueOrNull)), + ); + + final suggestions = ref.watch( + containerTabSuggestionsProvider(containerId).select( + (value) => EquatableValue( + value.valueOrNull.mapNotNull( + (result) => result + .whereNot( + (tabId) => excludedTabIds.value?.contains(tabId) ?? false, + ) + .map((tabId) => DefaultTabEntity(tabId: tabId)) + .toList(), + ) ?? + const [], + ), + ), + ); + + return suggestions; +} + @Riverpod() EquatableValue> seamlessFilteredTabEntities( Ref ref, { diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart index 32c528fc..03830ee3 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -282,6 +282,134 @@ class _AvailableTabStatesProviderElement (origin as AvailableTabStatesProvider).containerFilter; } +String _$suggestedTabEntitiesHash() => + r'f8c41ea3136ae0761e558323afb9d33bf5b8fd21'; + +/// See also [suggestedTabEntities]. +@ProviderFor(suggestedTabEntities) +const suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily(); + +/// See also [suggestedTabEntities]. +class SuggestedTabEntitiesFamily + extends Family>> { + /// See also [suggestedTabEntities]. + const SuggestedTabEntitiesFamily(); + + /// See also [suggestedTabEntities]. + SuggestedTabEntitiesProvider call(String? containerId) { + return SuggestedTabEntitiesProvider(containerId); + } + + @override + SuggestedTabEntitiesProvider getProviderOverride( + covariant SuggestedTabEntitiesProvider provider, + ) { + return call(provider.containerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'suggestedTabEntitiesProvider'; +} + +/// See also [suggestedTabEntities]. +class SuggestedTabEntitiesProvider + extends AutoDisposeProvider>> { + /// See also [suggestedTabEntities]. + SuggestedTabEntitiesProvider(String? containerId) + : this._internal( + (ref) => + suggestedTabEntities(ref as SuggestedTabEntitiesRef, containerId), + from: suggestedTabEntitiesProvider, + name: r'suggestedTabEntitiesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$suggestedTabEntitiesHash, + dependencies: SuggestedTabEntitiesFamily._dependencies, + allTransitiveDependencies: + SuggestedTabEntitiesFamily._allTransitiveDependencies, + containerId: containerId, + ); + + SuggestedTabEntitiesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String? containerId; + + @override + Override overrideWith( + EquatableValue> Function(SuggestedTabEntitiesRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: SuggestedTabEntitiesProvider._internal( + (ref) => create(ref as SuggestedTabEntitiesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + AutoDisposeProviderElement>> createElement() { + return _SuggestedTabEntitiesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SuggestedTabEntitiesProvider && + other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SuggestedTabEntitiesRef + on AutoDisposeProviderRef>> { + /// The parameter `containerId` of this provider. + String? get containerId; +} + +class _SuggestedTabEntitiesProviderElement + extends AutoDisposeProviderElement>> + with SuggestedTabEntitiesRef { + _SuggestedTabEntitiesProviderElement(super.provider); + + @override + String? get containerId => + (origin as SuggestedTabEntitiesProvider).containerId; +} + String _$seamlessFilteredTabEntitiesHash() => r'2cd698d4e7c9d9cda9e17abbf9a99e512425e4cd'; diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart new file mode 100644 index 00000000..320d948c --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tab_suggestions.g.dart'; + +@Riverpod(keepAlive: true) +class TabSuggestionsController extends _$TabSuggestionsController { + void toggle() { + state = !state; + } + + void hide() { + if (state) { + state = false; + } + } + + @override + bool build() { + return false; + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart new file mode 100644 index 00000000..1de40eb1 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_suggestions.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabSuggestionsControllerHash() => + r'5ce7f385b8aba14d432912cf0acb06aad04433fc'; + +/// See also [TabSuggestionsController]. +@ProviderFor(TabSuggestionsController) +final tabSuggestionsControllerProvider = + NotifierProvider.internal( + TabSuggestionsController.new, + name: r'tabSuggestionsControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabSuggestionsControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$TabSuggestionsController = Notifier; +// 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 diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart index 259c6248..7e172672 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -13,6 +13,7 @@ import 'package:weblibre/data/models/drag_data.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; @@ -29,9 +30,14 @@ import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; class _TabDraggable extends HookConsumerWidget { final TabEntity entity; + final String? suggestedContainerId; final VoidCallback onClose; - const _TabDraggable({required this.entity, required this.onClose}); + const _TabDraggable({ + required this.entity, + required this.onClose, + this.suggestedContainerId, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -49,16 +55,22 @@ class _TabDraggable extends HookConsumerWidget { }), ); - final tab = SingleTabPreview( - tabId: entity.tabId, - activeTabId: activeTab, - onClose: onClose, - sourceSearchQuery: switch (entity) { - DefaultTabEntity _ => null, - final SearchResultTabEntity entity => entity.searchQuery, - TabTreeEntity _ => throw UnimplementedError(), - }, - ); + final tab = (suggestedContainerId != null) + ? SuggestedSingleTabPreview( + tabId: entity.tabId, + activeTabId: activeTab, + containerId: suggestedContainerId!, + ) + : SingleTabPreview( + tabId: entity.tabId, + activeTabId: activeTab, + onClose: onClose, + sourceSearchQuery: switch (entity) { + DefaultTabEntity _ => null, + final SearchResultTabEntity entity => entity.searchQuery, + TabTreeEntity _ => throw UnimplementedError(), + }, + ); return switch (dragData) { ContainerDropData() => Opacity( @@ -132,6 +144,10 @@ class _TabSheetHeader extends HookConsumerWidget { searchTextFocus.requestFocus(); }, ), + const SizedBox( + height: 32, + child: VerticalDivider(indent: 4, endIndent: 4), + ), IconButton( icon: const Icon(MdiIcons.familyTree), selectedIcon: const Icon(MdiIcons.table), @@ -144,6 +160,27 @@ class _TabSheetHeader extends HookConsumerWidget { .toggle(); }, ), + Consumer( + builder: (context, ref, child) { + final tabSuggestionsEnabled = ref.watch( + tabSuggestionsControllerProvider, + ); + + return IconButton.filledTonal( + icon: const Icon(MdiIcons.imageAutoAdjust), + isSelected: tabSuggestionsEnabled, + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + ref + .read( + tabSuggestionsControllerProvider.notifier, + ) + .toggle(); + }, + ); + }, + ), ], ), TextButton.icon( @@ -298,7 +335,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { builder: (context, ref, child) { final gridViewKey = useMemoized(() => GlobalKey()); - final container = ref.watch(selectedContainerProvider); + final containerId = ref.watch(selectedContainerProvider); final filteredTabEntities = ref.watch( seamlessFilteredTabEntitiesProvider( @@ -306,35 +343,36 @@ class ViewTabsSheetWidget extends HookConsumerWidget { // ignore: document_ignores using fast equatable // ignore: provider_parameters containerFilter: ContainerFilterById( - containerId: container, + containerId: containerId, ), groupTrees: false, ), ); + final tabSuggestionsEnabled = ref.watch( + tabSuggestionsControllerProvider, + ); + final suggestedTabEntities = ref.watch( + suggestedTabEntitiesProvider( + tabSuggestionsEnabled ? containerId : null, + ), + ); + + final itemCount = + filteredTabEntities.value.length + + suggestedTabEntities.value.length; + final activeTab = ref.watch(selectedTabProvider); - final crossAxisCount = useMemoized( - () { - final calculatedCount = _calculateCrossAxisItemCount( - screenWidth: MediaQuery.of(context).size.width, - horizontalPadding: 4.0, - crossAxisSpacing: 8.0, - ); + final crossAxisCount = useMemoized(() { + final calculatedCount = _calculateCrossAxisItemCount( + screenWidth: MediaQuery.of(context).size.width, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); - return math.max( - math.min( - calculatedCount, - filteredTabEntities.value.length, - ), - 2, - ); - }, - [ - MediaQuery.of(context).size.width, - filteredTabEntities.value.length, - ], - ); + return math.max(math.min(calculatedCount, itemCount), 2); + }, [MediaQuery.of(context).size.width, itemCount]); final itemHeight = useMemoized( () => _calculateItemHeight( @@ -390,20 +428,29 @@ class ViewTabsSheetWidget extends HookConsumerWidget { ); final tabs = useMemoized(() { - return filteredTabEntities.value - .where((entity) => entity is! TabTreeEntity) - .map( - (entity) => CustomDraggable( - key: Key(entity.tabId), - data: TabDragData(entity.tabId), - child: _TabDraggable( - entity: entity, - onClose: onClose, - ), + return [ + ...filteredTabEntities.value.map( + (entity) => CustomDraggable( + key: Key(entity.tabId), + data: TabDragData(entity.tabId), + child: _TabDraggable( + entity: entity, + onClose: onClose, ), - ) - .toList(); - }, [filteredTabEntities]); + ), + ), + ...suggestedTabEntities.value.map( + (entity) => CustomDraggable( + key: Key('suggested_${entity.tabId}'), + child: _TabDraggable( + entity: entity, + onClose: onClose, + suggestedContainerId: containerId, + ), + ), + ), + ]; + }, [filteredTabEntities, suggestedTabEntities]); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), @@ -415,7 +462,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { //Rebuild when cross axis count changes key: ValueKey(crossAxisCount), scrollController: controller, - itemCount: tabs.length, + itemCount: itemCount, onDragStarted: (index) { ref.read(willAcceptDropProvider.notifier).clear(); }, @@ -432,6 +479,11 @@ class ViewTabsSheetWidget extends HookConsumerWidget { containerRepositoryProvider.notifier, ); + //Suggestions are at the end and not reorderable, so skip + if (oldIndex >= filteredTabEntities.value.length) { + return; + } + final tabId = filteredTabEntities.value[oldIndex].tabId; final containerId = await ref @@ -482,7 +534,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { crossAxisSpacing: 8.0, crossAxisCount: crossAxisCount, ), - itemCount: tabs.length, + itemCount: itemCount, itemBuilder: (context, index) => itemBuilder(tabs[index], index), ); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart index a13db2fa..9a16d1d2 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; @@ -14,15 +15,21 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/entitie import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; -class _TabBox extends StatelessWidget { +class TabContainer extends StatelessWidget { final bool isActive; final bool isPrivate; final Widget? child; - const _TabBox({required this.isActive, required this.isPrivate, this.child}); + const TabContainer({ + required this.isActive, + required this.isPrivate, + this.child, + super.key, + }); @override Widget build(BuildContext context) { @@ -47,6 +54,37 @@ class _TabBox extends StatelessWidget { } } +class TabMiniPreview extends HookConsumerWidget { + final String tabId; + + const TabMiniPreview({super.key, required this.tabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final thumbnail = ref.watch( + tabStateProvider(tabId).select((value) => value?.thumbnail), + ); + + return TabContainer( + isActive: false, + isPrivate: false, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(14.0)), + child: Skeleton.replace( + replace: thumbnail == null, + replacement: const Bone.square(size: double.infinity), + child: SizedBox( + width: double.infinity, + child: RepaintBoundary( + child: RawImage(image: thumbnail?.value, fit: BoxFit.fitWidth), + ), + ), + ), + ), + ); + } +} + class TabPreview extends HookWidget { final TabState tab; final bool isActive; @@ -57,6 +95,8 @@ class TabPreview extends HookWidget { final VoidCallback? onDelete; final void Function(String host)? onDeleteAll; + final Widget? trailingChild; + const TabPreview({ required this.tab, required this.isActive, @@ -65,13 +105,14 @@ class TabPreview extends HookWidget { this.onLongPress, this.onDelete, this.onDeleteAll, + this.trailingChild, super.key, }); @override Widget build(BuildContext context) { final extendedDeleteMenuController = useMenuController(); - return _TabBox( + return TabContainer( isActive: isActive, isPrivate: tab.isPrivate, child: InkWell( @@ -130,6 +171,7 @@ class TabPreview extends HookWidget { icon: const Icon(Icons.close), ), ), + ?trailingChild, ], ), Row( @@ -259,6 +301,51 @@ class SingleTabPreview extends HookConsumerWidget { } } +class SuggestedSingleTabPreview extends HookConsumerWidget { + final String tabId; + final String containerId; + + final String? activeTabId; + + SuggestedSingleTabPreview({ + required this.tabId, + required this.containerId, + required this.activeTabId, + }) : super(key: ValueKey(tabId)); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(tabStateProvider(tabId)); + + if (tab == null) { + return const SizedBox.shrink(); + } + + return Opacity( + opacity: 0.5, + child: TabPreview( + tab: tab, + isActive: tabId == activeTabId, + onTap: () async { + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId); + + if (containerData != null) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(tabId, containerData); + } + }, + trailingChild: const IconButton( + icon: Icon(MdiIcons.creation), + onPressed: null, + ), + ), + ); + } +} + class TabTreePreview extends HookConsumerWidget { final TabTreeEntity entity; final String? activeTabId; @@ -303,7 +390,7 @@ class TabTreePreview extends HookConsumerWidget { _addPadding( index, stackCount, - _TabBox( + TabContainer( isActive: entity.tabId == activeTabId, isPrivate: tab.isPrivate, ), diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index b487f349..0ed57b6d 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -73,11 +73,17 @@ Stream> tabDescendants(Ref ref, String tabId) { } @Riverpod() -Stream> containerTabsData(Ref ref, String containerId) { +Stream> containerTabsData(Ref ref, String? containerId) { final db = ref.watch(tabDatabaseProvider); return db.containerDao.getContainerTabsData(containerId).watch(); } +@Riverpod() +Stream containerData(Ref ref, String containerId) { + final db = ref.watch(tabDatabaseProvider); + return db.containerDao.getContainerData(containerId).watchSingleOrNull(); +} + @Riverpod() Stream watchContainerTabId(Ref ref, String tabId) { return ref diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index f134ceb6..233d82be 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -565,7 +565,7 @@ class _TabDescendantsProviderElement String get tabId => (origin as TabDescendantsProvider).tabId; } -String _$containerTabsDataHash() => r'1987b2d69f2ba663a7343e93572aad3c31a29be3'; +String _$containerTabsDataHash() => r'2961c89fc9e16c8e6005342356ecf677fb2ff63c'; /// See also [containerTabsData]. @ProviderFor(containerTabsData) @@ -577,7 +577,7 @@ class ContainerTabsDataFamily extends Family>> { const ContainerTabsDataFamily(); /// See also [containerTabsData]. - ContainerTabsDataProvider call(String containerId) { + ContainerTabsDataProvider call(String? containerId) { return ContainerTabsDataProvider(containerId); } @@ -607,7 +607,7 @@ class ContainerTabsDataFamily extends Family>> { class ContainerTabsDataProvider extends AutoDisposeStreamProvider> { /// See also [containerTabsData]. - ContainerTabsDataProvider(String containerId) + ContainerTabsDataProvider(String? containerId) : this._internal( (ref) => containerTabsData(ref as ContainerTabsDataRef, containerId), from: containerTabsDataProvider, @@ -631,7 +631,7 @@ class ContainerTabsDataProvider required this.containerId, }) : super.internal(); - final String containerId; + final String? containerId; @override Override overrideWith( @@ -675,7 +675,7 @@ class ContainerTabsDataProvider // ignore: unused_element mixin ContainerTabsDataRef on AutoDisposeStreamProviderRef> { /// The parameter `containerId` of this provider. - String get containerId; + String? get containerId; } class _ContainerTabsDataProviderElement @@ -684,7 +684,127 @@ class _ContainerTabsDataProviderElement _ContainerTabsDataProviderElement(super.provider); @override - String get containerId => (origin as ContainerTabsDataProvider).containerId; + String? get containerId => (origin as ContainerTabsDataProvider).containerId; +} + +String _$containerDataHash() => r'6adbec128876069189954832af35109b0779148c'; + +/// See also [containerData]. +@ProviderFor(containerData) +const containerDataProvider = ContainerDataFamily(); + +/// See also [containerData]. +class ContainerDataFamily extends Family> { + /// See also [containerData]. + const ContainerDataFamily(); + + /// See also [containerData]. + ContainerDataProvider call(String containerId) { + return ContainerDataProvider(containerId); + } + + @override + ContainerDataProvider getProviderOverride( + covariant ContainerDataProvider provider, + ) { + return call(provider.containerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'containerDataProvider'; +} + +/// See also [containerData]. +class ContainerDataProvider extends AutoDisposeStreamProvider { + /// See also [containerData]. + ContainerDataProvider(String containerId) + : this._internal( + (ref) => containerData(ref as ContainerDataRef, containerId), + from: containerDataProvider, + name: r'containerDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerDataHash, + dependencies: ContainerDataFamily._dependencies, + allTransitiveDependencies: + ContainerDataFamily._allTransitiveDependencies, + containerId: containerId, + ); + + ContainerDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String containerId; + + @override + Override overrideWith( + Stream Function(ContainerDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ContainerDataProvider._internal( + (ref) => create(ref as ContainerDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _ContainerDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContainerDataProvider && other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ContainerDataRef on AutoDisposeStreamProviderRef { + /// The parameter `containerId` of this provider. + String get containerId; +} + +class _ContainerDataProviderElement + extends AutoDisposeStreamProviderElement + with ContainerDataRef { + _ContainerDataProviderElement(super.provider); + + @override + String get containerId => (origin as ContainerDataProvider).containerId; } String _$watchContainerTabIdHash() => diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart deleted file mode 100644 index 24c2d838..00000000 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:fast_equatable/fast_equatable.dart'; -import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; -import 'package:riverpod/riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:synchronized/synchronized.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; -import 'package:weblibre/utils/lru_cache.dart'; - -part 'container_topic.g.dart'; - -@Riverpod(keepAlive: true) -class ContainerTopicRepository extends _$ContainerTopicRepository { - final _service = GeckoMlService(); - - //Wait for first complete page laod of any website after startup to ensure everything is ready - final _initialLoadComplete = Completer(); - final _lock = Lock(); - - final _cache = LRUCache, String>( - 50, - equals: (a, b) { - return const DeepCollectionEquality.unordered().equals(a, b); - }, - hashCode: (key) { - return const DeepCollectionEquality.unordered().hash(key); - }, - ); - - void markInitialLoadComplete() { - if (!_initialLoadComplete.isCompleted) { - _initialLoadComplete.complete(); - } - } - - Future getContainerTopic(Set titles) async { - if (titles.isNotEmpty) { - if (_cache.get(titles) case final String title) { - return title; - } - - try { - final title = await _lock.synchronized(() async { - await _initialLoadComplete.future; - - final title = await _service.getContainerTopic(titles); - - return _cache.set(titles, title); - }, timeout: const Duration(seconds: 120)); - - return title; - } on TimeoutException { - return null; - } - } - - return null; - } - - @override - void build() {} -} - -@Riverpod(keepAlive: true) -Future containerTopic(Ref ref, String containerId) async { - final titles = await ref.watch( - containerTabsDataProvider(containerId).selectAsync( - (tabData) => - EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()), - ), - ); - - final topic = await ref - .read(containerTopicRepositoryProvider.notifier) - .getContainerTopic(titles.value); - - return topic; -} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart new file mode 100644 index 00000000..ab399ba3 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/embedding_text_processing.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/nearest_neighbor.dart'; +import 'package:weblibre/utils/lru_cache.dart'; + +part 'gecko_inference.g.dart'; + +@Riverpod(keepAlive: true) +class GeckoInferenceRepository extends _$GeckoInferenceRepository { + final _service = GeckoMlService(); + + //Wait for first complete page laod of any website after startup to ensure everything is ready + final _initialLoadComplete = Completer(); + final _engineLock = Lock(); + + final _topicCache = LRUCache, String>( + 50, + equals: (a, b) { + return const DeepCollectionEquality.unordered().equals(a, b); + }, + hashCode: (key) { + return const DeepCollectionEquality.unordered().hash(key); + }, + ); + + final _embeddingCache = LRUCache>(100); + + void markInitialLoadComplete() { + if (!_initialLoadComplete.isCompleted) { + _initialLoadComplete.complete(); + } + } + + Future predictDocumentTopic(Set titles) async { + if (titles.isNotEmpty) { + if (_topicCache.get(titles) case final String title) { + return title; + } + + try { + final title = await _engineLock.synchronized(() async { + await _initialLoadComplete.future; + + final title = await _service.predictDocumentTopic(titles); + + return _topicCache.set(titles, title); + }, timeout: const Duration(seconds: 120)); + + return title; + } on TimeoutException { + return null; + } + } + + return null; + } + + Future?> suggestDocuments({ + required String topic, + required List assignedDocumentsInput, + required List unassignedDocumentsInput, + }) async { + final processedDocuments = {}; + final unassignedDocumentsProcessed = unassignedDocumentsInput.map((doc) { + final processed = preprocessText(doc); + if (processed != doc) { + processedDocuments[processed] = doc; + } + + return processed; + }).toList(); + + final assignedDocumentsProcessed = assignedDocumentsInput + .map((doc) => '$topic. ${preprocessText(doc)}') + .toList(); + + final embeddings = await generateDocumentEmbeddings([ + ...unassignedDocumentsProcessed, + ...assignedDocumentsProcessed, + ]); + + final neighbors = embeddings.mapNotNull( + (embeddings) => findNearestNeighborsRecursive( + embeddings: embeddings, + assignedDocuments: assignedDocumentsProcessed, + unassignedDocuments: unassignedDocumentsProcessed, + ).map((neighbor) => processedDocuments[neighbor] ?? neighbor).toList(), + ); + + return neighbors; + } + + Future>?> generateDocumentEmbeddings( + List documents, + ) async { + try { + final embeddings = Map.fromEntries( + documents.map((doc) => MapEntry(doc, _embeddingCache.get(doc))), + ); + + final embeddingsToGenerate = embeddings.entries + .where((e) => e.value == null) + .map((e) => e.key) + .toList(); + + if (embeddingsToGenerate.isNotEmpty) { + final generatedEmbeddings = await _engineLock.synchronized(() async { + await _initialLoadComplete.future; + + final embeddings = await _service.generateDocumentEmbeddings( + documents, + ); + + return embeddings; + }, timeout: const Duration(seconds: 120)); + + for (var i = 0; i < embeddingsToGenerate.length; i++) { + embeddings[embeddingsToGenerate[i]] = generatedEmbeddings[i]; + } + } + + return { + for (final MapEntry(:key, :value) in embeddings.entries) + if (value != null) key: value, + }; + } on TimeoutException { + return null; + } + } + + @override + void build() {} +} + +@Riverpod(keepAlive: true) +Future containerTopic(Ref ref, String containerId) async { + final titles = await ref.watch( + containerTabsDataProvider(containerId).selectAsync( + (tabData) => + EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()), + ), + ); + + final topic = await ref + .read(geckoInferenceRepositoryProvider.notifier) + .predictDocumentTopic(titles.value); + + return topic; +} + +@Riverpod() +Future?> containerTabSuggestions( + Ref ref, + String? containerId, +) async { + if (containerId == null) { + return null; + } + + final container = await ref.watch(containerDataProvider(containerId).future); + + final assignedTitles = await ref.watch( + containerTabsDataProvider(containerId).selectAsync( + (tabData) => EquatableValue( + tabData + .where((tab) => tab.title.isNotEmpty) + .map((tab) => (tab.id, tab.title!)) + .toSet(), + ), + ), + ); + + final unassignedTitles = await ref.watch( + containerTabsDataProvider(null).selectAsync( + (tabData) => EquatableValue( + tabData + .where((tab) => tab.title.isNotEmpty) + .map((tab) => (tab.id, tab.title!)) + .toSet(), + ), + ), + ); + + if (assignedTitles.value.isNotEmpty && unassignedTitles.value.isNotEmpty) { + final topic = + container?.name ?? + await ref + .read(geckoInferenceRepositoryProvider.notifier) + .predictDocumentTopic( + assignedTitles.value.map((tab) => tab.$2).toSet(), + ); + + if (topic != null) { + final suggestedTitles = await ref + .read(geckoInferenceRepositoryProvider.notifier) + .suggestDocuments( + topic: topic, + assignedDocumentsInput: assignedTitles.value + .map((tab) => tab.$2) + .toList(), + unassignedDocumentsInput: unassignedTitles.value + .map((tab) => tab.$2) + .toList(), + ); + + return suggestedTitles.mapNotNull( + (titles) => titles + .map( + (title) => unassignedTitles.value + .firstWhere((tab) => tab.$2 == title) + .$1, + ) + .toList(), + ); + } + } + + return null; +} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart similarity index 50% rename from app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart rename to app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart index d57f5cca..dcae4e89 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart @@ -1,12 +1,12 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'container_topic.dart'; +part of 'gecko_inference.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$containerTopicHash() => r'aa3fd27f26bb94b6c9e794d8470537240b8d2713'; +String _$containerTopicHash() => r'b680381eab3dded9ca6f174fbcc5da5c59d7e9f0'; /// Copied from Dart SDK class _SystemHash { @@ -146,22 +146,151 @@ class _ContainerTopicProviderElement extends FutureProviderElement String get containerId => (origin as ContainerTopicProvider).containerId; } -String _$containerTopicRepositoryHash() => - r'8dfc0bc8b4953b5230a1faa24c8b2f0a94ab1f04'; +String _$containerTabSuggestionsHash() => + r'ab8e596f0c7e8562bf998c701145e48eb1e520d5'; -/// See also [ContainerTopicRepository]. -@ProviderFor(ContainerTopicRepository) -final containerTopicRepositoryProvider = - NotifierProvider.internal( - ContainerTopicRepository.new, - name: r'containerTopicRepositoryProvider', +/// See also [containerTabSuggestions]. +@ProviderFor(containerTabSuggestions) +const containerTabSuggestionsProvider = ContainerTabSuggestionsFamily(); + +/// See also [containerTabSuggestions]. +class ContainerTabSuggestionsFamily extends Family?>> { + /// See also [containerTabSuggestions]. + const ContainerTabSuggestionsFamily(); + + /// See also [containerTabSuggestions]. + ContainerTabSuggestionsProvider call(String? containerId) { + return ContainerTabSuggestionsProvider(containerId); + } + + @override + ContainerTabSuggestionsProvider getProviderOverride( + covariant ContainerTabSuggestionsProvider provider, + ) { + return call(provider.containerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'containerTabSuggestionsProvider'; +} + +/// See also [containerTabSuggestions]. +class ContainerTabSuggestionsProvider + extends AutoDisposeFutureProvider?> { + /// See also [containerTabSuggestions]. + ContainerTabSuggestionsProvider(String? containerId) + : this._internal( + (ref) => containerTabSuggestions( + ref as ContainerTabSuggestionsRef, + containerId, + ), + from: containerTabSuggestionsProvider, + name: r'containerTabSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTabSuggestionsHash, + dependencies: ContainerTabSuggestionsFamily._dependencies, + allTransitiveDependencies: + ContainerTabSuggestionsFamily._allTransitiveDependencies, + containerId: containerId, + ); + + ContainerTabSuggestionsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String? containerId; + + @override + Override overrideWith( + FutureOr?> Function(ContainerTabSuggestionsRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: ContainerTabSuggestionsProvider._internal( + (ref) => create(ref as ContainerTabSuggestionsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement?> createElement() { + return _ContainerTabSuggestionsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContainerTabSuggestionsProvider && + other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ContainerTabSuggestionsRef + on AutoDisposeFutureProviderRef?> { + /// The parameter `containerId` of this provider. + String? get containerId; +} + +class _ContainerTabSuggestionsProviderElement + extends AutoDisposeFutureProviderElement?> + with ContainerTabSuggestionsRef { + _ContainerTabSuggestionsProviderElement(super.provider); + + @override + String? get containerId => + (origin as ContainerTabSuggestionsProvider).containerId; +} + +String _$geckoInferenceRepositoryHash() => + r'bdae6dab14c33c6503f37f2e17b8d50fb820ac26'; + +/// See also [GeckoInferenceRepository]. +@ProviderFor(GeckoInferenceRepository) +final geckoInferenceRepositoryProvider = + NotifierProvider.internal( + GeckoInferenceRepository.new, + name: r'geckoInferenceRepositoryProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$containerTopicRepositoryHash, + : _$geckoInferenceRepositoryHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$ContainerTopicRepository = Notifier; +typedef _$GeckoInferenceRepository = Notifier; // 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 diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart index ebc6f9e3..4e780b55 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart @@ -1,11 +1,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; part 'container_topic.g.dart'; @Riverpod() class ContainerTopicController extends _$ContainerTopicController { - Future getContainerTopic(String containerId) async { + Future predictDocumentTopic(String containerId) async { state = const AsyncLoading(); final result = await AsyncValue.guard(() async { diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart index af8d881c..bcea1e61 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart @@ -7,7 +7,7 @@ part of 'container_topic.dart'; // ************************************************************************** String _$containerTopicControllerHash() => - r'229709202edecb21b20432f8e4d0d7aee293f623'; + r'fa38ebd3c1352b471e58444b04d1cb0528aae1dd'; /// See also [ContainerTopicController]. @ProviderFor(ContainerTopicController) diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 3901a78f..581999a5 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -164,7 +164,7 @@ class ContainerEditScreen extends HookConsumerWidget { containerTopicControllerProvider .notifier, ) - .getContainerTopic( + .predictDocumentTopic( initialContainer.id, ); diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart index 7ece1c4c..48e22f0e 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart @@ -7,7 +7,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; class ContainerTitle extends HookConsumerWidget { final ContainerData container; diff --git a/app/lib/features/geckoview/features/tabs/utils/cosine_similarity.dart b/app/lib/features/geckoview/features/tabs/utils/cosine_similarity.dart new file mode 100644 index 00000000..dd7efd46 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/utils/cosine_similarity.dart @@ -0,0 +1,31 @@ +import 'dart:math'; + +/// Calculates cosine similarity between two lists of floats +/// The lists don't need to be normalized +/// +/// [a] first list +/// [b] second list +/// Returns cosine similarity value +double cosSim(List a, List b) { + if (a.length != b.length) { + throw ArgumentError("Lists should have same lengths"); + } + if (a.isEmpty) { + return 0; + } + + double dotProduct = 0; + double mA = 0; + double mB = 0; + + for (int i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + mA += a[i] * a[i]; + mB += b[i] * b[i]; + } + + mA = sqrt(mA); + mB = sqrt(mB); + + return mA == 0 || mB == 0 ? 0 : dotProduct / (mA * mB); +} diff --git a/app/lib/features/geckoview/features/tabs/utils/embedding_text_processing.dart b/app/lib/features/geckoview/features/tabs/utils/embedding_text_processing.dart new file mode 100644 index 00000000..197beda6 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/utils/embedding_text_processing.dart @@ -0,0 +1,34 @@ +/// Removes trailing domain-related text such as '... - Mail' or '... | News' +/// If there's not enough information remaining after, we keep the text as is +/// [text] tab title with potential domain information +/// Returns the processed string +String preprocessText(String text) { + // Matches 'xyz - Domain' or 'xyz | Domain' + // with a space before and after delimiter + // or if there are multiple delimiters next to each other + final delimiters = RegExp(r'(?<=\s)[|–-]+(?=\s)'); + final splitText = text.split(delimiters); + + // ensure there's enough info without the last element + final hasEnoughInfo = + splitText.isNotEmpty && + splitText.sublist(0, splitText.length - 1).join(' ').length > 5; + + // domain related texts are usually shorter, this takes care of the most common cases + final isPotentialDomainInfo = + splitText.length > 1 && splitText.last.length < 20; + + // If both conditions are met, remove the last chunk, filter out empty strings, + // join on space, trim, and lowercase + if (hasEnoughInfo && isPotentialDomainInfo) { + return splitText + .sublist(0, splitText.length - 1) // everything except the last element + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) // remove empty strings + .join(' ') // join with spaces + .trim(); // remove leading/trailing spaces + } + + // Otherwise, just return the text + return text; +} diff --git a/app/lib/features/geckoview/features/tabs/utils/nearest_neighbor.dart b/app/lib/features/geckoview/features/tabs/utils/nearest_neighbor.dart new file mode 100644 index 00000000..a858a725 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/utils/nearest_neighbor.dart @@ -0,0 +1,54 @@ +import 'package:collection/collection.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/cosine_similarity.dart'; + +List findNearestNeighborsRecursive({ + required Map> embeddings, + required List assignedDocuments, + required List unassignedDocuments, + int thresholdMills = 275, + int maxAssignedCount = 4, + int depth = 0, +}) { + final closestTabs = <(String, double)>[]; + final similarTabsIndices = []; + + for (final unassigned in unassignedDocuments) { + double? closestScore; + for (final assigned in assignedDocuments.take(maxAssignedCount)) { + final cosineSim = cosSim(embeddings[unassigned]!, embeddings[assigned]!); + + if (closestScore == null || cosineSim > closestScore) { + closestScore = cosineSim; + } + } + + // threshold could also be set via a nimbus experiment, in which case + // it will be an int <= 1000 + if (closestScore != null && closestScore > thresholdMills / 1000) { + closestTabs.add((unassigned, closestScore)); + similarTabsIndices.add(unassigned); + } + } + + closestTabs.sort((a, b) => b.$2.compareTo(a.$2)); + + final result = closestTabs.map((t) => t.$1).toList(); + + // recurse once if the initial call only had a single tab + // and we found at least 1 similar tab - this improves recall + if (assignedDocuments.length == 1 && closestTabs.isNotEmpty && depth == 1) { + final recurseSimilarTabs = findNearestNeighborsRecursive( + unassignedDocuments: unassignedDocuments + .whereNot(similarTabsIndices.contains) + .toList(), + assignedDocuments: similarTabsIndices, + thresholdMills: thresholdMills, + embeddings: embeddings, + depth: depth - 1, + ); + + result.addAll(recurseSimilarTabs); + } + + return result; +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e7a6abf4..34eee52b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -96,6 +96,8 @@ dev_dependencies: drift_dev: ^2.28.0 fast_equatable_lint: ^0.3.2 flutter_launcher_icons: ^0.14.4 + flutter_test: + sdk: flutter go_router_builder: ^3.0.1 json_serializable: ^6.9.5 lint: ^2.8.0 diff --git a/app/test/text_processing_test.dart b/app/test/text_processing_test.dart new file mode 100644 index 00000000..a81ded66 --- /dev/null +++ b/app/test/text_processing_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/embedding_text_processing.dart'; + +void main() { + group('Text processing basic cases', () { + test('trailing domain-like text should be removed', () { + expect( + preprocessText("Some Title - Random Mail"), + equals("Some Title"), + reason: "Should remove '- Random Mail' suffix", + ); + }); + + test('trailing domain-like text with |', () { + expect( + preprocessText("Another Title | Some Video Website"), + equals("Another Title"), + reason: "Should remove '| Some Video Website' suffix", + ); + }); + + test('no delimiter', () { + expect( + preprocessText("Simple Title"), + equals("Simple Title"), + reason: "Should remain unchanged since there's no recognized delimiter", + ); + }); + + test('not enough info in first part', () { + expect( + preprocessText("AB - Mail"), + equals("AB - Mail"), + reason: + "Should not remove '- Mail' because the first part is too short", + ); + }); + + test('should not match for texts such as check-in', () { + expect( + preprocessText("Check-in for flight"), + equals("Check-in for flight"), + reason: "Should not remove '-in'", + ); + }); + }); + + group('Text processing edge cases', () { + test('empty string', () { + expect( + preprocessText(""), + equals(""), + reason: "Empty string returns empty string", + ); + }); + + test('exactly 20 chars', () { + const domain20Chars = "12345678901234567890"; // 20 characters + expect( + preprocessText("My Title - $domain20Chars"), + equals("My Title - $domain20Chars"), + reason: + "Should not remove suffix because it's exactly 20 chars long, not < 20", + ); + }); + + test('multiple delimiters, remove last only', () { + expect( + preprocessText("Complex - Title - SomethingSmall"), + equals("Complex Title"), + reason: + "Should remove only the last '- SomethingSmall', ignoring earlier delimiters", + ); + }); + + test('repeated delimiters', () { + expect( + preprocessText("Title --- Domain"), + equals("Title"), + reason: "Should remove the last chunk and filter out empty strings", + ); + + expect( + preprocessText("Title || Domain"), + equals("Title"), + reason: "Should remove the last chunk with double pipe delimiters too", + ); + }); + + test('long trailing text', () { + const longDomain = "Useful information is present"; + expect( + preprocessText("Some Title - $longDomain"), + equals("Some Title - $longDomain"), + reason: "Should not remove suffix if it's >= 20 characters", + ); + }); + }); +} diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js index 7c53ff46..647ea7fe 100644 --- a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js @@ -46,9 +46,9 @@ const SMART_TAB_GROUPING_CONFIG = { */ function createModelInput(keywords, documents) { if (!keywords || keywords.length === 0) { - return `Topic from keywords: titles: \n${documents.join(" \n")}`; + return `Topic from keywords: titles: \n${documents.slice(0, 3).join(" \n")}`; } - return `Topic from keywords: ${keywords.join(", ")}. titles: \n${documents.join(" \n")}`; + return `Topic from keywords: ${keywords.join(", ")}. titles: \n${documents.slice(0, 3).join(" \n")}`; } /** @@ -81,7 +81,6 @@ function cutAtDuplicateWords(phrase) { return phrase; // return original phrase } - /** * * @param {MLEngine} engine the engine to check @@ -96,7 +95,29 @@ this.ml = class extends ExtensionAPI { return { experiments: { ml: { - async containerTopic(keywords, documents) { + async generateEmbeddings(textToEmbedList) { + const inputData = { + inputArgs: textToEmbedList, + runOptions: { + pooling: "mean", + normalize: true, + }, + }; + + if (isEngineClosed(this.embeddingEngine)) { + this.embeddingEngine = await createEngine(SMART_TAB_GROUPING_CONFIG.embedding); + } + + const request = { + args: [inputData.inputArgs], + options: inputData.runOptions, + }; + + const generated = await this.embeddingEngine.run(request); + + return JSON.stringify(generated); + }, + async predictTopic(keywords, documents) { if (isEngineClosed(this.topicEngine)) { const { featureId, diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js index 22323fe8..d5cc040a 100644 --- a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js @@ -26,15 +26,25 @@ function sendErrorForRequest(id) { port.onMessage.addListener(async (message) => { let requestId = message["id"] switch (message["action"]) { - case "getContainerTopic": + case "predictDocumentTopic": { const documents = message["args"]; const keywords = (documents.length > 1) ? await browser.experiments.nlp.extractKeywords([documents.slice(0, 3).join(" ")]) : [[]]; - browser.experiments.ml.containerTopic(keywords[0], documents) + browser.experiments.ml.predictTopic(keywords[0], documents) .then(sendJsonResultForRequest(requestId)) - .catch(sendErrorForRequest(requestId)) - break + .catch(sendErrorForRequest(requestId)); + + break; + } + case "generateDocumentEmbeddings": { + const documents = message["args"]; + await browser.experiments.ml.generateEmbeddings(documents) + .then(sendJsonResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)); + + break; + } } }); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json index 9f08b237..4466697b 100644 --- a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json @@ -33,7 +33,23 @@ "description": "Machine Learning utilities", "functions": [ { - "name": "containerTopic", + "name": "generateEmbeddings", + "type": "function", + "description": "Generate embeddings for a list of text strings using ML engine", + "async": true, + "parameters": [ + { + "name": "textToEmbedList", + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of text strings to generate embeddings for" + } + ] + }, + { + "name": "predictTopic", "type": "function", "description": "Generate topic from keywords and documents using ML engine", "async": true, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt index 75b0ba99..722e9be8 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt @@ -13,8 +13,8 @@ class GeckoMlApiImpl : GeckoMlApi { } } - override fun getContainerTopic(titles: List, callback: (Result) -> Unit) { - MLEngineFeature.scheduleRequest("getContainerTopic", titles.toJson(), object : ResultConsumer { + override fun predictDocumentTopic(documents: List, callback: (Result) -> Unit) { + MLEngineFeature.scheduleRequest("predictDocumentTopic", documents.toJson(), object : ResultConsumer { override fun success(result: JSONObject) { callback(Result.success(result.getString("result"))) } @@ -24,4 +24,38 @@ class GeckoMlApiImpl : GeckoMlApi { } }) } + + override fun generateDocumentEmbeddings( + documents: List, + callback: (Result>) -> Unit + ) { + MLEngineFeature.scheduleRequest("generateDocumentEmbeddings", documents.toJson(), object : ResultConsumer { + override fun success(result: JSONObject) { + try { + val encodedResult = result.getString("result") + val decodedJsonArray = JSONArray(encodedResult) + val embeddings = mutableListOf>() + + for (i in 0 until decodedJsonArray.length()) { + val embeddingArray = decodedJsonArray.getJSONArray(i) + val embedding = mutableListOf() + + for (j in 0 until embeddingArray.length()) { + embedding.add(embeddingArray.getDouble(j)) + } + embeddings.add(embedding) + } + + callback(Result.success(embeddings)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } + } \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 5e960e37..906c201c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -3425,7 +3425,8 @@ interface GeckoPrefApi { } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoMlApi { - fun getContainerTopic(titles: List, callback: (Result) -> Unit) + fun predictDocumentTopic(documents: List, callback: (Result) -> Unit) + fun generateDocumentEmbeddings(documents: List, callback: (Result>) -> Unit) companion object { /** The codec used by GeckoMlApi. */ @@ -3437,12 +3438,32 @@ interface GeckoMlApi { fun setUp(binaryMessenger: BinaryMessenger, api: GeckoMlApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.predictDocumentTopic$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List - val titlesArg = args[0] as List - api.getContainerTopic(titlesArg) { result: Result -> + val documentsArg = args[0] as List + api.predictDocumentTopic(documentsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.generateDocumentEmbeddings$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val documentsArg = args[0] as List + api.generateDocumentEmbeddings(documentsArg) { result: Result> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(GeckoPigeonUtils.wrapError(error)) diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart index 5fabc42d..e0c2d427 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart @@ -9,13 +9,23 @@ import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; final _apiInstance = GeckoMlApi(); class GeckoMlService { - Future getContainerTopic(Set titles, {int maxCount = 8}) { + Future predictDocumentTopic(Set titles, {int maxCount = 10}) { var selectedTitles = titles.toList(); if (selectedTitles.length > maxCount) { //TODO: Randomize for now, maybe use clusters later selectedTitles = (selectedTitles..shuffle()).take(maxCount).toList(); } - return _apiInstance.getContainerTopic(selectedTitles); + return _apiInstance.predictDocumentTopic(selectedTitles); + } + + Future>> generateDocumentEmbeddings( + List documents, + ) async { + final embeddings = await _apiInstance.generateDocumentEmbeddings(documents); + + return embeddings + .map((values) => (values! as List).cast()) + .toList(); } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 23e9c745..e31bfbc6 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -3968,14 +3968,14 @@ class GeckoMlApi { final String pigeonVar_messageChannelSuffix; - Future getContainerTopic(List titles) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$pigeonVar_messageChannelSuffix'; + Future predictDocumentTopic(List documents) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.predictDocumentTopic$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([titles]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([documents]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -3995,6 +3995,34 @@ class GeckoMlApi { return (pigeonVar_replyList[0] as String?)!; } } + + Future> generateDocumentEmbeddings(List documents) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.generateDocumentEmbeddings$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([documents]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!; + } + } } class GeckoBrowserExtensionApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 15590e2d..dee65450 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -956,7 +956,9 @@ abstract class GeckoPrefApi { @HostApi() abstract class GeckoMlApi { @async - String getContainerTopic(List titles); + String predictDocumentTopic(List documents); + @async + List generateDocumentEmbeddings(List documents); } @HostApi()