feat(app,learn,todo): the assistants in Settings and on the operator's screen

ADR 0021, step 2's app half. halkyon_app asks the module's assistant
endpoints (ServerpodAssistantGateway, ServerpodAssistantOperatorGateway)
and a person's own provider directly (halkyon_llm), behind
AssistantRepository: an AssistantChoice is a GrantedAssistant, asked
through the instance, which counts it, or a PersonalAssistant with the
person's own key, which the instance never sees. assistant_messages.dart
turns a conversation into the endpoint's models and back.

AssistantsCard, for an app's settings: what is granted and left of its
limits, agreed to once on a sheet saying what goes where, the person's
use by UTC month, the instance's privacy page, and their own assistants
where the operator allows own keys (kept ones stay listed, unasked,
where it doesn't). AssistantGrantsCard on InstanceOperatorScreen: grants
by handle with their limits in the pricing's unit, changed and taken
back after asking, everyone's use by month; a found account's menu
grants too. Nothing shows where nothing is offered or can be read.
package:halkyon_app/assistant_models.dart holds the models in pure Dart.

Learn keeps own assistants in the person's settings (ownAssistants,
schema 12, SettingsOwnAssistants); SettingsRepository.update refuses
(OwnAssistantRefused) to write a key where the settings would not be
sealed (SettingsGateway.seals). Learn depends on halkyon_llm directly.
Todo wires the gateways; a member offers no assistant. No feature asks
an assistant yet (step 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 18:59:41 +01:00
commit 36899cdc3b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
59 changed files with 7505 additions and 46 deletions

View file

@ -10,7 +10,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit: - **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German). - `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, limits and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how. - `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, limits and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app. - `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, agreed to, used, and the person's own keys) and the operator's grants on that screen (`AssistantGrantsCard`), with the repositories asking them through the module or a person's own key directly (step 2), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart). - `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`. - `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`. - `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.

File diff suppressed because one or more lines are too long

View file

