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:
parent
1e5d7040fd
commit
36899cdc3b
8 changed files with 7505 additions and 46 deletions
|
|
@ -120,6 +120,13 @@ class Dependencies({
|
|||
/// What the instance's operators do there (ADR 0021, D21 to D24).
|
||||
@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
|
||||
/// ([sourceCodeUrl]).
|
||||
required final Future<String> Function() _sourceCodeUrl,
|
||||
|
|
@ -261,6 +268,13 @@ class Dependencies({
|
|||
final instanceOperators = InstanceOperatorRepository(
|
||||
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));
|
||||
// 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
|
||||
|
|
@ -278,6 +292,8 @@ class Dependencies({
|
|||
passwords: passwords,
|
||||
simpleLogin: simpleLogin,
|
||||
instanceOperators: instanceOperators,
|
||||
assistants: assistants,
|
||||
assistantOperators: assistantOperators,
|
||||
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
|
||||
dictionaries: DictionaryRepository(
|
||||
ServerpodDictionaryGateway(client),
|
||||
|
|
@ -333,6 +349,8 @@ class Dependencies({
|
|||
await passwords.dispose();
|
||||
await simpleLogin.dispose();
|
||||
await instanceOperators.dispose();
|
||||
await assistants.dispose();
|
||||
await assistantOperators.dispose();
|
||||
await authGateway.dispose();
|
||||
client.close();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -19,6 +19,10 @@ abstract interface class SettingsGateway() {
|
|||
/// Deletes a row.
|
||||
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.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:developer';
|
||||
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/feature/home/model/home_layout.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/study/model/study_options.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
|
||||
// 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
|
||||
// id), left out while none is chosen. Schema 11 added that the library's
|
||||
// offer to make every studyset private was answered
|
||||
// (`privateOfferAnswered`, ADR 0019), left out while it wasn't. Older
|
||||
// schemas read with what they lack as it comes.
|
||||
// (`privateOfferAnswered`, ADR 0019), left out while it wasn't. Schema 12
|
||||
// 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.
|
||||
const settingsSchemaVersion = 11;
|
||||
const settingsSchemaVersion = 12;
|
||||
|
||||
/// Encodes [settings] into their payload.
|
||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||
|
|
@ -101,6 +107,19 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
|||
},
|
||||
if (settings.addDestinations.isNotEmpty)
|
||||
'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)
|
||||
if (value is String) key: value,
|
||||
},
|
||||
ownAssistants: _ownAssistants(json['ownAssistants']),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
final List<Object?> tags => [
|
||||
for (final tag in tags)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart' show OwnAssistantRefused;
|
||||
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/content/payload.dart';
|
||||
|
|
@ -41,13 +42,20 @@ class SettingsRepository(final SettingsGateway _gateway) {
|
|||
/// change.
|
||||
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(
|
||||
PersonSettings Function(PersonSettings settings) change,
|
||||
) async {
|
||||
await _queue.run(() async {
|
||||
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) {
|
||||
await _gateway.insert(payload);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@ class SyncSettingsGateway(
|
|||
_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
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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/study_goal.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
|
||||
/// not listed takes them itself.
|
||||
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 {
|
||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||
/// 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.
|
||||
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
|
||||
|
||||
|
|
@ -129,6 +153,7 @@ final class const PersonSettings({
|
|||
jellyfinTies: jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags,
|
||||
addDestinations: addDestinations,
|
||||
ownAssistants: ownAssistants,
|
||||
);
|
||||
|
||||
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
||||
|
|
@ -148,6 +173,7 @@ final class const PersonSettings({
|
|||
Map<String, List<JellyfinTie>>? jellyfinTies,
|
||||
bool? hideJellyfinTags,
|
||||
Map<String, String>? addDestinations,
|
||||
List<OwnAssistant>? ownAssistants,
|
||||
}) => PersonSettings(
|
||||
studyTags: studyTags ?? this.studyTags,
|
||||
study: study ?? this.study,
|
||||
|
|
@ -160,6 +186,7 @@ final class const PersonSettings({
|
|||
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||
addDestinations: addDestinations ?? this.addDestinations,
|
||||
ownAssistants: ownAssistants ?? this.ownAssistants,
|
||||
);
|
||||
|
||||
/// These settings with what only [other] has added: where both have a
|
||||
|
|
@ -182,6 +209,12 @@ final class const PersonSettings({
|
|||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||
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
|
||||
|
|
@ -197,5 +230,6 @@ final class const PersonSettings({
|
|||
jellyfinTies,
|
||||
hideJellyfinTags,
|
||||
addDestinations,
|
||||
ownAssistants,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/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/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/dictionary/widget/device_dictionary_widgets.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 AssistantSettingsCard(),
|
||||
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 SizedBox(height: 12),
|
||||
const DeviceDictionarySettingsCard(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue