container topic inference

This commit is contained in:
Fabian Freund
2025-07-20 00:37:51 +02:00
parent 2a3e0b6b6d
commit dc14a7d718
29 changed files with 1117 additions and 12 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ class ContainerSelectionRoute extends GoRouteData
}
class ContainerEditRoute extends GoRouteData with _$ContainerEditRoute {
final ContainerData $extra;
final ContainerDataWithCount $extra;
ContainerEditRoute(this.$extra);
+1 -1
View File
@@ -517,7 +517,7 @@ mixin _$ContainerCreateRoute on GoRouteData {
mixin _$ContainerEditRoute on GoRouteData {
static ContainerEditRoute _fromState(GoRouterState state) =>
ContainerEditRoute(state.extra as ContainerData);
ContainerEditRoute(state.extra as ContainerDataWithCount);
ContainerEditRoute get _self => this as ContainerEditRoute;
@@ -64,3 +64,9 @@ Stream<Map<String, String?>> tabDescendants(Ref ref, String tabId) {
);
});
}
@Riverpod()
Stream<List<TabData>> containerTabsData(Ref ref, String containerId) {
final db = ref.watch(tabDatabaseProvider);
return db.containerDao.getContainerTabsData(containerId).watch();
}
@@ -442,5 +442,127 @@ class _TabDescendantsProviderElement
String get tabId => (origin as TabDescendantsProvider).tabId;
}
String _$containerTabsDataHash() => r'1987b2d69f2ba663a7343e93572aad3c31a29be3';
/// See also [containerTabsData].
@ProviderFor(containerTabsData)
const containerTabsDataProvider = ContainerTabsDataFamily();
/// See also [containerTabsData].
class ContainerTabsDataFamily extends Family<AsyncValue<List<TabData>>> {
/// See also [containerTabsData].
const ContainerTabsDataFamily();
/// See also [containerTabsData].
ContainerTabsDataProvider call(String containerId) {
return ContainerTabsDataProvider(containerId);
}
@override
ContainerTabsDataProvider getProviderOverride(
covariant ContainerTabsDataProvider provider,
) {
return call(provider.containerId);
}
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'containerTabsDataProvider';
}
/// See also [containerTabsData].
class ContainerTabsDataProvider
extends AutoDisposeStreamProvider<List<TabData>> {
/// See also [containerTabsData].
ContainerTabsDataProvider(String containerId)
: this._internal(
(ref) => containerTabsData(ref as ContainerTabsDataRef, containerId),
from: containerTabsDataProvider,
name: r'containerTabsDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTabsDataHash,
dependencies: ContainerTabsDataFamily._dependencies,
allTransitiveDependencies:
ContainerTabsDataFamily._allTransitiveDependencies,
containerId: containerId,
);
ContainerTabsDataProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.containerId,
}) : super.internal();
final String containerId;
@override
Override overrideWith(
Stream<List<TabData>> Function(ContainerTabsDataRef provider) create,
) {
return ProviderOverride(
origin: this,
override: ContainerTabsDataProvider._internal(
(ref) => create(ref as ContainerTabsDataRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
containerId: containerId,
),
);
}
@override
AutoDisposeStreamProviderElement<List<TabData>> createElement() {
return _ContainerTabsDataProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ContainerTabsDataProvider &&
other.containerId == containerId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, containerId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ContainerTabsDataRef on AutoDisposeStreamProviderRef<List<TabData>> {
/// The parameter `containerId` of this provider.
String get containerId;
}
class _ContainerTabsDataProviderElement
extends AutoDisposeStreamProviderElement<List<TabData>>
with ContainerTabsDataRef {
_ContainerTabsDataProviderElement(super.provider);
@override
String get containerId => (origin as ContainerTabsDataProvider).containerId;
}
// 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
@@ -38,8 +38,7 @@ class SelectedContainer extends _$SelectedContainer {
.read(localAuthenticationServiceProvider.notifier)
.authenticate(
authKey: 'container_access::${container.id}',
localizedReason:
'Require authentication for container ${container.name ?? 'New Container'}',
localizedReason: 'Require authentication for container',
settings: container.metadata.authSettings,
useAuthCache: true,
);
@@ -25,7 +25,7 @@ final selectedContainerDataProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef SelectedContainerDataRef = AutoDisposeStreamProviderRef<ContainerData?>;
String _$selectedContainerHash() => r'736a60b8f19273d3bdc25d1717e3a613384e59a6';
String _$selectedContainerHash() => r'34457f0adc45d437a9ab817387be4b1664cd2e7a';
/// See also [SelectedContainer].
@ProviderFor(SelectedContainer)
@@ -0,0 +1,70 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/utils/lru_cache.dart';
part 'container_topic.g.dart';
@Riverpod(keepAlive: true)
class ContainerTopicRepository extends _$ContainerTopicRepository {
final _service = GeckoMlService();
final _lock = Lock();
final _cache = LRUCache<Set<String>, String>(
50,
equals: (a, b) {
return const DeepCollectionEquality.unordered().equals(a, b);
},
hashCode: (key) {
return const DeepCollectionEquality.unordered().hash(key);
},
);
Future<String?> getContainerTopic(Set<String> titles) async {
if (titles.isNotEmpty) {
if (_cache.get(titles) case final String title) {
return title;
}
try {
final title = await _lock.synchronized(() async {
final title = await _service.getContainerTopic(titles);
return _cache.set(titles, title);
}, timeout: const Duration(seconds: 120));
return title;
} on TimeoutException {
return null;
}
}
return null;
}
@override
void build() {}
}
@Riverpod(keepAlive: true)
Future<String?> containerTopic(Ref ref, String containerId) async {
final titles = await ref.watch(
containerTabsDataProvider(containerId).selectAsync(
(tabData) =>
EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()),
),
);
final topic = await ref
.read(containerTopicRepositoryProvider.notifier)
.getContainerTopic(titles.value);
return topic;
}
@@ -0,0 +1,167 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container_topic.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$containerTopicHash() => r'aa3fd27f26bb94b6c9e794d8470537240b8d2713';
/// 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 [containerTopic].
@ProviderFor(containerTopic)
const containerTopicProvider = ContainerTopicFamily();
/// See also [containerTopic].
class ContainerTopicFamily extends Family<AsyncValue<String?>> {
/// See also [containerTopic].
const ContainerTopicFamily();
/// See also [containerTopic].
ContainerTopicProvider call(String containerId) {
return ContainerTopicProvider(containerId);
}
@override
ContainerTopicProvider getProviderOverride(
covariant ContainerTopicProvider provider,
) {
return call(provider.containerId);
}
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'containerTopicProvider';
}
/// See also [containerTopic].
class ContainerTopicProvider extends FutureProvider<String?> {
/// See also [containerTopic].
ContainerTopicProvider(String containerId)
: this._internal(
(ref) => containerTopic(ref as ContainerTopicRef, containerId),
from: containerTopicProvider,
name: r'containerTopicProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTopicHash,
dependencies: ContainerTopicFamily._dependencies,
allTransitiveDependencies:
ContainerTopicFamily._allTransitiveDependencies,
containerId: containerId,
);
ContainerTopicProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.containerId,
}) : super.internal();
final String containerId;
@override
Override overrideWith(
FutureOr<String?> Function(ContainerTopicRef provider) create,
) {
return ProviderOverride(
origin: this,
override: ContainerTopicProvider._internal(
(ref) => create(ref as ContainerTopicRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
containerId: containerId,
),
);
}
@override
FutureProviderElement<String?> createElement() {
return _ContainerTopicProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ContainerTopicProvider && other.containerId == containerId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, containerId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ContainerTopicRef on FutureProviderRef<String?> {
/// The parameter `containerId` of this provider.
String get containerId;
}
class _ContainerTopicProviderElement extends FutureProviderElement<String?>
with ContainerTopicRef {
_ContainerTopicProviderElement(super.provider);
@override
String get containerId => (origin as ContainerTopicProvider).containerId;
}
String _$containerTopicRepositoryHash() =>
r'20ae14772821ac8a4ec0c16bc551e29098556189';
/// See also [ContainerTopicRepository].
@ProviderFor(ContainerTopicRepository)
final containerTopicRepositoryProvider =
NotifierProvider<ContainerTopicRepository, void>.internal(
ContainerTopicRepository.new,
name: r'containerTopicRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTopicRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ContainerTopicRepository = Notifier<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,23 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart';
part 'container_topic.g.dart';
@Riverpod()
class ContainerTopicController extends _$ContainerTopicController {
Future<String?> getContainerTopic(String containerId) async {
state = const AsyncLoading();
final result = await AsyncValue.guard(() async {
return await ref.read(containerTopicProvider(containerId).future);
});
state = result;
return result.valueOrNull;
}
@override
AsyncValue<void> build() {
return const AsyncData(null);
}
}
@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container_topic.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$containerTopicControllerHash() =>
r'229709202edecb21b20432f8e4d0d7aee293f623';
/// See also [ContainerTopicController].
@ProviderFor(ContainerTopicController)
final containerTopicControllerProvider =
AutoDisposeNotifierProvider<
ContainerTopicController,
AsyncValue<void>
>.internal(
ContainerTopicController.new,
name: r'containerTopicControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTopicControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ContainerTopicController = AutoDisposeNotifier<AsyncValue<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
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
import 'package:weblibre/features/user/domain/services/local_authentication.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
@@ -59,6 +60,11 @@ class ContainerEditScreen extends HookConsumerWidget {
text: initialContainer.name,
);
final containerHasTabs = switch (initialContainer) {
ContainerDataWithCount(:final tabCount?) when tabCount > 0 => true,
_ => false,
};
return Scaffold(
appBar: AppBar(
title: Text(switch (_mode) {
@@ -89,8 +95,7 @@ class ContainerEditScreen extends HookConsumerWidget {
.read(localAuthenticationServiceProvider.notifier)
.authenticate(
authKey: 'container_access::${container.id}',
localizedReason:
'Require authentication for container ${container.name ?? 'New Container'}',
localizedReason: 'Require authentication for container',
);
if (!authResult) {
@@ -140,6 +145,38 @@ class ContainerEditScreen extends HookConsumerWidget {
),
),
label: const Text('Name'),
suffixIcon:
(_mode == _DialogMode.edit && containerHasTabs)
? Consumer(
builder: (context, ref, child) {
final isLoading = ref.watch(
containerTopicControllerProvider.select(
(value) => value.isLoading,
),
);
return IconButton(
onPressed: isLoading
? null
: () async {
final topic = await ref
.read(
containerTopicControllerProvider
.notifier,
)
.getContainerTopic(
initialContainer.id,
);
if (topic != null) {
textController.text = topic;
}
},
icon: const Icon(MdiIcons.creation),
);
},
)
: null,
),
controller: textController,
),
@@ -9,6 +9,7 @@ import 'package:weblibre/data/models/drag_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
class ContainerChips extends HookConsumerWidget {
@@ -75,7 +76,7 @@ class ContainerChips extends HookConsumerWidget {
),
),
itemLabel: (container) =>
Text(container.name ?? 'New Container'),
ContainerTitle(container: container),
itemBadgeCount: (container) => container.tabCount,
itemWrap: (child, container) {
return HookBuilder(
@@ -169,6 +170,7 @@ class ContainerChips extends HookConsumerWidget {
),
if (displayMenu)
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () async {
await ContainerListRoute().push(context);
},
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
class ContainerListTile extends HookWidget {
final ContainerData container;
@@ -22,7 +23,7 @@ class ContainerListTile extends HookWidget {
child: ListTile(
selected: isSelected,
leading: CircleAvatar(backgroundColor: container.color),
title: Text(container.name ?? 'New Container'),
title: ContainerTitle(container: container),
onTap: onTap,
trailing: const Icon(Icons.chevron_right),
),
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart';
class ContainerTitle extends HookConsumerWidget {
final ContainerData container;
const ContainerTitle({super.key, required this.container});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (container.name.isNotEmpty) {
return Text(container.name!);
}
final topicAsync = ref.watch(containerTopicProvider(container.id));
return topicAsync.when(
skipLoadingOnReload: true,
data: (data) =>
data.mapNotNull(
(name) => RichText(
text: TextSpan(
children: [
TextSpan(text: name),
const WidgetSpan(child: SizedBox(width: 4)),
const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)),
],
),
),
) ??
const Text('New Container'),
error: (error, stackTrace) {
logger.e(
'Could not determine container name ${container.id}',
error: error,
stackTrace: stackTrace,
);
return const Text('New Container');
},
loading: () => const Skeletonizer(child: Text('container')),
);
}
}
+1
View File
@@ -72,6 +72,7 @@ dependencies:
url: https://github.com/FaFre/speech_to_text_google_dialog.git
sqlite3: ^2.7.7
sqlite3_flutter_libs: ^0.5.36
synchronized: ^3.4.0
text_scroll: ^0.2.0
timeago: ^3.7.1
tor:
@@ -0,0 +1,151 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { createEngine } = ChromeUtils.importESModule("chrome://global/content/ml/EngineProcess.sys.mjs");
const ML_TASK_FEATURE_EXTRACTION = "feature-extraction";
const ML_TASK_TEXT2TEXT = "text2text-generation";
const SMART_TAB_GROUPING_CONFIG = {
embedding: {
dtype: "q8",
timeoutMS: 2 * 60 * 1000, // 2 minutes
taskName: ML_TASK_FEATURE_EXTRACTION,
featureId: "smart-tab-embedding",
backend: "onnx",
},
topicGeneration: {
dtype: "q8",
timeoutMS: 2 * 60 * 1000, // 2 minutes
taskName: ML_TASK_TEXT2TEXT,
featureId: "smart-tab-topic",
backend: "onnx",
},
// dataConfig: {
// titleKey: "label",
// descriptionKey: "description",
// },
// clustering: {
// dimReductionMethod: null, // Not completed.
// clusterImplementation: CLUSTER_METHODS.KMEANS,
// clusteringTriesPerK: 3,
// anchorMethod: ANCHOR_METHODS.FIXED,
// pregroupedHandlingMethod: PREGROUPED_HANDLING_METHODS.EXCLUDE,
// pregroupedSilhouetteBoost: 2, // Relative weight of the cluster's score and all other cluster's combined
// suggestOtherTabsMethod: SUGGEST_OTHER_TABS_METHODS.NEAREST_NEIGHBOR,
// },
};
/**
* Generate model input from keywords and documents
* @param {string []} keywords
* @param {string []} documents
*/
function createModelInput(keywords, documents) {
if (!keywords || keywords.length === 0) {
return `Topic from keywords: titles: \n${documents.join(" \n")}`;
}
return `Topic from keywords: ${keywords.join(", ")}. titles: \n${documents.join(" \n")}`;
}
/**
* One artifact of the LLM output is that sometimes words are duplicated
* This function cuts the phrase when it sees the first duplicate word.
* Handles simple singluar / plural duplicates (-s only).
* @param {string} phrase Input phrase
* @returns {string} phrase cut before any duplicate word
*/
function cutAtDuplicateWords(phrase) {
if (!phrase.length) {
return phrase;
}
const wordsSet = new Set();
const wordList = phrase.split(" ");
for (let i = 0; i < wordList.length; i++) {
let baseWord = wordList[i].toLowerCase();
if (baseWord.length > 3) {
if (baseWord.slice(-1) === "s") {
baseWord = baseWord.slice(0, -1);
}
}
if (wordsSet.has(baseWord)) {
// We are seeing a baseWord word. Exit with just the words so far and don't
// add any new words
return wordList.slice(0, i).join(" ");
}
wordsSet.add(baseWord);
}
return phrase; // return original phrase
}
/**
*
* @param {MLEngine} engine the engine to check
* @return {boolean} true if the engine has not been initialized or closed
*/
function isEngineClosed(engine) {
return !engine || engine?.engineStatus === "closed";
}
this.ml = class extends ExtensionAPI {
getAPI(context) {
return {
experiments: {
ml: {
async containerTopic(keywords, documents) {
if (isEngineClosed(this.topicEngine)) {
const {
featureId,
engineId,
dtype,
taskName,
timeoutMS,
modelId,
modelRevision,
backend,
} = SMART_TAB_GROUPING_CONFIG.topicGeneration;
let initData = {
featureId,
engineId,
dtype,
taskName,
timeoutMS,
modelId,
modelRevision,
backend,
};
this.topicEngine = await createEngine(initData);
}
const inputArgs = createModelInput(
keywords,
documents
);
const requestInfo = {
inputArgs,
runOptions: {
max_length: 6,
},
};
const request = {
args: [requestInfo.inputArgs],
options: requestInfo.runOptions,
};
const res = await this.topicEngine.run(request);
const generated = cutAtDuplicateWords((res[0]["generated_text"] || "").trim());
return generated;
}
}
}
};
}
};
@@ -0,0 +1,29 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { KeywordExtractor } = ChromeUtils.importESModule(
"chrome://global/content/ml/NLPUtils.sys.mjs"
);
this.nlp = class extends ExtensionAPI {
getAPI(context) {
return {
experiments: {
nlp: {
async extractKeywords(corpus, maxKeywords = 3) {
try {
const keywordExtractor = new KeywordExtractor();
const keywords = keywordExtractor.fitTransform(corpus, maxKeywords);
return keywords;
} catch (error) {
throw new ExtensionError(`Keyword extraction failed: ${error.message}`);
}
},
}
}
};
}
};
@@ -0,0 +1,38 @@
'use strict';
const port = browser.runtime.connectNative("mlEngine");
function sendJsonResultForRequest(id) {
return function (result) {
port.postMessage({
"id": id,
"status": "success",
"result": result
})
}
}
function sendErrorForRequest(id) {
return function (error) {
console.error(error);
port.postMessage({
"id": id,
"status": "error",
"error": error
});
}
}
port.onMessage.addListener(async (message) => {
let requestId = message["id"]
switch (message["action"]) {
case "getContainerTopic":
const documents = message["args"];
const keywords = await browser.experiments.nlp.extractKeywords([documents.slice(0, 3).join(" ")]);
browser.experiments.ml.containerTopic(keywords[0], documents)
.then(sendJsonResultForRequest(requestId))
.catch(sendErrorForRequest(requestId))
break
}
});
@@ -0,0 +1,61 @@
{
"manifest_version": 2,
"name": "ml-engine",
"version": "1.0",
"description": "WebLibre ML Engine",
"browser_specific_settings": {
"gecko": {
"id": "ml-engine@weblibre.eu"
}
},
"experiment_apis": {
"nlp": {
"schema": "schema.json",
"parent": {
"scopes": [
"addon_parent"
],
"script": "api/nlp.js",
"paths": [
[
"experiments",
"nlp"
]
]
}
},
"ml": {
"schema": "schema.json",
"parent": {
"scopes": [
"addon_parent"
],
"script": "api/ml.js",
"paths": [
[
"experiments",
"ml"
]
]
}
}
},
"background": {
"scripts": [
"background.js"
]
},
"optional_permissions": [
"trialML"
],
"permissions": [
"nativeMessaging",
"nativeMessagingFromContent",
"geckoViewAddons",
"cookies",
"menus",
"scripting",
"storage",
"<all_urls>"
]
}
@@ -0,0 +1,61 @@
[
{
"namespace": "experiments.nlp",
"description": "Natural Language Processing utilities",
"functions": [
{
"name": "extractKeywords",
"type": "function",
"description": "Extract keywords from a corpus of text documents",
"async": true,
"parameters": [
{
"name": "corpus",
"type": "array",
"items": {
"type": "string"
},
"description": "Array of text documents to extract keywords from"
},
{
"name": "maxKeywords",
"type": "integer",
"optional": true,
"default": 3,
"description": "Maximum number of keywords to extract per document"
}
]
}
]
},
{
"namespace": "experiments.ml",
"description": "Machine Learning utilities",
"functions": [
{
"name": "containerTopic",
"type": "function",
"description": "Generate topic from keywords and documents using ML engine",
"async": true,
"parameters": [
{
"name": "keywords",
"type": "array",
"items": {
"type": "string"
},
"description": "Array of keywords to generate topic from"
},
{
"name": "documents",
"type": "array",
"items": {
"type": "string"
},
"description": "Array of document titles/content"
}
]
}
]
}
]
@@ -9,6 +9,7 @@ import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature
import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature
import eu.weblibre.flutter_mozilla_components.feature.PrefManagerFeature
import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature
import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
import mozilla.components.browser.engine.gecko.GeckoEngine
import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
@@ -39,7 +40,7 @@ object EngineProvider {
builder.extensionsWebAPIEnabled(true)
// Disable output for now to improve performance
//builder.consoleOutput(true)
builder.consoleOutput(true)
runtime = GeckoRuntime.create(context, builder.build())
}
@@ -57,6 +58,7 @@ object EngineProvider {
PrefManagerFeature.install(it)
ContainerProxyFeature.install(it)
BrowserExtensionFeature.install(it, extensionEvents)
MLEngineFeature.install(it)
}
}
@@ -27,6 +27,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDownloadsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoFindApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
@@ -152,6 +153,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GeckoTabsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTabsApiImpl())
GeckoIconsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoIconsApiImpl())
GeckoCookieApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoCookieApiImpl())
GeckoMlApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoMlApiImpl())
GeckoPrefApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPrefApiImpl())
GeckoContainerProxyApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoContainerProxyApiImpl())
GeckoFindApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFindApiImpl())
@@ -0,0 +1,27 @@
package eu.weblibre.flutter_mozilla_components.api
import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature
import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
import org.json.JSONArray
import org.json.JSONObject
class GeckoMlApiImpl : GeckoMlApi {
private fun List<String>?.toJson(): JSONArray {
return JSONArray().apply {
this@toJson?.forEach { put(it) }
}
}
override fun getContainerTopic(titles: List<String>, callback: (Result<String>) -> Unit) {
MLEngineFeature.scheduleRequest("getContainerTopic", titles.toJson(), object : ResultConsumer<JSONObject> {
override fun success(result: JSONObject) {
callback(Result.success(result.getString("result")))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails")))
}
})
}
}
@@ -0,0 +1,126 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.feature
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import mozilla.components.concept.engine.webextension.MessageHandler
import mozilla.components.concept.engine.webextension.Port
import mozilla.components.concept.engine.webextension.WebExtension
import mozilla.components.concept.engine.webextension.WebExtensionRuntime
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.webextensions.BuiltInWebExtensionController
import org.json.JSONObject
object MLEngineFeature {
private val logger = Logger("ml-engine")
private const val ML_ENGINE_REPORTER_EXTENSION_ID = "ml-engine@weblibre.eu"
private const val ML_ENGINE_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/ml_engine/"
private const val ML_ENGINE_REPORTER_MESSAGING_ID = "mlEngine"
private var nextRequestId: Int = 0
private val requestHandlers = HashMap<Int, ResultConsumer<JSONObject>>()
private val mutex = Mutex()
@VisibleForTesting
// This is an internal var to make it mutable for unit testing purposes only
internal var extensionController = BuiltInWebExtensionController(
ML_ENGINE_REPORTER_EXTENSION_ID,
ML_ENGINE_REPORTER_EXTENSION_URL,
ML_ENGINE_REPORTER_MESSAGING_ID,
)
fun scheduleRequest(command: String, args: Any, callback: ResultConsumer<JSONObject>) {
val message = JSONObject()
message.put("action", command);
message.put("args", args)
runBlocking {
withContext(Dispatchers.Default) {
mutex.withLock {
message.put("id", nextRequestId)
requestHandlers[nextRequestId] = callback
nextRequestId += 1
extensionController.sendBackgroundMessage(message)
}
}
}
}
private class PrefManagerReporterBackgroundMessageHandler() : MessageHandler {
override fun onPortMessage(message: Any, port: Port) {
runBlocking {
withContext(Dispatchers.Default) {
mutex.withLock {
val messageJSON = message as JSONObject;
val requestId = messageJSON.getInt("id")
val status = messageJSON.getString("status")
if (status == "success") {
requestHandlers[requestId]?.success(message)
} else {
requestHandlers[requestId]?.error(
"ML Engine",
"Failed to perform operation",
message.getString("error")
)
}
}
}
}
}
}
/**
* Installs the web extension in the runtime through the WebExtensionRuntime install method
*
* @param runtime a WebExtensionRuntime.
* @param productName a custom product name used to automatically label reports. Defaults to
* "android-components".
*/
fun install(runtime: WebExtensionRuntime) {
extensionController.registerBackgroundMessageHandler(
PrefManagerReporterBackgroundMessageHandler(),
)
extensionController.install(
runtime,
onSuccess = {
logger.debug("Installed ml-engine webextension: ${it.id}")
grantPermissions(runtime, it)
},
onError = { throwable ->
logger.error("Failed to install ml-engine webextension: ", throwable)
},
)
}
private fun grantPermissions(runtime: WebExtensionRuntime, extension: WebExtension) {
val permissions = listOf("trialML")
val origins = emptyList<String>() // Add any host permissions if needed
runtime.addOptionalPermissions(
ML_ENGINE_REPORTER_EXTENSION_ID,
permissions,
origins,
onSuccess = { grantedExtension ->
logger.debug("Successfully granted permissions to extension: ${grantedExtension.id}")
},
onError = { throwable ->
logger.error("Failed to grant permissions to extension: ", throwable)
}
)
}
}
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -3424,6 +3424,42 @@ interface GeckoPrefApi {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoMlApi {
fun getContainerTopic(titles: List<String>, callback: (Result<String>) -> Unit)
companion object {
/** The codec used by GeckoMlApi. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
/** Sets up an instance of `GeckoMlApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoMlApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val titlesArg = args[0] as List<String>
api.getContainerTopic(titlesArg) { result: Result<String> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoBrowserExtensionApi {
fun getMarkdown(htmlList: List<String>, callback: (Result<List<Any>>) -> Unit)
@@ -18,6 +18,7 @@ export 'src/domain/services/gecko_engine_settings.dart';
export 'src/domain/services/gecko_event.dart';
export 'src/domain/services/gecko_find_in_page.dart';
export 'src/domain/services/gecko_icon.dart';
export 'src/domain/services/gecko_ml.dart';
export 'src/domain/services/gecko_pref.dart';
export 'src/domain/services/gecko_readerable.dart';
export 'src/domain/services/gecko_selection_action.dart';
@@ -0,0 +1,15 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart';
final _apiInstance = GeckoMlApi();
class GeckoMlService {
Future<String> getContainerTopic(Set<String> titles, {int maxCount = 10}) {
return _apiInstance.getContainerTopic(titles.take(maxCount).toList());
}
}
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v25.3.2), do not edit directly.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
@@ -3955,6 +3955,48 @@ class GeckoPrefApi {
}
}
class GeckoMlApi {
/// Constructor for [GeckoMlApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
GeckoMlApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<String> getContainerTopic(List<String> titles) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[titles]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as String?)!;
}
}
}
class GeckoBrowserExtensionApi {
/// Constructor for [GeckoBrowserExtensionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
@@ -953,6 +953,12 @@ abstract class GeckoPrefApi {
void resetPrefs(List<String>? preferenceNames);
}
@HostApi()
abstract class GeckoMlApi {
@async
String getContainerTopic(List<String> titles);
}
@HostApi()
abstract class GeckoBrowserExtensionApi {
@async