@ -120,6 +120,13 @@ class Dependencies({
/// What the instance's operators do there (ADR 0021, D21 to D24). /// What the instance's operators do there (ADR 0021, D21 to D24).
@override required final InstanceOperatorRepository instanceOperators, @override required final InstanceOperatorRepository instanceOperators,
/// The assistants the operator granted the person, and asking them or
/// the person's own (ADR 0021, step 2).
@override required final AssistantRepository assistants,
/// Granting assistants, as the instance's operators do (ADR 0021).
@override required final AssistantOperatorRepository assistantOperators,
/// Where the source of what the instance runs is, as it says /// Where the source of what the instance runs is, as it says
/// ([sourceCodeUrl]). /// ([sourceCodeUrl]).
required final Future<String> Function() _sourceCodeUrl, required final Future<String> Function() _sourceCodeUrl,
@ -261,6 +268,13 @@ class Dependencies({
final instanceOperators = InstanceOperatorRepository( final instanceOperators = InstanceOperatorRepository(
ServerpodInstanceOperatorGateway(client.modules.base), ServerpodInstanceOperatorGateway(client.modules.base),
); );
final assistants = AssistantRepository(
ServerpodAssistantGateway(client.modules.base),
);
final assistantOperators = AssistantOperatorRepository(
ServerpodAssistantOperatorGateway(client.modules.base),
ServerpodInstanceOperatorGateway(client.modules.base),
);
final folders = PersonFolders(device.storage.peopleOf(instance)); final folders = PersonFolders(device.storage.peopleOf(instance));
// One person at a time: before the next person's data opens, the previous // One person at a time: before the next person's data opens, the previous
// person's database has closed, so their folder can go. If it doesn't // person's database has closed, so their folder can go. If it doesn't
@ -278,6 +292,8 @@ class Dependencies({
passwords: passwords, passwords: passwords,
simpleLogin: simpleLogin, simpleLogin: simpleLogin,
instanceOperators: instanceOperators, instanceOperators: instanceOperators,
assistants: assistants,
assistantOperators: assistantOperators,
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl, sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
dictionaries: DictionaryRepository( dictionaries: DictionaryRepository(
ServerpodDictionaryGateway(client), ServerpodDictionaryGateway(client),
@ -333,6 +349,8 @@ class Dependencies({
await passwords.dispose(); await passwords.dispose();
await simpleLogin.dispose(); await simpleLogin.dispose();
await instanceOperators.dispose(); await instanceOperators.dispose();
await assistants.dispose();
await assistantOperators.dispose();
await authGateway.dispose(); await authGateway.dispose();
client.close(); client.close();
}, },

View file

@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
/// The person's own assistants as Learn keeps them (ADR 0021, point 10): in
/// their settings, which follow them to every device, sealed with their
/// key; a key is refused where they wouldn't be ([SettingsRepository]).
class SettingsOwnAssistants(final SettingsRepository _settings)
implements OwnAssistantStore {
@override
Stream<List<OwnAssistant>> watch() => _settings
.watch()
.map((settings) => settings.ownAssistants)
.distinct(listEquals);
@override
Future<void> save(OwnAssistant assistant) =>
_settings.update((settings) => settings.withOwnAssistant(assistant));
@override
Future<void> remove(String id) =>
_settings.update((settings) => settings.withoutOwnAssistant(id));
}

View file

@ -19,6 +19,10 @@ abstract interface class SettingsGateway() {
/// Deletes a row. /// Deletes a row.
Future<void> delete(String id); Future<void> delete(String id);
/// Whether what it writes now is sealed with the person's key, so the
/// instance can't read it: on a device holding the account's keys.
Future<bool> seals();
/// Fires when a sync brought changes from the person's other devices. /// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges; Stream<void> get remoteChanges;
} }

View file

@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_app/assistant_models.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
@ -9,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profil
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
// A person's settings as their synced row stores them (see // A person's settings as their synced row stores them (see
// core/content/payload). // core/content/payload).
@ -32,11 +34,15 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
// added from a studyset go (`addDestinations`: by studyset id, a topic's // added from a studyset go (`addDestinations`: by studyset id, a topic's
// id), left out while none is chosen. Schema 11 added that the library's // id), left out while none is chosen. Schema 11 added that the library's
// offer to make every studyset private was answered // offer to make every studyset private was answered
// (`privateOfferAnswered`, ADR 0019), left out while it wasn't. Older // (`privateOfferAnswered`, ADR 0019), left out while it wasn't. Schema 12
// schemas read with what they lack as it comes. // added the person's own assistants (`ownAssistants`, ADR 0021, point 10:
// each an id, name, protocol by its key, address, model, API key and
// whether it reads images), left out while there are none, and kept only
// in a row sealed with the person's key. Older schemas read with what they
// lack as it comes.
/// The schema this app writes. /// The schema this app writes.
const settingsSchemaVersion = 11; const settingsSchemaVersion = 12;
/// Encodes [settings] into their payload. /// Encodes [settings] into their payload.
ByteData encodeSettingsPayload(PersonSettings settings) { ByteData encodeSettingsPayload(PersonSettings settings) {
@ -101,6 +107,19 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
}, },
if (settings.addDestinations.isNotEmpty) if (settings.addDestinations.isNotEmpty)
'addDestinations': settings.addDestinations, 'addDestinations': settings.addDestinations,
if (settings.ownAssistants.isNotEmpty)
'ownAssistants': [
for (final own in settings.ownAssistants)
{
'id': own.id,
'name': own.name,
'protocol': own.protocol.key,
'baseUrl': ?own.baseUrl,
'model': own.model,
'apiKey': own.apiKey,
if (own.images) 'images': true,
},
],
}, },
); );
} }
@ -141,11 +160,39 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
for (final MapEntry(:key, :value) in chosen.entries) for (final MapEntry(:key, :value) in chosen.entries)
if (value is String) key: value, if (value is String) key: value,
}, },
ownAssistants: _ownAssistants(json['ownAssistants']),
); );
} }
throw const FormatException('Not a settings payload.'); throw const FormatException('Not a settings payload.');
} }
/// The person's own assistants in [json]; one in a protocol this app
/// doesn't know (a newer one's), or malformed, is left out.
List<OwnAssistant> _ownAssistants(Object? json) => [
if (json is List<Object?>)
for (final own in json)
if (own case {
'id': final String id,
'name': final String name,
'protocol': final String protocol,
'model': final String model,
'apiKey': final String apiKey,
})
if (LlmProtocol.named(protocol) case final known?)
OwnAssistant(
id: id,
name: name,
protocol: known,
baseUrl: switch (own['baseUrl']) {
final String baseUrl => baseUrl,
_ => null,
},
model: model,
apiKey: apiKey,
images: own['images'] == true,
),
];
List<TagRef> _tagRefs(Object? json) => switch (json) { List<TagRef> _tagRefs(Object? json) => switch (json) {
final List<Object?> tags => [ final List<Object?> tags => [
for (final tag in tags) for (final tag in tags)

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:halkyon_app/halkyon_app.dart' show OwnAssistantRefused;
import 'package:halkyon_core/halkyon_core.dart' show SerialQueue; import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart'; import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
@ -41,13 +42,20 @@ class SettingsRepository(final SettingsGateway _gateway) {
/// change. /// change.
Future<PersonSettings> read() async => (await _queue.run(_read)).settings; Future<PersonSettings> read() async => (await _queue.run(_read)).settings;
/// Stores the settings [change] makes of them as they are. /// Stores the settings [change] makes of them as they are; refused
/// ([OwnAssistantRefused]) where they would hold a person's own
/// assistant, with its key, and this device wouldn't seal them (ADR 0021,
/// point 10).
Future<void> update( Future<void> update(
PersonSettings Function(PersonSettings settings) change, PersonSettings Function(PersonSettings settings) change,
) async { ) async {
await _queue.run(() async { await _queue.run(() async {
final (:id, :settings) = await _read(); final (:id, :settings) = await _read();
final payload = encodeSettingsPayload(change(settings)); final changed = change(settings);
if (changed.ownAssistants.isNotEmpty && !await _gateway.seals()) {
throw const OwnAssistantRefused();
}
final payload = encodeSettingsPayload(changed);
if (id == null) { if (id == null) {
await _gateway.insert(payload); await _gateway.insert(payload);
} else { } else {

View file

@ -113,6 +113,16 @@ class SyncSettingsGateway(
_sync.requestSync(); _sync.requestSync();
} }
@override
Future<bool> seals() async {
try {
return await _privacy.personalKey() != null;
} on PrivacyRefused {
// An account with keys this device doesn't hold: it writes nothing.
return false;
}
}
@override @override
Stream<void> get remoteChanges => _sync.merges; Stream<void> get remoteChanges => _sync.merges;

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:halkyon_app/assistant_models.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
@ -52,6 +53,11 @@ final class const PersonSettings({
/// the studyset's id: one of its topics (decided 2026-10-16). A studyset /// the studyset's id: one of its topics (decided 2026-10-16). A studyset
/// not listed takes them itself. /// not listed takes them itself.
final Map<String, String> addDestinations = const {}, final Map<String, String> addDestinations = const {},
/// The person's own assistants, each with its key, in the order added
/// (ADR 0021, point 10): kept only where the settings are sealed with the
/// person's key (`SettingsRepository.update`).
final List<OwnAssistant> ownAssistants = const [],
}) with Equatable { }) with Equatable {
/// The tags the studyset [studysetId] is studied by; none for all its /// The tags the studyset [studysetId] is studied by; none for all its
/// cards. /// cards.
@ -113,6 +119,24 @@ final class const PersonSettings({
}, },
); );
/// These settings with [assistant] among the person's own, in place of
/// the one with its ID, else last.
PersonSettings withOwnAssistant(OwnAssistant assistant) => copyWith(
ownAssistants: [
for (final each in ownAssistants)
if (each.id == assistant.id) assistant else each,
if (!ownAssistants.any((each) => each.id == assistant.id)) assistant,
],
);
/// These settings without the person's own assistant [id] names.
PersonSettings withoutOwnAssistant(String id) => copyWith(
ownAssistants: [
for (final each in ownAssistants)
if (each.id != id) each,
],
);
/// These settings with [study] as how the person studies. /// These settings with [study] as how the person studies.
PersonSettings withStudy(StudyOptions study) => copyWith(study: study); PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
@ -129,6 +153,7 @@ final class const PersonSettings({
jellyfinTies: jellyfinTies, jellyfinTies: jellyfinTies,
hideJellyfinTags: hideJellyfinTags, hideJellyfinTags: hideJellyfinTags,
addDestinations: addDestinations, addDestinations: addDestinations,
ownAssistants: ownAssistants,
); );
/// These settings with [pomodoro] as the Pomodoro timer's profiles. /// These settings with [pomodoro] as the Pomodoro timer's profiles.
@ -148,6 +173,7 @@ final class const PersonSettings({
Map<String, List<JellyfinTie>>? jellyfinTies, Map<String, List<JellyfinTie>>? jellyfinTies,
bool? hideJellyfinTags, bool? hideJellyfinTags,
Map<String, String>? addDestinations, Map<String, String>? addDestinations,
List<OwnAssistant>? ownAssistants,
}) => PersonSettings( }) => PersonSettings(
studyTags: studyTags ?? this.studyTags, studyTags: studyTags ?? this.studyTags,
study: study ?? this.study, study: study ?? this.study,
@ -160,6 +186,7 @@ final class const PersonSettings({
jellyfinTies: jellyfinTies ?? this.jellyfinTies, jellyfinTies: jellyfinTies ?? this.jellyfinTies,
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags, hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
addDestinations: addDestinations ?? this.addDestinations, addDestinations: addDestinations ?? this.addDestinations,
ownAssistants: ownAssistants ?? this.ownAssistants,
); );
/// These settings with what only [other] has added: where both have a /// These settings with what only [other] has added: where both have a
@ -182,6 +209,12 @@ final class const PersonSettings({
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies}, jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags, hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
addDestinations: {...other.addDestinations, ...addDestinations}, addDestinations: {...other.addDestinations, ...addDestinations},
// By their IDs, as ties are by studyset: these win.
ownAssistants: [
...ownAssistants,
for (final each in other.ownAssistants)
if (!ownAssistants.any((own) => own.id == each.id)) each,
],
); );
@override @override
@ -197,5 +230,6 @@ final class const PersonSettings({
jellyfinTies, jellyfinTies,
hideJellyfinTags, hideJellyfinTags,
addDestinations, addDestinations,
ownAssistants,
]; ];
} }

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart'; import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_assistants.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_settings_card.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_settings_card.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/device_dictionary_widgets.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/device_dictionary_widgets.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_settings_card.dart'; import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_settings_card.dart';
@ -64,6 +65,15 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
const AssistantSettingsCard(), const AssistantSettingsCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
// The operator's assistants and the person's own (ADR
// 0021), beside the web search they are to answer in;
// nothing where none is offered or allowed.
AssistantsCard(
ownAssistants: SettingsOwnAssistants(
PersonDependencies.of(context).settings,
),
below: 12,
),
const JellyfinSettingsCard(), const JellyfinSettingsCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const DeviceDictionarySettingsCard(), const DeviceDictionarySettingsCard(),

View file

@ -81,6 +81,10 @@ dependencies:
path: ../../../packages/halkyon_core path: ../../../packages/halkyon_core
halkyon_learn_client: halkyon_learn_client:
path: ../halkyon_learn_client path: ../halkyon_learn_client
# Language models' APIs (a shared package, ADR 0021, D15): the protocols a
# person's own assistant is asked in, which their settings keep.
halkyon_llm:
path: ../../../packages/halkyon_llm
# The device's half of signing in with OpenID Connect (ADR 0023): the # The device's half of signing in with OpenID Connect (ADR 0023): the
# browser that SimpleLogin's sign-in opens, and on Linux and Windows the # browser that SimpleLogin's sign-in opens, and on Linux and Windows the
# loopback it comes back to, which the app builds with its name. # loopback it comes back to, which the app builds with its name.

View file

@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'
show OwnAssistant, OwnAssistantRefused;
import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_assistants.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
import '../../../support/fake_settings_gateway.dart';
// The person's own assistants as Learn keeps them: in their settings,
// sealed with their key (ADR 0021, point 10).
const _claude = OwnAssistant(
id: 'own-1',
name: 'My Claude',
protocol: LlmProtocol.anthropic,
model: 'claude-sonnet-5-5',
apiKey: 'sk-ant-own',
);
void main() {
late FakeSettingsGateway gateway;
late SettingsRepository settings;
late SettingsOwnAssistants store;
setUp(() {
gateway = FakeSettingsGateway();
settings = SettingsRepository(gateway);
store = SettingsOwnAssistants(settings);
});
tearDown(() => settings.dispose());
test('follows the assistants kept, saved, changed and removed', () async {
final watched = expectLater(
store.watch(),
emitsInOrder([
isEmpty,
[_claude],
[
isA<OwnAssistant>().having((own) => own.model, 'model', 'opus'),
],
isEmpty,
]),
);
await Future<void>.delayed(Duration.zero);
await store.save(_claude);
await store.save(
const OwnAssistant(
id: 'own-1',
name: 'My Claude',
protocol: LlmProtocol.anthropic,
model: 'opus',
apiKey: 'sk-ant-own',
),
);
await store.remove('own-1');
await watched;
});
test("refuses a key where the settings wouldn't be sealed", () async {
gateway.sealing = false;
await expectLater(store.save(_claude), throwsA(isA<OwnAssistantRefused>()));
expect(gateway.writes, isEmpty);
});
}

View file

@ -1,8 +1,11 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'
show OwnAssistant, OwnAssistantRefused;
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
import '../../../support/fake_settings_gateway.dart'; import '../../../support/fake_settings_gateway.dart';
@ -116,4 +119,35 @@ void main() {
); );
await expectation; await expectation;
}); });
group("the person's own assistants (ADR 0021, point 10)", () {
const own = OwnAssistant(
id: 'own-1',
name: 'My Claude',
protocol: LlmProtocol.anthropic,
model: 'claude-sonnet-5-5',
apiKey: 'sk-ant-own',
);
test('are kept where the settings are sealed', () async {
await repository.update((s) => s.withOwnAssistant(own));
expect((await repository.read()).ownAssistants, [own]);
});
test("are refused where the settings wouldn't be sealed, and nothing "
'is written; other settings still are', () async {
gateway.sealing = false;
await expectLater(
repository.update((s) => s.withOwnAssistant(own)),
throwsA(isA<OwnAssistantRefused>()),
);
expect(gateway.writes, isEmpty);
await repository.update((s) => s.withStudyTags('japanese', [verb]));
expect(gateway.writes, hasLength(1));
expect((await repository.read()).ownAssistants, isEmpty);
});
});
} }

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/assistant_models.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
@ -7,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profil
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
void main() { void main() {
const verb = (id: 'tag-1', name: 'verb'); const verb = (id: 'tag-1', name: 'verb');
@ -488,4 +490,117 @@ void main() {
}); });
}); });
}); });
group("the person's own assistants (ADR 0021, point 10)", () {
const claude = OwnAssistant(
id: 'own-1',
name: 'My Claude',
protocol: LlmProtocol.anthropic,
model: 'claude-sonnet-5-5',
apiKey: 'sk-ant-own',
images: true,
);
const local = OwnAssistant(
id: 'own-2',
name: 'Local',
protocol: LlmProtocol.openai,
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
apiKey: '',
);
test('are added in order, changed in place by their ID, and '
'removed', () {
final settings = const PersonSettings()
.withOwnAssistant(claude)
.withOwnAssistant(local);
const renamed = OwnAssistant(
id: 'own-1',
name: 'Claude',
protocol: LlmProtocol.anthropic,
model: 'claude-opus-5-5',
apiKey: 'sk-ant-own',
);
expect(settings.ownAssistants, [claude, local]);
expect(settings.withOwnAssistant(renamed).ownAssistants, [
renamed,
local,
]);
expect(settings.withoutOwnAssistant('own-1').ownAssistants, [local]);
});
test('survive a round trip with their keys, in the twelfth schema, '
'left out while there are none; a payload from before has '
'none', () {
final settings = const PersonSettings()
.withOwnAssistant(claude)
.withOwnAssistant(local);
expect(settingsSchemaVersion, greaterThanOrEqualTo(12));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('ownAssistants')),
);
expect(
decodeSettingsPayload(
encodePayload(11, {
'studyTags': <String, Object?>{},
'ownAssistants': 'not yet',
}),
).ownAssistants,
isEmpty,
);
});
test("one in a protocol this app doesn't know, or malformed, is left "
'out, the others kept', () {
final read = decodeSettingsPayload(
encodePayload(settingsSchemaVersion, {
'studyTags': <String, Object?>{},
'ownAssistants': [
{
'id': 'own-9',
'name': 'Gemini',
'protocol': 'gemini',
'model': 'gemini-3',
'apiKey': 'key',
},
{'id': 'own-8', 'name': 'No model'},
{
'id': 'own-1',
'name': 'My Claude',
'protocol': 'anthropic',
'model': 'claude-sonnet-5-5',
'apiKey': 'sk-ant-own',
'images': true,
},
],
}),
);
expect(read.ownAssistants, [claude]);
});
test('fold by their IDs: these settings win, those only the other has '
'are added', () {
const changed = OwnAssistant(
id: 'own-1',
name: 'Changed there',
protocol: LlmProtocol.anthropic,
model: 'claude-sonnet-5-5',
apiKey: 'sk-ant-other',
);
final here = const PersonSettings().withOwnAssistant(claude);
final there = const PersonSettings()
.withOwnAssistant(changed)
.withOwnAssistant(local);
expect(here.foldedWith(there).ownAssistants, [claude, local]);
});
});
} }

