feat(learn): a private studyset goes to an assistant only once agreed
ADR 0021, D9 and D17. Before a question sends a private studyset's content to a remote model, through the parts chosen or a wider reach with private studysets switched on, a warning names the studysets, the provider's host and its terms (where checked: Anthropic, OpenAI, Google, OpenRouter), and is asked once per studyset and assistant: the agreement is kept in the person's sealed settings (schema 13, privateAgreements, folded only where both rows hold it), shown in the studyset's privacy dialog with Take back, and marked on the folded button while it holds. Follow-ups and Try again ask again once taken back, as the scope keeps where the parts came from (partsFrom). No warning for an own assistant on this machine (loopback or localhost, halkyon_llm's LlmEndpoint.onThisMachine); the local network may be shared. Texts that step 3 made untrue: the consent sheet now says what an assistant looks up goes too and that conversations are kept encrypted for the person's devices; the privacy dialogs no longer mention showing a studyset to Claude; the privacy page says what is looked up and when a private studyset goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
114b75cdab
commit
bf46f8c460
38 changed files with 1488 additions and 107 deletions
|
|
@ -1856,6 +1856,15 @@
|
|||
"assistantLookedUpNotes": "Notizen nach „{query}“ durchsucht: {count, plural, =0{keine gefunden} =1{1 gefunden} other{{count} gefunden}}",
|
||||
"assistantLookedUpNote": "Eine Notiz gelesen",
|
||||
"assistantLookedUpNoNote": "Eine Notiz gesucht, keine gefunden",
|
||||
"assistantPrivateTitle": "{count, plural, =1{Ein privates Lernset an {name} senden?} other{Private Lernsets an {name} senden?}}",
|
||||
"assistantPrivateGranted": "{count, plural, =1{Es ist Ende-zu-Ende-verschlüsselt, sodass nicht einmal {instance} es lesen kann. Was {name} darin nachschlägt oder was du daraus auswählst, geht durch {instance}, das nichts davon aufbewahrt, an {host}, wo es nach den Bedingungen des Anbieters gelesen wird.} other{Sie sind Ende-zu-Ende-verschlüsselt, sodass nicht einmal {instance} sie lesen kann. Was {name} darin nachschlägt oder was du daraus auswählst, geht durch {instance}, das nichts davon aufbewahrt, an {host}, wo es nach den Bedingungen des Anbieters gelesen wird.}}",
|
||||
"assistantPrivateOwn": "{count, plural, =1{Es ist Ende-zu-Ende-verschlüsselt, sodass nicht einmal die Instanz es lesen kann. Was {name} darin nachschlägt oder was du daraus auswählst, geht direkt an {host}, wo es nach den Bedingungen des Anbieters gelesen wird.} other{Sie sind Ende-zu-Ende-verschlüsselt, sodass nicht einmal die Instanz sie lesen kann. Was {name} darin nachschlägt oder was du daraus auswählst, geht direkt an {host}, wo es nach den Bedingungen des Anbieters gelesen wird.}}",
|
||||
"assistantPrivateOnce": "Das wird einmal je Lernset und Assistent gefragt. Zurücknehmen kannst du es über das Schloss beim Lernset.",
|
||||
"assistantPrivateTerms": "Bedingungen des Anbieters",
|
||||
"assistantPrivateSend": "Senden",
|
||||
"assistantPrivateMark": "{name} darf dieses private Lernset bekommen",
|
||||
"studysetPrivacyAssistants": "Assistenten, an die es gehen darf",
|
||||
"studysetPrivacyTakeBack": "Zurücknehmen",
|
||||
"settingsSearchEngine": "Suchmaschine",
|
||||
"searchEngineDevice": "Suche des Geräts",
|
||||
"searchEngineCustom": "Eigene Adresse",
|
||||
|
|
@ -2060,7 +2069,7 @@
|
|||
"studysetMenuMakePrivate": "Privat machen",
|
||||
"studysetMenuMakeReadable": "Nicht mehr privat",
|
||||
"studysetPrivateTitle": "„{title}“ privat machen?",
|
||||
"studysetPrivateBody": "Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen noch Claude zeigen. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
|
||||
"studysetPrivateBody": "Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen, und ein Assistent bekommt nur, was du ihm zu senden erlaubst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
|
||||
"studysetReadableTitle": "„{title}“ nicht mehr privat?",
|
||||
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen.",
|
||||
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
|
||||
|
|
@ -2100,7 +2109,7 @@
|
|||
"studysetVisibilityReadable": "Nicht privat, nicht veröffentlicht",
|
||||
"studysetShareReadable": "Teilen: nicht privat",
|
||||
"studysetPrivacyPrivateTitle": "„{title}“ ist privat",
|
||||
"studysetPrivacyPrivateBody": "Alles darin ist Ende-zu-Ende-verschlüsselt: Nur deine Geräte können es lesen. Die Instanz kann es weder veröffentlichen noch Claude zeigen. Nicht mehr privat, wird es entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann.",
|
||||
"studysetPrivacyPrivateBody": "Alles darin ist Ende-zu-Ende-verschlüsselt: Nur deine Geräte können es lesen. Die Instanz kann es nicht veröffentlichen, und ein Assistent bekommt nur, was du ihm zu senden erlaubst. Nicht mehr privat, wird es entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann.",
|
||||
"studysetPrivacyReadableTitle": "„{title}“ ist nicht privat",
|
||||
"studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
|
||||
"studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.",
|
||||
|
|
|
|||
|
|
@ -2178,6 +2178,23 @@
|
|||
"@assistantLookedUpNotes": {"placeholders": {"query": {"type": "String"}, "count": {"type": "int"}}},
|
||||
"assistantLookedUpNote": "Read a note",
|
||||
"assistantLookedUpNoNote": "Looked for a note, found none",
|
||||
"assistantPrivateTitle": "{count, plural, =1{Send a private studyset to {name}?} other{Send private studysets to {name}?}}",
|
||||
"@assistantPrivateTitle": {"description": "The warning before a private studyset's content goes to a remote model (ADR 0021, D9).", "placeholders": {"count": {"type": "int"}, "name": {"type": "String"}}},
|
||||
"assistantPrivateGranted": "{count, plural, =1{It is end-to-end encrypted, so that not even {instance} can read it. What {name} looks up in it, or what you chose of it, passes through {instance}, which keeps none of it, to {host}, which reads it under the provider's terms.} other{They are end-to-end encrypted, so that not even {instance} can read them. What {name} looks up in them, or what you chose of them, passes through {instance}, which keeps none of it, to {host}, which reads it under the provider's terms.}}",
|
||||
"@assistantPrivateGranted": {"description": "The warning for an assistant the operator granted, asked through the instance.", "placeholders": {"count": {"type": "int"}, "name": {"type": "String"}, "instance": {"type": "String"}, "host": {"type": "String"}}},
|
||||
"assistantPrivateOwn": "{count, plural, =1{It is end-to-end encrypted, so that not even the instance can read it. What {name} looks up in it, or what you chose of it, goes straight to {host}, which reads it under the provider's terms.} other{They are end-to-end encrypted, so that not even the instance can read them. What {name} looks up in them, or what you chose of them, goes straight to {host}, which reads it under the provider's terms.}}",
|
||||
"@assistantPrivateOwn": {"description": "The warning for the person's own assistant, asked directly.", "placeholders": {"count": {"type": "int"}, "name": {"type": "String"}, "host": {"type": "String"}}},
|
||||
"assistantPrivateOnce": "You're asked once for each studyset and assistant. Take it back from the lock beside the studyset.",
|
||||
"@assistantPrivateOnce": {"description": "D17: asked once, taken back in the privacy dialog."},
|
||||
"assistantPrivateTerms": "The provider's terms",
|
||||
"assistantPrivateSend": "Send",
|
||||
"@assistantPrivateSend": {"description": "Agrees and sends."},
|
||||
"assistantPrivateMark": "{name} may be sent this private studyset",
|
||||
"@assistantPrivateMark": {"description": "The mark on the assistant's button while an agreement holds (D17).", "placeholders": {"name": {"type": "String"}}},
|
||||
"studysetPrivacyAssistants": "Assistants it may go to",
|
||||
"@studysetPrivacyAssistants": {"description": "The privacy dialog's list of the assistants agreed to (D17)."},
|
||||
"studysetPrivacyTakeBack": "Take back",
|
||||
"@studysetPrivacyTakeBack": {"description": "Takes an agreement back."},
|
||||
"settingsSearchEngine": "Search engine",
|
||||
"searchEngineDevice": "The device's search",
|
||||
"searchEngineCustom": "Your own address",
|
||||
|
|
@ -2421,7 +2438,7 @@
|
|||
"studysetMenuMakeReadable": "Make non-private",
|
||||
"studysetPrivateTitle": "Make “{title}” private?",
|
||||
"@studysetPrivateTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetPrivateBody": "Everything in it is end-to-end encrypted on your devices: the instance can't read it, publish it or show it to Claude. Copies in the instance's backups stay readable until those expire.",
|
||||
"studysetPrivateBody": "Everything in it is end-to-end encrypted on your devices: the instance can't read it or publish it, and an assistant gets only what you agree to send it. Copies in the instance's backups stay readable until those expire.",
|
||||
"studysetReadableTitle": "Make “{title}” non-private?",
|
||||
"@studysetReadableTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it.",
|
||||
|
|
@ -2463,7 +2480,7 @@
|
|||
"studysetShareReadable": "Share: not private",
|
||||
"studysetPrivacyPrivateTitle": "“{title}” is private",
|
||||
"@studysetPrivacyPrivateTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetPrivacyPrivateBody": "Everything in it is end-to-end encrypted: only your devices can read it. The instance can't publish it, nor show it to Claude. Making it non-private decrypts it and syncs it again, so the instance can read it.",
|
||||
"studysetPrivacyPrivateBody": "Everything in it is end-to-end encrypted: only your devices can read it. The instance can't publish it, and an assistant gets only what you agree to send it. Making it non-private decrypts it and syncs it again, so the instance can read it.",
|
||||
"studysetPrivacyReadableTitle": "“{title}” isn't private",
|
||||
"@studysetPrivacyReadableTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetPrivacyReadableBody": "The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.",
|
||||
|
|
|
|||
|
|
@ -10953,6 +10953,65 @@ abstract class AppLocalizations {
|
|||
/// **'Looked for a note, found none'**
|
||||
String get assistantLookedUpNoNote;
|
||||
|
||||
/// The warning before a private studyset's content goes to a remote model (ADR 0021, D9).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Send a private studyset to {name}?} other{Send private studysets to {name}?}}'**
|
||||
String assistantPrivateTitle(int count, String name);
|
||||
|
||||
/// The warning for an assistant the operator granted, asked through the instance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{It is end-to-end encrypted, so that not even {instance} can read it. What {name} looks up in it, or what you chose of it, passes through {instance}, which keeps none of it, to {host}, which reads it under the provider\'s terms.} other{They are end-to-end encrypted, so that not even {instance} can read them. What {name} looks up in them, or what you chose of them, passes through {instance}, which keeps none of it, to {host}, which reads it under the provider\'s terms.}}'**
|
||||
String assistantPrivateGranted(
|
||||
int count,
|
||||
String name,
|
||||
String instance,
|
||||
String host,
|
||||
);
|
||||
|
||||
/// The warning for the person's own assistant, asked directly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{It is end-to-end encrypted, so that not even the instance can read it. What {name} looks up in it, or what you chose of it, goes straight to {host}, which reads it under the provider\'s terms.} other{They are end-to-end encrypted, so that not even the instance can read them. What {name} looks up in them, or what you chose of them, goes straight to {host}, which reads it under the provider\'s terms.}}'**
|
||||
String assistantPrivateOwn(int count, String name, String host);
|
||||
|
||||
/// D17: asked once, taken back in the privacy dialog.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You\'re asked once for each studyset and assistant. Take it back from the lock beside the studyset.'**
|
||||
String get assistantPrivateOnce;
|
||||
|
||||
/// No description provided for @assistantPrivateTerms.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The provider\'s terms'**
|
||||
String get assistantPrivateTerms;
|
||||
|
||||
/// Agrees and sends.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send'**
|
||||
String get assistantPrivateSend;
|
||||
|
||||
/// The mark on the assistant's button while an agreement holds (D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} may be sent this private studyset'**
|
||||
String assistantPrivateMark(String name);
|
||||
|
||||
/// The privacy dialog's list of the assistants agreed to (D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Assistants it may go to'**
|
||||
String get studysetPrivacyAssistants;
|
||||
|
||||
/// Takes an agreement back.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take back'**
|
||||
String get studysetPrivacyTakeBack;
|
||||
|
||||
/// No description provided for @settingsSearchEngine.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -12144,7 +12203,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studysetPrivateBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it, publish it or show it to Claude. Copies in the instance\'s backups stay readable until those expire.'**
|
||||
/// **'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it or publish it, and an assistant gets only what you agree to send it. Copies in the instance\'s backups stay readable until those expire.'**
|
||||
String get studysetPrivateBody;
|
||||
|
||||
/// No description provided for @studysetReadableTitle.
|
||||
|
|
@ -12366,7 +12425,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studysetPrivacyPrivateBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Everything in it is end-to-end encrypted: only your devices can read it. The instance can\'t publish it, nor show it to Claude. Making it non-private decrypts it and syncs it again, so the instance can read it.'**
|
||||
/// **'Everything in it is end-to-end encrypted: only your devices can read it. The instance can\'t publish it, and an assistant gets only what you agree to send it. Making it non-private decrypts it and syncs it again, so the instance can read it.'**
|
||||
String get studysetPrivacyPrivateBody;
|
||||
|
||||
/// No description provided for @studysetPrivacyReadableTitle.
|
||||
|
|
|
|||
|
|
@ -6870,6 +6870,69 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get assistantLookedUpNoNote => 'Eine Notiz gesucht, keine gefunden';
|
||||
|
||||
@override
|
||||
String assistantPrivateTitle(int count, String name) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Private Lernsets an $name senden?',
|
||||
one: 'Ein privates Lernset an $name senden?',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String assistantPrivateGranted(
|
||||
int count,
|
||||
String name,
|
||||
String instance,
|
||||
String host,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'Sie sind Ende-zu-Ende-verschlüsselt, sodass nicht einmal $instance sie lesen kann. Was $name darin nachschlägt oder was du daraus auswählst, geht durch $instance, das nichts davon aufbewahrt, an $host, wo es nach den Bedingungen des Anbieters gelesen wird.',
|
||||
one:
|
||||
'Es ist Ende-zu-Ende-verschlüsselt, sodass nicht einmal $instance es lesen kann. Was $name darin nachschlägt oder was du daraus auswählst, geht durch $instance, das nichts davon aufbewahrt, an $host, wo es nach den Bedingungen des Anbieters gelesen wird.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String assistantPrivateOwn(int count, String name, String host) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'Sie sind Ende-zu-Ende-verschlüsselt, sodass nicht einmal die Instanz sie lesen kann. Was $name darin nachschlägt oder was du daraus auswählst, geht direkt an $host, wo es nach den Bedingungen des Anbieters gelesen wird.',
|
||||
one:
|
||||
'Es ist Ende-zu-Ende-verschlüsselt, sodass nicht einmal die Instanz es lesen kann. Was $name darin nachschlägt oder was du daraus auswählst, geht direkt an $host, wo es nach den Bedingungen des Anbieters gelesen wird.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get assistantPrivateOnce =>
|
||||
'Das wird einmal je Lernset und Assistent gefragt. Zurücknehmen kannst du es über das Schloss beim Lernset.';
|
||||
|
||||
@override
|
||||
String get assistantPrivateTerms => 'Bedingungen des Anbieters';
|
||||
|
||||
@override
|
||||
String get assistantPrivateSend => 'Senden';
|
||||
|
||||
@override
|
||||
String assistantPrivateMark(String name) {
|
||||
return '$name darf dieses private Lernset bekommen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetPrivacyAssistants => 'Assistenten, an die es gehen darf';
|
||||
|
||||
@override
|
||||
String get studysetPrivacyTakeBack => 'Zurücknehmen';
|
||||
|
||||
@override
|
||||
String get settingsSearchEngine => 'Suchmaschine';
|
||||
|
||||
|
|
@ -7708,7 +7771,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetPrivateBody =>
|
||||
'Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen noch Claude zeigen. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
|
||||
'Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen, und ein Assistent bekommt nur, was du ihm zu senden erlaubst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
|
||||
|
||||
@override
|
||||
String studysetReadableTitle(String title) {
|
||||
|
|
@ -7840,7 +7903,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetPrivacyPrivateBody =>
|
||||
'Alles darin ist Ende-zu-Ende-verschlüsselt: Nur deine Geräte können es lesen. Die Instanz kann es weder veröffentlichen noch Claude zeigen. Nicht mehr privat, wird es entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann.';
|
||||
'Alles darin ist Ende-zu-Ende-verschlüsselt: Nur deine Geräte können es lesen. Die Instanz kann es nicht veröffentlichen, und ein Assistent bekommt nur, was du ihm zu senden erlaubst. Nicht mehr privat, wird es entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann.';
|
||||
|
||||
@override
|
||||
String studysetPrivacyReadableTitle(String title) {
|
||||
|
|
|
|||
|
|
@ -6806,6 +6806,69 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get assistantLookedUpNoNote => 'Looked for a note, found none';
|
||||
|
||||
@override
|
||||
String assistantPrivateTitle(int count, String name) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Send private studysets to $name?',
|
||||
one: 'Send a private studyset to $name?',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String assistantPrivateGranted(
|
||||
int count,
|
||||
String name,
|
||||
String instance,
|
||||
String host,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'They are end-to-end encrypted, so that not even $instance can read them. What $name looks up in them, or what you chose of them, passes through $instance, which keeps none of it, to $host, which reads it under the provider\'s terms.',
|
||||
one:
|
||||
'It is end-to-end encrypted, so that not even $instance can read it. What $name looks up in it, or what you chose of it, passes through $instance, which keeps none of it, to $host, which reads it under the provider\'s terms.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String assistantPrivateOwn(int count, String name, String host) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'They are end-to-end encrypted, so that not even the instance can read them. What $name looks up in them, or what you chose of them, goes straight to $host, which reads it under the provider\'s terms.',
|
||||
one:
|
||||
'It is end-to-end encrypted, so that not even the instance can read it. What $name looks up in it, or what you chose of it, goes straight to $host, which reads it under the provider\'s terms.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get assistantPrivateOnce =>
|
||||
'You\'re asked once for each studyset and assistant. Take it back from the lock beside the studyset.';
|
||||
|
||||
@override
|
||||
String get assistantPrivateTerms => 'The provider\'s terms';
|
||||
|
||||
@override
|
||||
String get assistantPrivateSend => 'Send';
|
||||
|
||||
@override
|
||||
String assistantPrivateMark(String name) {
|
||||
return '$name may be sent this private studyset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetPrivacyAssistants => 'Assistants it may go to';
|
||||
|
||||
@override
|
||||
String get studysetPrivacyTakeBack => 'Take back';
|
||||
|
||||
@override
|
||||
String get settingsSearchEngine => 'Search engine';
|
||||
|
||||
|
|
@ -7635,7 +7698,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetPrivateBody =>
|
||||
'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it, publish it or show it to Claude. Copies in the instance\'s backups stay readable until those expire.';
|
||||
'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it or publish it, and an assistant gets only what you agree to send it. Copies in the instance\'s backups stay readable until those expire.';
|
||||
|
||||
@override
|
||||
String studysetReadableTitle(String title) {
|
||||
|
|
@ -7767,7 +7830,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetPrivacyPrivateBody =>
|
||||
'Everything in it is end-to-end encrypted: only your devices can read it. The instance can\'t publish it, nor show it to Claude. Making it non-private decrypts it and syncs it again, so the instance can read it.';
|
||||
'Everything in it is end-to-end encrypted: only your devices can read it. The instance can\'t publish it, and an assistant gets only what you agree to send it. Making it non-private decrypts it and syncs it again, so the instance can read it.';
|
||||
|
||||
@override
|
||||
String studysetPrivacyReadableTitle(String title) {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,31 @@ class AssistantLookups({
|
|||
return byId[named]?.content.title;
|
||||
}
|
||||
|
||||
/// The private studysets whose content a question within [scope] sends
|
||||
/// to a model, for the warning of D9: those its parts came from and,
|
||||
/// with private studysets switched on, those within its reach; each
|
||||
/// named by the studyset its tree is private by (its top, or a private
|
||||
/// topic's own inside a readable tree), in the library's order. Never one
|
||||
/// locked here, whose content can't be read.
|
||||
Future<List<Studyset>> privateSent(AssistantScope scope) async {
|
||||
final all = notDeleted(await studysets());
|
||||
final byId = {for (final studyset in all) studyset.id: studyset};
|
||||
final from = {
|
||||
...scope.partsFrom,
|
||||
if (scope.includePrivate && scope.reach != AssistantReach.ticked)
|
||||
for (final studyset in await reach(scope)) studyset.id,
|
||||
};
|
||||
final deciding = {
|
||||
for (final id in from)
|
||||
if (byId[id] case final studyset? when !studyset.isLocked)
|
||||
?privateTopOf(id, all),
|
||||
};
|
||||
return [
|
||||
for (final studyset in all)
|
||||
if (deciding.contains(studyset.id)) studyset,
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [studyset] is within [scope]: not locked, not private unless
|
||||
/// switched on, and not inside one left out ([skipped], filled as the tree
|
||||
/// is walked from the top).
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import 'package:halkyon_llm/halkyon_llm.dart';
|
|||
|
||||
// A conversation's and its turns' content as their synced rows store it (see
|
||||
// core/content/payload), sealed with the person's key (ADR 0021, D13, D51).
|
||||
// Schema 1 of both: a conversation's title, assistant and scope; a turn's
|
||||
// messages and what they counted.
|
||||
// Schema 1 of both: a conversation's title, assistant and scope (where the
|
||||
// parts chosen came from among it); a turn's messages and what they
|
||||
// counted.
|
||||
|
||||
/// The schema this app writes a conversation in.
|
||||
const conversationSchemaVersion = 1;
|
||||
|
|
@ -31,6 +32,8 @@ ByteData encodeConversationPayload({
|
|||
'reach': scope.reach.name,
|
||||
'studyset': ?scope.studysetId,
|
||||
if (scope.includePrivate) 'private': true,
|
||||
if (scope.partsFrom.isNotEmpty)
|
||||
'partsFrom': scope.partsFrom.toList()..sort(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -67,6 +70,10 @@ AssistantConversation decodeConversationPayload(
|
|||
_ => null,
|
||||
},
|
||||
includePrivate: scope['private'] == true,
|
||||
partsFrom: {
|
||||
if (scope['partsFrom'] case final List<Object?> from)
|
||||
...from.whereType<String>(),
|
||||
},
|
||||
),
|
||||
startedAt: startedAt,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ final class const AssistantRef({
|
|||
/// Whether it is one of the person's own.
|
||||
final bool own = false,
|
||||
}) with Equatable {
|
||||
/// How settings name it: `DeviceSettings.askWith`, and the agreements to
|
||||
/// send it private studysets (`PersonSettings.privateAgreements`).
|
||||
String get key => own ? 'own:$id' : 'assistant:$id';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, own];
|
||||
}
|
||||
|
|
@ -42,9 +46,15 @@ final class const AssistantScope({
|
|||
/// Whether private studysets are within [reach] (D8): never unless the
|
||||
/// person switches it on.
|
||||
final bool includePrivate = false,
|
||||
|
||||
/// The studysets the parts chosen with the first question came from:
|
||||
/// the one asked on, and a card's own studyset where a card was asked
|
||||
/// about; none while none were chosen. A follow-up sends them again, so
|
||||
/// the warning of D9 holds for them as for the question.
|
||||
final Set<String> partsFrom = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [reach, studysetId, includePrivate];
|
||||
List<Object?> get props => [reach, studysetId, includePrivate, partsFrom];
|
||||
}
|
||||
|
||||
/// A conversation with an assistant, kept sealed (ADR 0021, D13, D51): its
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ sealed class const AssistantTopic() {
|
|||
/// The studyset asked on, which an assistant's scope may reach from
|
||||
/// (ADR 0021, D7); null on Home and in the Library.
|
||||
String? get studysetId;
|
||||
|
||||
/// The studysets the parts [chosen] come from, for the warning before a
|
||||
/// private one's go to a model (D9).
|
||||
Set<String> partsFrom(Iterable<AssistantPart> chosen);
|
||||
}
|
||||
|
||||
/// The person's learning as a whole: asked on Home or in the Library.
|
||||
|
|
@ -23,6 +27,9 @@ final class const AssistantTopicLearning() extends AssistantTopic {
|
|||
|
||||
@override
|
||||
String? get studysetId => null;
|
||||
|
||||
@override
|
||||
Set<String> partsFrom(Iterable<AssistantPart> chosen) => const {};
|
||||
}
|
||||
|
||||
/// One studyset, asked on its screen: its name, and its description where
|
||||
|
|
@ -40,6 +47,11 @@ final class const AssistantTopicStudyset({
|
|||
@override
|
||||
String get studysetId => id;
|
||||
|
||||
@override
|
||||
Set<String> partsFrom(Iterable<AssistantPart> chosen) => {
|
||||
if (chosen.isNotEmpty) id,
|
||||
};
|
||||
|
||||
@override
|
||||
List<AssistantPart> get parts => [
|
||||
AssistantPart(kind: AssistantPartKind.studyset, text: title),
|
||||
|
|
@ -57,9 +69,22 @@ final class const AssistantTopicCard({
|
|||
/// Its title.
|
||||
required final String studysetTitle,
|
||||
|
||||
/// The studyset the card is in: the one studied, or one inside it.
|
||||
required final String cardStudysetId,
|
||||
|
||||
/// The card.
|
||||
required final CardContent card,
|
||||
}) extends AssistantTopic {
|
||||
/// The studied studyset's name comes from it, the rest from the card's.
|
||||
@override
|
||||
Set<String> partsFrom(Iterable<AssistantPart> chosen) => {
|
||||
for (final part in chosen)
|
||||
if (part.kind == AssistantPartKind.studyset)
|
||||
studysetId
|
||||
else
|
||||
cardStudysetId,
|
||||
};
|
||||
|
||||
@override
|
||||
List<AssistantPart> get parts => [
|
||||
if (cardFrontText(card) case final front when front.isNotEmpty)
|
||||
|
|
|
|||
|
|
@ -19,13 +19,9 @@ final class const WebAnswerer() extends Answerer {
|
|||
final class const AssistantAnswerer(final AssistantChoice choice)
|
||||
extends Answerer {
|
||||
@override
|
||||
String get key => askKeyOf(assistantRefOf(choice));
|
||||
String get key => assistantRefOf(choice).key;
|
||||
}
|
||||
|
||||
/// How `DeviceSettings.askWith` names the assistant [ref].
|
||||
String askKeyOf(AssistantRef ref) =>
|
||||
ref.own ? 'own:${ref.id}' : 'assistant:${ref.id}';
|
||||
|
||||
/// The assistants [state] offers to ask: those the operator granted, then
|
||||
/// the person's own, only where the operator allows them (D6).
|
||||
List<AssistantChoice> assistantChoicesOf(AssistantsState state) => [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
|
|
@ -14,6 +15,8 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dar
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_history.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/private_sends.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -204,7 +207,18 @@ class _AssistantFieldState() extends State<AssistantField> {
|
|||
reach: _reach,
|
||||
studysetId: widget.topic.studysetId,
|
||||
includePrivate: _reach != AssistantReach.ticked && _includePrivate,
|
||||
partsFrom: widget.topic.partsFrom(chosen),
|
||||
);
|
||||
// A private studyset's content goes only once agreed to (D9, D17);
|
||||
// otherwise the question stays in the field.
|
||||
if (!await confirmPrivateSends(
|
||||
context,
|
||||
choice: choice,
|
||||
scope: scope,
|
||||
) ||
|
||||
!mounted) {
|
||||
return;
|
||||
}
|
||||
settings.add(DeviceSettingsEventAskedWith(answerer.key));
|
||||
_clear();
|
||||
unawaited(
|
||||
|
|
@ -656,12 +670,13 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
final theme = Theme.of(context);
|
||||
final size = widget.small ? 44.0 : 56.0;
|
||||
// Folded, it shows who answers next.
|
||||
final asksAssistant = currentAnswerer(
|
||||
final next = currentAnswerer(
|
||||
answerersOf(context),
|
||||
askWith: context.select<DeviceSettingsBloc, String?>(
|
||||
(bloc) => bloc.state.askWith,
|
||||
),
|
||||
) is AssistantAnswerer;
|
||||
);
|
||||
final asksAssistant = next is AssistantAnswerer;
|
||||
final button = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width =
|
||||
|
|
@ -677,32 +692,50 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
onAsked: _fold,
|
||||
),
|
||||
)
|
||||
: Tooltip(
|
||||
message: asksAssistant
|
||||
? l10n.assistantSendAsk
|
||||
: l10n.assistantOpen,
|
||||
child: DecoratedBox(
|
||||
decoration: const ShapeDecoration(
|
||||
shape: CircleBorder(),
|
||||
gradient: _ring,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_ringWidth),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
shape: const CircleBorder(),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _expanded = true),
|
||||
child: SizedBox.square(
|
||||
dimension: size - 2 * _ringWidth,
|
||||
child: Icon(
|
||||
asksAssistant
|
||||
? LucideIcons.sparkles
|
||||
: LucideIcons.search,
|
||||
color: theme.colorScheme.primary,
|
||||
size: widget.small ? 20 : 24,
|
||||
: _AgreementMark(
|
||||
topic: widget.topic,
|
||||
answerer: next,
|
||||
builder: (context, agreed) => Tooltip(
|
||||
message: [
|
||||
if (asksAssistant)
|
||||
l10n.assistantSendAsk
|
||||
else
|
||||
l10n.assistantOpen,
|
||||
if (agreed case AssistantAnswerer(:final choice))
|
||||
l10n.assistantPrivateMark(choice.name),
|
||||
].join('\n'),
|
||||
child: Badge(
|
||||
isLabelVisible: agreed != null,
|
||||
backgroundColor: theme.colorScheme.tertiary,
|
||||
label: Icon(
|
||||
LucideIcons.lockOpen,
|
||||
size: 10,
|
||||
color: theme.colorScheme.onTertiary,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: const ShapeDecoration(
|
||||
shape: CircleBorder(),
|
||||
gradient: _ring,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_ringWidth),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
shape: const CircleBorder(),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _expanded = true),
|
||||
child: SizedBox.square(
|
||||
dimension: size - 2 * _ringWidth,
|
||||
child: Icon(
|
||||
asksAssistant
|
||||
? LucideIcons.sparkles
|
||||
: LucideIcons.search,
|
||||
color: theme.colorScheme.primary,
|
||||
size: widget.small ? 20 : 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -734,3 +767,70 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds with [answerer] where it is an assistant the person agreed may
|
||||
/// be sent the private studysets [topic]'s parts come from (ADR 0021, D17),
|
||||
/// for the button's mark while that holds; with null otherwise.
|
||||
class const _AgreementMark({
|
||||
required final AssistantTopic topic,
|
||||
required final Answerer? answerer,
|
||||
required final Widget Function(BuildContext context, Answerer? agreed)
|
||||
builder,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_AgreementMark> createState() => _AgreementMarkState();
|
||||
}
|
||||
|
||||
class _AgreementMarkState() extends State<_AgreementMark> {
|
||||
late final _settings = PersonDependencies.of(context).settings.watch();
|
||||
|
||||
/// The studysets the topic's parts come from.
|
||||
late Set<String> _from = _fromOf(widget.topic);
|
||||
|
||||
/// The private studysets among them, by those they are private by.
|
||||
late Future<List<String>> _private = _privateOf(_from);
|
||||
|
||||
static Set<String> _fromOf(AssistantTopic topic) =>
|
||||
topic.partsFrom(topic.parts);
|
||||
|
||||
Future<List<String>> _privateOf(Set<String> from) async => from.isEmpty
|
||||
? const []
|
||||
: [
|
||||
for (final studyset in await PersonDependencies.of(
|
||||
context,
|
||||
).assistantLookups.privateSent(AssistantScope(partsFrom: from)))
|
||||
studyset.id,
|
||||
];
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_AgreementMark oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final from = _fromOf(widget.topic);
|
||||
if (!setEquals(from, _from)) {
|
||||
_from = from;
|
||||
_private = _privateOf(from);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final answerer = widget.answerer;
|
||||
if (answerer is! AssistantAnswerer || _from.isEmpty) {
|
||||
return widget.builder(context, null);
|
||||
}
|
||||
return FutureBuilder<List<String>>(
|
||||
future: _private,
|
||||
builder: (context, private) => StreamBuilder<PersonSettings>(
|
||||
stream: _settings,
|
||||
builder: (context, settings) {
|
||||
final agreed =
|
||||
settings.data != null &&
|
||||
(private.data ?? const []).any(
|
||||
(id) => settings.data!.agreedToSend(id, answerer.key),
|
||||
);
|
||||
return widget.builder(context, agreed ? answerer : null);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conv
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/private_sends.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -59,11 +60,7 @@ Future<void> showConversation(
|
|||
// Closing the sheet closes the cubit, which stops an answer on its
|
||||
// way.
|
||||
create: (_) => cubit,
|
||||
child: _ConversationSheet(
|
||||
name: name,
|
||||
scope: scope,
|
||||
canAsk: choice != null,
|
||||
),
|
||||
child: _ConversationSheet(name: name, scope: scope, choice: choice),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -71,7 +68,7 @@ Future<void> showConversation(
|
|||
class const _ConversationSheet({
|
||||
required final String name,
|
||||
required final AssistantScope scope,
|
||||
required final bool canAsk,
|
||||
required final AssistantChoice? choice,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
|
|
@ -87,7 +84,7 @@ class const _ConversationSheet({
|
|||
controller: controller,
|
||||
name: name,
|
||||
scope: scope,
|
||||
canAsk: canAsk,
|
||||
choice: choice,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -97,7 +94,10 @@ class const _ConversationView({
|
|||
required final ScrollController controller,
|
||||
required final String name,
|
||||
required final AssistantScope scope,
|
||||
required final bool canAsk,
|
||||
|
||||
/// Who answers; null for an assistant no longer offered, which can't go
|
||||
/// on.
|
||||
required final AssistantChoice? choice,
|
||||
}) extends StatelessWidget {
|
||||
/// How close to its end the conversation counts as followed: closer, it
|
||||
/// follows the answer as it grows.
|
||||
|
|
@ -155,7 +155,11 @@ class const _ConversationView({
|
|||
streaming: asking.text,
|
||||
),
|
||||
if (state.unanswered case final unanswered?)
|
||||
_Unanswered(unanswered: unanswered),
|
||||
_Unanswered(
|
||||
unanswered: unanswered,
|
||||
choice: choice,
|
||||
scope: scope,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -163,8 +167,12 @@ class const _ConversationView({
|
|||
),
|
||||
),
|
||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||
if (canAsk)
|
||||
_FollowUp(asking: state.asking != null || state.reading)
|
||||
if (choice case final choice?)
|
||||
_FollowUp(
|
||||
asking: state.asking != null || state.reading,
|
||||
choice: choice,
|
||||
scope: scope,
|
||||
)
|
||||
else
|
||||
_Note(icon: LucideIcons.circleSlash, text: l10n.assistantGoneNote),
|
||||
],
|
||||
|
|
@ -443,9 +451,22 @@ class _AnswerState() extends State<_Answer> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A question that got no answer: why, and Try again.
|
||||
class const _Unanswered({required final Unanswered unanswered})
|
||||
extends StatelessWidget {
|
||||
/// A question that got no answer: why, and Try again, which asks
|
||||
/// [choice] once more within [scope].
|
||||
class const _Unanswered({
|
||||
required final Unanswered unanswered,
|
||||
required final AssistantChoice? choice,
|
||||
required final AssistantScope scope,
|
||||
}) extends StatelessWidget {
|
||||
Future<void> _retry(BuildContext context, AssistantChoice choice) async {
|
||||
final cubit = context.read<ConversationCubit>();
|
||||
// Taken back since, a private studyset's agreement is asked again
|
||||
// (D17).
|
||||
if (await confirmPrivateSends(context, choice: choice, scope: scope)) {
|
||||
await cubit.retry();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -468,24 +489,28 @@ class const _Unanswered({required final Unanswered unanswered})
|
|||
: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.rotateCcw),
|
||||
label: Text(l10n.commonTryAgain),
|
||||
onPressed: () =>
|
||||
unawaited(context.read<ConversationCubit>().retry()),
|
||||
if (choice case final choice?)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.rotateCcw),
|
||||
label: Text(l10n.commonTryAgain),
|
||||
onPressed: () => unawaited(_retry(context, choice)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The field for a follow-up question, with Ask, or Stop while an answer
|
||||
/// is on its way.
|
||||
class const _FollowUp({required final bool asking}) extends StatefulWidget {
|
||||
/// The field for a follow-up question to [choice] within [scope], with
|
||||
/// Ask, or Stop while an answer is on its way.
|
||||
class const _FollowUp({
|
||||
required final bool asking,
|
||||
required final AssistantChoice choice,
|
||||
required final AssistantScope scope,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_FollowUp> createState() => _FollowUpState();
|
||||
}
|
||||
|
|
@ -499,13 +524,24 @@ class _FollowUpState() extends State<_FollowUp> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _ask() {
|
||||
Future<void> _ask() async {
|
||||
final question = _question.text;
|
||||
if (question.trim().isEmpty || widget.asking) {
|
||||
return;
|
||||
}
|
||||
final cubit = context.read<ConversationCubit>();
|
||||
// A follow-up sends the conversation again, a private studyset's
|
||||
// content with it, so it goes only while agreed to (D9, D17).
|
||||
if (!await confirmPrivateSends(
|
||||
context,
|
||||
choice: widget.choice,
|
||||
scope: widget.scope,
|
||||
) ||
|
||||
!mounted) {
|
||||
return;
|
||||
}
|
||||
_question.clear();
|
||||
unawaited(context.read<ConversationCubit>().ask(question, const []));
|
||||
unawaited(cubit.ask(question, const []));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -526,7 +562,7 @@ class _FollowUpState() extends State<_FollowUp> {
|
|||
maxLines: 5,
|
||||
textInputAction: TextInputAction.send,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onSubmitted: (_) => _ask(),
|
||||
onSubmitted: (_) => unawaited(_ask()),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.assistantFollowUp,
|
||||
border: InputBorder.none,
|
||||
|
|
@ -550,7 +586,9 @@ class _FollowUpState() extends State<_FollowUp> {
|
|||
builder: (context, _) => IconButton.filled(
|
||||
tooltip: l10n.assistantSendAsk,
|
||||
icon: const Icon(LucideIcons.arrowUp),
|
||||
onPressed: _question.text.trim().isEmpty ? null : _ask,
|
||||
onPressed: _question.text.trim().isEmpty
|
||||
? null
|
||||
: () => unawaited(_ask()),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Whether [choice] may be sent what a question within [scope] sends of
|
||||
/// private studysets (ADR 0021, D9, D17): at once where nothing private
|
||||
/// goes, where it runs on this machine, or where the person agreed for each
|
||||
/// of them already; else once they agree to the warning, which names the
|
||||
/// provider's host and links its terms, and is kept in their settings, so
|
||||
/// it is asked once per studyset and assistant. False where they don't
|
||||
/// agree.
|
||||
Future<bool> confirmPrivateSends(
|
||||
BuildContext context, {
|
||||
required AssistantChoice choice,
|
||||
required AssistantScope scope,
|
||||
}) async {
|
||||
if (choice case PersonalAssistant(:final assistant)
|
||||
when _onThisMachine(assistant)) {
|
||||
return true;
|
||||
}
|
||||
final person = PersonDependencies.of(context);
|
||||
final instance = AppScope.of(context).serverName;
|
||||
final key = assistantRefOf(choice).key;
|
||||
final sent = await person.assistantLookups.privateSent(scope);
|
||||
if (sent.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final settings = await person.settings.read();
|
||||
final asked = [
|
||||
for (final studyset in sent)
|
||||
if (!settings.agreedToSend(studyset.id, key)) studyset,
|
||||
];
|
||||
if (asked.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
return false;
|
||||
}
|
||||
final agreed =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _PrivateSendDialog(
|
||||
choice: choice,
|
||||
instance: instance,
|
||||
studysets: asked,
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
if (!agreed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await person.settings.update(
|
||||
(settings) => settings.withPrivateAgreements([
|
||||
for (final studyset in asked) studyset.id,
|
||||
], key),
|
||||
);
|
||||
} on OwnAssistantRefused {
|
||||
// Settings this device can't seal keep nothing new; this question goes
|
||||
// as agreed, and the next asks again.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether [assistant] runs on this machine; one whose address won't do
|
||||
/// fails before anything is sent, so it counts as elsewhere.
|
||||
bool _onThisMachine(OwnAssistant assistant) {
|
||||
try {
|
||||
return assistant.onThisMachine;
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning of D9: which private [studysets] would go to [choice],
|
||||
/// where to, under whose terms, and that it is asked once.
|
||||
class const _PrivateSendDialog({
|
||||
required final AssistantChoice choice,
|
||||
required final String instance,
|
||||
required final List<Studyset> studysets,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final count = studysets.length;
|
||||
final host = choice.host;
|
||||
final terms = providerTermsOf(host);
|
||||
return AlertDialog(
|
||||
icon: const Icon(LucideIcons.shieldAlert),
|
||||
title: Text(l10n.assistantPrivateTitle(count, choice.name)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final studyset in studysets)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.lock,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
studyset.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(switch (choice) {
|
||||
GrantedAssistant() => l10n.assistantPrivateGranted(
|
||||
count,
|
||||
choice.name,
|
||||
instance,
|
||||
host,
|
||||
),
|
||||
PersonalAssistant() => l10n.assistantPrivateOwn(
|
||||
count,
|
||||
choice.name,
|
||||
host,
|
||||
),
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.assistantPrivateOnce),
|
||||
if (terms != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
||||
icon: const Icon(LucideIcons.externalLink, size: 16),
|
||||
label: Text(l10n.assistantPrivateTerms),
|
||||
onPressed: () => unawaited(
|
||||
launchUrl(terms, mode: LaunchMode.externalApplication),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.assistantPrivateSend),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The assistants the person agreed may be sent [studyset]'s content, as
|
||||
/// its privacy dialog lists them (D17): those of the studyset it is private
|
||||
/// by, each with Take back. Nothing while there are none.
|
||||
class const PrivateAgreements({
|
||||
required final Studyset studyset,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<PrivateAgreements> createState() => _PrivateAgreementsState();
|
||||
}
|
||||
|
||||
class _PrivateAgreementsState() extends State<PrivateAgreements> {
|
||||
late final _person = PersonDependencies.of(context);
|
||||
late final _settings = _person.settings.watch();
|
||||
|
||||
/// The studyset [PrivateAgreements.studyset] is private by.
|
||||
late final Future<String?> _top = _person.studysets.all().then(
|
||||
(all) => privateTopOf(widget.studyset.id, all),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final assistants = context.watch<AssistantsCubit>().state;
|
||||
return FutureBuilder<String?>(
|
||||
future: _top,
|
||||
builder: (context, top) => StreamBuilder<PersonSettings>(
|
||||
stream: _settings,
|
||||
builder: (context, settings) {
|
||||
final id = top.data;
|
||||
final agreed = id == null
|
||||
? const <String>{}
|
||||
: settings.data?.privateAgreementsOf(id) ?? const <String>{};
|
||||
if (id == null || agreed.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final choices = assistantChoicesOf(assistants);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.studysetPrivacyAssistants,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
for (final key in agreed.toList()..sort())
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
key.startsWith('own:')
|
||||
? LucideIcons.keyRound
|
||||
: LucideIcons.sparkles,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
choices
|
||||
.where(
|
||||
(choice) =>
|
||||
assistantRefOf(choice).key == key,
|
||||
)
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
l10n.assistantGone,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(
|
||||
_person.settings.update(
|
||||
(settings) =>
|
||||
settings.withoutPrivateAgreement(id, key),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.studysetPrivacyTakeBack),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,11 +38,14 @@ import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
|
|||
// 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.
|
||||
// in a row sealed with the person's key. Schema 13 added the agreements to
|
||||
// send a private studyset's content to an assistant (`privateAgreements`,
|
||||
// ADR 0021, D17: by studyset id, the assistants as `askWith` names them),
|
||||
// left out while there are none. Older schemas read with what they lack as
|
||||
// it comes.
|
||||
|
||||
/// The schema this app writes.
|
||||
const settingsSchemaVersion = 12;
|
||||
const settingsSchemaVersion = 13;
|
||||
|
||||
/// Encodes [settings] into their payload.
|
||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||
|
|
@ -120,6 +123,12 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
|||
if (own.images) 'images': true,
|
||||
},
|
||||
],
|
||||
if (settings.privateAgreements.isNotEmpty)
|
||||
'privateAgreements': {
|
||||
for (final MapEntry(key: studysetId, value: agreed)
|
||||
in settings.privateAgreements.entries)
|
||||
studysetId: agreed.toList()..sort(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -161,6 +170,7 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
|||
if (value is String) key: value,
|
||||
},
|
||||
ownAssistants: _ownAssistants(json['ownAssistants']),
|
||||
privateAgreements: _privateAgreements(json['privateAgreements']),
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a settings payload.');
|
||||
|
|
@ -193,6 +203,17 @@ List<OwnAssistant> _ownAssistants(Object? json) => [
|
|||
),
|
||||
];
|
||||
|
||||
/// The agreements to send private studysets in [json]; an assistant that
|
||||
/// isn't named by text is skipped, and a studyset left with none.
|
||||
Map<String, Set<String>> _privateAgreements(Object? json) => {
|
||||
if (json is Map<String, Object?>)
|
||||
for (final MapEntry(key: studysetId, value: agreed) in json.entries)
|
||||
if (agreed is List<Object?>)
|
||||
if (agreed.whereType<String>().toSet() case final named
|
||||
when named.isNotEmpty)
|
||||
studysetId: named,
|
||||
};
|
||||
|
||||
List<TagRef> _tagRefs(Object? json) => switch (json) {
|
||||
final List<Object?> tags => [
|
||||
for (final tag in tags)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ final class const PersonSettings({
|
|||
/// (ADR 0021, point 10): kept only where the settings are sealed with the
|
||||
/// person's key (`SettingsRepository.update`).
|
||||
final List<OwnAssistant> ownAssistants = const [],
|
||||
|
||||
/// The assistants the person agreed may be sent a private studyset's
|
||||
/// content (ADR 0021, D9, D17), by the id of the private studyset its
|
||||
/// tree is private by, each as `DeviceSettings.askWith` names it
|
||||
/// (`assistant:<id>`, `own:<id>`): asked once per studyset and
|
||||
/// assistant, and taken back in the studyset's privacy dialog.
|
||||
final Map<String, Set<String>> privateAgreements = const {},
|
||||
}) with Equatable {
|
||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||
/// cards.
|
||||
|
|
@ -137,6 +144,48 @@ final class const PersonSettings({
|
|||
],
|
||||
);
|
||||
|
||||
/// The assistants agreed to be sent the private studyset [studysetId]'s
|
||||
/// content.
|
||||
Set<String> privateAgreementsOf(String studysetId) =>
|
||||
privateAgreements[studysetId] ?? const {};
|
||||
|
||||
/// Whether the person agreed that [assistantKey] may be sent the private
|
||||
/// studyset [studysetId]'s content.
|
||||
bool agreedToSend(String studysetId, String assistantKey) =>
|
||||
privateAgreementsOf(studysetId).contains(assistantKey);
|
||||
|
||||
/// These settings with [assistantKey] agreed to be sent the content of
|
||||
/// each private studyset of [studysetIds].
|
||||
PersonSettings withPrivateAgreements(
|
||||
Iterable<String> studysetIds,
|
||||
String assistantKey,
|
||||
) {
|
||||
final agreed = studysetIds.toSet();
|
||||
return copyWith(
|
||||
privateAgreements: {
|
||||
for (final MapEntry(:key, :value) in privateAgreements.entries)
|
||||
if (!agreed.contains(key)) key: value,
|
||||
for (final id in agreed) id: {...privateAgreementsOf(id), assistantKey},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// These settings with the agreement that [assistantKey] may be sent the
|
||||
/// private studyset [studysetId]'s content taken back.
|
||||
PersonSettings withoutPrivateAgreement(
|
||||
String studysetId,
|
||||
String assistantKey,
|
||||
) {
|
||||
final left = {...privateAgreementsOf(studysetId)}..remove(assistantKey);
|
||||
return copyWith(
|
||||
privateAgreements: {
|
||||
for (final MapEntry(:key, :value) in privateAgreements.entries)
|
||||
if (key != studysetId) key: value,
|
||||
if (left.isNotEmpty) studysetId: left,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// These settings with [study] as how the person studies.
|
||||
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
|
||||
|
||||
|
|
@ -154,6 +203,7 @@ final class const PersonSettings({
|
|||
hideJellyfinTags: hideJellyfinTags,
|
||||
addDestinations: addDestinations,
|
||||
ownAssistants: ownAssistants,
|
||||
privateAgreements: privateAgreements,
|
||||
);
|
||||
|
||||
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
||||
|
|
@ -174,6 +224,7 @@ final class const PersonSettings({
|
|||
bool? hideJellyfinTags,
|
||||
Map<String, String>? addDestinations,
|
||||
List<OwnAssistant>? ownAssistants,
|
||||
Map<String, Set<String>>? privateAgreements,
|
||||
}) => PersonSettings(
|
||||
studyTags: studyTags ?? this.studyTags,
|
||||
study: study ?? this.study,
|
||||
|
|
@ -187,6 +238,7 @@ final class const PersonSettings({
|
|||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||
addDestinations: addDestinations ?? this.addDestinations,
|
||||
ownAssistants: ownAssistants ?? this.ownAssistants,
|
||||
privateAgreements: privateAgreements ?? this.privateAgreements,
|
||||
);
|
||||
|
||||
/// These settings with what only [other] has added: where both have a
|
||||
|
|
@ -215,6 +267,17 @@ final class const PersonSettings({
|
|||
for (final each in other.ownAssistants)
|
||||
if (!ownAssistants.any((own) => own.id == each.id)) each,
|
||||
],
|
||||
// Only where both hold them, as Count me: an instance handing back an
|
||||
// older row, or a row another device wrote before a Take back, could
|
||||
// otherwise send a private studyset again; the person is asked again
|
||||
// at worst.
|
||||
privateAgreements: {
|
||||
for (final MapEntry(key: studysetId, value: agreed)
|
||||
in privateAgreements.entries)
|
||||
if (agreed.intersection(other.privateAgreementsOf(studysetId))
|
||||
case final both when both.isNotEmpty)
|
||||
studysetId: both,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -231,5 +294,6 @@ final class const PersonSettings({
|
|||
hideJellyfinTags,
|
||||
addDestinations,
|
||||
ownAssistants,
|
||||
privateAgreements,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -688,6 +688,7 @@ class const _Showing(
|
|||
topic: AssistantTopicCard(
|
||||
studysetId: studysetId,
|
||||
studysetTitle: title,
|
||||
cardStudysetId: state.studysetId,
|
||||
card: card.content,
|
||||
),
|
||||
small: true,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,25 @@ List<Studyset> ancestorsOf(String id, List<Studyset> all) {
|
|||
return ancestors;
|
||||
}
|
||||
|
||||
/// The studyset the studyset [id] is private by (ADR 0019): the farthest
|
||||
/// of itself and the studysets it sits in that are private, with none
|
||||
/// between them that isn't, which is its tree's top or a private topic
|
||||
/// inside a readable tree; null for one that isn't private.
|
||||
String? privateTopOf(String id, List<Studyset> all) {
|
||||
final studyset = all.where((each) => each.id == id).firstOrNull;
|
||||
if (studyset == null || !studyset.isPrivate) {
|
||||
return null;
|
||||
}
|
||||
var top = studyset;
|
||||
for (final ancestor in ancestorsOf(id, all).reversed) {
|
||||
if (!ancestor.isPrivate) {
|
||||
break;
|
||||
}
|
||||
top = ancestor;
|
||||
}
|
||||
return top.id;
|
||||
}
|
||||
|
||||
int _bySiblingOrder(Studyset a, Studyset b) =>
|
||||
switch (a.position.compareTo(b.position)) {
|
||||
0 => a.id.compareTo(b.id),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/private_sends.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -187,16 +188,27 @@ Future<void> showStudysetPrivacy(
|
|||
? l10n.studysetPrivacyPrivateTitle(title)
|
||||
: l10n.studysetPrivacyReadableTitle(title),
|
||||
),
|
||||
content: Text(
|
||||
[
|
||||
if (studyset.isPrivate)
|
||||
l10n.studysetPrivacyPrivateBody
|
||||
else
|
||||
l10n.studysetPrivacyReadableBody,
|
||||
if (studyset.isPrivate && studyset.isPartlyReadable)
|
||||
l10n.studysetPrivacyPartlyBody,
|
||||
if (!topLevel) l10n.studysetPrivacyTopic,
|
||||
].join('\n\n'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
[
|
||||
if (studyset.isPrivate)
|
||||
l10n.studysetPrivacyPrivateBody
|
||||
else
|
||||
l10n.studysetPrivacyReadableBody,
|
||||
if (studyset.isPrivate && studyset.isPartlyReadable)
|
||||
l10n.studysetPrivacyPartlyBody,
|
||||
if (!topLevel) l10n.studysetPrivacyTopic,
|
||||
].join('\n\n'),
|
||||
),
|
||||
// The assistants it may go to, taken back here (ADR 0021,
|
||||
// D17).
|
||||
if (studyset.isPrivate) PrivateAgreements(studyset: studyset),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ void main() {
|
|||
final parts = AssistantTopicCard(
|
||||
studysetId: 'japanese',
|
||||
studysetTitle: 'Japanese',
|
||||
cardStudysetId: 'japanese',
|
||||
card: card,
|
||||
).parts;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_re
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.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/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_conversation_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/memory_device_settings_store.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
|
|
@ -24,7 +27,7 @@ import '../../support/test_dependencies.dart';
|
|||
// Asking an assistant from the field (ADR 0021, D47 to D52): who answers
|
||||
// picked in the field, how far it may look widened only by the person, the
|
||||
// conversation in a sheet over the screen, kept conversations listed in the
|
||||
// field.
|
||||
// field; a private studyset's content sent only once agreed to.
|
||||
|
||||
final _agreed = DateTime.utc(2026, 10, 25);
|
||||
|
||||
|
|
@ -54,6 +57,8 @@ void main() {
|
|||
late FakeAssistantGateway assistants;
|
||||
late FakeConversationGateway kept;
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeSettingsGateway settings;
|
||||
late FakeLlmClient llm;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
late List<Uri> searched;
|
||||
|
||||
|
|
@ -63,7 +68,15 @@ void main() {
|
|||
kept = FakeConversationGateway();
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange('japanese', _studyset('Japanese'))
|
||||
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese');
|
||||
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese')
|
||||
..receiveRemoteChange(
|
||||
'diary',
|
||||
_studyset('Diary'),
|
||||
position: 'a1',
|
||||
isPrivate: true,
|
||||
);
|
||||
settings = FakeSettingsGateway();
|
||||
llm = FakeLlmClient();
|
||||
searched = [];
|
||||
});
|
||||
|
||||
|
|
@ -83,6 +96,8 @@ void main() {
|
|||
assistantGateway: assistants,
|
||||
conversationGateway: kept,
|
||||
studysetGateway: studysets,
|
||||
settingsGateway: settings,
|
||||
llm: llm,
|
||||
webSearch: WebSearch(
|
||||
open: (url) async {
|
||||
searched.add(url);
|
||||
|
|
@ -120,11 +135,18 @@ void main() {
|
|||
}
|
||||
|
||||
/// Unfolds the floating button, an assistant answering.
|
||||
Future<void> unfold(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Ask'));
|
||||
Future<void> unfold(WidgetTester tester, {String tooltip = 'Ask'}) async {
|
||||
await tester.tap(find.byTooltip(tooltip));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The settings the person's devices last wrote.
|
||||
PersonSettings lastSettings() =>
|
||||
decodeSettingsPayload(settings.writes.last.$2);
|
||||
|
||||
const warning = 'Send a private studyset to Claude?';
|
||||
const marked = 'Ask\nClaude may be sent this private studyset';
|
||||
|
||||
testWidgets("Home's field asks the assistant granted, and the answer comes "
|
||||
'in a sheet over Home, kept', (tester) async {
|
||||
await showApp(tester);
|
||||
|
|
@ -338,4 +360,211 @@ void main() {
|
|||
expect(find.text('Now it works.', findRichText: true), findsOneWidget);
|
||||
expect(find.text('Try again'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a private studyset's content goes to a remote assistant only "
|
||||
'once the person agreed, which is asked once per studyset and '
|
||||
'assistant, the button marked while it holds (D9, D17)', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('diary'));
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
|
||||
await ask(tester, 'What is this about?');
|
||||
|
||||
expect(find.text(warning), findsOneWidget);
|
||||
expect(find.textContaining('to api.anthropic.com'), findsOneWidget);
|
||||
expect(find.text("The provider's terms"), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(assistants.asked, isEmpty);
|
||||
expect(find.text('What is this about?'), findsOneWidget);
|
||||
|
||||
assistants.answers.add(_says('Your diary.'));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip('Ask'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Send'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(assistants.asked, hasLength(1));
|
||||
expect(find.text('Your diary.', findRichText: true), findsOneWidget);
|
||||
expect(lastSettings().agreedToSend('diary', 'assistant:claude'), isTrue);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip(marked), findsOneWidget);
|
||||
await unfold(tester, tooltip: marked);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
assistants.answers.add(_says('Still your diary.'));
|
||||
await ask(tester, 'And now?');
|
||||
|
||||
expect(find.text(warning), findsNothing);
|
||||
expect(assistants.asked, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('a wider reach with private studysets switched on warns of '
|
||||
'those within it, and sends nothing unless agreed (D8, D9)', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
Finder inField(String text) => find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.text(text),
|
||||
);
|
||||
await tester.tap(inField('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('My whole library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(inField('Library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Include private studysets'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(inField('Library, private ones too'), findsOneWidget);
|
||||
|
||||
await ask(tester, 'What do I study?');
|
||||
|
||||
expect(find.text(warning), findsOneWidget);
|
||||
expect(find.text('Diary'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(assistants.asked, isEmpty);
|
||||
expect(settings.writes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an own assistant on this machine is sent a private studyset '
|
||||
'without a warning; one elsewhere on the network is not (D9)', (
|
||||
tester,
|
||||
) async {
|
||||
assistants.offer = const AssistantsOffer(ownKeys: true);
|
||||
settings.receiveRemoteRow(
|
||||
'settings-own',
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings(
|
||||
ownAssistants: [
|
||||
OwnAssistant(
|
||||
id: 'local',
|
||||
name: 'Local',
|
||||
protocol: LlmProtocol.openai,
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
apiKey: '',
|
||||
),
|
||||
OwnAssistant(
|
||||
id: 'desk',
|
||||
name: 'Desk',
|
||||
protocol: LlmProtocol.openai,
|
||||
baseUrl: 'http://192.168.1.20:11434/v1',
|
||||
model: 'qwen3',
|
||||
apiKey: '',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.studyset('diary'));
|
||||
await unfold(tester);
|
||||
expect(find.text('Ask Local…'), findsOneWidget);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
llm.replyText('A diary.');
|
||||
|
||||
await ask(tester, 'What is this?');
|
||||
|
||||
expect(find.textContaining('Send a private studyset'), findsNothing);
|
||||
expect(llm.sent, hasLength(1));
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Desk'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
await ask(tester, 'And this?');
|
||||
|
||||
expect(find.text('Send a private studyset to Desk?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('goes straight to 192.168.1.20'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text("The provider's terms"), findsNothing);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(llm.sent, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets("the studyset's privacy lists the assistants it may go to, "
|
||||
'and takes one back (D17)', (tester) async {
|
||||
settings.receiveRemoteRow(
|
||||
'settings-agreed',
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings(
|
||||
privateAgreements: {
|
||||
'diary': {'assistant:claude', 'assistant:gone'},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
|
||||
await tester.tap(find.byTooltip('Private'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('“Diary” is private'), findsOneWidget);
|
||||
expect(find.text('Assistants it may go to'), findsOneWidget);
|
||||
expect(find.text('Claude'), findsOneWidget);
|
||||
expect(find.text('An assistant no longer offered'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Take back').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Claude'), findsNothing);
|
||||
expect(lastSettings().privateAgreementsOf('diary'), {'assistant:gone'});
|
||||
});
|
||||
|
||||
testWidgets('a follow-up sends the conversation again, so it asks again '
|
||||
'once the agreement was taken back (D17)', (tester) async {
|
||||
final conversations = ConversationRepository(kept);
|
||||
addTearDown(conversations.dispose);
|
||||
final id = await conversations.start(
|
||||
question: 'What is my diary about?',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(studysetId: 'diary', partsFrom: {'diary'}),
|
||||
);
|
||||
await conversations.keep(id, [
|
||||
LlmMessage.user(const [
|
||||
LlmText('What is my diary about?'),
|
||||
LlmText('\n\nStudyset:\nDiary'),
|
||||
]),
|
||||
LlmMessage.assistant(const [LlmText('Days.')]),
|
||||
], LlmUsage.none);
|
||||
await showApp(tester);
|
||||
await tester.tap(find.byTooltip('Conversations'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('What is my diary about?'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Ask more…'), 'Why?');
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip('Ask'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(warning), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(assistants.asked, isEmpty);
|
||||
expect(find.text('Why?'), findsOneWidget, reason: 'the question stays');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue