feat(learn): conversations with assistants, kept sealed (ADR 0021, step 3)
The first part of step 3, decided as D47 to D52 in ADR 0021: where a conversation shows (a sheet over the screen), where kept ones are found (the field), the scope's chip and that it starts with only what is ticked, how conversations are kept, and what a device that can't seal does. Two synced tables: assistant_conversation (its title, assistant and scope) and assistant_turn (one question and everything up to its answer, never changed once written, ADR 0001, rule 2), so two devices continuing a conversation offline lose nothing. SyncConversationGateway seals every row with the person's key, a turn bound to its conversation, and reads no row that isn't sealed: a device that can't seal keeps nothing (D52, ConversationNotKept). Deleting one blanks it and its turns first (ADR 0002, rule 2). A turn keeps every message a follow-up sends back (tool calls and results, the providers' own blocks), but no image, only that one was sent. The instance's privacy page says conversations are kept, encrypted, and what the instance sees of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
36899cdc3b
commit
b965e05fdb
2 changed files with 36798 additions and 4 deletions
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
||||
|
||||
/// A [ConversationGateway] in memory: conversation ids are
|
||||
/// `conversation-001`, …, turn ids `turn-001`, …, in the order of creation
|
||||
/// as UUID v7s are, each started or written a minute after the one before.
|
||||
class FakeConversationGateway() implements ConversationGateway {
|
||||
final _conversations = <String, ByteData>{};
|
||||
final _turns = <String, ({String conversationId, ByteData payload})>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _next = 1;
|
||||
|
||||
/// Whether what it writes is sealed with the person's key, as on a device
|
||||
/// holding the account's keys.
|
||||
bool sealing = true;
|
||||
|
||||
/// The ids of the conversations blanked, in order.
|
||||
final blanked = <String>[];
|
||||
|
||||
/// Every payload written, by the row's id.
|
||||
final written = <String, ByteData>{};
|
||||
|
||||
static final _start = DateTime.utc(2026, 10, 26, 9);
|
||||
|
||||
DateTime _timeOf(String id) =>
|
||||
_start.add(Duration(minutes: int.parse(id.split('-').last)));
|
||||
|
||||
String _id(String kind) => '$kind-${'${_next++}'.padLeft(3, '0')}';
|
||||
|
||||
/// Stores a conversation as if another device had written it and a sync
|
||||
/// had brought it here.
|
||||
void receiveConversation(String id, ByteData payload) {
|
||||
_conversations[id] = payload;
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ConversationRow>> conversations() async => [
|
||||
for (final MapEntry(key: id, value: payload) in _conversations.entries)
|
||||
if (payload.lengthInBytes > 0)
|
||||
(id: id, payload: payload, startedAt: _timeOf(id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<TurnRow>> turnsOf(String conversationId) async => [
|
||||
for (final MapEntry(key: id, value: turn) in _turns.entries)
|
||||
if (turn.conversationId == conversationId &&
|
||||
turn.payload.lengthInBytes > 0)
|
||||
(id: id, payload: turn.payload, writtenAt: _timeOf(id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<bool> seals() async => sealing;
|
||||
|
||||
@override
|
||||
Future<String> insertConversation(ByteData payload) async {
|
||||
_refuseUnsealed();
|
||||
final id = _id('conversation');
|
||||
_conversations[id] = payload;
|
||||
written[id] = payload;
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertTurn(String conversationId, ByteData payload) async {
|
||||
_refuseUnsealed();
|
||||
final id = _id('turn');
|
||||
_turns[id] = (conversationId: conversationId, payload: payload);
|
||||
written[id] = payload;
|
||||
return id;
|
||||
}
|
||||
|
||||
void _refuseUnsealed() {
|
||||
if (!sealing) {
|
||||
throw StateError('Nothing is written unsealed.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankConversation(String conversationId) async {
|
||||
blanked.add(conversationId);
|
||||
if (_conversations.containsKey(conversationId)) {
|
||||
_conversations[conversationId] = ByteData(0);
|
||||
}
|
||||
for (final MapEntry(key: id, value: turn) in _turns.entries.toList()) {
|
||||
if (turn.conversationId == conversationId) {
|
||||
_turns[id] = (conversationId: conversationId, payload: ByteData(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteConversation(String conversationId) async {
|
||||
_conversations.remove(conversationId);
|
||||
_turns.removeWhere((_, turn) => turn.conversationId == conversationId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
|
|
@ -79,6 +80,7 @@ import 'fake_asset_gateway.dart';
|
|||
import 'fake_asset_sealing.dart';
|
||||
import 'fake_asset_server_gateway.dart';
|
||||
import 'fake_card_gateway.dart';
|
||||
import 'fake_conversation_gateway.dart';
|
||||
import 'fake_device_dictionaries.dart';
|
||||
import 'fake_dictionary_gateway.dart';
|
||||
import 'fake_discussion_gateway.dart';
|
||||
|
|
@ -112,8 +114,8 @@ import 'memory_studyset_tab_store.dart';
|
|||
/// database; each test passes in only the fakes it looks at.
|
||||
///
|
||||
/// Every person gets [studysetGateway], [cardGateway], [noteGateway],
|
||||
/// [pathGateway], [answerGateway], [tagGateway], [settingsGateway] and the
|
||||
/// asset fakes (or fresh fakes of their own),
|
||||
/// [pathGateway], [answerGateway], [tagGateway], [settingsGateway],
|
||||
/// [conversationGateway] and the asset fakes (or fresh fakes of their own),
|
||||
/// unless [openPerson] replaces how a person's dependencies are opened.
|
||||
///
|
||||
/// The device starts with [deviceSettings], kept in memory unless a test
|
||||
|
|
@ -141,6 +143,7 @@ Dependencies testDependencies({
|
|||
YoutubeVideos? videos,
|
||||
PdfEngine? pdfEngine,
|
||||
SettingsGateway? settingsGateway,
|
||||
FakeConversationGateway? conversationGateway,
|
||||
AssetGateway? assetGateway,
|
||||
AssetServerGateway? assetServer,
|
||||
AssetFileCache? assetFiles,
|
||||
|
|
@ -288,6 +291,9 @@ Dependencies testDependencies({
|
|||
final settings = SettingsRepository(
|
||||
settingsGateway ?? FakeSettingsGateway(),
|
||||
);
|
||||
final conversations = ConversationRepository(
|
||||
conversationGateway ?? FakeConversationGateway(),
|
||||
);
|
||||
final reviews = ReviewRepository(
|
||||
reviewGateway ?? FakeReviewGateway(),
|
||||
);
|
||||
|
|
@ -338,6 +344,7 @@ Dependencies testDependencies({
|
|||
studysetProgress: studysetProgress,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
conversations: conversations,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
noteDeletions: noteDeletions,
|
||||
|
|
@ -402,6 +409,7 @@ Dependencies testDependencies({
|
|||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
await conversations.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue