intermediate

This commit is contained in:
Fabian Freund
2025-01-23 14:03:55 +01:00
parent bd45ed64ee
commit 78c17a70c5
122 changed files with 8730 additions and 1662 deletions
+42
View File
@@ -0,0 +1,42 @@
import 'dart:io';
import 'package:langchain_openai/langchain_openai.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'models.g.dart';
// @Riverpod(keepAlive: true)
// ChatOpenAI summarizerModel(Ref ref) {
// final openaiApiKey = Platform.environment['OPENAI_API_KEY'];
// return ChatOpenAI(
// apiKey: openaiApiKey,
// baseUrl: 'https://api.together.xyz/v1',
// defaultOptions: const ChatOpenAIOptions(model: 'deepseek-ai/DeepSeek-V3'),
// );
// }
@Riverpod(keepAlive: true)
ChatOpenAI chatModel(Ref ref) {
final openaiApiKey = 'FfTBKW1t5dOOXVoiaF2hbXPAHiz3fvfy';
return ChatOpenAI(
apiKey: openaiApiKey,
baseUrl: 'https://api.deepinfra.com/v1/openai',
defaultOptions: const ChatOpenAIOptions(
model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
),
);
}
@Riverpod(keepAlive: true)
OpenAIEmbeddings embeddingModel(Ref ref) {
final openaiApiKey = 'FfTBKW1t5dOOXVoiaF2hbXPAHiz3fvfy';
return OpenAIEmbeddings(
apiKey: openaiApiKey,
baseUrl: 'https://api.deepinfra.com/v1/openai',
model: 'BAAI/bge-m3',
);
}
@Riverpod(keepAlive: true)
int embeddingDimensions(Ref ref) => 1024;
+61
View File
@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'models.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatModelHash() => r'e7538b9f0dd43ea64953164c5402e76fde96f5e9';
/// See also [chatModel].
@ProviderFor(chatModel)
final chatModelProvider = Provider<ChatOpenAI>.internal(
chatModel,
name: r'chatModelProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatModelHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatModelRef = ProviderRef<ChatOpenAI>;
String _$embeddingModelHash() => r'5d65c75df195a26adefac5a75f0e8f6b0f1b8590';
/// See also [embeddingModel].
@ProviderFor(embeddingModel)
final embeddingModelProvider = Provider<OpenAIEmbeddings>.internal(
embeddingModel,
name: r'embeddingModelProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$embeddingModelHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef EmbeddingModelRef = ProviderRef<OpenAIEmbeddings>;
String _$embeddingDimensionsHash() =>
r'e34f544a18a7e2bd6a479b21f2016b0c0e2ba96c';
/// See also [embeddingDimensions].
@ProviderFor(embeddingDimensions)
final embeddingDimensionsProvider = Provider<int>.internal(
embeddingDimensions,
name: r'embeddingDimensionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$embeddingDimensionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef EmbeddingDimensionsRef = ProviderRef<int>;
// 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
+6 -4
View File
@@ -6,7 +6,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart'; part 'providers.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
GoRouter router(Ref ref) => GoRouter( GoRouter router(Ref ref) {
debugLogDiagnostics: true, return GoRouter(
routes: $appRoutes, debugLogDiagnostics: true,
); routes: $appRoutes,
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'providers.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$routerHash() => r'dcafae2924a2ad3d5d836c6756d2e4a4ead36b4e'; String _$routerHash() => r'25fc899b87c080600f6e1d87d510db333378bdfd';
/// See also [router]. /// See also [router].
@ProviderFor(router) @ProviderFor(router)
+34
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lensai/core/routing/dialog_page.dart'; import 'package:lensai/core/routing/dialog_page.dart';
import 'package:lensai/data/models/web_page_info.dart';
import 'package:lensai/features/about/presentation/screens/about.dart'; import 'package:lensai/features/about/presentation/screens/about.dart';
import 'package:lensai/features/bangs/presentation/screens/categories.dart'; import 'package:lensai/features/bangs/presentation/screens/categories.dart';
import 'package:lensai/features/bangs/presentation/screens/list.dart'; import 'package:lensai/features/bangs/presentation/screens/list.dart';
@@ -8,10 +9,12 @@ import 'package:lensai/features/bangs/presentation/screens/search.dart';
import 'package:lensai/features/chat_archive/presentation/screens/detail.dart'; import 'package:lensai/features/chat_archive/presentation/screens/detail.dart';
import 'package:lensai/features/chat_archive/presentation/screens/list.dart'; import 'package:lensai/features/chat_archive/presentation/screens/list.dart';
import 'package:lensai/features/chat_archive/presentation/screens/search.dart'; import 'package:lensai/features/chat_archive/presentation/screens/search.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/screens/browser.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_list.dart'; import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_list.dart';
import 'package:lensai/features/search/presentation/screens/search.dart'; import 'package:lensai/features/search/presentation/screens/search.dart';
import 'package:lensai/features/settings/presentation/screens/settings.dart'; import 'package:lensai/features/settings/presentation/screens/settings.dart';
import 'package:lensai/features/user/presentation/screens/auth.dart';
part 'routes.g.dart'; part 'routes.g.dart';
@@ -19,10 +22,18 @@ part 'routes.g.dart';
name: 'BrowserRoute', name: 'BrowserRoute',
path: '/', path: '/',
routes: [ routes: [
TypedGoRoute<WebPageRoute>(
name: 'WebPageRoute',
path: 'page/:url',
),
TypedGoRoute<AboutRoute>( TypedGoRoute<AboutRoute>(
name: 'AboutRoute', name: 'AboutRoute',
path: 'about', path: 'about',
), ),
TypedGoRoute<UserAuthRoute>(
name: 'UserAuthRoute',
path: 'userAuth',
),
TypedGoRoute<SearchRoute>( TypedGoRoute<SearchRoute>(
name: 'SearchRoute', name: 'SearchRoute',
path: 'search/:searchText', path: 'search/:searchText',
@@ -60,6 +71,22 @@ class BrowserRoute extends GoRouteData {
} }
} }
class WebPageRoute extends GoRouteData {
final String url;
const WebPageRoute({required this.url});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder: (_) => WebPageDialog(
url: Uri.parse(url),
precachedInfo: state.extra as WebPageInfo?,
),
);
}
}
class AboutRoute extends GoRouteData { class AboutRoute extends GoRouteData {
@override @override
Page<void> buildPage(BuildContext context, GoRouterState state) { Page<void> buildPage(BuildContext context, GoRouterState state) {
@@ -67,6 +94,13 @@ class AboutRoute extends GoRouteData {
} }
} }
class UserAuthRoute extends GoRouteData {
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => const UserAuthScreen());
}
}
class SearchRoute extends GoRouteData { class SearchRoute extends GoRouteData {
static const String emptySearchText = ' '; static const String emptySearchText = ' ';
+46
View File
@@ -17,11 +17,21 @@ RouteBase get $browserRoute => GoRouteData.$route(
name: 'BrowserRoute', name: 'BrowserRoute',
factory: $BrowserRouteExtension._fromState, factory: $BrowserRouteExtension._fromState,
routes: [ routes: [
GoRouteData.$route(
path: 'page/:url',
name: 'WebPageRoute',
factory: $WebPageRouteExtension._fromState,
),
GoRouteData.$route( GoRouteData.$route(
path: 'about', path: 'about',
name: 'AboutRoute', name: 'AboutRoute',
factory: $AboutRouteExtension._fromState, factory: $AboutRouteExtension._fromState,
), ),
GoRouteData.$route(
path: 'userAuth',
name: 'UserAuthRoute',
factory: $UserAuthRouteExtension._fromState,
),
GoRouteData.$route( GoRouteData.$route(
path: 'search/:searchText', path: 'search/:searchText',
name: 'SearchRoute', name: 'SearchRoute',
@@ -76,6 +86,25 @@ extension $BrowserRouteExtension on BrowserRoute {
void replace(BuildContext context) => context.replace(location); void replace(BuildContext context) => context.replace(location);
} }
extension $WebPageRouteExtension on WebPageRoute {
static WebPageRoute _fromState(GoRouterState state) => WebPageRoute(
url: state.pathParameters['url']!,
);
String get location => GoRouteData.$location(
'/page/${Uri.encodeComponent(url)}',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
extension $AboutRouteExtension on AboutRoute { extension $AboutRouteExtension on AboutRoute {
static AboutRoute _fromState(GoRouterState state) => AboutRoute(); static AboutRoute _fromState(GoRouterState state) => AboutRoute();
@@ -93,6 +122,23 @@ extension $AboutRouteExtension on AboutRoute {
void replace(BuildContext context) => context.replace(location); void replace(BuildContext context) => context.replace(location);
} }
extension $UserAuthRouteExtension on UserAuthRoute {
static UserAuthRoute _fromState(GoRouterState state) => UserAuthRoute();
String get location => GoRouteData.$location(
'/userAuth',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
extension $SearchRouteExtension on SearchRoute { extension $SearchRouteExtension on SearchRoute {
static SearchRoute _fromState(GoRouterState state) => SearchRoute( static SearchRoute _fromState(GoRouterState state) => SearchRoute(
searchText: searchText:
+4
View File
@@ -0,0 +1,4 @@
extension StringExtension on String {
String toCapitalized() =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}
@@ -91,7 +91,17 @@ class BangDao extends DatabaseAccessor<BangDatabase> with _$BangDaoMixin {
} }
Selectable<BangData> queryBangs(String searchString) { Selectable<BangData> queryBangs(String searchString) {
return db.bangQuery(query: db.buildFtsQuery(searchString)); final ftsQuery = db.buildFtsQuery(searchString);
if (ftsQuery.isNotEmpty) {
return db.queryBangs(
query: ftsQuery,
);
} else {
return db.queryBangsBasic(
query: db.buildLikeQuery(searchString),
);
}
} }
Future<int> addSearchEntry(String trigger, String searchQuery) { Future<int> addSearchEntry(String trigger, String searchQuery) {
@@ -72,20 +72,56 @@ END;
optimizeFtsIndex: optimizeFtsIndex:
INSERT INTO bang_fts(bang_fts) VALUES ('optimize'); INSERT INTO bang_fts(bang_fts) VALUES ('optimize');
bangQuery WITH BangData: queryBangs WITH BangData:
SELECT WITH weights AS (
SELECT
-- Customize these weights (higher = more important)
10.0 AS "trigger", -- Title matches are most important
5.0 AS website_name -- URL matches are quite important
)
SELECT
b.*, b.*,
bf.frequency, bf.frequency,
bf.last_used bf.last_used,
FROM bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts(:query) fts bang_fts(:query) fts
INNER JOIN INNER JOIN
bang b ON b.rowid = fts.rowid bang b ON b.rowid = fts.rowid
LEFT JOIN LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" bang_frequency bf ON b."trigger" = bf."trigger"
ORDER BY CROSS JOIN
RANK, weights
bf.frequency NULLS LAST; ORDER BY
weighted_rank ASC,
bf.frequency NULLS LAST;
queryBangsBasic WITH BangData:
WITH weights AS (
SELECT
-- Customize these weights (higher = more important)
10.0 AS "trigger", -- Title matches are most important
5.0 AS website_name -- URL matches are quite important
)
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts fts
INNER JOIN
bang b ON b.rowid = fts.rowid
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger"
CROSS JOIN
weights
WHERE
fts."trigger" LIKE :query OR
fts.website_name LIKE :query
ORDER BY
weighted_rank ASC,
bf.frequency NULLS LAST;
categoriesJson: categoriesJson:
WITH categories AS ( WITH categories AS (
@@ -1095,16 +1095,40 @@ abstract class _$BangDatabase extends GeneratedDatabase {
); );
} }
Selectable<BangData> bangQuery({required String query}) { Selectable<BangData> queryBangs({required String query}) {
return customSelect( return customSelect(
'SELECT b.*, bf.frequency, bf.last_used FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" ORDER BY RANK, bf.frequency NULLS LAST', 'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights ORDER BY weighted_rank ASC, bf.frequency NULLS LAST',
variables: [ variables: [
Variable<String>(query) Variable<String>(query)
], ],
readsFrom: { readsFrom: {
bangFrequency, bangFrequency,
bang,
bangFts, bangFts,
bang,
}).map((QueryRow row) => BangData(
websiteName: row.read<String>('website_name'),
domain: row.read<String>('domain'),
trigger: row.read<String>('trigger'),
urlTemplate: row.read<String>('url_template'),
category: row.readNullable<String>('category'),
subCategory: row.readNullable<String>('sub_category'),
format: BangTable.$converterformat
.fromSql(row.readNullable<String>('format')),
frequency: row.readNullable<int>('frequency'),
lastUsed: row.readNullable<DateTime>('last_used'),
));
}
Selectable<BangData> queryBangsBasic({required String query}) {
return customSelect(
'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights WHERE fts."trigger" LIKE ?1 OR fts.website_name LIKE ?1 ORDER BY weighted_rank ASC, bf.frequency NULLS LAST',
variables: [
Variable<String>(query)
],
readsFrom: {
bangFrequency,
bangFts,
bang,
}).map((QueryRow row) => BangData( }).map((QueryRow row) => BangData(
websiteName: row.read<String>('website_name'), websiteName: row.read<String>('website_name'),
domain: row.read<String>('domain'), domain: row.read<String>('domain'),
@@ -1236,8 +1260,8 @@ final class $BangTableReferences
db.bang.trigger, db.bangFrequency.trigger)); db.bang.trigger, db.bangFrequency.trigger));
$BangFrequencyProcessedTableManager get bangFrequencyRefs { $BangFrequencyProcessedTableManager get bangFrequencyRefs {
final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency) final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency).filter(
.filter((f) => f.trigger.trigger($_item.trigger)); (f) => f.trigger.trigger.sqlEquals($_itemColumn<String>('trigger')!));
final cache = $_typedResult.readTableOrNull(_bangFrequencyRefsTable($_db)); final cache = $_typedResult.readTableOrNull(_bangFrequencyRefsTable($_db));
return ProcessedTableManager( return ProcessedTableManager(
@@ -1251,8 +1275,8 @@ final class $BangTableReferences
$_aliasNameGenerator(db.bang.trigger, db.bangHistory.trigger)); $_aliasNameGenerator(db.bang.trigger, db.bangHistory.trigger));
$BangHistoryProcessedTableManager get bangHistoryRefs { $BangHistoryProcessedTableManager get bangHistoryRefs {
final manager = $BangHistoryTableManager($_db, $_db.bangHistory) final manager = $BangHistoryTableManager($_db, $_db.bangHistory).filter(
.filter((f) => f.trigger.trigger($_item.trigger)); (f) => f.trigger.trigger.sqlEquals($_itemColumn<String>('trigger')!));
final cache = $_typedResult.readTableOrNull(_bangHistoryRefsTable($_db)); final cache = $_typedResult.readTableOrNull(_bangHistoryRefsTable($_db));
return ProcessedTableManager( return ProcessedTableManager(
@@ -1704,8 +1728,10 @@ final class $BangFrequencyReferences
$_aliasNameGenerator(db.bangFrequency.trigger, db.bang.trigger)); $_aliasNameGenerator(db.bangFrequency.trigger, db.bang.trigger));
$BangTableProcessedTableManager get trigger { $BangTableProcessedTableManager get trigger {
final $_column = $_itemColumn<String>('trigger')!;
final manager = $BangTableTableManager($_db, $_db.bang) final manager = $BangTableTableManager($_db, $_db.bang)
.filter((f) => f.trigger($_item.trigger!)); .filter((f) => f.trigger.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_triggerTable($_db)); final item = $_typedResult.readTableOrNull(_triggerTable($_db));
if (item == null) return manager; if (item == null) return manager;
return ProcessedTableManager( return ProcessedTableManager(
@@ -1941,8 +1967,10 @@ final class $BangHistoryReferences
$_aliasNameGenerator(db.bangHistory.trigger, db.bang.trigger)); $_aliasNameGenerator(db.bangHistory.trigger, db.bang.trigger));
$BangTableProcessedTableManager get trigger { $BangTableProcessedTableManager get trigger {
final $_column = $_itemColumn<String>('trigger')!;
final manager = $BangTableTableManager($_db, $_db.bang) final manager = $BangTableTableManager($_db, $_db.bang)
.filter((f) => f.trigger($_item.trigger!)); .filter((f) => f.trigger.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_triggerTable($_db)); final item = $_typedResult.readTableOrNull(_triggerTable($_db));
if (item == null) return manager; if (item == null) return manager;
return ProcessedTableManager( return ProcessedTableManager(
+9 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:lensai/features/bangs/data/database/database.dart'; import 'package:lensai/features/bangs/data/database/database.dart';
@@ -13,7 +15,7 @@ part 'providers.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
BangDatabase bangDatabase(Ref ref) { BangDatabase bangDatabase(Ref ref) {
return BangDatabase( final db = BangDatabase(
LazyDatabase(() async { LazyDatabase(() async {
// put the database file, called db.sqlite here, into the documents folder // put the database file, called db.sqlite here, into the documents folder
// for your app. // for your app.
@@ -35,4 +37,10 @@ BangDatabase bangDatabase(Ref ref) {
return NativeDatabase.createInBackground(file); return NativeDatabase.createInBackground(file);
}), }),
); );
ref.onDispose(() {
unawaited(db.close());
});
return db;
} }
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'providers.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$bangDatabaseHash() => r'd7964e9cdbb6e60fb6d2a78f07bfd4fa34bcb236'; String _$bangDatabaseHash() => r'1ff3fb8bfa29269f6e515f3b22c216998769f578';
/// See also [bangDatabase]. /// See also [bangDatabase].
@ProviderFor(bangDatabase) @ProviderFor(bangDatabase)
@@ -6,7 +6,7 @@ import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart';
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.dart'; import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:lensai/features/kagi/data/entities/modes.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart';
import 'package:lensai/presentation/widgets/failure_widget.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart';
@@ -1,205 +0,0 @@
// The default Material-style Autocomplete options.
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart';
import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
import 'package:lensai/features/user/domain/repositories/settings.dart';
import 'package:lensai/presentation/widgets/autocomplete.dart';
class SearchField extends HookConsumerWidget {
static const defaultMaxOptionsHeight = 158.0;
final TextEditingController textController;
final BangData? activeBang;
final OptionsViewOpenDirection openDirection;
final double maxOptionsHeight;
final void Function(String)? onFieldSubmitted;
const SearchField({
required this.textController,
required this.activeBang,
this.openDirection = OptionsViewOpenDirection.up,
this.maxOptionsHeight = defaultMaxOptionsHeight,
this.onFieldSubmitted,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch(
settingsRepositoryProvider.select((value) => value.incognitoMode),
);
final optionsStream = ref.watch(searchSuggestionsProvider());
final focusNode = useFocusNode();
// final quickAnswer = useListenableSelector(
// textController,
// () => textController.text.endsWith('?'),
// );
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ExternalResultsAutocomplete<String>(
textEditingController: textController,
optionsStream: optionsStream,
focusNode: focusNode,
optionsViewOpenDirection: openDirection,
displayStringForOption:
// ignore: avoid_redundant_argument_values
RawAutocomplete.defaultStringForOption,
optionsViewBuilder: (context, onSelected, options) {
return _AutocompleteOptions(
//Must match RawAutocomplete parent
displayStringForOption: RawAutocomplete.defaultStringForOption,
onSelected: onSelected,
options: options,
//Must match RawAutocomplete parent
openDirection: openDirection,
maxOptionsHeight: maxOptionsHeight,
);
},
onTextChanged: (textEditingValue) {
ref
.read(searchSuggestionsProvider().notifier)
.addQuery(textEditingValue.text);
},
fieldViewBuilder: (
context,
textEditingController,
focusNode,
onFieldSubmitted,
) {
return TextFormField(
controller: textEditingController,
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: focusNode,
decoration: InputDecoration(
prefixIcon: (activeBang != null)
? Padding(
padding: const EdgeInsetsDirectional.all(12.0),
child: BangIcon(activeBang!, iconSize: 24.0),
)
: null,
// label: const Text('Query'),
hintText: 'Ask anything...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: SpeechToTextButton(
onTextReceived: (data) {
textEditingController.text = data.toString();
},
),
),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
if (value?.isEmpty ?? true) {
return '';
}
return null;
},
onTapOutside: (event) {
focusNode.unfocus();
},
onFieldSubmitted: this.onFieldSubmitted,
);
},
),
// if (activeBang?.domain.endsWith('kagi.com') == true)
// SwitchListTile(
// value: quickAnswer,
// onChanged: (_) {
// if (textController.text.endsWith('?')) {
// textController.text = textController.text
// .substring(0, textController.text.length - 1);
// } else {
// textController.text = '${textController.text}?';
// }
// },
// contentPadding: EdgeInsets.zero,
// title: const Text('Quick Answer'),
// secondary: const Icon(MdiIcons.lightningBolt),
// ),
],
);
}
}
class _AutocompleteOptions<T extends Object> extends StatelessWidget {
const _AutocompleteOptions({
super.key,
required this.displayStringForOption,
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
});
final AutocompleteOptionToString<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected;
final OptionsViewOpenDirection openDirection;
final Iterable<T> options;
final double maxOptionsHeight;
@override
Widget build(BuildContext context) {
final AlignmentDirectional optionsAlignment = switch (openDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
};
return Align(
alignment: optionsAlignment,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
reverse: switch (openDirection) {
OptionsViewOpenDirection.up => true,
OptionsViewOpenDirection.down => false,
},
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final T option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Builder(
builder: (BuildContext context) {
final bool highlight =
AutocompleteHighlightedOption.of(context) == index;
// if (highlight) {
// SchedulerBinding.instance.addPostFrameCallback(
// (Duration timeStamp) async {
// await Scrollable.ensureVisible(
// context,
// alignment: 0.5,
// );
// },
// debugLabel: 'AutocompleteOptions.ensureVisible',
// );
// }
return Container(
color: highlight ? Theme.of(context).focusColor : null,
padding: const EdgeInsets.all(16.0),
child: Text(displayStringForOption(option)),
);
},
),
);
},
),
),
),
);
}
}
@@ -1,14 +1,18 @@
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/providers/search.dart'; import 'package:lensai/features/bangs/domain/providers/search.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; import 'package:lensai/features/geckoview/domain/controllers/overlay_dialog.dart';
import 'package:lensai/features/geckoview/domain/repositories/tab.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/domain/providers.dart';
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart'; import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
import 'package:lensai/features/search/presentation/widgets/search_field.dart';
import 'package:lensai/features/search/presentation/widgets/search_suggestion_list.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:lensai/presentation/widgets/selectable_chips.dart'; import 'package:lensai/presentation/widgets/selectable_chips.dart';
class SiteSearch extends HookConsumerWidget { class SiteSearch extends HookConsumerWidget {
@@ -25,6 +29,17 @@ class SiteSearch extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>()); final formKey = useMemoized(() => GlobalKey<FormState>());
final searchTextController = useTextEditingController();
useListenableCallback(
searchTextController,
() async {
ref
.read(searchSuggestionsProvider().notifier)
.addQuery(searchTextController.text);
},
);
final selectedBang = ref.watch( final selectedBang = ref.watch(
selectedBangDataProvider(domain: domain) selectedBangDataProvider(domain: domain)
.select((value) => value.valueOrNull), .select((value) => value.valueOrNull),
@@ -32,17 +47,17 @@ class SiteSearch extends HookConsumerWidget {
final activeBang = selectedBang ?? availableBangs.firstOrNull; final activeBang = selectedBang ?? availableBangs.firstOrNull;
final textController = useTextEditingController(); Future<void> submitSearch(String query) async {
Future<void> submitSearch() async {
if (activeBang != null && (formKey.currentState?.validate() == true)) { if (activeBang != null && (formKey.currentState?.validate() == true)) {
final searchUri = await ref.read( final searchUri = await ref.read(
triggerBangSearchProvider(activeBang, textController.text).future, triggerBangSearchProvider(activeBang, query).future,
); );
await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri); await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri);
ref.read(overlayDialogControllerProvider.notifier).dismiss(); if (context.mounted) {
context.pop();
}
} }
} }
@@ -82,21 +97,29 @@ class SiteSearch extends HookConsumerWidget {
), ),
), ),
SearchField( SearchField(
textController: textController, textEditingController: searchTextController,
activeBang: activeBang, activeBang: activeBang,
onFieldSubmitted: (_) async { onFieldSubmitted: (_) async {
await submitSearch(); await submitSearch(searchTextController.text);
}, },
), ),
const SizedBox( ConstrainedBox(
height: 12, constraints: const BoxConstraints(maxHeight: 150),
), child: FadingScroll(
SizedBox( fadingSize: 25,
width: double.infinity, builder: (context, controller) {
child: FilledButton.icon( return CustomScrollView(
onPressed: submitSearch, shrinkWrap: true,
label: const Text('Search on Site'), controller: controller,
icon: const Icon(MdiIcons.cloudSearch), slivers: [
SearchSuggestionList(
searchTextController: searchTextController,
submitSearch: submitSearch,
showHistory: false,
),
],
);
},
), ),
), ),
], ],
@@ -0,0 +1,18 @@
import 'dart:convert';
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
import 'package:drift/drift.dart';
class ChatMetadataConverter extends TypeConverter<ChatMetadata, String> {
const ChatMetadataConverter();
@override
ChatMetadata fromSql(String fromDb) {
return ChatMetadata.fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(ChatMetadata value) {
return jsonEncode(value.toJson());
}
}
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:lensai/features/chat/features/chat_store/data/database/database.dart';
import 'package:drift/drift.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:lexo_rank/lexo_rank.dart';
part 'messages.g.dart';
@DriftAccessor()
class MessagesDao extends DatabaseAccessor<ChatDatabase>
with _$MessagesDaoMixin {
MessagesDao(super.db);
Selectable<int> messageCount({required String chatId}) {
return db.chatMessage.count(
where: (row) => row.chatId.equals(chatId),
);
}
Selectable<ChatMessageData> chatMessages({required String chatId}) {
return db.chatMessage.select()
..where((t) => t.chatId.equals(chatId))
..orderBy([(row) => OrderingTerm(expression: row.orderKey)]);
}
Selectable<bool> messageExists(String messageId) {
final existsExpression = existsQuery(
db.chatMessage.select()..where((row) => row.messageId.equals(messageId)),
);
return db.selectExpressions([existsExpression]).map(
(row) => row.read(existsExpression)!,
);
}
SingleOrNullSelectable<int> messageIndex({
required String chatId,
required String messageId,
}) {
return db
.messageIndex(chatId: chatId, messageId: messageId)
.map((rowId) => rowId - 1);
}
Future<int?> insertMessage({
required String chatId,
required Message message,
int? index,
}) async {
if (await messageExists(message.id).getSingle()) {
return null;
}
return db.transaction(
() async {
final String orderKey;
if (index != null) {
if (index <= 0) {
orderKey =
await db.leadingOrderKey(bucket: 0, chatId: chatId).getSingle();
} else {
orderKey = await db
.orderKeyBeforeIndex(chatId: chatId, rowIdx: index + 1)
.getSingle();
}
} else {
orderKey =
await db.trailingOrderKey(bucket: 0, chatId: chatId).getSingle();
}
await db.chatMessage.insertOne(
ChatMessageCompanion.insert(
messageId: message.id,
chatId: chatId,
orderKey: orderKey,
messageJson: jsonEncode(message.toJson()),
),
);
final rowIdx = await db
.messageIndex(chatId: chatId, messageId: message.id)
.getSingle();
return rowIdx - 1;
},
);
}
Future<int> removeMessage({required String messageId}) {
return db.chatMessage.deleteWhere((row) => row.messageId.equals(messageId));
}
Future<int> updateMessage({
required String messageId,
required Message message,
}) {
return (db.chatMessage.update()
..where((row) => row.messageId.equals(messageId)))
.write(
ChatMessageCompanion(
messageJson: Value(jsonEncode(message.toJson())),
),
);
}
Future<int> deleteChatMessages({required String chatId}) {
return (db.chatMessage.delete()..where((t) => t.chatId.equals(chatId)))
.go();
}
Future<void> replaceChatMessages({
required String chatId,
required List<Message> messages,
}) async {
return db.transaction(() async {
await deleteChatMessages(chatId: chatId);
var currentOrderKey =
await db.leadingOrderKey(bucket: 0, chatId: chatId).getSingle();
await db.chatMessage.insertAll(
messages.map(
(message) {
final insertable = ChatMessageCompanion.insert(
messageId: message.id,
chatId: chatId,
messageJson: jsonEncode(message.toJson()),
orderKey: currentOrderKey,
// timestamp: DateTime.now(),
);
currentOrderKey = LexoRank.parse(currentOrderKey).genPrev().value;
return insertable;
},
),
);
});
}
}
@@ -0,0 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'messages.dart';
// ignore_for_file: type=lint
mixin _$MessagesDaoMixin on DatabaseAccessor<ChatDatabase> {}
@@ -0,0 +1,26 @@
import 'package:lensai/features/chat/features/chat_store/data/database/database.dart';
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
import 'package:drift/drift.dart';
part 'metadata.g.dart';
@DriftAccessor()
class MetadataDao extends DatabaseAccessor<ChatDatabase>
with _$MetadataDaoMixin {
MetadataDao(super.db);
SingleOrNullSelectable<ChatMetadata> chatMetadata(String chatId) {
final statement = db.chatData.selectOnly()
..addColumns([db.chatData.metadata])
..where(db.chatData.chatId.equals(chatId));
return statement.map((row) => row.readWithConverter(db.chatData.metadata)!);
}
Future<void> updateChatMetadata(String chatId, ChatMetadata metadata) {
return db.chatData.insertOne(
ChatDataCompanion.insert(chatId: chatId, metadata: metadata),
mode: InsertMode.insertOrReplace,
);
}
}
@@ -0,0 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'metadata.dart';
// ignore_for_file: type=lint
mixin _$MetadataDaoMixin on DatabaseAccessor<ChatDatabase> {}
@@ -0,0 +1,16 @@
import 'package:lensai/features/chat/features/chat_store/data/database/converters/chat_metadata_converter.dart';
import 'package:lensai/features/chat/features/chat_store/data/database/daos/messages.dart';
import 'package:lensai/features/chat/features/chat_store/data/database/daos/metadata.dart';
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
import 'package:drift/drift.dart';
part 'database.g.dart';
@DriftDatabase(include: {'database.drift'}, daos: [MessagesDao, MetadataDao])
class ChatDatabase extends _$ChatDatabase {
ChatDatabase(super.e);
@override
int get schemaVersion => 1;
}
@@ -0,0 +1,65 @@
import 'package:lensai/features/chat/features/chat_store/data/database/converters/chat_metadata_converter.dart';
CREATE TABLE chat_data (
chat_id TEXT PRIMARY KEY NOT NULL,
metadata TEXT NOT NULL MAPPED BY `const ChatMetadataConverter()`
);
CREATE TABLE chat_message (
message_id TEXT PRIMARY KEY NOT NULL,
chat_id TEXT NOT NULL,
order_key TEXT NOT NULL,
message_json TEXT NOT NULL
);
CREATE UNIQUE INDEX group_order_idx on chat_message (chat_id, order_key);
leadingOrderKey:
SELECT lexo_rank_previous(
:bucket,
(
SELECT order_key
FROM chat_message
WHERE chat_id = :chat_id
ORDER BY order_key
LIMIT 1
)
);
trailingOrderKey:
SELECT lexo_rank_next(
:bucket,
(
SELECT order_key
FROM chat_message
WHERE chat_id = :chat_id
ORDER BY order_key DESC
LIMIT 1
)
);
orderKeyBeforeIndex:
WITH ordered_table AS
(
SELECT message_id,
order_key,
LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key,
ROW_NUMBER() OVER (ORDER BY order_key) AS row_idx,
COUNT(*) OVER () as total_rows
FROM chat_message
WHERE chat_id = :chat_id
)
SELECT lexo_rank_reorder_after(order_key, next_order_key)
FROM ordered_table
WHERE row_idx = MIN(:row_idx, total_rows);
messageIndex:
WITH ordered_table AS
(
SELECT message_id,
ROW_NUMBER() OVER (ORDER BY order_key) AS row_idx
FROM chat_message
WHERE chat_id = :chat_id
)
SELECT row_idx
FROM ordered_table
WHERE message_id = :message_id;
@@ -0,0 +1,764 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// ignore_for_file: type=lint
class ChatData extends Table with TableInfo<ChatData, ChatDataData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ChatData(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> chatId = GeneratedColumn<String>(
'chat_id', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL');
late final GeneratedColumnWithTypeConverter<ChatMetadata, String> metadata =
GeneratedColumn<String>('metadata', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL')
.withConverter<ChatMetadata>(ChatData.$convertermetadata);
@override
List<GeneratedColumn> get $columns => [chatId, metadata];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'chat_data';
@override
Set<GeneratedColumn> get $primaryKey => {chatId};
@override
ChatDataData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ChatDataData(
chatId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}chat_id'])!,
metadata: ChatData.$convertermetadata.fromSql(attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}metadata'])!),
);
}
@override
ChatData createAlias(String alias) {
return ChatData(attachedDatabase, alias);
}
static TypeConverter<ChatMetadata, String> $convertermetadata =
const ChatMetadataConverter();
@override
bool get dontWriteConstraints => true;
}
class ChatDataData extends DataClass implements Insertable<ChatDataData> {
final String chatId;
final ChatMetadata metadata;
const ChatDataData({required this.chatId, required this.metadata});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['chat_id'] = Variable<String>(chatId);
{
map['metadata'] =
Variable<String>(ChatData.$convertermetadata.toSql(metadata));
}
return map;
}
factory ChatDataData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ChatDataData(
chatId: serializer.fromJson<String>(json['chat_id']),
metadata: serializer.fromJson<ChatMetadata>(json['metadata']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'chat_id': serializer.toJson<String>(chatId),
'metadata': serializer.toJson<ChatMetadata>(metadata),
};
}
ChatDataData copyWith({String? chatId, ChatMetadata? metadata}) =>
ChatDataData(
chatId: chatId ?? this.chatId,
metadata: metadata ?? this.metadata,
);
ChatDataData copyWithCompanion(ChatDataCompanion data) {
return ChatDataData(
chatId: data.chatId.present ? data.chatId.value : this.chatId,
metadata: data.metadata.present ? data.metadata.value : this.metadata,
);
}
@override
String toString() {
return (StringBuffer('ChatDataData(')
..write('chatId: $chatId, ')
..write('metadata: $metadata')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(chatId, metadata);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ChatDataData &&
other.chatId == this.chatId &&
other.metadata == this.metadata);
}
class ChatDataCompanion extends UpdateCompanion<ChatDataData> {
final Value<String> chatId;
final Value<ChatMetadata> metadata;
final Value<int> rowid;
const ChatDataCompanion({
this.chatId = const Value.absent(),
this.metadata = const Value.absent(),
this.rowid = const Value.absent(),
});
ChatDataCompanion.insert({
required String chatId,
required ChatMetadata metadata,
this.rowid = const Value.absent(),
}) : chatId = Value(chatId),
metadata = Value(metadata);
static Insertable<ChatDataData> custom({
Expression<String>? chatId,
Expression<String>? metadata,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (chatId != null) 'chat_id': chatId,
if (metadata != null) 'metadata': metadata,
if (rowid != null) 'rowid': rowid,
});
}
ChatDataCompanion copyWith(
{Value<String>? chatId,
Value<ChatMetadata>? metadata,
Value<int>? rowid}) {
return ChatDataCompanion(
chatId: chatId ?? this.chatId,
metadata: metadata ?? this.metadata,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (chatId.present) {
map['chat_id'] = Variable<String>(chatId.value);
}
if (metadata.present) {
map['metadata'] =
Variable<String>(ChatData.$convertermetadata.toSql(metadata.value));
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ChatDataCompanion(')
..write('chatId: $chatId, ')
..write('metadata: $metadata, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class ChatMessage extends Table with TableInfo<ChatMessage, ChatMessageData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ChatMessage(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> messageId = GeneratedColumn<String>(
'message_id', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL');
late final GeneratedColumn<String> chatId = GeneratedColumn<String>(
'chat_id', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL');
late final GeneratedColumn<String> orderKey = GeneratedColumn<String>(
'order_key', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL');
late final GeneratedColumn<String> messageJson = GeneratedColumn<String>(
'message_json', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL');
@override
List<GeneratedColumn> get $columns =>
[messageId, chatId, orderKey, messageJson];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'chat_message';
@override
Set<GeneratedColumn> get $primaryKey => {messageId};
@override
ChatMessageData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ChatMessageData(
messageId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}message_id'])!,
chatId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}chat_id'])!,
orderKey: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}order_key'])!,
messageJson: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}message_json'])!,
);
}
@override
ChatMessage createAlias(String alias) {
return ChatMessage(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class ChatMessageData extends DataClass implements Insertable<ChatMessageData> {
final String messageId;
final String chatId;
final String orderKey;
final String messageJson;
const ChatMessageData(
{required this.messageId,
required this.chatId,
required this.orderKey,
required this.messageJson});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['message_id'] = Variable<String>(messageId);
map['chat_id'] = Variable<String>(chatId);
map['order_key'] = Variable<String>(orderKey);
map['message_json'] = Variable<String>(messageJson);
return map;
}
factory ChatMessageData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ChatMessageData(
messageId: serializer.fromJson<String>(json['message_id']),
chatId: serializer.fromJson<String>(json['chat_id']),
orderKey: serializer.fromJson<String>(json['order_key']),
messageJson: serializer.fromJson<String>(json['message_json']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'message_id': serializer.toJson<String>(messageId),
'chat_id': serializer.toJson<String>(chatId),
'order_key': serializer.toJson<String>(orderKey),
'message_json': serializer.toJson<String>(messageJson),
};
}
ChatMessageData copyWith(
{String? messageId,
String? chatId,
String? orderKey,
String? messageJson}) =>
ChatMessageData(
messageId: messageId ?? this.messageId,
chatId: chatId ?? this.chatId,
orderKey: orderKey ?? this.orderKey,
messageJson: messageJson ?? this.messageJson,
);
ChatMessageData copyWithCompanion(ChatMessageCompanion data) {
return ChatMessageData(
messageId: data.messageId.present ? data.messageId.value : this.messageId,
chatId: data.chatId.present ? data.chatId.value : this.chatId,
orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey,
messageJson:
data.messageJson.present ? data.messageJson.value : this.messageJson,
);
}
@override
String toString() {
return (StringBuffer('ChatMessageData(')
..write('messageId: $messageId, ')
..write('chatId: $chatId, ')
..write('orderKey: $orderKey, ')
..write('messageJson: $messageJson')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(messageId, chatId, orderKey, messageJson);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ChatMessageData &&
other.messageId == this.messageId &&
other.chatId == this.chatId &&
other.orderKey == this.orderKey &&
other.messageJson == this.messageJson);
}
class ChatMessageCompanion extends UpdateCompanion<ChatMessageData> {
final Value<String> messageId;
final Value<String> chatId;
final Value<String> orderKey;
final Value<String> messageJson;
final Value<int> rowid;
const ChatMessageCompanion({
this.messageId = const Value.absent(),
this.chatId = const Value.absent(),
this.orderKey = const Value.absent(),
this.messageJson = const Value.absent(),
this.rowid = const Value.absent(),
});
ChatMessageCompanion.insert({
required String messageId,
required String chatId,
required String orderKey,
required String messageJson,
this.rowid = const Value.absent(),
}) : messageId = Value(messageId),
chatId = Value(chatId),
orderKey = Value(orderKey),
messageJson = Value(messageJson);
static Insertable<ChatMessageData> custom({
Expression<String>? messageId,
Expression<String>? chatId,
Expression<String>? orderKey,
Expression<String>? messageJson,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (messageId != null) 'message_id': messageId,
if (chatId != null) 'chat_id': chatId,
if (orderKey != null) 'order_key': orderKey,
if (messageJson != null) 'message_json': messageJson,
if (rowid != null) 'rowid': rowid,
});
}
ChatMessageCompanion copyWith(
{Value<String>? messageId,
Value<String>? chatId,
Value<String>? orderKey,
Value<String>? messageJson,
Value<int>? rowid}) {
return ChatMessageCompanion(
messageId: messageId ?? this.messageId,
chatId: chatId ?? this.chatId,
orderKey: orderKey ?? this.orderKey,
messageJson: messageJson ?? this.messageJson,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (messageId.present) {
map['message_id'] = Variable<String>(messageId.value);
}
if (chatId.present) {
map['chat_id'] = Variable<String>(chatId.value);
}
if (orderKey.present) {
map['order_key'] = Variable<String>(orderKey.value);
}
if (messageJson.present) {
map['message_json'] = Variable<String>(messageJson.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ChatMessageCompanion(')
..write('messageId: $messageId, ')
..write('chatId: $chatId, ')
..write('orderKey: $orderKey, ')
..write('messageJson: $messageJson, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$ChatDatabase extends GeneratedDatabase {
_$ChatDatabase(QueryExecutor e) : super(e);
$ChatDatabaseManager get managers => $ChatDatabaseManager(this);
late final ChatData chatData = ChatData(this);
late final ChatMessage chatMessage = ChatMessage(this);
late final Index groupOrderIdx = Index('group_order_idx',
'CREATE UNIQUE INDEX group_order_idx ON chat_message (chat_id, order_key)');
late final MessagesDao messagesDao = MessagesDao(this as ChatDatabase);
late final MetadataDao metadataDao = MetadataDao(this as ChatDatabase);
Selectable<String> leadingOrderKey(
{required int bucket, required String chatId}) {
return customSelect(
'SELECT lexo_rank_previous(?1, (SELECT order_key FROM chat_message WHERE chat_id = ?2 ORDER BY order_key LIMIT 1)) AS _c0',
variables: [
Variable<int>(bucket),
Variable<String>(chatId)
],
readsFrom: {
chatMessage,
}).map((QueryRow row) => row.read<String>('_c0'));
}
Selectable<String> trailingOrderKey(
{required int bucket, required String chatId}) {
return customSelect(
'SELECT lexo_rank_next(?1, (SELECT order_key FROM chat_message WHERE chat_id = ?2 ORDER BY order_key DESC LIMIT 1)) AS _c0',
variables: [
Variable<int>(bucket),
Variable<String>(chatId)
],
readsFrom: {
chatMessage,
}).map((QueryRow row) => row.read<String>('_c0'));
}
Selectable<String> orderKeyBeforeIndex(
{required String chatId, required int rowIdx}) {
return customSelect(
'WITH ordered_table AS (SELECT message_id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key, ROW_NUMBER()OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS row_idx, COUNT(*)OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS total_rows FROM chat_message WHERE chat_id = ?1) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE row_idx = MIN(?2, total_rows)',
variables: [
Variable<String>(chatId),
Variable<int>(rowIdx)
],
readsFrom: {
chatMessage,
}).map((QueryRow row) => row.read<String>('_c0'));
}
Selectable<int> messageIndex(
{required String chatId, required String messageId}) {
return customSelect(
'WITH ordered_table AS (SELECT message_id, ROW_NUMBER()OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS row_idx FROM chat_message WHERE chat_id = ?1) SELECT row_idx FROM ordered_table WHERE message_id = ?2',
variables: [
Variable<String>(chatId),
Variable<String>(messageId)
],
readsFrom: {
chatMessage,
}).map((QueryRow row) => row.read<int>('row_idx'));
}
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities =>
[chatData, chatMessage, groupOrderIdx];
}
typedef $ChatDataCreateCompanionBuilder = ChatDataCompanion Function({
required String chatId,
required ChatMetadata metadata,
Value<int> rowid,
});
typedef $ChatDataUpdateCompanionBuilder = ChatDataCompanion Function({
Value<String> chatId,
Value<ChatMetadata> metadata,
Value<int> rowid,
});
class $ChatDataFilterComposer extends Composer<_$ChatDatabase, ChatData> {
$ChatDataFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get chatId => $composableBuilder(
column: $table.chatId, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<ChatMetadata, ChatMetadata, String>
get metadata => $composableBuilder(
column: $table.metadata,
builder: (column) => ColumnWithTypeConverterFilters(column));
}
class $ChatDataOrderingComposer extends Composer<_$ChatDatabase, ChatData> {
$ChatDataOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get chatId => $composableBuilder(
column: $table.chatId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get metadata => $composableBuilder(
column: $table.metadata, builder: (column) => ColumnOrderings(column));
}
class $ChatDataAnnotationComposer extends Composer<_$ChatDatabase, ChatData> {
$ChatDataAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get chatId =>
$composableBuilder(column: $table.chatId, builder: (column) => column);
GeneratedColumnWithTypeConverter<ChatMetadata, String> get metadata =>
$composableBuilder(column: $table.metadata, builder: (column) => column);
}
class $ChatDataTableManager extends RootTableManager<
_$ChatDatabase,
ChatData,
ChatDataData,
$ChatDataFilterComposer,
$ChatDataOrderingComposer,
$ChatDataAnnotationComposer,
$ChatDataCreateCompanionBuilder,
$ChatDataUpdateCompanionBuilder,
(ChatDataData, BaseReferences<_$ChatDatabase, ChatData, ChatDataData>),
ChatDataData,
PrefetchHooks Function()> {
$ChatDataTableManager(_$ChatDatabase db, ChatData table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$ChatDataFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$ChatDataOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$ChatDataAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<String> chatId = const Value.absent(),
Value<ChatMetadata> metadata = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
ChatDataCompanion(
chatId: chatId,
metadata: metadata,
rowid: rowid,
),
createCompanionCallback: ({
required String chatId,
required ChatMetadata metadata,
Value<int> rowid = const Value.absent(),
}) =>
ChatDataCompanion.insert(
chatId: chatId,
metadata: metadata,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $ChatDataProcessedTableManager = ProcessedTableManager<
_$ChatDatabase,
ChatData,
ChatDataData,
$ChatDataFilterComposer,
$ChatDataOrderingComposer,
$ChatDataAnnotationComposer,
$ChatDataCreateCompanionBuilder,
$ChatDataUpdateCompanionBuilder,
(ChatDataData, BaseReferences<_$ChatDatabase, ChatData, ChatDataData>),
ChatDataData,
PrefetchHooks Function()>;
typedef $ChatMessageCreateCompanionBuilder = ChatMessageCompanion Function({
required String messageId,
required String chatId,
required String orderKey,
required String messageJson,
Value<int> rowid,
});
typedef $ChatMessageUpdateCompanionBuilder = ChatMessageCompanion Function({
Value<String> messageId,
Value<String> chatId,
Value<String> orderKey,
Value<String> messageJson,
Value<int> rowid,
});
class $ChatMessageFilterComposer extends Composer<_$ChatDatabase, ChatMessage> {
$ChatMessageFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get messageId => $composableBuilder(
column: $table.messageId, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get chatId => $composableBuilder(
column: $table.chatId, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get orderKey => $composableBuilder(
column: $table.orderKey, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get messageJson => $composableBuilder(
column: $table.messageJson, builder: (column) => ColumnFilters(column));
}
class $ChatMessageOrderingComposer
extends Composer<_$ChatDatabase, ChatMessage> {
$ChatMessageOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get messageId => $composableBuilder(
column: $table.messageId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get chatId => $composableBuilder(
column: $table.chatId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get orderKey => $composableBuilder(
column: $table.orderKey, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get messageJson => $composableBuilder(
column: $table.messageJson, builder: (column) => ColumnOrderings(column));
}
class $ChatMessageAnnotationComposer
extends Composer<_$ChatDatabase, ChatMessage> {
$ChatMessageAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get messageId =>
$composableBuilder(column: $table.messageId, builder: (column) => column);
GeneratedColumn<String> get chatId =>
$composableBuilder(column: $table.chatId, builder: (column) => column);
GeneratedColumn<String> get orderKey =>
$composableBuilder(column: $table.orderKey, builder: (column) => column);
GeneratedColumn<String> get messageJson => $composableBuilder(
column: $table.messageJson, builder: (column) => column);
}
class $ChatMessageTableManager extends RootTableManager<
_$ChatDatabase,
ChatMessage,
ChatMessageData,
$ChatMessageFilterComposer,
$ChatMessageOrderingComposer,
$ChatMessageAnnotationComposer,
$ChatMessageCreateCompanionBuilder,
$ChatMessageUpdateCompanionBuilder,
(
ChatMessageData,
BaseReferences<_$ChatDatabase, ChatMessage, ChatMessageData>
),
ChatMessageData,
PrefetchHooks Function()> {
$ChatMessageTableManager(_$ChatDatabase db, ChatMessage table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$ChatMessageFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$ChatMessageOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$ChatMessageAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<String> messageId = const Value.absent(),
Value<String> chatId = const Value.absent(),
Value<String> orderKey = const Value.absent(),
Value<String> messageJson = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
ChatMessageCompanion(
messageId: messageId,
chatId: chatId,
orderKey: orderKey,
messageJson: messageJson,
rowid: rowid,
),
createCompanionCallback: ({
required String messageId,
required String chatId,
required String orderKey,
required String messageJson,
Value<int> rowid = const Value.absent(),
}) =>
ChatMessageCompanion.insert(
messageId: messageId,
chatId: chatId,
orderKey: orderKey,
messageJson: messageJson,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $ChatMessageProcessedTableManager = ProcessedTableManager<
_$ChatDatabase,
ChatMessage,
ChatMessageData,
$ChatMessageFilterComposer,
$ChatMessageOrderingComposer,
$ChatMessageAnnotationComposer,
$ChatMessageCreateCompanionBuilder,
$ChatMessageUpdateCompanionBuilder,
(
ChatMessageData,
BaseReferences<_$ChatDatabase, ChatMessage, ChatMessageData>
),
ChatMessageData,
PrefetchHooks Function()>;
class $ChatDatabaseManager {
final _$ChatDatabase _db;
$ChatDatabaseManager(this._db);
$ChatDataTableManager get chatData =>
$ChatDataTableManager(_db, _db.chatData);
$ChatMessageTableManager get chatMessage =>
$ChatMessageTableManager(_db, _db.chatMessage);
}
@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
part 'chat_metadata.g.dart';
@JsonSerializable()
class ChatMetadata {
final String? mainDocumentId;
final String? contextId;
ChatMetadata({this.mainDocumentId, this.contextId});
factory ChatMetadata.fromJson(Map<String, dynamic> json) =>
_$ChatMetadataFromJson(json);
Map<String, dynamic> toJson() => _$ChatMetadataToJson(this);
}
@@ -0,0 +1,18 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_metadata.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ChatMetadata _$ChatMetadataFromJson(Map<String, dynamic> json) => ChatMetadata(
mainDocumentId: json['mainDocumentId'] as String?,
contextId: json['contextId'] as String?,
);
Map<String, dynamic> _$ChatMetadataToJson(ChatMetadata instance) =>
<String, dynamic>{
'mainDocumentId': instance.mainDocumentId,
'contextId': instance.contextId,
};
@@ -0,0 +1,22 @@
import 'package:flutter_chat_core/flutter_chat_core.dart';
enum MessageAuthor {
///langchain SystemChatMessage
system(User(id: 'system')),
///langchain HumanChatMessage
human(User(id: 'human')),
///langchain AIChatMessage
ai(User(id: 'ai')),
///langchain ToolChatMessage
tool(User(id: 'tool')),
///langchain CustomChatMessage
custom(User(id: 'custom'));
final User user;
const MessageAuthor(this.user);
}
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:lensai/data/database/functions/lexo_rank_functions.dart';
import 'package:lensai/features/chat/features/chat_store/data/database/database.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as path_provider;
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:universal_io/io.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
ChatDatabase chatDatabase(Ref ref) {
final db = ChatDatabase(
LazyDatabase(() async {
// put the database file, called db.sqlite here, into the documents folder
// for your app.
final dbFolder = await path_provider.getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'chat.db'));
// Also work around limitations on old Android versions
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
}
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sandboxing.
final cachebase = (await path_provider.getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cachebase;
return NativeDatabase.createInBackground(
file,
setup: (database) {
registerLexorankFunctions(database);
},
);
}),
);
ref.onDispose(() {
unawaited(db.close());
});
return db;
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatDatabaseHash() => r'b48955bc016f8c74684ed6e2c5cba99f5ab38dfe';
/// See also [chatDatabase].
@ProviderFor(chatDatabase)
final chatDatabaseProvider = Provider<ChatDatabase>.internal(
chatDatabase,
name: r'chatDatabaseProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatDatabaseHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatDatabaseRef = ProviderRef<ChatDatabase>;
// 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
@@ -0,0 +1,152 @@
// ignore_for_file: avoid_dynamic_calls
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:langchain/langchain.dart';
import 'package:lensai/features/chat/features/chat_store/data/models/message_types.dart';
class ChatMemory implements BaseMemory {
final ChatController _controller;
/// Max number of tokens to use.
final int maxTokenLimit;
/// Language model to use for counting tokens.
final BaseLanguageModel llm;
/// The memory key to use for the chat history.
/// This will be passed as input variable to the prompt.
final String memoryKey;
/// If true, when [loadMemoryVariables] is called, it will return
/// [ChatMessage] objects. If false, it will return a String representation
/// of the messages.
///
/// Set this to true when you are using a Chat model like `ChatOpenAI`.
/// Set this to false when you are use a text LLM like `OpenAI`.
final bool returnMessages;
/// The prefix to use for system messages if [returnMessages] is false.
final String systemPrefix;
/// The prefix to use for human messages if [returnMessages] is false.
final String humanPrefix;
/// The prefix to use for AI messages if [returnMessages] is false.
final String aiPrefix;
/// The prefix to use for tool messages if [returnMessages] is false.
final String toolPrefix;
ChatMemory(
this._controller, {
required this.returnMessages,
this.maxTokenLimit = 2000,
required this.llm,
this.memoryKey = BaseMemory.defaultMemoryKey,
this.systemPrefix = SystemChatMessage.defaultPrefix,
this.humanPrefix = HumanChatMessage.defaultPrefix,
this.aiPrefix = AIChatMessage.defaultPrefix,
this.toolPrefix = ToolChatMessage.defaultPrefix,
});
@override
Set<String> get memoryKeys => {memoryKey};
@override
Future<MemoryVariables> loadMemoryVariables([
MemoryInputValues values = const {},
]) async {
final messages = _controller.messages
.whereNot(
(message) => message.metadata?['hideFromModelChatHistory'] == true,
)
.map(
(message) => switch (message) {
TextMessage(
authorId: final authorId,
text: final text,
metadata: final metadata,
) =>
() {
if (authorId == MessageAuthor.system.user.id) {
return SystemChatMessage(content: text);
}
if (authorId == MessageAuthor.human.user.id) {
return HumanChatMessage(
content: ChatMessageContent.text(text),
);
}
if (authorId == MessageAuthor.ai.user.id) {
final toolsRaw = metadata?['toolCalls'];
if (toolsRaw is String) {
return AIChatMessage(
content: text,
toolCalls: _deserializeToolCalls(toolsRaw),
);
}
return AIChatMessage(content: text);
}
throw UnimplementedError();
}(),
ImageMessage() => throw UnimplementedError(),
CustomMessage() => throw UnimplementedError(),
UnsupportedMessage() => throw UnimplementedError()
},
)
.toList();
int currentBufferLength = await llm.countTokens(PromptValue.chat(messages));
// Prune buffer if it exceeds max token limit
if (currentBufferLength > maxTokenLimit) {
while (currentBufferLength > maxTokenLimit) {
//remove oldest entry
messages.removeAt(0);
currentBufferLength = await llm.countTokens(PromptValue.chat(messages));
}
}
if (returnMessages) {
return {memoryKey: messages};
}
return {
memoryKey: messages.toBufferString(
systemPrefix: systemPrefix,
humanPrefix: humanPrefix,
aiPrefix: aiPrefix,
toolPrefix: toolPrefix,
),
};
}
List<AIChatMessageToolCall> _deserializeToolCalls(String toolsRaw) {
final decoded = jsonDecode(toolsRaw) as List<dynamic>;
final tools = decoded
.map(
(tool) => AIChatMessageToolCall(
id: tool['id'] as String,
name: tool['name'] as String,
arguments: tool['arguments'] as Map<String, dynamic>,
argumentsRaw: tool['argumentsRaw'] as String,
),
)
.toList();
return tools;
}
@override
Future<void> saveContext({
required MemoryInputValues inputValues,
required MemoryOutputValues outputValues,
}) {
throw UnimplementedError('This is a read only memory');
}
@override
Future<void> clear() {
throw UnimplementedError('This is a read only memory');
}
}
@@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:lensai/features/chat/features/chat_store/data/database/daos/messages.dart';
import 'package:rxdart/rxdart.dart';
class DriftChatController implements ChatController {
final MessagesDao _dao;
final String chatId;
final _operationsController = StreamController<ChatOperation>.broadcast();
late final ValueStream<List<Message>> _mergedMessagesStream;
late final StreamSubscription<List<Message>> _mergedMessagesStreamSub;
late final BehaviorSubject<Map<String, Message>> _intermediateMessages;
DriftChatController(this._dao, {required this.chatId}) {
final chatMessagesStream = _dao
.chatMessages(chatId: chatId)
.map(
(data) => Message.fromJson(
jsonDecode(data.messageJson) as Map<String, dynamic>,
),
)
.watch();
_intermediateMessages = BehaviorSubject.seeded({});
_mergedMessagesStream = Rx.combineLatest2(
chatMessagesStream,
_intermediateMessages,
(a, b) {
if (a.isNotEmpty && b.isNotEmpty) {
return [
for (final message in a) b[message.id] ?? message,
];
}
return a;
},
).shareValue();
//Set initial messages
unawaited(
_mergedMessagesStream.first.then((messages) {
if (messages.isNotEmpty) {
_operationsController.add(ChatOperation.set());
}
}),
);
_mergedMessagesStreamSub = _mergedMessagesStream.listen(null);
}
@override
List<Message> get messages => _mergedMessagesStream.valueOrNull ?? [];
@override
Future<void> insert(Message message, {int? index}) async {
final insertedIndex = await _dao.insertMessage(
chatId: chatId,
message: message,
index: index,
);
if (insertedIndex != null) {
await _mergedMessagesStream
.firstWhere(
(messages) =>
messages.length > insertedIndex &&
messages[insertedIndex].id == message.id,
)
.timeout(const Duration(milliseconds: 100));
_operationsController.add(ChatOperation.insert(message, insertedIndex));
}
}
@override
Future<void> remove(Message message) async {
final index = await _dao
.messageIndex(chatId: chatId, messageId: message.id)
.getSingleOrNull();
if (index != null) {
await _dao.removeMessage(messageId: message.id);
await _mergedMessagesStream
.firstWhere(
(messages) =>
!messages.any((dbMessage) => dbMessage.id == message.id),
)
.timeout(const Duration(milliseconds: 100));
_operationsController.add(ChatOperation.remove(message, index));
}
}
@override
Future<void> update(
Message oldMessage,
Message newMessage, {
bool persistMessage = true,
}) async {
assert(oldMessage.id == newMessage.id);
if (persistMessage) {
final intermediateMessage = _intermediateMessages.value[newMessage.id];
if (intermediateMessage == null && oldMessage == newMessage) {
//nothing to update
return;
}
final rows = await _dao.updateMessage(
messageId: oldMessage.id,
message: newMessage,
);
if (intermediateMessage != null) {
_intermediateMessages.add(
{..._intermediateMessages.value}..remove(newMessage.id),
);
}
if (rows <= 0 || intermediateMessage == newMessage) {
//Dont send event
return;
}
} else if (oldMessage != newMessage) {
_intermediateMessages.add({
..._intermediateMessages.value,
newMessage.id: newMessage,
});
}
await _mergedMessagesStream
.firstWhere(
(messages) => messages.any((message) => message == newMessage),
)
.timeout(const Duration(milliseconds: 100));
_operationsController.add(ChatOperation.update(oldMessage, newMessage));
}
@override
Future<void> set(List<Message> messages) async {
await _dao.replaceChatMessages(chatId: chatId, messages: messages);
await _mergedMessagesStream
.firstWhere(
(replacedMessages) =>
const DeepCollectionEquality().equals(messages, replacedMessages),
)
.timeout(const Duration(milliseconds: 100));
_operationsController.add(ChatOperation.set());
}
@override
Stream<ChatOperation> get operationsStream => _operationsController.stream;
@override
void dispose() {
unawaited(_intermediateMessages.close());
unawaited(_mergedMessagesStreamSub.cancel());
unawaited(_operationsController.close());
}
}
@@ -0,0 +1,25 @@
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
import 'package:lensai/features/chat/features/chat_store/data/providers.dart';
import 'package:lensai/features/chat/features/chat_store/domain/drift_chat_controller.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@Riverpod()
DriftChatController chatController(Ref ref, String chatId) {
final db = ref.watch(chatDatabaseProvider);
final controller = DriftChatController(db.messagesDao, chatId: chatId);
ref.onDispose(() {
controller.dispose();
});
return controller;
}
@Riverpod()
Stream<ChatMetadata?> chatMetadata(Ref ref, String chatId) {
final db = ref.watch(chatDatabaseProvider);
return db.metadataDao.chatMetadata(chatId).watchSingleOrNull();
}
@@ -0,0 +1,292 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatControllerHash() => r'493264a4413ffc2d767d73a90634e1a1744cfcfd';
/// 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));
}
}
/// See also [chatController].
@ProviderFor(chatController)
const chatControllerProvider = ChatControllerFamily();
/// See also [chatController].
class ChatControllerFamily extends Family<DriftChatController> {
/// See also [chatController].
const ChatControllerFamily();
/// See also [chatController].
ChatControllerProvider call(
String chatId,
) {
return ChatControllerProvider(
chatId,
);
}
@override
ChatControllerProvider getProviderOverride(
covariant ChatControllerProvider 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'chatControllerProvider';
}
/// See also [chatController].
class ChatControllerProvider extends AutoDisposeProvider<DriftChatController> {
/// See also [chatController].
ChatControllerProvider(
String chatId,
) : this._internal(
(ref) => chatController(
ref as ChatControllerRef,
chatId,
),
from: chatControllerProvider,
name: r'chatControllerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatControllerHash,
dependencies: ChatControllerFamily._dependencies,
allTransitiveDependencies:
ChatControllerFamily._allTransitiveDependencies,
chatId: chatId,
);
ChatControllerProvider._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
Override overrideWith(
DriftChatController Function(ChatControllerRef provider) create,
) {
return ProviderOverride(
origin: this,
override: ChatControllerProvider._internal(
(ref) => create(ref as ChatControllerRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
chatId: chatId,
),
);
}
@override
AutoDisposeProviderElement<DriftChatController> createElement() {
return _ChatControllerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatControllerProvider && 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 ChatControllerRef on AutoDisposeProviderRef<DriftChatController> {
/// The parameter `chatId` of this provider.
String get chatId;
}
class _ChatControllerProviderElement
extends AutoDisposeProviderElement<DriftChatController>
with ChatControllerRef {
_ChatControllerProviderElement(super.provider);
@override
String get chatId => (origin as ChatControllerProvider).chatId;
}
String _$chatMetadataHash() => r'22252e4a4c4e9cb039c5eee0e0f6eb5b8bd03944';
/// See also [chatMetadata].
@ProviderFor(chatMetadata)
const chatMetadataProvider = ChatMetadataFamily();
/// See also [chatMetadata].
class ChatMetadataFamily extends Family<AsyncValue<ChatMetadata?>> {
/// See also [chatMetadata].
const ChatMetadataFamily();
/// See also [chatMetadata].
ChatMetadataProvider call(
String chatId,
) {
return ChatMetadataProvider(
chatId,
);
}
@override
ChatMetadataProvider getProviderOverride(
covariant ChatMetadataProvider 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'chatMetadataProvider';
}
/// See also [chatMetadata].
class ChatMetadataProvider extends AutoDisposeStreamProvider<ChatMetadata?> {
/// See also [chatMetadata].
ChatMetadataProvider(
String chatId,
) : this._internal(
(ref) => chatMetadata(
ref as ChatMetadataRef,
chatId,
),
from: chatMetadataProvider,
name: r'chatMetadataProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatMetadataHash,
dependencies: ChatMetadataFamily._dependencies,
allTransitiveDependencies:
ChatMetadataFamily._allTransitiveDependencies,
chatId: chatId,
);
ChatMetadataProvider._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
Override overrideWith(
Stream<ChatMetadata?> Function(ChatMetadataRef provider) create,
) {
return ProviderOverride(
origin: this,
override: ChatMetadataProvider._internal(
(ref) => create(ref as ChatMetadataRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
chatId: chatId,
),
);
}
@override
AutoDisposeStreamProviderElement<ChatMetadata?> createElement() {
return _ChatMetadataProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatMetadataProvider && 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 ChatMetadataRef on AutoDisposeStreamProviderRef<ChatMetadata?> {
/// The parameter `chatId` of this provider.
String get chatId;
}
class _ChatMetadataProviderElement
extends AutoDisposeStreamProviderElement<ChatMetadata?>
with ChatMetadataRef {
_ChatMetadataProviderElement(super.provider);
@override
String get chatId => (origin as ChatMetadataProvider).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
@@ -0,0 +1,87 @@
import 'package:collection/collection.dart';
import 'package:exceptions/exceptions.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:langchain/langchain.dart';
import 'package:lensai/core/uuid.dart';
import 'package:lensai/features/chat/features/chat_store/data/models/message_types.dart';
import 'package:lensai/features/chat/features/chat_store/domain/drift_chat_controller.dart';
import 'package:lensai/features/chat/features/chat_store/domain/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_message.g.dart';
@Riverpod()
class ChatMessageRepository extends _$ChatMessageRepository {
late DriftChatController _controller;
final _isTyping = <MessageAuthor>{};
bool isTyping(MessageAuthor author) => _isTyping.contains(author);
Future<void> setTyping({
required MessageAuthor author,
required bool typing,
}) async {
if (isTyping(author) == typing) {
return;
}
if (typing) {
await _controller.insert(
CustomMessage(
id: uuid.v4(),
authorId: author.user.id,
createdAt: DateTime.now().toUtc(),
metadata: {
'type': 'typing',
},
),
);
_isTyping.add(author);
} else {
final typingMessages = _controller.messages.where(
(message) =>
message.authorId == author.user.id &&
message.metadata?['type'] == 'typing',
);
await Future.wait(
typingMessages.map((message) => _controller.remove(message)),
);
_isTyping.remove(author);
}
}
Future<Result<TextMessage>> insertTextMessage({
required MessageAuthor author,
required String content,
List<AIChatMessageToolCall>? toolCalls,
bool hideFromModelChatHistory = false,
}) async {
final message = TextMessage(
id: uuid.v4(),
authorId: author.user.id,
createdAt: DateTime.now().toUtc(),
text: content,
isOnlyEmoji: isOnlyEmoji(content),
metadata: (hideFromModelChatHistory || (toolCalls?.isNotEmpty ?? false))
? {
'toolCalls': toolCalls?.map((tool) => tool.toMap()).toList(),
if (hideFromModelChatHistory) 'hideFromModelChatHistory': true,
}
: null,
);
return Result.fromAsync(() async {
await _controller.insert(message);
return message;
});
}
@override
void build(String chatId) {
_controller = ref.watch(chatControllerProvider(chatId));
}
}
@@ -0,0 +1,177 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_message.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatMessageRepositoryHash() =>
r'a4144618b339f7c0c329a01a294b6bebbb648bf6';
/// 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 _$ChatMessageRepository
extends BuildlessAutoDisposeNotifier<void> {
late final String chatId;
void build(
String chatId,
);
}
/// See also [ChatMessageRepository].
@ProviderFor(ChatMessageRepository)
const chatMessageRepositoryProvider = ChatMessageRepositoryFamily();
/// See also [ChatMessageRepository].
class ChatMessageRepositoryFamily extends Family<void> {
/// See also [ChatMessageRepository].
const ChatMessageRepositoryFamily();
/// See also [ChatMessageRepository].
ChatMessageRepositoryProvider call(
String chatId,
) {
return ChatMessageRepositoryProvider(
chatId,
);
}
@override
ChatMessageRepositoryProvider getProviderOverride(
covariant ChatMessageRepositoryProvider 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'chatMessageRepositoryProvider';
}
/// See also [ChatMessageRepository].
class ChatMessageRepositoryProvider
extends AutoDisposeNotifierProviderImpl<ChatMessageRepository, void> {
/// See also [ChatMessageRepository].
ChatMessageRepositoryProvider(
String chatId,
) : this._internal(
() => ChatMessageRepository()..chatId = chatId,
from: chatMessageRepositoryProvider,
name: r'chatMessageRepositoryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatMessageRepositoryHash,
dependencies: ChatMessageRepositoryFamily._dependencies,
allTransitiveDependencies:
ChatMessageRepositoryFamily._allTransitiveDependencies,
chatId: chatId,
);
ChatMessageRepositoryProvider._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 ChatMessageRepository notifier,
) {
return notifier.build(
chatId,
);
}
@override
Override overrideWith(ChatMessageRepository Function() create) {
return ProviderOverride(
origin: this,
override: ChatMessageRepositoryProvider._internal(
() => create()..chatId = chatId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
chatId: chatId,
),
);
}
@override
AutoDisposeNotifierProviderElement<ChatMessageRepository, void>
createElement() {
return _ChatMessageRepositoryProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatMessageRepositoryProvider && 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 ChatMessageRepositoryRef on AutoDisposeNotifierProviderRef<void> {
/// The parameter `chatId` of this provider.
String get chatId;
}
class _ChatMessageRepositoryProviderElement
extends AutoDisposeNotifierProviderElement<ChatMessageRepository, void>
with ChatMessageRepositoryRef {
_ChatMessageRepositoryProviderElement(super.provider);
@override
String get chatId => (origin as ChatMessageRepositoryProvider).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
@@ -0,0 +1,23 @@
import 'package:exceptions/exceptions.dart';
import 'package:lensai/features/chat/features/chat_store/data/database/database.dart';
import 'package:lensai/features/chat/features/chat_store/data/models/chat_metadata.dart';
import 'package:lensai/features/chat/features/chat_store/data/providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_metadata.g.dart';
@Riverpod()
class ChatMetadataRepository extends _$ChatMetadataRepository {
late ChatDatabase _db;
Future<Result<void>> updateMetadata(ChatMetadata metadata) {
return Result.fromAsync(() {
return _db.metadataDao.updateChatMetadata(chatId, metadata);
});
}
@override
void build(String chatId) {
_db = ref.watch(chatDatabaseProvider);
}
}
@@ -0,0 +1,177 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_metadata.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatMetadataRepositoryHash() =>
r'72a0226ed7e87f452f4dfe44594bb30663c2db45';
/// 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 _$ChatMetadataRepository
extends BuildlessAutoDisposeNotifier<void> {
late final String chatId;
void build(
String chatId,
);
}
/// See also [ChatMetadataRepository].
@ProviderFor(ChatMetadataRepository)
const chatMetadataRepositoryProvider = ChatMetadataRepositoryFamily();
/// See also [ChatMetadataRepository].
class ChatMetadataRepositoryFamily extends Family<void> {
/// See also [ChatMetadataRepository].
const ChatMetadataRepositoryFamily();
/// See also [ChatMetadataRepository].
ChatMetadataRepositoryProvider call(
String chatId,
) {
return ChatMetadataRepositoryProvider(
chatId,
);
}
@override
ChatMetadataRepositoryProvider getProviderOverride(
covariant ChatMetadataRepositoryProvider 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'chatMetadataRepositoryProvider';
}
/// See also [ChatMetadataRepository].
class ChatMetadataRepositoryProvider
extends AutoDisposeNotifierProviderImpl<ChatMetadataRepository, void> {
/// See also [ChatMetadataRepository].
ChatMetadataRepositoryProvider(
String chatId,
) : this._internal(
() => ChatMetadataRepository()..chatId = chatId,
from: chatMetadataRepositoryProvider,
name: r'chatMetadataRepositoryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatMetadataRepositoryHash,
dependencies: ChatMetadataRepositoryFamily._dependencies,
allTransitiveDependencies:
ChatMetadataRepositoryFamily._allTransitiveDependencies,
chatId: chatId,
);
ChatMetadataRepositoryProvider._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 ChatMetadataRepository notifier,
) {
return notifier.build(
chatId,
);
}
@override
Override overrideWith(ChatMetadataRepository Function() create) {
return ProviderOverride(
origin: this,
override: ChatMetadataRepositoryProvider._internal(
() => create()..chatId = chatId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
chatId: chatId,
),
);
}
@override
AutoDisposeNotifierProviderElement<ChatMetadataRepository, void>
createElement() {
return _ChatMetadataRepositoryProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatMetadataRepositoryProvider && 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 ChatMetadataRepositoryRef on AutoDisposeNotifierProviderRef<void> {
/// The parameter `chatId` of this provider.
String get chatId;
}
class _ChatMetadataRepositoryProviderElement
extends AutoDisposeNotifierProviderElement<ChatMetadataRepository, void>
with ChatMetadataRepositoryRef {
_ChatMetadataRepositoryProviderElement(super.provider);
@override
String get chatId => (origin as ChatMetadataRepositoryProvider).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
@@ -403,8 +403,8 @@ abstract class _$ChatSearchDatabase extends GeneratedDatabase {
chatFts, chatFts,
}).map((QueryRow row) => ChatQueryResult( }).map((QueryRow row) => ChatQueryResult(
fileName: row.read<String>('file_name'), fileName: row.read<String>('file_name'),
title: row.read<String>('title'), title: row.readNullable<String>('title'),
contentSnippet: row.read<String>('content_snippet'), contentSnippet: row.readNullable<String>('content_snippet'),
)); ));
} }
@@ -702,11 +702,11 @@ class $ChatSearchDatabaseManager {
class ChatQueryResult { class ChatQueryResult {
final String fileName; final String fileName;
final String title; final String? title;
final String contentSnippet; final String? contentSnippet;
ChatQueryResult({ ChatQueryResult({
required this.fileName, required this.fileName,
required this.title, this.title,
required this.contentSnippet, this.contentSnippet,
}); });
} }
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:lensai/features/chat_archive/data/database/database.dart'; import 'package:lensai/features/chat_archive/data/database/database.dart';
@@ -12,7 +14,7 @@ part 'providers.g.dart';
@Riverpod() @Riverpod()
ChatSearchDatabase chatSearchDatabase(Ref ref) { ChatSearchDatabase chatSearchDatabase(Ref ref) {
return ChatSearchDatabase( final db = ChatSearchDatabase(
LazyDatabase(() async { LazyDatabase(() async {
// Also work around limitations on old Android versions // Also work around limitations on old Android versions
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -29,4 +31,10 @@ ChatSearchDatabase chatSearchDatabase(Ref ref) {
return NativeDatabase.memory(); return NativeDatabase.memory();
}), }),
); );
ref.onDispose(() {
unawaited(db.close());
});
return db;
} }
@@ -7,7 +7,7 @@ part of 'providers.dart';
// ************************************************************************** // **************************************************************************
String _$chatSearchDatabaseHash() => String _$chatSearchDatabaseHash() =>
r'99700f1a2c25b24742c60fa8dd91fe744dcf21be'; r'17c972b3da21b6a2365cc5fd56a4eade0ac0a3f3';
/// See also [chatSearchDatabase]. /// See also [chatSearchDatabase].
@ProviderFor(chatSearchDatabase) @ProviderFor(chatSearchDatabase)
@@ -91,9 +91,10 @@ class ChatArchiveSearchScreen extends HookConsumerWidget {
chatEntity.dateTime!.formatWithMinutePrecision(), chatEntity.dateTime!.formatWithMinutePrecision(),
), ),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
MarkdownBody( if (chat.contentSnippet != null)
data: chat.contentSnippet, MarkdownBody(
), data: chat.contentSnippet!,
),
], ],
), ),
), ),
@@ -78,13 +78,13 @@ class TabRepository extends _$TabRepository {
final tabContentSub = final tabContentSub =
tabContentService.tabContentStream.listen((content) async { tabContentService.tabContentStream.listen((content) async {
await _db.tabDao.updateTab( await _db.tabDao.updateTabContent(
content.tabId, content.tabId,
isProbablyReaderable: Value(content.isProbablyReaderable), isProbablyReaderable: content.isProbablyReaderable,
extractedContentMarkdown: Value(content.extractedContentMarkdown), extractedContentMarkdown: content.extractedContentMarkdown,
extractedContentPlain: Value(content.extractedContentPlain), extractedContentPlain: content.extractedContentPlain,
fullContentMarkdown: Value(content.fullContentMarkdown), fullContentMarkdown: content.fullContentMarkdown,
fullContentPlain: Value(content.fullContentPlain), fullContentPlain: content.fullContentPlain,
); );
}); });
@@ -6,7 +6,7 @@ part of 'tab.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$tabRepositoryHash() => r'4269add3c89250ab30835055e3567a1e6d5c185a'; String _$tabRepositoryHash() => r'b9caf89fe372b79f37c4a3ebff800ccc2d9289d2';
/// See also [TabRepository]. /// See also [TabRepository].
@ProviderFor(TabRepository) @ProviderFor(TabRepository)
@@ -26,3 +26,15 @@ class ViewTabsSheet extends Sheet {
@override @override
List<Object?> get hashParameters => []; 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];
}
@@ -1,16 +1,24 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/data/models/web_page_info.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/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/presentation/widgets/site_search.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.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.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/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/data/entities/modes.dart';
import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder; import 'package:lensai/features/kagi/utils/url_builder.dart' as uri_builder;
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
@@ -24,12 +32,9 @@ class WebPageDialog extends HookConsumerWidget {
final Uri url; final Uri url;
final WebPageInfo? precachedInfo; final WebPageInfo? precachedInfo;
final void Function()? onDismiss;
const WebPageDialog({ const WebPageDialog({
required this.url, required this.url,
this.precachedInfo, this.precachedInfo,
this.onDismiss,
super.key, super.key,
}); });
@@ -56,21 +61,19 @@ class WebPageDialog extends HookConsumerWidget {
useTextEditingController(text: url.toString()); useTextEditingController(text: url.toString());
final addressTextFocusNode = useFocusNode(); final addressTextFocusNode = useFocusNode();
return Stack( return MediaQuery.removeViewInsets(
children: [ context: context,
ModalBarrier( removeBottom: true,
color: Theme.of(context).dialogTheme.barrierColor ?? Colors.black54, child: Dialog(
onDismiss: onDismiss, insetPadding:
), const EdgeInsets.symmetric(horizontal: 16.0, vertical: 64.0),
SimpleDialog( child: Column(
titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), mainAxisSize: MainAxisSize.min,
contentPadding: EdgeInsets.zero,
insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0,
vertical: 24.0,
),
title: WebsiteTitleTile(url, precachedInfo: precachedInfo),
children: [ children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0),
child: WebsiteTitleTile(url, precachedInfo: precachedInfo),
),
SizedBox( SizedBox(
//We need this to stretch the dialog, then padding from dialog is applied //We need this to stretch the dialog, then padding from dialog is applied
width: double.maxFinite, width: double.maxFinite,
@@ -82,6 +85,7 @@ class WebPageDialog extends HookConsumerWidget {
controller: addressTextController, controller: addressTextController,
focusNode: addressTextFocusNode, focusNode: addressTextFocusNode,
enableIMEPersonalizedLearning: !incognitoEnabled, enableIMEPersonalizedLearning: !incognitoEnabled,
keyboardType: TextInputType.url,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Address', labelText: 'Address',
), ),
@@ -106,136 +110,212 @@ class WebPageDialog extends HookConsumerWidget {
.read(tabSessionProvider(tabId: null).notifier) .read(tabSessionProvider(tabId: null).notifier)
.loadUrl(url: Uri.tryParse(value)!); .loadUrl(url: Uri.tryParse(value)!);
onDismiss?.call(); if (context.mounted) {
context.pop();
}
}, },
), ),
), ),
), ),
), ),
const SizedBox(
height: 4,
),
const Divider(), const Divider(),
Padding( Flexible(
padding: const EdgeInsets.symmetric(horizontal: 16.0), child: FadingScroll(
child: availableBangsAsync.when( fadingSize: 25,
data: (availableBangs) { builder: (context, controller) {
if (availableBangs.isEmpty) { return SingleChildScrollView(
return const SizedBox.shrink(); 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( return SiteSearch(
domain: url.host, domain: url.host,
availableBangs: availableBangs, 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, Align(
), alignment: Alignment.bottomRight,
loading: () => SiteSearch( child: Padding(
domain: url.host, padding: const EdgeInsets.only(right: 16.0, bottom: 16.0),
availableBangs: [ child: TextButton(
// BangData( onPressed: () {
// websiteName: 'websiteName', context.pop();
// domain: 'domain', },
// trigger: 'trigger', child: const Text('Close'),
// 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()),
);
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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.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.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_list.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_session.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/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/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/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/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/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/sheets/view_tabs.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tabs_action_button.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/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/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_appearance_button.dart';
import 'package:lensai/features/geckoview/features/readerview/presentation/widgets/reader_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/kagi/data/entities/modes.dart';
import 'package:lensai/features/user/domain/repositories/settings.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/presentation/hooks/overlay_portal_controller.dart';
import 'package:lensai/utils/ui_helper.dart' as ui_helper; import 'package:lensai/utils/ui_helper.dart' as ui_helper;
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -52,7 +56,8 @@ class BrowserScreen extends HookConsumerWidget {
final selectedTabId = final selectedTabId =
ref.watch(selectedTabStateProvider.select((value) => value?.id)); ref.watch(selectedTabStateProvider.select((value) => value?.id));
final menuController = useMemoized(() => MenuController()); final trippleDotMenuController = useMenuController();
final tabMenuController = useMenuController();
final lastBackButtonPress = useRef<DateTime?>(null); final lastBackButtonPress = useRef<DateTime?>(null);
@@ -120,24 +125,13 @@ class BrowserScreen extends HookConsumerWidget {
return (tabState != null) return (tabState != null)
? AppBarTitle( ? AppBarTitle(
tab: tabState, tab: tabState,
onTap: () { onTap: () async {
ref await context.push(
.read( WebPageRoute(
overlayDialogControllerProvider url: tabState.url.toString(),
.notifier, ).location,
) extra: tabState,
.show( );
WebPageDialog(
url: tabState.url,
precachedInfo: tabState,
onDismiss: ref
.read(
overlayDialogControllerProvider
.notifier,
)
.dismiss,
),
);
}, },
) )
: const SizedBox.shrink(); : const SizedBox.shrink();
@@ -261,22 +255,58 @@ class BrowserScreen extends HookConsumerWidget {
// ), // ),
// ), // ),
// ), // ),
TabsActionButton( MenuAnchor(
isActive: displayedSheet is ViewTabsSheet, controller: tabMenuController,
onTap: () { builder: (context, controller, child) {
if (displayedSheet case ViewTabsSheet()) { return child!;
ref
.read(bottomSheetControllerProvider.notifier)
.dismiss();
} else {
ref
.read(bottomSheetControllerProvider.notifier)
.show(ViewTabsSheet());
}
}, },
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( MenuAnchor(
controller: menuController, controller: trippleDotMenuController,
builder: (context, controller, child) { builder: (context, controller, child) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4.0), padding: const EdgeInsets.only(right: 4.0),
@@ -487,7 +517,7 @@ class BrowserScreen extends HookConsumerWidget {
); );
await controller.reload(); await controller.reload();
menuController.close(); trippleDotMenuController.close();
}, },
leadingIcon: const Icon(Icons.refresh), leadingIcon: const Icon(Icons.refresh),
child: const Text('Reload'), child: const Text('Reload'),
@@ -514,7 +544,7 @@ class BrowserScreen extends HookConsumerWidget {
); );
await controller.goBack(); await controller.goBack();
menuController.close(); trippleDotMenuController.close();
}, },
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
) )
@@ -526,7 +556,7 @@ class BrowserScreen extends HookConsumerWidget {
.notifier, .notifier,
) )
.closeTab(selectedTabId); .closeTab(selectedTabId);
menuController.close(); trippleDotMenuController.close();
}, },
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
), ),
@@ -546,7 +576,7 @@ class BrowserScreen extends HookConsumerWidget {
); );
await controller.goForward(); await controller.goForward();
menuController.close(); trippleDotMenuController.close();
} }
: null, : null,
icon: const Icon(Icons.arrow_forward), 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; return false;
}, },
child: switch (displayedSheet) { child: switch (displayedSheet) {
ViewTabsSheet() => DraggableScrollableSheet( ViewTabsSheet() => HookBuilder(
key: ValueKey(displayedSheet), builder: (localContext) {
expand: false, final draggableScrollableController =
minChildSize: 0.1, useDraggableScrollableController();
maxChildSize: _realtiveSafeArea(context),
builder: (context, scrollController) { return DraggableScrollableSheet(
return ClipRRect( key: ValueKey(displayedSheet),
borderRadius: const BorderRadius.only( controller: draggableScrollableController,
topLeft: Radius.circular(28), expand: false,
topRight: Radius.circular(28), minChildSize: 0.1,
), maxChildSize: _realtiveSafeArea(context),
child: ViewTabsSheetWidget( builder: (context, scrollController) {
sheetScrollController: scrollController, return ClipRRect(
onClose: () { borderRadius: const BorderRadius.only(
ref topLeft: Radius.circular(28),
.read(bottomSheetControllerProvider.notifier) topRight: Radius.circular(28),
.dismiss(); ),
}, 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, : 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/tab_state.dart';
import 'package:lensai/features/geckoview/domain/providers/web_extensions_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/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'; import 'package:lensai/features/user/domain/repositories/cache.dart';
class BrowserView extends StatefulHookConsumerWidget { class BrowserView extends StatefulHookConsumerWidget {
@@ -87,6 +88,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
//Initialize and register dependencies //Initialize and register dependencies
ref.listenManual(tabRepositoryProvider, (previous, next) {}); ref.listenManual(tabRepositoryProvider, (previous, next) {});
ref.listenManual(documentRepositoryProvider, (previous, next) {});
ref.listenManual( ref.listenManual(
selectionActionServiceProvider, selectionActionServiceProvider,
@@ -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,
);
}
}
@@ -79,10 +79,10 @@ class CreateTabSheetWidget extends HookConsumerWidget {
TabBar( TabBar(
controller: tabController, controller: tabController,
tabs: [ tabs: [
Tab( // Tab(
icon: Icon(KagiTool.search.icon), // icon: Icon(KagiTool.search.icon),
text: 'Search', // text: 'Search',
), // ),
Tab( Tab(
icon: Icon(KagiTool.summarizer.icon), icon: Icon(KagiTool.summarizer.icon),
text: 'Summarize', text: 'Summarize',
@@ -99,10 +99,10 @@ class CreateTabSheetWidget extends HookConsumerWidget {
child: ExpandablePageView( child: ExpandablePageView(
controller: pageController, controller: pageController,
children: [ children: [
SearchTab( // SearchTab(
sharedContent: sharedContent, // sharedContent: sharedContent,
onSubmit: onSubmit, // onSubmit: onSubmit,
), // ),
SummarizeTab( SummarizeTab(
sharedContent: sharedContent, sharedContent: sharedContent,
onSubmit: onSubmit, onSubmit: onSubmit,
@@ -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/domain/repositories/tab.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.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/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/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/providers/selected_container.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab.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'; import 'package:reorderable_grid/reorderable_grid.dart';
class _Tab extends HookConsumerWidget { class _Tab extends HookConsumerWidget {
static const headerSize = 124.0;
final VoidCallback onClose; final VoidCallback onClose;
const _Tab({required this.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 { class ViewTabsSheetWidget extends HookConsumerWidget {
final ScrollController sheetScrollController; final ScrollController sheetScrollController;
final DraggableScrollableController draggableScrollableController;
final VoidCallback onClose; final VoidCallback onClose;
const ViewTabsSheetWidget({ const ViewTabsSheetWidget({
required this.onClose, required this.onClose,
required this.sheetScrollController, required this.sheetScrollController,
required this.draggableScrollableController,
super.key, super.key,
}); });
@@ -209,181 +187,191 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
return Stack( return Stack(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
children: [ children: [
CustomScrollView( Column(
controller: sheetScrollController, children: [
slivers: [ DraggableScrollableHeader(
SliverPersistentHeader( controller: draggableScrollableController,
pinned: true, child: _Tab(onClose: onClose),
delegate: _SliverHeaderDelagate(onClose: onClose),
), ),
HookConsumer( Expanded(
builder: (context, ref, child) { child: CustomScrollView(
final container = ref.watch(selectedContainerProvider); controller: sheetScrollController,
slivers: [
HookConsumer(
builder: (context, ref, child) {
final container = ref.watch(selectedContainerProvider);
final filteredTabs = ref final filteredTabs = ref
.watch( .watch(
seamlessFilteredTabsProvider(container).select( seamlessFilteredTabsProvider(container).select(
(value) => EquatableCollection(value, immutable: true), (value) =>
), EquatableCollection(value, immutable: true),
) ),
.collection; )
.collection;
final activeTab = ref.watch(selectedTabProvider); final activeTab = ref.watch(selectedTabProvider);
final itemHeight = useMemoized( final itemHeight = useMemoized(
() => _calculateItemHeight( () => _calculateItemHeight(
screenWidth: MediaQuery.of(context).size.width, screenWidth: MediaQuery.of(context).size.width,
childAspectRatio: 0.75, childAspectRatio: 0.75,
horizontalPadding: 4.0, horizontalPadding: 4.0,
mainAxisSpacing: 8.0, mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0, crossAxisSpacing: 8.0,
crossAxisCount: 2, crossAxisCount: 2,
), ),
[MediaQuery.of(context).size.width], [MediaQuery.of(context).size.width],
); );
useEffect( useEffect(
() { () {
final index = filteredTabs final index = filteredTabs
.indexWhere((webView) => webView == activeTab); .indexWhere((webView) => webView == activeTab);
if (index > -1) { if (index > -1) {
final offset = (index ~/ 2) * itemHeight; final offset = (index ~/ 2) * itemHeight;
if (offset != sheetScrollController.offset) { if (offset != sheetScrollController.offset) {
unawaited( unawaited(
sheetScrollController.animateTo( sheetScrollController.animateTo(
offset, offset,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut, curve: Curves.easeInOut,
), ),
); );
} }
} }
return null; return null;
}, },
[filteredTabs, activeTab], [filteredTabs, activeTab],
); );
final tabs = useMemoized( final tabs = useMemoized(
() { () {
return filteredTabs return filteredTabs
.mapIndexed( .mapIndexed(
(index, tabId) => (index, tabId) =>
ReorderableGridDelayedDragStartListener( ReorderableGridDelayedDragStartListener(
key: ValueKey(tabId), key: ValueKey(tabId),
index: index, index: index,
child: Consumer( child: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final tab = ref.watch(tabStateProvider(tabId)); final tab =
return (tab != null) ref.watch(tabStateProvider(tabId));
? TabPreview( return (tab != null)
tab: tab, ? TabPreview(
isActive: tabId == activeTab, tab: tab,
onTap: () async { isActive: tabId == activeTab,
if (tabId != activeTab) { onTap: () async {
//Close first to avoid rebuilds if (tabId != activeTab) {
onClose(); //Close first to avoid rebuilds
await ref onClose();
.read( await ref
tabRepositoryProvider
.notifier,
)
.selectTab(tab.id);
} else {
onClose();
}
},
onDoubleTap: () {
ref
.read(
overlayDialogControllerProvider
.notifier,
)
.show(
TabActionDialog(
initialTab: tab,
onDismiss: ref
.read( .read(
overlayDialogControllerProvider tabRepositoryProvider
.notifier, .notifier,
) )
.dismiss, .selectTab(tab.id);
), } else {
); onClose();
}, }
onDelete: () async { },
await ref onDoubleTap: () {
.read( ref
tabRepositoryProvider.notifier, .read(
) overlayDialogControllerProvider
.closeTab(tab.id); .notifier,
}, )
) .show(
: const SizedBox.shrink(); 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,
), ),
) itemCount: tabs.length,
.toList(); itemBuilder: (context, index) => tabs[index],
}, onReorder: (oldIndex, newIndex) async {
[ final containerRepository =
EquatableCollection(filteredTabs, immutable: true), ref.read(containerRepositoryProvider.notifier);
activeTab
],
);
return SliverPadding( final tabId = filteredTabs[oldIndex];
padding: const EdgeInsets.symmetric(horizontal: 4.0), final containerId = await ref
sliver: SliverReorderableGrid( .read(tabDataRepositoryProvider.notifier)
gridDelegate: .containerTabId(tabId);
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 String key;
final containerId = await ref if (newIndex <= 0) {
.read(tabDataRepositoryProvider.notifier) key = await containerRepository
.containerTabId(tabId); .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; await ref
if (newIndex <= 0) { .read(tabDataRepositoryProvider.notifier)
key = await containerRepository .assignOrderKey(tabId, key);
.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);
}, },
), ),
); ],
}, ),
), ),
], ],
), ),
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: _SliverHeaderDelagate.headerSize + 4, top: _Tab.headerSize + 4,
right: 4, right: 4,
), ),
child: FloatingActionButton.small( child: FloatingActionButton.small(
@@ -5,9 +5,11 @@ import 'package:lensai/features/geckoview/domain/providers/tab_list.dart';
class TabsActionButton extends HookConsumerWidget { class TabsActionButton extends HookConsumerWidget {
final bool isActive; final bool isActive;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onLongPress;
const TabsActionButton({ const TabsActionButton({
required this.onTap, required this.onTap,
required this.onLongPress,
this.isActive = false, this.isActive = false,
super.key, super.key,
}); });
@@ -20,6 +22,7 @@ class TabsActionButton extends HookConsumerWidget {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8.0, horizontal: 8.0,
@@ -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/entities/readerable_state.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_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/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'; import 'package:lensai/presentation/widgets/animate_gradient_shader.dart';
class ReaderButton extends HookConsumerWidget { class ReaderButton extends HookConsumerWidget {
@@ -14,6 +15,10 @@ class ReaderButton extends HookConsumerWidget {
final readerChanging = ref.watch(readerableScreenControllerProvider); final readerChanging = ref.watch(readerableScreenControllerProvider);
final enableReadability = ref.watch(
settingsRepositoryProvider.select((value) => value.enableReadability),
);
final readerabilityState = ref.watch( final readerabilityState = ref.watch(
selectedTabStateProvider.select( selectedTabStateProvider.select(
(state) => state?.readerableState ?? ReaderableState.$default(), (state) => state?.readerableState ?? ReaderableState.$default(),
@@ -34,7 +39,8 @@ class ReaderButton extends HookConsumerWidget {
); );
return Visibility( return Visibility(
visible: readerabilityState.readerable, visible: readerabilityState.readerable &&
(enableReadability || readerabilityState.active),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 15.0, vertical: 15.0,
@@ -110,38 +110,25 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
); );
} }
Future<void> updateTab( Future<void> updateTabContent(
String id, { String id, {
Value<String?> url = const Value.absent(), required bool isProbablyReaderable,
Value<String?> title = const Value.absent(), required String? extractedContentMarkdown,
Value<bool> isProbablyReaderable = const Value.absent(), required String? extractedContentPlain,
Value<String?> extractedContentMarkdown = const Value.absent(), required String? fullContentMarkdown,
Value<String?> extractedContentPlain = const Value.absent(), required String? fullContentPlain,
Value<String?> fullContentMarkdown = const Value.absent(),
Value<String?> fullContentPlain = const Value.absent(),
}) async { }) async {
final doUpdate = url != const Value.absent() || final statement = _updateByIdStatement(id);
title != const Value.absent() ||
isProbablyReaderable != const Value.absent() ||
extractedContentMarkdown != const Value.absent() ||
extractedContentPlain != const Value.absent() ||
fullContentMarkdown != const Value.absent() ||
fullContentPlain != const Value.absent();
if (doUpdate) { await statement.write(
final statement = _updateByIdStatement(id); TabCompanion(
await statement.write( isProbablyReaderable: Value(isProbablyReaderable),
TabCompanion( extractedContentMarkdown: Value(extractedContentMarkdown),
url: url, extractedContentPlain: Value(extractedContentPlain),
title: title, fullContentMarkdown: Value(fullContentMarkdown),
isProbablyReaderable: isProbablyReaderable, fullContentPlain: Value(fullContentPlain),
extractedContentMarkdown: extractedContentMarkdown, ),
extractedContentPlain: extractedContentPlain, );
fullContentMarkdown: fullContentMarkdown,
fullContentPlain: fullContentPlain,
),
);
}
} }
Future<void> updateTabs( Future<void> updateTabs(
@@ -209,7 +196,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with _$TabDaoMixin {
if (ftsQuery.isNotEmpty) { if (ftsQuery.isNotEmpty) {
return db.queryTabsFullContent( return db.queryTabsFullContent(
query: db.buildFtsQuery(searchString), query: ftsQuery,
snippetLength: snippetLength, snippetLength: snippetLength,
beforeMatch: matchPrefix, beforeMatch: matchPrefix,
afterMatch: matchSuffix, afterMatch: matchSuffix,
@@ -1,5 +1,4 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show Color, IconData; import 'package:flutter/widgets.dart' show Color, IconData;
import 'package:lensai/data/database/converters/color.dart'; import 'package:lensai/data/database/converters/color.dart';
import 'package:lensai/data/database/converters/icon_data.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/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/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/tab_query_result.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/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/search/domain/fts_tokenizer.dart'; import 'package:lensai/features/search/domain/fts_tokenizer.dart';
import 'package:lensai/features/search/domain/unix_tokenizer.dart'; import 'package:lensai/features/search/domain/unix_tokenizer.dart';
@@ -15,9 +17,11 @@ part 'database.g.dart';
@DriftDatabase( @DriftDatabase(
include: {'database.drift'}, 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 @override
final int schemaVersion = 2; final int schemaVersion = 2;
@@ -26,61 +30,25 @@ class TabDatabase extends _$TabDatabase implements IQueryBuilder {
@override @override
final int ftsMinTokenLength = 3; 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 @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async { onCreate: (m) async {
// disable foreign_keys before migrations final migrator =
await customStatement('PRAGMA foreign_keys = OFF'); VectorDatabaseMigrator(dimensions: embeddingDimensions);
if (from < 2) { await m.database.customStatement(migrator.vectorTableDefinition);
await transaction(() async {
// await m.dropColumn(tabLink, 'url');
// await m.dropColumn(tabLink, 'title');
// await m.dropColumn(tabLink, 'screenshot');
// await m.alterTable(TableMigration(tab)); //instead of m.createAll(); we igoner vec0 table
}); for (final entity
} in allSchemaEntities.where((entity) => entity is! DocumentVec)) {
await m.create(entity);
// 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)}',
);
} }
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON;'); 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 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 CREATE VIRTUAL TABLE tab_fts
USING fts5( USING fts5(
title, 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); VALUES (new.rowid, new.title, new.url, new.extracted_content_plain, new.full_content_plain);
END; END;
optimizeFtsIndex:
INSERT INTO tab_fts(tab_fts) VALUES ('optimize');
containersWithCount WITH ContainerDataWithCount: containersWithCount WITH ContainerDataWithCount:
SELECT SELECT
container.*, container.*,
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
class TabQueryResult { class TabQueryResult {
final String id; final String id;
final String title; final String? title;
final String url; final String? url;
final String? extractedContent; final String? extractedContent;
final String? fullContent; final String? fullContent;
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.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/data/database/functions/lexo_rank_functions.dart';
import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart'; import 'package:lensai/features/geckoview/features/tabs/data/database/database.dart';
import 'package:path/path.dart' as p; 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:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
import 'package:sqlite3_vec/sqlite3_vec.dart';
import 'package:universal_io/io.dart'; import 'package:universal_io/io.dart';
part 'providers.g.dart'; part 'providers.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
TabDatabase tabDatabase(Ref ref) { TabDatabase tabDatabase(Ref ref) {
return TabDatabase( final dimensions = ref.watch(embeddingDimensionsProvider);
final db = TabDatabase(
LazyDatabase(() async { LazyDatabase(() async {
// put the database file, called db.sqlite here, into the documents folder // put the database file, called db.sqlite here, into the documents folder
// for your app. // for your app.
@@ -33,6 +39,8 @@ TabDatabase tabDatabase(Ref ref) {
// Explicitly tell it about the correct temporary directory. // Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cachebase; sqlite3.tempDirectory = cachebase;
Sqlite3Vec.ensureExtensionLoaded();
return NativeDatabase.createInBackground( return NativeDatabase.createInBackground(
file, file,
setup: (database) { 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 // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$tabDatabaseHash() => r'1379e222fe4d43c119a89ec89a779ef35fbe1563'; String _$tabDatabaseHash() => r'940dd2a1f1df6f2a0b77a1a3e9b8318550b832d2';
/// See also [tabDatabase]. /// See also [tabDatabase].
@ProviderFor(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
@@ -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,
),
),
);
}
}
@@ -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();
}
}
}
@@ -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>[]);
}
}
@@ -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
@@ -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,
);
}
}
@@ -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> {}
@@ -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});
}
@@ -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;
@@ -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,
);
}
}
@@ -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());
});
}
}
@@ -1,29 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cache.dart'; part of 'document.dart';
// ************************************************************************** // **************************************************************************
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$iconCacheSizeMegabytesHash() => String _$documentRepositoryHash() =>
r'43162fce3ce8b04a5a55ed52021b274afbde2861'; r'0c93116e0f9887d452b7c493dd4da45e5bf0c2d4';
/// See also [iconCacheSizeMegabytes]. /// See also [DocumentRepository].
@ProviderFor(iconCacheSizeMegabytes) @ProviderFor(DocumentRepository)
final iconCacheSizeMegabytesProvider = final documentRepositoryProvider =
AutoDisposeStreamProvider<double>.internal( AutoDisposeNotifierProvider<DocumentRepository, void>.internal(
iconCacheSizeMegabytes, DocumentRepository.new,
name: r'iconCacheSizeMegabytesProvider', name: r'documentRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null ? null
: _$iconCacheSizeMegabytesHash, : _$documentRepositoryHash,
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
@Deprecated('Will be removed in 3.0. Use Ref instead') typedef _$DocumentRepository = AutoDisposeNotifier<void>;
// ignore: unused_element
typedef IconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef<double>;
// ignore_for_file: type=lint // 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 // 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
@@ -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();
}
}
@@ -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);
}
}
@@ -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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Containers'), 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( body: HookConsumer(
builder: (context, ref, child) { 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),
),
); );
} }
} }
@@ -1,225 +1,225 @@
import 'package:expandable_page_view/expandable_page_view.dart'; // import 'package:expandable_page_view/expandable_page_view.dart';
import 'package:flutter/material.dart'; // import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; // import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; // import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; // import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; // import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart'; // import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart'; // import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
import 'package:lensai/features/kagi/data/entities/modes.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/kagi/utils/url_builder.dart' as uri_builder;
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; // import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
import 'package:lensai/features/user/domain/repositories/settings.dart'; // import 'package:lensai/features/user/domain/repositories/settings.dart';
import 'package:lensai/presentation/hooks/sync_page_tab.dart'; // import 'package:lensai/presentation/hooks/sync_page_tab.dart';
class AssistantTab extends HookConsumerWidget { // class AssistantTab extends HookConsumerWidget {
final SharedContent? sharedContent; // final SharedContent? sharedContent;
final OnSubmitUri onSubmit; // final OnSubmitUri onSubmit;
const AssistantTab({ // const AssistantTab({
required this.sharedContent, // required this.sharedContent,
required this.onSubmit, // required this.onSubmit,
super.key, // super.key,
}); // });
@override // @override
Widget build(BuildContext context, WidgetRef ref) { // Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch( // final incognitoEnabled = ref.watch(
settingsRepositoryProvider.select((value) => value.incognitoMode), // settingsRepositoryProvider.select((value) => value.incognitoMode),
); // );
final researchVariant = ref.watch(activeResearchVariantProvider); // final researchVariant = ref.watch(activeResearchVariantProvider);
final chatModel = ref.watch(activeChatModelProvider); // final chatModel = ref.watch(activeChatModelProvider);
useAutomaticKeepAlive(); // useAutomaticKeepAlive();
final formKey = useMemoized(() => GlobalKey<FormState>()); // final formKey = useMemoized(() => GlobalKey<FormState>());
final textController = // final textController =
useTextEditingController(text: sharedContent?.toString()); // useTextEditingController(text: sharedContent?.toString());
final tabController = useTabController( // final tabController = useTabController(
initialLength: AssistantMode.values.length, // initialLength: AssistantMode.values.length,
initialIndex: ref.read(lastUsedAssistantModeProvider).index, // initialIndex: ref.read(lastUsedAssistantModeProvider).index,
); // );
final pageController = usePageController(initialPage: tabController.index); // final pageController = usePageController(initialPage: tabController.index);
useSyncPageWithTab( // useSyncPageWithTab(
tabController, // tabController,
pageController, // pageController,
onIndexChanged: (index) { // onIndexChanged: (index) {
ref // ref
.read(lastUsedAssistantModeProvider.notifier) // .read(lastUsedAssistantModeProvider.notifier)
.update(AssistantMode.values[index]); // .update(AssistantMode.values[index]);
}, // },
); // );
return Form( // return Form(
key: formKey, // key: formKey,
child: Column( // child: Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
TabBar.secondary( // TabBar.secondary(
controller: tabController, // controller: tabController,
tabs: const [ // tabs: const [
Tab( // Tab(
text: 'Research', // text: 'Research',
icon: Icon(MdiIcons.layersSearch), // icon: Icon(MdiIcons.layersSearch),
), // ),
Tab( // Tab(
text: 'Code', // text: 'Code',
icon: Icon(MdiIcons.codeJson), // icon: Icon(MdiIcons.codeJson),
), // ),
Tab( // Tab(
text: 'Chat', // text: 'Chat',
icon: Icon(MdiIcons.commentTextMultiple), // icon: Icon(MdiIcons.commentTextMultiple),
), // ),
Tab( // Tab(
text: 'Custom', // text: 'Custom',
icon: Icon(MdiIcons.creation), // icon: Icon(MdiIcons.creation),
), // ),
], // ],
), // ),
ExpandablePageView( // ExpandablePageView(
controller: pageController, // controller: pageController,
children: [ // children: [
Column( // Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
SegmentedButton<ResearchVariant>( // SegmentedButton<ResearchVariant>(
segments: const [ // segments: const [
ButtonSegment( // ButtonSegment(
value: ResearchVariant.expert, // value: ResearchVariant.expert,
icon: Icon(MdiIcons.textSearch), // icon: Icon(MdiIcons.textSearch),
label: Text('Research'), // label: Text('Research'),
), // ),
ButtonSegment( // ButtonSegment(
value: ResearchVariant.fast, // value: ResearchVariant.fast,
icon: Icon(MdiIcons.invoiceTextFast), // icon: Icon(MdiIcons.invoiceTextFast),
label: Text('Fast'), // label: Text('Fast'),
), // ),
], // ],
selected: {researchVariant}, // selected: {researchVariant},
onSelectionChanged: (value) { // onSelectionChanged: (value) {
ref // ref
.read(activeResearchVariantProvider.notifier) // .read(activeResearchVariantProvider.notifier)
.update(value.first); // .update(value.first);
}, // },
), // ),
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
_PromptField(textController, incognitoEnabled), // _PromptField(textController, incognitoEnabled),
], // ],
), // ),
Column( // Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
_PromptField(textController, incognitoEnabled), // _PromptField(textController, incognitoEnabled),
], // ],
), // ),
Column( // Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
DropdownMenu<ChatModel>( // DropdownMenu<ChatModel>(
initialSelection: chatModel, // initialSelection: chatModel,
expandedInsets: EdgeInsets.zero, // expandedInsets: EdgeInsets.zero,
label: const Text('Model'), // label: const Text('Model'),
inputDecorationTheme: const InputDecorationTheme(), // inputDecorationTheme: const InputDecorationTheme(),
dropdownMenuEntries: ChatModel.values // dropdownMenuEntries: ChatModel.values
.map( // .map(
(model) => DropdownMenuEntry( // (model) => DropdownMenuEntry(
value: model, // value: model,
label: model.label, // label: model.label,
), // ),
) // )
.toList(), // .toList(),
onSelected: (value) { // onSelected: (value) {
ref.read(activeChatModelProvider.notifier).update(value!); // ref.read(activeChatModelProvider.notifier).update(value!);
}, // },
), // ),
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
_PromptField(textController, incognitoEnabled), // _PromptField(textController, incognitoEnabled),
], // ],
), // ),
Column( // Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
_PromptField(textController, incognitoEnabled), // _PromptField(textController, incognitoEnabled),
], // ],
), // ),
], // ],
), // ),
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
SizedBox( // SizedBox(
width: double.infinity, // width: double.infinity,
child: FilledButton.icon( // child: FilledButton.icon(
onPressed: () { // onPressed: () {
if (formKey.currentState?.validate() ?? false) { // if (formKey.currentState?.validate() ?? false) {
onSubmit( // onSubmit(
uri_builder.assistantUri( // uri_builder.assistantUri(
prompt: textController.text, // prompt: textController.text,
assistantMode: AssistantMode.values[tabController.index], // assistantMode: AssistantMode.values[tabController.index],
researchVariant: researchVariant, // researchVariant: researchVariant,
chatModel: chatModel, // chatModel: chatModel,
), // ),
); // );
} // }
}, // },
label: const Text('Submit'), // label: const Text('Submit'),
icon: const Icon(MdiIcons.invoiceTextSend), // icon: const Icon(MdiIcons.invoiceTextSend),
), // ),
), // ),
], // ],
), // ),
); // );
} // }
} // }
class _PromptField extends StatelessWidget { // class _PromptField extends StatelessWidget {
final TextEditingController textController; // final TextEditingController textController;
final bool incognitoEnabled; // final bool incognitoEnabled;
const _PromptField(this.textController, this.incognitoEnabled); // const _PromptField(this.textController, this.incognitoEnabled);
@override // @override
Widget build(BuildContext context) { // Widget build(BuildContext context) {
return TextFormField( // return TextFormField(
controller: textController, // controller: textController,
enableIMEPersonalizedLearning: !incognitoEnabled, // enableIMEPersonalizedLearning: !incognitoEnabled,
decoration: InputDecoration( // decoration: InputDecoration(
label: const Text('Prompt'), // label: const Text('Prompt'),
hintText: 'Enter your prompt...', // hintText: 'Enter your prompt...',
floatingLabelBehavior: FloatingLabelBehavior.always, // floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: SpeechToTextButton( // suffixIcon: SpeechToTextButton(
onTextReceived: (data) { // onTextReceived: (data) {
textController.text = data.toString(); // textController.text = data.toString();
}, // },
), // ),
), // ),
maxLines: null, // maxLines: null,
validator: (value) { // validator: (value) {
if (value?.isEmpty ?? true) { // if (value?.isEmpty ?? true) {
return ''; // return '';
} // }
return null; // return null;
}, // },
); // );
} // }
} // }
@@ -1,195 +1,195 @@
import 'package:flutter/material.dart'; // import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; // import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; // import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart'; // import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; // import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart'; // import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart'; // import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/domain/providers/search.dart'; // import 'package:lensai/features/bangs/domain/providers/search.dart';
import 'package:lensai/features/bangs/domain/repositories/data.dart'; // import 'package:lensai/features/bangs/domain/repositories/data.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; // import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/features/bangs/presentation/widgets/search_field.dart'; // import 'package:lensai/features/bangs/presentation/widgets/search_field.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; // import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart'; // import 'package:lensai/features/geckoview/features/browser/presentation/widgets/sheets/create_tab.dart';
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.dart'; // import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; // import 'package:lensai/features/share_intent/domain/entities/shared_content.dart';
import 'package:lensai/presentation/widgets/selectable_chips.dart'; // import 'package:lensai/presentation/widgets/selectable_chips.dart';
class SearchTab extends HookConsumerWidget { // class SearchTab extends HookConsumerWidget {
final SharedContent? sharedContent; // final SharedContent? sharedContent;
final OnSubmitUri onSubmit; // final OnSubmitUri onSubmit;
const SearchTab({ // const SearchTab({
required this.sharedContent, // required this.sharedContent,
required this.onSubmit, // required this.onSubmit,
super.key, // super.key,
}); // });
@override // @override
Widget build(BuildContext context, WidgetRef ref) { // Widget build(BuildContext context, WidgetRef ref) {
useAutomaticKeepAlive(); // useAutomaticKeepAlive();
final formKey = useMemoized(() => GlobalKey<FormState>()); // final formKey = useMemoized(() => GlobalKey<FormState>());
final selectedBang = ref // final selectedBang = ref
.watch(selectedBangDataProvider().select((value) => value.valueOrNull)); // .watch(selectedBangDataProvider().select((value) => value.valueOrNull));
final defaultSearchBang = ref.watch( // final defaultSearchBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.valueOrNull), // defaultSearchBangDataProvider.select((value) => value.valueOrNull),
); // );
final activeBang = selectedBang ?? defaultSearchBang; // final activeBang = selectedBang ?? defaultSearchBang;
final textController = // final textController =
useTextEditingController(text: sharedContent?.toString()); // useTextEditingController(text: sharedContent?.toString());
Future<void> submitSearch() async { // Future<void> submitSearch() async {
if (activeBang != null && (formKey.currentState?.validate() == true)) { // if (activeBang != null && (formKey.currentState?.validate() == true)) {
final searchUri = await ref.read( // final searchUri = await ref.read(
triggerBangSearchProvider(activeBang, textController.text).future, // triggerBangSearchProvider(activeBang, textController.text).future,
); // );
onSubmit(searchUri); // onSubmit(searchUri);
} // }
} // }
return Form( // return Form(
key: formKey, // key: formKey,
child: Column( // child: Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
children: [ // children: [
const SizedBox(height: 4), // const SizedBox(height: 4),
Consumer( // Consumer(
builder: (context, ref, child) { // builder: (context, ref, child) {
final frequentBangsAsync = // final frequentBangsAsync =
ref.watch(frequentBangDataListProvider); // ref.watch(frequentBangDataListProvider);
return frequentBangsAsync.when( // return frequentBangsAsync.when(
data: (availableBangs) => SizedBox( // data: (availableBangs) => SizedBox(
height: 48, // height: 48,
child: Row( // child: Row(
children: [ // children: [
if (selectedBang != null || availableBangs.isNotEmpty) // if (selectedBang != null || availableBangs.isNotEmpty)
Expanded( // Expanded(
child: SelectableChips( // child: SelectableChips(
itemId: (bang) => bang.trigger, // itemId: (bang) => bang.trigger,
itemAvatar: (bang) => BangIcon(bang, iconSize: 20), // itemAvatar: (bang) => BangIcon(bang, iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName), // itemLabel: (bang) => Text(bang.websiteName),
availableItems: availableBangs, // availableItems: availableBangs,
selectedItem: selectedBang, // selectedItem: selectedBang,
onSelected: (bang) { // onSelected: (bang) {
ref // ref
.read(selectedBangTriggerProvider().notifier) // .read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger); // .setTrigger(bang.trigger);
}, // },
onDeleted: (bang) async { // onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) == // if (ref.read(selectedBangTriggerProvider()) ==
bang.trigger) { // bang.trigger) {
ref // ref
.read( // .read(
selectedBangTriggerProvider().notifier, // selectedBangTriggerProvider().notifier,
) // )
.clearTrigger(); // .clearTrigger();
} else { // } else {
final dialogResult = await showDialog<bool>( // final dialogResult = await showDialog<bool>(
context: context, // context: context,
builder: (context) => AlertDialog( // builder: (context) => AlertDialog(
title: Text( // title: Text(
'Reset usage frequency of !${bang.trigger}?', // 'Reset usage frequency of !${bang.trigger}?',
), // ),
content: const Text( // content: const Text(
'This will remove the Bang from quick select.', // 'This will remove the Bang from quick select.',
), // ),
actions: <Widget>[ // actions: <Widget>[
TextButton( // TextButton(
onPressed: () => // onPressed: () =>
Navigator.pop(context, false), // Navigator.pop(context, false),
child: const Text('Cancel'), // child: const Text('Cancel'),
), // ),
TextButton( // TextButton(
onPressed: () => // onPressed: () =>
Navigator.pop(context, true), // Navigator.pop(context, true),
child: const Text('Reset'), // child: const Text('Reset'),
), // ),
], // ],
), // ),
); // );
if (dialogResult == true) { // if (dialogResult == true) {
await ref // await ref
.read(bangDataRepositoryProvider.notifier) // .read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger); // .resetFrequency(bang.trigger);
} // }
} // }
}, // },
), // ),
) // )
else // else
Expanded( // Expanded(
child: Text( // child: Text(
"Press '>' to search Bangs.", // "Press '>' to search Bangs.",
style: TextStyle( // style: TextStyle(
color: Theme.of(context).hintColor, // color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic, // fontStyle: FontStyle.italic,
), // ),
), // ),
), // ),
IconButton( // IconButton(
onPressed: () async { // onPressed: () async {
await context.push(BangSearchRoute().location); // await context.push(BangSearchRoute().location);
}, // },
icon: const Icon(Icons.chevron_right), // icon: const Icon(Icons.chevron_right),
), // ),
], // ],
), // ),
), // ),
error: (error, stackTrace) => const SizedBox.shrink(), // error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox( // loading: () => const SizedBox(
height: 48, // height: 48,
width: double.infinity, // width: double.infinity,
), // ),
); // );
}, // },
), // ),
Consumer( // Consumer(
builder: (context, ref, child) { // builder: (context, ref, child) {
const maxOptionsHeight = SearchField.defaultMaxOptionsHeight; // const maxOptionsHeight = SearchField.defaultMaxOptionsHeight;
final openDirection = ref.watch( // final openDirection = ref.watch(
bottomSheetExtendProvider.select((value) { // bottomSheetExtendProvider.select((value) {
final extend = value.valueOrNull; // final extend = value.valueOrNull;
if (extend == null || // if (extend == null ||
(MediaQuery.of(context).size.height * (1 - extend)) > // (MediaQuery.of(context).size.height * (1 - extend)) >
maxOptionsHeight) { // maxOptionsHeight) {
return OptionsViewOpenDirection.up; // return OptionsViewOpenDirection.up;
} else { // } else {
return OptionsViewOpenDirection.down; // return OptionsViewOpenDirection.down;
} // }
}), // }),
); // );
return SearchField( // return SearchField(
textController: textController, // textController: textController,
activeBang: activeBang, // activeBang: activeBang,
openDirection: openDirection, // openDirection: openDirection,
onFieldSubmitted: (_) async { // onFieldSubmitted: (_) async {
await submitSearch(); // await submitSearch();
}, // },
); // );
}, // },
), // ),
const SizedBox( // const SizedBox(
height: 12, // height: 12,
), // ),
SizedBox( // SizedBox(
width: double.infinity, // width: double.infinity,
child: FilledButton.icon( // child: FilledButton.icon(
onPressed: submitSearch, // onPressed: submitSearch,
label: const Text('Search'), // label: const Text('Search'),
icon: const Icon(MdiIcons.cloudSearch), // icon: const Icon(MdiIcons.cloudSearch),
), // ),
), // ),
], // ],
), // ),
); // );
} // }
} // }
@@ -3,4 +3,5 @@ abstract interface class IQueryBuilder {
int get ftsMinTokenLength; int get ftsMinTokenLength;
String buildFtsQuery(String input); String buildFtsQuery(String input);
String buildLikeQuery(String input);
} }
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart'; import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/search/domain/entities/bareword.dart'; import 'package:lensai/features/search/domain/entities/bareword.dart';
import 'package:lensai/features/search/domain/unix_tokenizer.dart';
typedef _Phrase = List<Bareword>; typedef _Phrase = List<Bareword>;
@@ -228,7 +229,26 @@ mixin PrefixQueryBuilderMixin implements IQueryBuilder {
tokenLimit: ftsTokenLimit, tokenLimit: ftsTokenLimit,
); );
return queryBuilder.build(); if (queryBuilder.hasTokens) {
return queryBuilder.build();
}
return '';
}
@override
String buildLikeQuery(String input) {
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
input: input,
minTokenLength: 1,
tokenLimit: 5,
);
if (likeQueryBuilder.hasTokens) {
return likeQueryBuilder.build();
}
return '';
} }
} }
@@ -241,6 +261,25 @@ mixin TrigramQueryBuilderMixin implements IQueryBuilder {
tokenLimit: ftsTokenLimit, tokenLimit: ftsTokenLimit,
); );
return queryBuilder.build(); if (queryBuilder.hasTokens) {
return queryBuilder.build();
}
return '';
}
@override
String buildLikeQuery(String input) {
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
input: input,
minTokenLength: 1,
tokenLimit: 5,
);
if (likeQueryBuilder.hasTokens) {
return likeQueryBuilder.build();
}
return '';
} }
} }
@@ -19,6 +19,7 @@ import 'package:lensai/features/geckoview/features/tabs/presentation/widgets/con
import 'package:lensai/features/search/domain/providers/search_suggestions.dart'; import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
import 'package:lensai/features/search/presentation/widgets/bang_chips.dart'; import 'package:lensai/features/search/presentation/widgets/bang_chips.dart';
import 'package:lensai/features/search/presentation/widgets/search_field.dart'; import 'package:lensai/features/search/presentation/widgets/search_field.dart';
import 'package:lensai/features/search/presentation/widgets/search_suggestion_list.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:lensai/presentation/widgets/failure_widget.dart'; import 'package:lensai/presentation/widgets/failure_widget.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -166,76 +167,9 @@ class SearchScreen extends HookConsumerWidget {
), ),
), ),
), ),
HookConsumer( SearchSuggestionList(
builder: (context, ref, child) { searchTextController: searchTextController,
useListenableSelector( submitSearch: submitSearch,
searchTextController,
() => searchTextController.text.isNotEmpty,
);
final suggestions =
useStream(ref.watch(searchSuggestionsProvider()));
final searchHistory = ref.watch(searchHistoryProvider);
final searchText = searchTextController.text;
if ((!searchText.isNotEmpty || !suggestions.hasData) &&
(searchHistory.value?.isNotEmpty ?? false)) {
final entries = searchHistory.value!;
return SliverList.builder(
itemCount: entries.length,
itemBuilder: (context, index) {
final query = entries[index].searchQuery;
return ListTile(
leading: const Icon(Icons.history),
title: Text(query),
onLongPress: () {
searchTextController.text = query;
},
onTap: () async {
await submitSearch(query);
},
trailing: IconButton(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.removeSearchEntry(query);
},
icon: const Icon(Icons.close),
),
);
},
);
}
final prioritizedSuggestions = [
if (searchText.isNotEmpty) searchText,
if (suggestions.data != null)
...suggestions.data!
.whereNot((suggestion) => suggestion == searchText),
];
return SliverList.builder(
itemCount: prioritizedSuggestions.length,
itemBuilder: (context, index) {
final suggestion = prioritizedSuggestions[index];
return ListTile(
leading: const Icon(Icons.search),
title: Text(suggestion),
onLongPress: () {
searchTextController.text = suggestion;
},
onTap: () async {
await submitSearch(suggestion);
},
);
},
);
},
), ),
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Divider(), child: Divider(),
@@ -274,7 +208,9 @@ class SearchScreen extends HookConsumerWidget {
child: tabs.when( child: tabs.when(
data: (data) { data: (data) {
if (data == null) { if (data == null) {
return const SizedBox.shrink(); return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
} }
return SliverList.builder( return SliverList.builder(
@@ -283,12 +219,14 @@ class SearchScreen extends HookConsumerWidget {
final result = data[index]; final result = data[index];
final headHasMatch = final headHasMatch =
result.title.contains(_matchPrefix); result.title?.contains(_matchPrefix) ?? false;
final bodyResult = final bodyResult =
result.extractedContent ?? result.fullContent; result.extractedContent ?? result.fullContent;
return ListTile( return ListTile(
title: MarkdownBody(data: result.title), title: (result.title != null)
? MarkdownBody(data: result.title!)
: null,
subtitle: (!headHasMatch && bodyResult != null) subtitle: (!headHasMatch && bodyResult != null)
? MarkdownBody(data: bodyResult) ? MarkdownBody(data: bodyResult)
: null, : null,
@@ -3,8 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart';
import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/features/user/domain/repositories/settings.dart';
import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
class SearchField extends HookConsumerWidget { class SearchField extends HookConsumerWidget {
final TextEditingController textEditingController; final TextEditingController textEditingController;
@@ -64,14 +64,6 @@ class SearchField extends HookConsumerWidget {
}, },
), ),
), ),
// autovalidateMode: AutovalidateMode.onUserInteraction,
// validator: (value) {
// if (value?.isEmpty ?? true) {
// return '';
// }
// return null;
// },
onTapOutside: (focusNode != null) onTapOutside: (focusNode != null)
? (event) { ? (event) {
focusNode!.unfocus(); focusNode!.unfocus();
@@ -0,0 +1,90 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/domain/repositories/data.dart';
import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
class SearchSuggestionList extends HookConsumerWidget {
final TextEditingController searchTextController;
final Future<void> Function(String query) submitSearch;
final bool showHistory;
const SearchSuggestionList({
required this.searchTextController,
required this.submitSearch,
this.showHistory = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenableSelector(
searchTextController,
() => searchTextController.text.isNotEmpty,
);
final suggestions = useStream(ref.watch(searchSuggestionsProvider()));
final searchHistory = ref.watch(searchHistoryProvider);
final searchText = searchTextController.text;
if (showHistory &&
(!searchText.isNotEmpty || !suggestions.hasData) &&
(searchHistory.value?.isNotEmpty ?? false)) {
final entries = searchHistory.value!;
return SliverList.builder(
itemCount: entries.length,
itemBuilder: (context, index) {
final query = entries[index].searchQuery;
return ListTile(
leading: const Icon(Icons.history),
title: Text(query),
onLongPress: () {
searchTextController.text = query;
},
onTap: () async {
await submitSearch(query);
},
trailing: IconButton(
onPressed: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.removeSearchEntry(query);
},
icon: const Icon(Icons.close),
),
);
},
);
}
final prioritizedSuggestions = [
if (searchText.isNotEmpty) searchText,
if (suggestions.data != null)
...suggestions.data!.whereNot((suggestion) => suggestion == searchText),
];
return SliverList.builder(
itemCount: prioritizedSuggestions.length,
itemBuilder: (context, index) {
final suggestion = prioritizedSuggestions[index];
return ListTile(
leading: const Icon(Icons.search),
title: Text(suggestion),
onLongPress: () {
searchTextController.text = suggestion;
},
onTap: () async {
await submitSearch(suggestion);
},
);
},
);
}
}
@@ -8,7 +8,7 @@ import 'package:lensai/features/settings/presentation/controllers/save_settings.
import 'package:lensai/features/settings/presentation/widgets/bang_group_list_tile.dart'; import 'package:lensai/features/settings/presentation/widgets/bang_group_list_tile.dart';
import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart'; import 'package:lensai/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:lensai/features/user/data/models/settings.dart'; import 'package:lensai/features/user/data/models/settings.dart';
import 'package:lensai/features/user/domain/providers/cache.dart'; import 'package:lensai/features/user/domain/providers.dart';
import 'package:lensai/features/user/domain/repositories/cache.dart'; import 'package:lensai/features/user/domain/repositories/cache.dart';
import 'package:lensai/features/user/domain/repositories/settings.dart'; import 'package:lensai/features/user/domain/repositories/settings.dart';
@@ -149,11 +149,6 @@ class SettingsScreen extends HookConsumerWidget {
const SizedBox( const SizedBox(
height: 16, height: 16,
), ),
_buildSection(theme, 'Content Blocking'),
_buildSubSection(theme, 'Lists'),
const SizedBox(
height: 16,
),
_buildSection(theme, 'Bangs'), _buildSection(theme, 'Bangs'),
CustomListTile( CustomListTile(
title: 'Bang Frequencies', title: 'Bang Frequencies',
+9 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:lensai/features/user/data/database/database.dart'; import 'package:lensai/features/user/data/database/database.dart';
@@ -13,7 +15,7 @@ part 'providers.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
UserDatabase userDatabase(Ref ref) { UserDatabase userDatabase(Ref ref) {
return UserDatabase( final db = UserDatabase(
LazyDatabase(() async { LazyDatabase(() async {
// put the database file, called db.sqlite here, into the documents folder // put the database file, called db.sqlite here, into the documents folder
// for your app. // for your app.
@@ -35,4 +37,10 @@ UserDatabase userDatabase(Ref ref) {
return NativeDatabase.createInBackground(file); return NativeDatabase.createInBackground(file);
}), }),
); );
ref.onDispose(() {
unawaited(db.close());
});
return db;
} }
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'providers.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$userDatabaseHash() => r'a84f951e1673e34b6857e1618cd7d80497c6f3a1'; String _$userDatabaseHash() => r'717de293750f5da91dc7da3f964bd07f5dd61e10';
/// See also [userDatabase]. /// See also [userDatabase].
@ProviderFor(userDatabase) @ProviderFor(userDatabase)
@@ -0,0 +1,49 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:lensai/features/user/data/providers.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
const _authKey = 'pb_auth';
@Riverpod()
Stream<double> iconCacheSizeMegabytes(Ref ref) {
final repository = ref.watch(userDatabaseProvider);
return repository.cacheDao.iconCacheSize().watchSingle();
}
@Riverpod()
Future<String?> _storedAuthData(Ref ref) {
const secureStorage = FlutterSecureStorage();
return secureStorage.read(key: _authKey);
}
@Riverpod(keepAlive: true)
AsyncAuthStore authStore(Ref ref) {
const secureStorage = FlutterSecureStorage();
final intial =
ref.watch(_storedAuthDataProvider.select((value) => value.valueOrNull));
return AsyncAuthStore(
initial: intial,
save: (data) async {
await secureStorage.write(key: _authKey, value: data);
},
);
}
@Riverpod()
Stream<AuthStoreEvent> authState(Ref ref) {
final authStore = ref.watch(authStoreProvider);
return authStore.onChange;
}
@Riverpod(keepAlive: true)
PocketBase pocketBase(Ref ref) {
final authStore = ref.watch(authStoreProvider);
return PocketBase('http://192.168.2.104:8090', authStore: authStore);
}
@@ -0,0 +1,94 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$iconCacheSizeMegabytesHash() =>
r'43162fce3ce8b04a5a55ed52021b274afbde2861';
/// See also [iconCacheSizeMegabytes].
@ProviderFor(iconCacheSizeMegabytes)
final iconCacheSizeMegabytesProvider =
AutoDisposeStreamProvider<double>.internal(
iconCacheSizeMegabytes,
name: r'iconCacheSizeMegabytesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$iconCacheSizeMegabytesHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef IconCacheSizeMegabytesRef = AutoDisposeStreamProviderRef<double>;
String _$storedAuthDataHash() => r'5f7e3ef6233a2036f7ce3728131901a46b1e548e';
/// See also [_storedAuthData].
@ProviderFor(_storedAuthData)
final _storedAuthDataProvider = AutoDisposeFutureProvider<String?>.internal(
_storedAuthData,
name: r'_storedAuthDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$storedAuthDataHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef _StoredAuthDataRef = AutoDisposeFutureProviderRef<String?>;
String _$authStoreHash() => r'766c001bbb8d7dc4e148eff3816c414d78e77f26';
/// See also [authStore].
@ProviderFor(authStore)
final authStoreProvider = Provider<AsyncAuthStore>.internal(
authStore,
name: r'authStoreProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authStoreHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthStoreRef = ProviderRef<AsyncAuthStore>;
String _$authStateHash() => r'ffa411ae0abc5c7bb3e8a93e783bd8398ce7f666';
/// See also [authState].
@ProviderFor(authState)
final authStateProvider = AutoDisposeStreamProvider<AuthStoreEvent>.internal(
authState,
name: r'authStateProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authStateHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthStateRef = AutoDisposeStreamProviderRef<AuthStoreEvent>;
String _$pocketBaseHash() => r'9e1dc8057e300bc7ca697da9f68ec61e70fdbaab';
/// See also [pocketBase].
@ProviderFor(pocketBase)
final pocketBaseProvider = Provider<PocketBase>.internal(
pocketBase,
name: r'pocketBaseProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$pocketBaseHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PocketBaseRef = ProviderRef<PocketBase>;
// 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
@@ -1,11 +0,0 @@
import 'package:lensai/features/user/data/providers.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'cache.g.dart';
@Riverpod()
Stream<double> iconCacheSizeMegabytes(Ref ref) {
final repository = ref.watch(userDatabaseProvider);
return repository.cacheDao.iconCacheSize().watchSingle();
}
@@ -0,0 +1,52 @@
import 'package:lensai/features/user/domain/providers.dart';
import 'package:lensai/features/user/extensions/client_exception.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth.g.dart';
class AuthException implements Exception {
final String message;
AuthException(this.message);
@override
String toString() {
return message;
}
}
@Riverpod()
class AuthRepository extends _$AuthRepository {
late PocketBase _pb;
Future<RecordAuth> authWithPassword(String user, String password) async {
try {
return await _pb.collection('users').authWithPassword(user, password);
} on ClientException catch (e) {
throw AuthException(e.errorMessage);
}
}
Future<RecordModel> createUserWithPassword(
String email,
String password,
) async {
try {
return await _pb.collection('users').create(
body: {
"email": email,
"password": password,
"passwordConfirm": password,
},
);
} on ClientException catch (e) {
throw AuthException(e.errorMessage);
}
}
@override
void build() {
_pb = ref.watch(pocketBaseProvider);
}
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authRepositoryHash() => r'90ea6f082ef968831763c7f7ee31863f1fe329fe';
/// See also [AuthRepository].
@ProviderFor(AuthRepository)
final authRepositoryProvider =
AutoDisposeNotifierProvider<AuthRepository, void>.internal(
AuthRepository.new,
name: r'authRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AuthRepository = 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
@@ -0,0 +1,24 @@
import 'package:lensai/extensions/string.dart';
import 'package:pocketbase/pocketbase.dart';
extension ClientExceptionExtension on ClientException {
String get errorMessage {
var message = "${response["message"] as String? ?? ""}\n\n";
final data = response["data"] as Map<String, dynamic>;
try {
data.forEach((key, value) {
final detailedMessage = (value as Map<String, dynamic>)["message"];
if (detailedMessage != null) {
message += '${key.toCapitalized()}: $detailedMessage\n';
}
});
} catch (_) {}
try {
if (originalError != null) {
message += ' $originalError';
}
} catch (_) {}
return message.trim();
}
}
@@ -0,0 +1,33 @@
import 'package:lensai/features/user/domain/repositories/auth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'controllers.g.dart';
@Riverpod()
class AuthController extends _$AuthController {
@override
FutureOr<void> build() {}
Future<void> authWithPassword(String user, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(authRepositoryProvider.notifier)
.authWithPassword(user, password),
);
}
Future<void> registerWithPassword(String user, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() {
final repo = ref.read(authRepositoryProvider.notifier);
return repo
.createUserWithPassword(user, password)
.then((_) => repo.authWithPassword(user, password));
});
}
void clearState() {
state = const AsyncValue.data(null);
}
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'controllers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authControllerHash() => r'5b56651948683d669f29946fe02bdb004f436afb';
/// See also [AuthController].
@ProviderFor(AuthController)
final authControllerProvider =
AutoDisposeAsyncNotifierProvider<AuthController, void>.internal(
AuthController.new,
name: r'authControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AuthController = AutoDisposeAsyncNotifier<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

Some files were not shown because too many files have changed in this diff Show More