View file

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart'; import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart'; import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart'; import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart'; import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
@ -438,4 +439,142 @@ void main() {
); );
}); });
}); });
group('Assistants (ADR 0021, step 2)', () {
FakeAssistantGateway offering({required bool ownKeys}) =>
FakeAssistantGateway()
..offer = AssistantsOffer(
assistants: const [
OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
),
],
ownKeys: ownKeys,
);
Future<void> show(WidgetTester tester, Dependencies dependencies) async {
tester.view.physicalSize = const Size(900, 3000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(tester, dependencies, location: RoutePaths.settings);
}
testWidgets('what is granted shows after the web search', (tester) async {
await show(
tester,
testDependencies(assistantGateway: offering(ownKeys: false)),
);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Assistants')).dy,
greaterThan(tester.getTopLeft(find.text('Web search')).dy),
);
expect(find.text('With your own key'), findsNothing);
});
testWidgets('nothing shows where nothing is offered or allowed', (
tester,
) async {
await show(tester, testDependencies());
expect(find.text('Assistants'), findsNothing);
});
testWidgets("an own key is kept in the person's settings, which follow "
'them to every device', (tester) async {
final settings = FakeSettingsGateway();
await show(
tester,
testDependencies(
assistantGateway: offering(ownKeys: true),
settingsGateway: settings,
),
);
await tester.tap(find.text('Add an assistant with your own key'));
await tester.pumpAndSettle();
final sheet = find.byType(SheetScaffold);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Name'),
),
'Mine',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Model'),
),
'claude-opus-5-5',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Key'),
),
'sk-ant-mine',
);
await tester.tap(
find.descendant(
of: sheet,
matching: find.widgetWithText(FilledButton, 'Save'),
),
);
await tester.pumpAndSettle();
expect(find.text('Mine'), findsOneWidget);
final kept = (await SettingsRepository(settings).read()).ownAssistants;
expect(kept.single.apiKey, 'sk-ant-mine');
});
testWidgets("an own key is refused where this device can't seal the "
'settings', (tester) async {
final settings = FakeSettingsGateway()..sealing = false;
await show(
tester,
testDependencies(
assistantGateway: offering(ownKeys: true),
settingsGateway: settings,
),
);
await tester.tap(find.text('Add an assistant with your own key'));
await tester.pumpAndSettle();
final sheet = find.byType(SheetScaffold);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Name'),
),
'Mine',
);
await tester.enterText(
find.descendant(
of: sheet,
matching: find.widgetWithText(TextField, 'Model'),
),
'claude-opus-5-5',
);
await tester.tap(
find.descendant(
of: sheet,
matching: find.widgetWithText(FilledButton, 'Save'),
),
);
await tester.pumpAndSettle();
expect(
find.textContaining("this device can't encrypt your settings"),
findsOneWidget,
);
expect(settings.writes, isEmpty);
});
});
} }

View file

@ -17,6 +17,10 @@ class FakeSettingsGateway() implements SettingsGateway {
/// The ids of the deleted rows, in order. /// The ids of the deleted rows, in order.
final deleted = <String>[]; final deleted = <String>[];
/// Whether what it writes is sealed with the person's key, as on a device
/// holding the account's keys.
bool sealing = true;
/// Stores the row [id] as if another device had written it and a sync had /// Stores the row [id] as if another device had written it and a sync had
/// brought it here. /// brought it here.
void receiveRemoteRow(String id, ByteData payload) { void receiveRemoteRow(String id, ByteData payload) {
@ -52,6 +56,9 @@ class FakeSettingsGateway() implements SettingsGateway {
deleted.add(id); deleted.add(id);
} }
@override
Future<bool> seals() async => sealing;
@override @override
Stream<void> get remoteChanges => _remoteChanges.stream; Stream<void> get remoteChanges => _remoteChanges.stream;
} }

View file

@ -178,7 +178,13 @@ Dependencies testDependencies({
FakeSimpleLoginGateway? simpleLoginGateway, FakeSimpleLoginGateway? simpleLoginGateway,
// Nobody operates the instance unless a test says so. // Nobody operates the instance unless a test says so.
InstanceOperatorGateway? operatorGateway, InstanceOperatorGateway? operatorGateway,
// Nothing offered unless a test offers it.
AssistantGateway? assistantGateway,
AssistantOperatorGateway? assistantOperatorGateway,
// A person's own key's provider; none answers unless scripted.
FakeLlmClient? llm,
}) { }) {
final operators = operatorGateway ?? FakeInstanceOperatorGateway();
final device = deviceDictionaries ?? noDeviceDictionaries(); final device = deviceDictionaries ?? noDeviceDictionaries();
final auth = authGateway ?? FakeAuthGateway(); final auth = authGateway ?? FakeAuthGateway();
final addresses = signInAddresses ?? MemorySignInAddresses(); final addresses = signInAddresses ?? MemorySignInAddresses();
@ -205,8 +211,14 @@ Dependencies testDependencies({
callbackScheme: 'app.halkyon.learn', callbackScheme: 'app.halkyon.learn',
loopbackPort: 43823, loopbackPort: 43823,
), ),
instanceOperators: InstanceOperatorRepository( instanceOperators: InstanceOperatorRepository(operators),
operatorGateway ?? FakeInstanceOperatorGateway(), assistants: AssistantRepository(
assistantGateway ?? FakeAssistantGateway(),
llm: llm ?? FakeLlmClient(),
),
assistantOperators: AssistantOperatorRepository(
assistantOperatorGateway ?? FakeAssistantOperatorGateway(),
operators,
), ),
sourceCodeUrl: sourceCodeUrl:
sourceCodeUrl ?? () async => 'https://example.org/halkyon-learn', sourceCodeUrl ?? () async => 'https://example.org/halkyon-learn',

View file

@ -47,6 +47,16 @@ class TodoDependencies({
/// until Todo's server holds the register. /// until Todo's server holds the register.
@override required final InstanceOperatorRepository instanceOperators, @override required final InstanceOperatorRepository instanceOperators,
/// The assistants the operator granted the person (Halkyon Learn's ADR
/// 0021, step 2). A member of the instance offers none for now, so Todo
/// shows none; it asks all the same, as an instance whose register Todo's
/// server holds would answer.
@override required final AssistantRepository assistants,
/// Granting assistants, as the instance's operators do (ADR 0021); as
/// [instanceOperators], nothing on a member.
@override required final AssistantOperatorRepository assistantOperators,
/// Keeps a file the app made where the person chooses (a recovery phrase, /// Keeps a file the app made where the person chooses (a recovery phrase,
/// recovery codes). /// recovery codes).
@override required final FileSaver fileSaver, @override required final FileSaver fileSaver,
@ -124,12 +134,21 @@ class TodoDependencies({
final instanceOperators = InstanceOperatorRepository( final instanceOperators = InstanceOperatorRepository(
ServerpodInstanceOperatorGateway(client.modules.base), ServerpodInstanceOperatorGateway(client.modules.base),
); );
final assistants = AssistantRepository(
ServerpodAssistantGateway(client.modules.base),
);
final assistantOperators = AssistantOperatorRepository(
ServerpodAssistantOperatorGateway(client.modules.base),
ServerpodInstanceOperatorGateway(client.modules.base),
);
return TodoDependencies( return TodoDependencies(
serverName: instance.name, serverName: instance.name,
auth: AuthRepository(authGateway, addresses), auth: AuthRepository(authGateway, addresses),
passwords: passwords, passwords: passwords,
simpleLogin: simpleLogin, simpleLogin: simpleLogin,
instanceOperators: instanceOperators, instanceOperators: instanceOperators,
assistants: assistants,
assistantOperators: assistantOperators,
fileSaver: PlatformFileSaver(), fileSaver: PlatformFileSaver(),
sourceCodeUrl: () async => sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl, (await client.modules.base.instance.basics()).sourceCodeUrl,
@ -142,6 +161,8 @@ class TodoDependencies({
await passwords.dispose(); await passwords.dispose();
await simpleLogin.dispose(); await simpleLogin.dispose();
await instanceOperators.dispose(); await instanceOperators.dispose();
await assistants.dispose();
await assistantOperators.dispose();
await authGateway.dispose(); await authGateway.dispose();
client.close(); client.close();
}, },

View file

@ -72,6 +72,14 @@ void main() {
instanceOperators: InstanceOperatorRepository( instanceOperators: InstanceOperatorRepository(
FakeInstanceOperatorGateway(), FakeInstanceOperatorGateway(),
), ),
assistants: AssistantRepository(
FakeAssistantGateway(),
llm: FakeLlmClient(),
),
assistantOperators: AssistantOperatorRepository(
FakeAssistantOperatorGateway(),
FakeInstanceOperatorGateway(),
),
fileSaver: MemoryFileSaver(), fileSaver: MemoryFileSaver(),
sourceCodeUrl: () async => sourceCodeUrl: () async =>
'https://code.halkyon.app/project-halkyon/halkyon', 'https://code.halkyon.app/project-halkyon/halkyon',

View file

@ -67,6 +67,23 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
refused; who operates, named by handle and taken back (never the last); refused; who operates, named by handle and taken back (never the last);
accounts found by the start of their handle, suspended or named. Only the accounts found by the start of their handle, suspended or named. Only the
register's service answers: on a member, nothing shows. register's service answers: on a member, nothing shows.
- **Assistants** (`lib/src/assistant/`, Halkyon Learn's ADR 0021, step 2):
`AssistantRepository` on `ServerpodAssistantGateway` (the module's
`assistant` endpoint; refusals as `AssistantFailed`) asks an
`AssistantChoice` in halkyon_llm's terms: a `GrantedAssistant` through the
instance, which counts it, or a `PersonalAssistant` with the person's own
key directly (`LlmClient`), the instance seeing neither key nor question;
`assistant_messages.dart` turns a conversation into the endpoint's models
and back, part by part. `AssistantsCard` is an app's settings' card: what
is granted and left of its limits, agreed to once (the consent sheet),
the person's use by UTC month, the instance's privacy page, and the
person's own assistants where the operator allows them, kept where the
app says (`OwnAssistantStore`: Learn's sealed settings). For the operator,
`AssistantOperatorRepository` (grants by exact handle) and
`AssistantGrantsCard` on `InstanceOperatorScreen`: grants with their
limits, granted, changed and taken back, and everyone's use by month.
`package:halkyon_app/assistant_models.dart` holds the models alone, in
pure Dart, for an app's own models.
- **About** (`showAppAbout`): the app's name (its frame's), its version, - **About** (`showAppAbout`): the app's name (its frame's), its version,
icon and legalese where it gives them, where the source of what the icon and legalese where it gives them, where the source of what the
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`; instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;

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