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
35 changed files with 36798 additions and 4 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,218 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "assistant_conversation",
|
||||
"dartName": "AssistantConversation",
|
||||
"module": "halkyon_learn",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "assistant_conversation_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "assistant_conversation_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "assistant_turn",
|
||||
"dartName": "AssistantTurn",
|
||||
"module": "halkyon_learn",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"columnDefault": "random_v7",
|
||||
"dartType": "UuidValue?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "spaceId",
|
||||
"fieldName": "spaceId",
|
||||
"columnType": 6,
|
||||
"isNullable": true,
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "conversationId",
|
||||
"fieldName": "conversationId",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"dartType": "UuidValue"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "keyId",
|
||||
"fieldName": "keyId",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "payload",
|
||||
"fieldName": "payload",
|
||||
"columnType": 5,
|
||||
"isNullable": false,
|
||||
"dartType": "dart:typed_data:ByteData"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "nonce",
|
||||
"fieldName": "nonce",
|
||||
"columnType": 5,
|
||||
"isNullable": true,
|
||||
"dartType": "dart:typed_data:ByteData?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "assistant_turn_fk_0",
|
||||
"columns": [
|
||||
"spaceId"
|
||||
],
|
||||
"referenceTable": "offline_sync_spaces",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ForeignKeyDefinition",
|
||||
"constraintName": "assistant_turn_fk_1",
|
||||
"columns": [
|
||||
"conversationId"
|
||||
],
|
||||
"referenceTable": "assistant_conversation",
|
||||
"referenceTableSchema": "public",
|
||||
"referenceColumns": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": 3,
|
||||
"onDelete": 4,
|
||||
"deferrable": "initiallyDeferred"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "assistant_turn_space_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "spaceId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "assistant_turn_conversation_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "conversationId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": false,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ part '20261023181810112-oidc-module/migration.dart';
|
|||
part '20261023192913178-simplelogin-keys/migration.dart';
|
||||
part '20261025135744595-waiting-accounts/migration.dart';
|
||||
part '20261025165102861-assistants/migration.dart';
|
||||
part '20261025184004860-assistant-conversations/migration.dart';
|
||||
|
||||
/// Migration registry for the client-side database.
|
||||
class MigrationRegistry {
|
||||
|
|
@ -66,6 +67,7 @@ class MigrationRegistry {
|
|||
_Migration20261023192913178_simplelogin_keys(),
|
||||
_Migration20261025135744595_waiting_accounts(),
|
||||
_Migration20261025165102861_assistants(),
|
||||
_Migration20261025184004860_assistant_conversations(),
|
||||
];
|
||||
|
||||
/// List of all client-side database migration versions.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,679 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:typed_data' as _idt;
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
|
||||
/// A conversation with an assistant (ADR 0021, D13, D51): which assistant, what
|
||||
/// it is about and its title, in the payload, sealed with the person's key; its
|
||||
/// turns are AssistantTurn rows. Only ever written sealed: a device that can't
|
||||
/// seal keeps no conversation (D52), and one that reaches it readable is never
|
||||
/// shown. Synced; its space owns it.
|
||||
abstract class AssistantConversation
|
||||
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
|
||||
AssistantConversation._({
|
||||
this.id,
|
||||
this.spaceId,
|
||||
this.keyId,
|
||||
required this.payload,
|
||||
this.nonce,
|
||||
});
|
||||
|
||||
factory AssistantConversation({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) = _AssistantConversationImpl;
|
||||
|
||||
factory AssistantConversation.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return AssistantConversation(
|
||||
id: jsonSerialization['id'] == null
|
||||
? null
|
||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
|
||||
spaceId: jsonSerialization['spaceId'] as int?,
|
||||
keyId: jsonSerialization['keyId'] as String?,
|
||||
payload: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['payload'],
|
||||
),
|
||||
nonce: jsonSerialization['nonce'] == null
|
||||
? null
|
||||
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
|
||||
);
|
||||
}
|
||||
|
||||
static final t = AssistantConversationTable();
|
||||
|
||||
static const db = AssistantConversationRepository._();
|
||||
|
||||
@override
|
||||
_isc.UuidValue? id;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
int? spaceId;
|
||||
|
||||
/// The key it is sealed with: the person's personal key.
|
||||
String? keyId;
|
||||
|
||||
/// The content as UTF-8 JSON: {"schemaVersion", "title", "assistant",
|
||||
/// "scope"}.
|
||||
_idt.ByteData payload;
|
||||
|
||||
/// The encryption nonce.
|
||||
_idt.ByteData? nonce;
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => t;
|
||||
|
||||
/// Returns a shallow copy of this [AssistantConversation]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
AssistantConversation copyWith({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
String? keyId,
|
||||
_idt.ByteData? payload,
|
||||
_idt.ByteData? nonce,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'AssistantConversation',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'AssistantConversation',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static AssistantConversationInclude include() {
|
||||
return AssistantConversationInclude._();
|
||||
}
|
||||
|
||||
static AssistantConversationIncludeList includeList({
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
AssistantConversationInclude? include,
|
||||
}) {
|
||||
return AssistantConversationIncludeList._(
|
||||
where: where,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
include: include,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _AssistantConversationImpl extends AssistantConversation {
|
||||
_AssistantConversationImpl({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) : super._(
|
||||
id: id,
|
||||
spaceId: spaceId,
|
||||
keyId: keyId,
|
||||
payload: payload,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [AssistantConversation]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
AssistantConversation copyWith({
|
||||
Object? id = _Undefined,
|
||||
Object? spaceId = _Undefined,
|
||||
Object? keyId = _Undefined,
|
||||
_idt.ByteData? payload,
|
||||
Object? nonce = _Undefined,
|
||||
}) {
|
||||
return AssistantConversation(
|
||||
id: id is _isc.UuidValue? ? id : this.id,
|
||||
spaceId: spaceId is int? ? spaceId : this.spaceId,
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
payload: payload ?? this.payload.clone(),
|
||||
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantConversationUpdateTable
|
||||
extends _isd.UpdateTable<AssistantConversationTable> {
|
||||
AssistantConversationUpdateTable(super.table);
|
||||
|
||||
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
|
||||
table.spaceId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
|
||||
table.keyId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
|
||||
_isd.ColumnValue(
|
||||
table.payload,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
|
||||
_isd.ColumnValue(
|
||||
table.nonce,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
class AssistantConversationTable extends _isd.Table<_isc.UuidValue?> {
|
||||
AssistantConversationTable({super.tableRelation})
|
||||
: super(tableName: 'assistant_conversation') {
|
||||
updateTable = AssistantConversationUpdateTable(this);
|
||||
spaceId = _isd.ColumnInt(
|
||||
'spaceId',
|
||||
this,
|
||||
);
|
||||
keyId = _isd.ColumnString(
|
||||
'keyId',
|
||||
this,
|
||||
);
|
||||
payload = _isd.ColumnByteData(
|
||||
'payload',
|
||||
this,
|
||||
);
|
||||
nonce = _isd.ColumnByteData(
|
||||
'nonce',
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
late final AssistantConversationUpdateTable updateTable;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
late final _isd.ColumnInt spaceId;
|
||||
|
||||
/// The key it is sealed with: the person's personal key.
|
||||
late final _isd.ColumnString keyId;
|
||||
|
||||
/// The content as UTF-8 JSON: {"schemaVersion", "title", "assistant",
|
||||
/// "scope"}.
|
||||
late final _isd.ColumnByteData payload;
|
||||
|
||||
/// The encryption nonce.
|
||||
late final _isd.ColumnByteData nonce;
|
||||
|
||||
@override
|
||||
List<_isd.Column> get columns => [
|
||||
id,
|
||||
spaceId,
|
||||
keyId,
|
||||
payload,
|
||||
nonce,
|
||||
];
|
||||
}
|
||||
|
||||
class AssistantConversationInclude extends _isd.IncludeObject {
|
||||
AssistantConversationInclude._();
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => {};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => AssistantConversation.t;
|
||||
}
|
||||
|
||||
class AssistantConversationIncludeList extends _isd.IncludeList {
|
||||
AssistantConversationIncludeList._({
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? where,
|
||||
super.limit,
|
||||
super.offset,
|
||||
super.orderBy,
|
||||
super.orderByList,
|
||||
super.include,
|
||||
}) {
|
||||
super.where = where?.call(AssistantConversation.t);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => include?.includes ?? {};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => AssistantConversation.t;
|
||||
}
|
||||
|
||||
class AssistantConversationRepository {
|
||||
const AssistantConversationRepository._();
|
||||
|
||||
/// Returns a list of [AssistantConversation]s matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order of the items use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// The maximum number of items can be set by [limit]. If no limit is set,
|
||||
/// all items matching the query will be returned.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which [limit] (or all)
|
||||
/// items are read from the database.
|
||||
///
|
||||
/// ```dart
|
||||
/// var persons = await Persons.db.find(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.firstName,
|
||||
/// limit: 100,
|
||||
/// );
|
||||
/// ```
|
||||
Future<List<AssistantConversation>> find(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.find<AssistantConversation>(
|
||||
where: where?.call(AssistantConversation.t),
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the first matching [AssistantConversation] matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which the next one will be picked.
|
||||
///
|
||||
/// ```dart
|
||||
/// var youngestPerson = await Persons.db.findFirstRow(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.age,
|
||||
/// );
|
||||
/// ```
|
||||
Future<AssistantConversation?> findFirstRow(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? where,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findFirstRow<AssistantConversation>(
|
||||
where: where?.call(AssistantConversation.t),
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds a single [AssistantConversation] by its [id] or null if no such row exists.
|
||||
Future<AssistantConversation?> findById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
_isd.Transaction? transaction,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findById<AssistantConversation>(
|
||||
id,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts all [AssistantConversation]s in the list and returns the inserted rows.
|
||||
///
|
||||
/// The returned [AssistantConversation]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// insert, none of the rows will be inserted.
|
||||
///
|
||||
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
|
||||
/// rows are silently skipped, and only the successfully inserted rows are
|
||||
/// returned.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the inserted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> insert(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantConversation> rows, {
|
||||
_isd.Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.insert<AssistantConversation>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a single [AssistantConversation] and returns the inserted row.
|
||||
///
|
||||
/// The returned [AssistantConversation] will have its `id` field set.
|
||||
Future<AssistantConversation> insertRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantConversation row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.insertRow<AssistantConversation>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts all [AssistantConversation]s in the list and returns the resulting rows.
|
||||
///
|
||||
/// If a row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated with the new values. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies to rows matching the
|
||||
/// given expression. Conflicting rows that don't match are skipped and not
|
||||
/// returned, so the resulting list may be shorter than [rows].
|
||||
///
|
||||
/// The returned [AssistantConversation]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails,
|
||||
/// none of the rows will be affected.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the resulting rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> upsert(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantConversation> rows, {
|
||||
required _isd.ColumnSelections<AssistantConversationTable> conflictColumns,
|
||||
_isd.ColumnSelections<AssistantConversationTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.upsert<AssistantConversation>(
|
||||
rows,
|
||||
conflictColumns: conflictColumns(AssistantConversation.t),
|
||||
updateColumns: updateColumns?.call(AssistantConversation.t),
|
||||
updateWhere: updateWhere?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a single [AssistantConversation] and returns the resulting row.
|
||||
///
|
||||
/// If the row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies when the existing
|
||||
/// row matches the expression. Returns `null` if no row was affected — for
|
||||
/// example when [updateWhere] does not match the conflicting row.
|
||||
///
|
||||
/// The returned [AssistantConversation] will have its `id` field set.
|
||||
Future<AssistantConversation?> upsertRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantConversation row, {
|
||||
required _isd.ColumnSelections<AssistantConversationTable> conflictColumns,
|
||||
_isd.ColumnSelections<AssistantConversationTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.upsertRow<AssistantConversation>(
|
||||
row,
|
||||
conflictColumns: conflictColumns(AssistantConversation.t),
|
||||
updateColumns: updateColumns?.call(AssistantConversation.t),
|
||||
updateWhere: updateWhere?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [AssistantConversation]s in the list and returns the updated rows. If
|
||||
/// [columns] is provided, only those columns will be updated. Defaults to
|
||||
/// all columns.
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// update, none of the rows will be updated.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> update(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantConversation> rows, {
|
||||
_isd.ColumnSelections<AssistantConversationTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.update<AssistantConversation>(
|
||||
rows,
|
||||
columns: columns?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [AssistantConversation]. The row needs to have its id set.
|
||||
/// Optionally, a list of [columns] can be provided to only update those
|
||||
/// columns. Defaults to all columns.
|
||||
Future<AssistantConversation> updateRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantConversation row, {
|
||||
_isd.ColumnSelections<AssistantConversationTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateRow<AssistantConversation>(
|
||||
row,
|
||||
columns: columns?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [AssistantConversation] by its [id] with the specified [columnValues].
|
||||
/// Returns the updated row or null if no row with the given id exists.
|
||||
Future<AssistantConversation?> updateById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
required _isd.ColumnValueListBuilder<AssistantConversationUpdateTable>
|
||||
columnValues,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateById<AssistantConversation>(
|
||||
id,
|
||||
columnValues: columnValues(AssistantConversation.t.updateTable),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [AssistantConversation]s matching the [where] expression with the specified [columnValues].
|
||||
/// Returns the list of updated rows.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> updateWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.ColumnValueListBuilder<AssistantConversationUpdateTable>
|
||||
columnValues,
|
||||
required _isd.WhereExpressionBuilder<AssistantConversationTable> where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.updateWhere<AssistantConversation>(
|
||||
columnValues: columnValues(AssistantConversation.t.updateTable),
|
||||
where: where(AssistantConversation.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all [AssistantConversation]s in the list and returns the deleted rows.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fail to
|
||||
/// be deleted, none of the rows will be deleted.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> delete(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantConversation> rows, {
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.delete<AssistantConversation>(
|
||||
rows,
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a single [AssistantConversation].
|
||||
Future<AssistantConversation> deleteRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantConversation row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.deleteRow<AssistantConversation>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all rows matching the [where] expression.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantConversation>> deleteWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AssistantConversationTable> where,
|
||||
_isd.OrderByBuilder<AssistantConversationTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantConversationTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.deleteWhere<AssistantConversation>(
|
||||
where: where(AssistantConversation.t),
|
||||
orderBy: orderBy?.call(AssistantConversation.t),
|
||||
orderByList: orderByList?.call(AssistantConversation.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts the number of rows matching the [where] expression. If omitted,
|
||||
/// will return the count of all rows in the table.
|
||||
Future<int> count(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantConversationTable>? where,
|
||||
int? limit,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.count<AssistantConversation>(
|
||||
where: where?.call(AssistantConversation.t),
|
||||
limit: limit,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Acquires row-level locks on [AssistantConversation] rows matching the [where] expression.
|
||||
Future<void> lockRows(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AssistantConversationTable> where,
|
||||
required _isd.LockMode lockMode,
|
||||
required _isd.Transaction transaction,
|
||||
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
|
||||
}) async {
|
||||
return session.db.lockRows<AssistantConversation>(
|
||||
where: where(AssistantConversation.t),
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,796 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
// ignore_for_file: dead_code, unnecessary_null_comparison
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:typed_data' as _idt;
|
||||
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
import '../assistant/assistant_conversation.dart' as _ix0ywfho;
|
||||
|
||||
/// One turn of a conversation with an assistant: a question and everything up
|
||||
/// to its answer (the lookups' calls and results too), never changed once
|
||||
/// written (ADR 0001, rule 2; ADR 0021, D51), so two devices continuing a
|
||||
/// conversation offline lose nothing. Sealed with the person's key, bound to its
|
||||
/// conversation. Turns are read in the order of their UUID v7 ids (ADR 0001,
|
||||
/// rule 7). Deleting a conversation blanks every turn's payload first (ADR 0002,
|
||||
/// rule 2).
|
||||
abstract class AssistantTurn
|
||||
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
|
||||
AssistantTurn._({
|
||||
this.id,
|
||||
this.spaceId,
|
||||
required this.conversationId,
|
||||
this.conversation,
|
||||
this.keyId,
|
||||
required this.payload,
|
||||
this.nonce,
|
||||
});
|
||||
|
||||
factory AssistantTurn({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
required _isc.UuidValue conversationId,
|
||||
_ix0ywfho.AssistantConversation? conversation,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) = _AssistantTurnImpl;
|
||||
|
||||
factory AssistantTurn.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return AssistantTurn(
|
||||
id: jsonSerialization['id'] == null
|
||||
? null
|
||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
|
||||
spaceId: jsonSerialization['spaceId'] as int?,
|
||||
conversationId: _isc.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['conversationId'],
|
||||
),
|
||||
conversation: jsonSerialization['conversation'] == null
|
||||
? null
|
||||
: _itx5j3f5.Protocol().deserialize<_ix0ywfho.AssistantConversation>(
|
||||
jsonSerialization['conversation'],
|
||||
),
|
||||
keyId: jsonSerialization['keyId'] as String?,
|
||||
payload: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['payload'],
|
||||
),
|
||||
nonce: jsonSerialization['nonce'] == null
|
||||
? null
|
||||
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
|
||||
);
|
||||
}
|
||||
|
||||
static final t = AssistantTurnTable();
|
||||
|
||||
static const db = AssistantTurnRepository._();
|
||||
|
||||
@override
|
||||
_isc.UuidValue? id;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
int? spaceId;
|
||||
|
||||
_isc.UuidValue conversationId;
|
||||
|
||||
/// The conversation it is a turn of; its turns go with it.
|
||||
_ix0ywfho.AssistantConversation? conversation;
|
||||
|
||||
/// The key it is sealed with: the person's personal key.
|
||||
String? keyId;
|
||||
|
||||
/// The content as UTF-8 JSON: {"schemaVersion", "messages", "usage"}.
|
||||
_idt.ByteData payload;
|
||||
|
||||
/// The encryption nonce.
|
||||
_idt.ByteData? nonce;
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => t;
|
||||
|
||||
/// Returns a shallow copy of this [AssistantTurn]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
AssistantTurn copyWith({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
_isc.UuidValue? conversationId,
|
||||
_ix0ywfho.AssistantConversation? conversation,
|
||||
String? keyId,
|
||||
_idt.ByteData? payload,
|
||||
_idt.ByteData? nonce,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'AssistantTurn',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'conversationId': conversationId.toJson(),
|
||||
if (conversation != null) 'conversation': conversation?.toJson(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'AssistantTurn',
|
||||
if (id != null) 'id': id?.toJson(),
|
||||
if (spaceId != null) 'spaceId': spaceId,
|
||||
'conversationId': conversationId.toJson(),
|
||||
if (conversation != null)
|
||||
'conversation': conversation?.toJsonForProtocol(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
'payload': payload.toJson(),
|
||||
if (nonce != null) 'nonce': nonce?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
static AssistantTurnInclude include({
|
||||
_ix0ywfho.AssistantConversationInclude? conversation,
|
||||
}) {
|
||||
return AssistantTurnInclude._(conversation: conversation);
|
||||
}
|
||||
|
||||
static AssistantTurnIncludeList includeList({
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
AssistantTurnInclude? include,
|
||||
}) {
|
||||
return AssistantTurnIncludeList._(
|
||||
where: where,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
include: include,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _AssistantTurnImpl extends AssistantTurn {
|
||||
_AssistantTurnImpl({
|
||||
_isc.UuidValue? id,
|
||||
int? spaceId,
|
||||
required _isc.UuidValue conversationId,
|
||||
_ix0ywfho.AssistantConversation? conversation,
|
||||
String? keyId,
|
||||
required _idt.ByteData payload,
|
||||
_idt.ByteData? nonce,
|
||||
}) : super._(
|
||||
id: id,
|
||||
spaceId: spaceId,
|
||||
conversationId: conversationId,
|
||||
conversation: conversation,
|
||||
keyId: keyId,
|
||||
payload: payload,
|
||||
nonce: nonce,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [AssistantTurn]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
AssistantTurn copyWith({
|
||||
Object? id = _Undefined,
|
||||
Object? spaceId = _Undefined,
|
||||
_isc.UuidValue? conversationId,
|
||||
Object? conversation = _Undefined,
|
||||
Object? keyId = _Undefined,
|
||||
_idt.ByteData? payload,
|
||||
Object? nonce = _Undefined,
|
||||
}) {
|
||||
return AssistantTurn(
|
||||
id: id is _isc.UuidValue? ? id : this.id,
|
||||
spaceId: spaceId is int? ? spaceId : this.spaceId,
|
||||
conversationId: conversationId ?? this.conversationId,
|
||||
conversation: conversation is _ix0ywfho.AssistantConversation?
|
||||
? conversation
|
||||
: this.conversation?.copyWith(),
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
payload: payload ?? this.payload.clone(),
|
||||
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantTurnUpdateTable extends _isd.UpdateTable<AssistantTurnTable> {
|
||||
AssistantTurnUpdateTable(super.table);
|
||||
|
||||
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
|
||||
table.spaceId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_isc.UuidValue, _isc.UuidValue> conversationId(
|
||||
_isc.UuidValue value,
|
||||
) => _isd.ColumnValue(
|
||||
table.conversationId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
|
||||
table.keyId,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
|
||||
_isd.ColumnValue(
|
||||
table.payload,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
|
||||
_isd.ColumnValue(
|
||||
table.nonce,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
class AssistantTurnTable extends _isd.Table<_isc.UuidValue?> {
|
||||
AssistantTurnTable({super.tableRelation})
|
||||
: super(tableName: 'assistant_turn') {
|
||||
updateTable = AssistantTurnUpdateTable(this);
|
||||
spaceId = _isd.ColumnInt(
|
||||
'spaceId',
|
||||
this,
|
||||
);
|
||||
conversationId = _isd.ColumnUuid(
|
||||
'conversationId',
|
||||
this,
|
||||
);
|
||||
keyId = _isd.ColumnString(
|
||||
'keyId',
|
||||
this,
|
||||
);
|
||||
payload = _isd.ColumnByteData(
|
||||
'payload',
|
||||
this,
|
||||
);
|
||||
nonce = _isd.ColumnByteData(
|
||||
'nonce',
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
late final AssistantTurnUpdateTable updateTable;
|
||||
|
||||
/// The space owning this row. Maintained by the sync engine.
|
||||
late final _isd.ColumnInt spaceId;
|
||||
|
||||
late final _isd.ColumnUuid conversationId;
|
||||
|
||||
/// The conversation it is a turn of; its turns go with it.
|
||||
_ix0ywfho.AssistantConversationTable? _conversation;
|
||||
|
||||
/// The key it is sealed with: the person's personal key.
|
||||
late final _isd.ColumnString keyId;
|
||||
|
||||
/// The content as UTF-8 JSON: {"schemaVersion", "messages", "usage"}.
|
||||
late final _isd.ColumnByteData payload;
|
||||
|
||||
/// The encryption nonce.
|
||||
late final _isd.ColumnByteData nonce;
|
||||
|
||||
_ix0ywfho.AssistantConversationTable get conversation {
|
||||
if (_conversation != null) return _conversation!;
|
||||
_conversation = _isd.createRelationTable(
|
||||
relationFieldName: 'conversation',
|
||||
field: AssistantTurn.t.conversationId,
|
||||
foreignField: _ix0ywfho.AssistantConversation.t.id,
|
||||
tableRelation: tableRelation,
|
||||
createTable: (foreignTableRelation) =>
|
||||
_ix0ywfho.AssistantConversationTable(
|
||||
tableRelation: foreignTableRelation,
|
||||
),
|
||||
);
|
||||
return _conversation!;
|
||||
}
|
||||
|
||||
@override
|
||||
List<_isd.Column> get columns => [
|
||||
id,
|
||||
spaceId,
|
||||
conversationId,
|
||||
keyId,
|
||||
payload,
|
||||
nonce,
|
||||
];
|
||||
|
||||
@override
|
||||
_isd.Table? getRelationTable(String relationField) {
|
||||
if (relationField == 'conversation') {
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantTurnInclude extends _isd.IncludeObject {
|
||||
AssistantTurnInclude._({
|
||||
_ix0ywfho.AssistantConversationInclude? conversation,
|
||||
}) {
|
||||
_conversation = conversation;
|
||||
}
|
||||
|
||||
_ix0ywfho.AssistantConversationInclude? _conversation;
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => {'conversation': _conversation};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => AssistantTurn.t;
|
||||
}
|
||||
|
||||
class AssistantTurnIncludeList extends _isd.IncludeList {
|
||||
AssistantTurnIncludeList._({
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? where,
|
||||
super.limit,
|
||||
super.offset,
|
||||
super.orderBy,
|
||||
super.orderByList,
|
||||
super.include,
|
||||
}) {
|
||||
super.where = where?.call(AssistantTurn.t);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, _isd.Include?> get includes => include?.includes ?? {};
|
||||
|
||||
@override
|
||||
_isd.Table<_isc.UuidValue?> get table => AssistantTurn.t;
|
||||
}
|
||||
|
||||
class AssistantTurnRepository {
|
||||
const AssistantTurnRepository._();
|
||||
|
||||
final attachRow = const AssistantTurnAttachRowRepository._();
|
||||
|
||||
/// Returns a list of [AssistantTurn]s matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order of the items use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// The maximum number of items can be set by [limit]. If no limit is set,
|
||||
/// all items matching the query will be returned.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which [limit] (or all)
|
||||
/// items are read from the database.
|
||||
///
|
||||
/// ```dart
|
||||
/// var persons = await Persons.db.find(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.firstName,
|
||||
/// limit: 100,
|
||||
/// );
|
||||
/// ```
|
||||
Future<List<AssistantTurn>> find(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
AssistantTurnInclude? include,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.find<AssistantTurn>(
|
||||
where: where?.call(AssistantTurn.t),
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
include: include,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the first matching [AssistantTurn] matching the given query parameters.
|
||||
///
|
||||
/// Use [where] to specify which items to include in the return value.
|
||||
/// If none is specified, all items will be returned.
|
||||
///
|
||||
/// To specify the order use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// [offset] defines how many items to skip, after which the next one will be picked.
|
||||
///
|
||||
/// ```dart
|
||||
/// var youngestPerson = await Persons.db.findFirstRow(
|
||||
/// session,
|
||||
/// where: (t) => t.lastName.equals('Jones'),
|
||||
/// orderBy: (t) => t.age,
|
||||
/// );
|
||||
/// ```
|
||||
Future<AssistantTurn?> findFirstRow(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? where,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
AssistantTurnInclude? include,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findFirstRow<AssistantTurn>(
|
||||
where: where?.call(AssistantTurn.t),
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
include: include,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds a single [AssistantTurn] by its [id] or null if no such row exists.
|
||||
Future<AssistantTurn?> findById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
_isd.Transaction? transaction,
|
||||
AssistantTurnInclude? include,
|
||||
_isd.LockMode? lockMode,
|
||||
_isd.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findById<AssistantTurn>(
|
||||
id,
|
||||
transaction: transaction,
|
||||
include: include,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts all [AssistantTurn]s in the list and returns the inserted rows.
|
||||
///
|
||||
/// The returned [AssistantTurn]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// insert, none of the rows will be inserted.
|
||||
///
|
||||
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
|
||||
/// rows are silently skipped, and only the successfully inserted rows are
|
||||
/// returned.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the inserted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> insert(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantTurn> rows, {
|
||||
_isd.Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.insert<AssistantTurn>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a single [AssistantTurn] and returns the inserted row.
|
||||
///
|
||||
/// The returned [AssistantTurn] will have its `id` field set.
|
||||
Future<AssistantTurn> insertRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantTurn row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.insertRow<AssistantTurn>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts all [AssistantTurn]s in the list and returns the resulting rows.
|
||||
///
|
||||
/// If a row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated with the new values. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies to rows matching the
|
||||
/// given expression. Conflicting rows that don't match are skipped and not
|
||||
/// returned, so the resulting list may be shorter than [rows].
|
||||
///
|
||||
/// The returned [AssistantTurn]s will have their `id` fields set.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fails,
|
||||
/// none of the rows will be affected.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the resulting rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> upsert(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantTurn> rows, {
|
||||
required _isd.ColumnSelections<AssistantTurnTable> conflictColumns,
|
||||
_isd.ColumnSelections<AssistantTurnTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.upsert<AssistantTurn>(
|
||||
rows,
|
||||
conflictColumns: conflictColumns(AssistantTurn.t),
|
||||
updateColumns: updateColumns?.call(AssistantTurn.t),
|
||||
updateWhere: updateWhere?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a single [AssistantTurn] and returns the resulting row.
|
||||
///
|
||||
/// If the row conflicts on the given [conflictColumns], the existing row is
|
||||
/// updated. Otherwise, a new row is inserted.
|
||||
///
|
||||
/// If [updateColumns] is provided, only those columns will be updated on
|
||||
/// conflict. If null, all non-conflict, non-id columns are updated.
|
||||
///
|
||||
/// If [updateWhere] is provided, the update only applies when the existing
|
||||
/// row matches the expression. Returns `null` if no row was affected — for
|
||||
/// example when [updateWhere] does not match the conflicting row.
|
||||
///
|
||||
/// The returned [AssistantTurn] will have its `id` field set.
|
||||
Future<AssistantTurn?> upsertRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantTurn row, {
|
||||
required _isd.ColumnSelections<AssistantTurnTable> conflictColumns,
|
||||
_isd.ColumnSelections<AssistantTurnTable>? updateColumns,
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? updateWhere,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.upsertRow<AssistantTurn>(
|
||||
row,
|
||||
conflictColumns: conflictColumns(AssistantTurn.t),
|
||||
updateColumns: updateColumns?.call(AssistantTurn.t),
|
||||
updateWhere: updateWhere?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [AssistantTurn]s in the list and returns the updated rows. If
|
||||
/// [columns] is provided, only those columns will be updated. Defaults to
|
||||
/// all columns.
|
||||
/// This is an atomic operation, meaning that if one of the rows fails to
|
||||
/// update, none of the rows will be updated.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> update(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantTurn> rows, {
|
||||
_isd.ColumnSelections<AssistantTurnTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.update<AssistantTurn>(
|
||||
rows,
|
||||
columns: columns?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [AssistantTurn]. The row needs to have its id set.
|
||||
/// Optionally, a list of [columns] can be provided to only update those
|
||||
/// columns. Defaults to all columns.
|
||||
Future<AssistantTurn> updateRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantTurn row, {
|
||||
_isd.ColumnSelections<AssistantTurnTable>? columns,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateRow<AssistantTurn>(
|
||||
row,
|
||||
columns: columns?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [AssistantTurn] by its [id] with the specified [columnValues].
|
||||
/// Returns the updated row or null if no row with the given id exists.
|
||||
Future<AssistantTurn?> updateById(
|
||||
_isd.DatabaseSession session,
|
||||
_isc.UuidValue id, {
|
||||
required _isd.ColumnValueListBuilder<AssistantTurnUpdateTable> columnValues,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateById<AssistantTurn>(
|
||||
id,
|
||||
columnValues: columnValues(AssistantTurn.t.updateTable),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [AssistantTurn]s matching the [where] expression with the specified [columnValues].
|
||||
/// Returns the list of updated rows.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the updated rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> updateWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.ColumnValueListBuilder<AssistantTurnUpdateTable> columnValues,
|
||||
required _isd.WhereExpressionBuilder<AssistantTurnTable> where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.updateWhere<AssistantTurn>(
|
||||
columnValues: columnValues(AssistantTurn.t.updateTable),
|
||||
where: where(AssistantTurn.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all [AssistantTurn]s in the list and returns the deleted rows.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// This is an atomic operation, meaning that if one of the rows fail to
|
||||
/// be deleted, none of the rows will be deleted.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> delete(
|
||||
_isd.DatabaseSession session,
|
||||
List<AssistantTurn> rows, {
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.delete<AssistantTurn>(
|
||||
rows,
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a single [AssistantTurn].
|
||||
Future<AssistantTurn> deleteRow(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantTurn row, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.deleteRow<AssistantTurn>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all rows matching the [where] expression.
|
||||
///
|
||||
/// To specify the order of the returned rows use [orderBy] or [orderByList]
|
||||
/// when sorting by multiple columns.
|
||||
///
|
||||
/// If [noReturn] is set to `true`, the deleted rows are not read back from
|
||||
/// the database and an empty list is returned. This avoids the overhead of
|
||||
/// transferring and deserializing the rows when the result is not needed.
|
||||
Future<List<AssistantTurn>> deleteWhere(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AssistantTurnTable> where,
|
||||
_isd.OrderByBuilder<AssistantTurnTable>? orderBy,
|
||||
_isd.OrderByListBuilder<AssistantTurnTable>? orderByList,
|
||||
_isd.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.deleteWhere<AssistantTurn>(
|
||||
where: where(AssistantTurn.t),
|
||||
orderBy: orderBy?.call(AssistantTurn.t),
|
||||
orderByList: orderByList?.call(AssistantTurn.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts the number of rows matching the [where] expression. If omitted,
|
||||
/// will return the count of all rows in the table.
|
||||
Future<int> count(
|
||||
_isd.DatabaseSession session, {
|
||||
_isd.WhereExpressionBuilder<AssistantTurnTable>? where,
|
||||
int? limit,
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.count<AssistantTurn>(
|
||||
where: where?.call(AssistantTurn.t),
|
||||
limit: limit,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Acquires row-level locks on [AssistantTurn] rows matching the [where] expression.
|
||||
Future<void> lockRows(
|
||||
_isd.DatabaseSession session, {
|
||||
required _isd.WhereExpressionBuilder<AssistantTurnTable> where,
|
||||
required _isd.LockMode lockMode,
|
||||
required _isd.Transaction transaction,
|
||||
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
|
||||
}) async {
|
||||
return session.db.lockRows<AssistantTurn>(
|
||||
where: where(AssistantTurn.t),
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantTurnAttachRowRepository {
|
||||
const AssistantTurnAttachRowRepository._();
|
||||
|
||||
/// Creates a relation between the given [AssistantTurn] and [AssistantConversation]
|
||||
/// by setting the [AssistantTurn]'s foreign key `conversationId` to refer to the [AssistantConversation].
|
||||
Future<void> conversation(
|
||||
_isd.DatabaseSession session,
|
||||
AssistantTurn assistantTurn,
|
||||
_ix0ywfho.AssistantConversation conversation, {
|
||||
_isd.Transaction? transaction,
|
||||
}) async {
|
||||
if (assistantTurn.id == null) {
|
||||
throw ArgumentError.notNull('assistantTurn.id');
|
||||
}
|
||||
if (conversation.id == null) {
|
||||
throw ArgumentError.notNull('conversation.id');
|
||||
}
|
||||
|
||||
var $assistantTurn = assistantTurn.copyWith(
|
||||
conversationId: conversation.id,
|
||||
);
|
||||
await session.db.updateRow<AssistantTurn>(
|
||||
$assistantTurn,
|
||||
columns: [AssistantTurn.t.conversationId],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,8 @@ import 'asset/asset.dart' as _ipb7vkxr;
|
|||
import 'asset/file_allowance.dart' as _i9uv0f1n;
|
||||
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
|
||||
import 'asset/file_usage.dart' as _iurwo9k8;
|
||||
import 'assistant/assistant_conversation.dart' as _ikd6xdrt;
|
||||
import 'assistant/assistant_turn.dart' as _i5vmmfj2;
|
||||
import 'auth/device.dart' as _i6c5ny7n;
|
||||
import 'card/card.dart' as _ipiw1cmx;
|
||||
import 'card/card_revision.dart' as _ipbyuef1;
|
||||
|
|
@ -98,6 +100,8 @@ export 'asset/asset.dart';
|
|||
export 'asset/file_allowance.dart';
|
||||
export 'asset/file_limit_exceeded_exception.dart';
|
||||
export 'asset/file_usage.dart';
|
||||
export 'assistant/assistant_conversation.dart';
|
||||
export 'assistant/assistant_turn.dart';
|
||||
export 'auth/device.dart';
|
||||
export 'card/card.dart';
|
||||
export 'card/card_revision.dart';
|
||||
|
|
@ -262,6 +266,170 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
],
|
||||
managed: true,
|
||||
),
|
||||
_isd.TableDefinition(
|
||||
name: 'assistant_conversation',
|
||||
dartName: 'AssistantConversation',
|
||||
schema: 'public',
|
||||
module: 'halkyon_learn',
|
||||
columns: [
|
||||
_isd.ColumnDefinition(
|
||||
name: 'id',
|
||||
columnType: _isd.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue?',
|
||||
columnDefault: 'random_v7',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'spaceId',
|
||||
columnType: _isd.ColumnType.bigint,
|
||||
isNullable: true,
|
||||
dartType: 'int?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'keyId',
|
||||
columnType: _isd.ColumnType.text,
|
||||
isNullable: true,
|
||||
dartType: 'String?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'payload',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: false,
|
||||
dartType: 'dart:typed_data:ByteData',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'nonce',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: true,
|
||||
dartType: 'dart:typed_data:ByteData?',
|
||||
),
|
||||
],
|
||||
foreignKeys: [
|
||||
_isd.ForeignKeyDefinition(
|
||||
constraintName: 'assistant_conversation_fk_0',
|
||||
columns: ['spaceId'],
|
||||
referenceTable: 'offline_sync_spaces',
|
||||
referenceTableSchema: 'public',
|
||||
referenceColumns: ['id'],
|
||||
onUpdate: _isd.ForeignKeyAction.noAction,
|
||||
onDelete: _isd.ForeignKeyAction.cascade,
|
||||
matchType: null,
|
||||
),
|
||||
],
|
||||
indexes: [
|
||||
_isd.IndexDefinition(
|
||||
indexName: 'assistant_conversation_space_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'spaceId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
managed: true,
|
||||
),
|
||||
_isd.TableDefinition(
|
||||
name: 'assistant_turn',
|
||||
dartName: 'AssistantTurn',
|
||||
schema: 'public',
|
||||
module: 'halkyon_learn',
|
||||
columns: [
|
||||
_isd.ColumnDefinition(
|
||||
name: 'id',
|
||||
columnType: _isd.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue?',
|
||||
columnDefault: 'random_v7',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'spaceId',
|
||||
columnType: _isd.ColumnType.bigint,
|
||||
isNullable: true,
|
||||
dartType: 'int?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'conversationId',
|
||||
columnType: _isd.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'keyId',
|
||||
columnType: _isd.ColumnType.text,
|
||||
isNullable: true,
|
||||
dartType: 'String?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'payload',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: false,
|
||||
dartType: 'dart:typed_data:ByteData',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'nonce',
|
||||
columnType: _isd.ColumnType.bytea,
|
||||
isNullable: true,
|
||||
dartType: 'dart:typed_data:ByteData?',
|
||||
),
|
||||
],
|
||||
foreignKeys: [
|
||||
_isd.ForeignKeyDefinition(
|
||||
constraintName: 'assistant_turn_fk_0',
|
||||
columns: ['spaceId'],
|
||||
referenceTable: 'offline_sync_spaces',
|
||||
referenceTableSchema: 'public',
|
||||
referenceColumns: ['id'],
|
||||
onUpdate: _isd.ForeignKeyAction.noAction,
|
||||
onDelete: _isd.ForeignKeyAction.cascade,
|
||||
matchType: null,
|
||||
),
|
||||
_isd.ForeignKeyDefinition(
|
||||
constraintName: 'assistant_turn_fk_1',
|
||||
columns: ['conversationId'],
|
||||
referenceTable: 'assistant_conversation',
|
||||
referenceTableSchema: 'public',
|
||||
referenceColumns: ['id'],
|
||||
onUpdate: _isd.ForeignKeyAction.noAction,
|
||||
onDelete: _isd.ForeignKeyAction.cascade,
|
||||
matchType: null,
|
||||
deferrable: _isd.DeferrableConstraint.initiallyDeferred,
|
||||
),
|
||||
],
|
||||
indexes: [
|
||||
_isd.IndexDefinition(
|
||||
indexName: 'assistant_turn_space_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'spaceId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
),
|
||||
_isd.IndexDefinition(
|
||||
indexName: 'assistant_turn_conversation_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isd.IndexElementDefinition(
|
||||
type: _isd.IndexElementDefinitionType.column,
|
||||
definition: 'conversationId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
managed: true,
|
||||
),
|
||||
_isd.TableDefinition(
|
||||
name: 'card',
|
||||
dartName: 'Card',
|
||||
|
|
@ -2408,6 +2576,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _iurwo9k8.FileUsage) {
|
||||
return _iurwo9k8.FileUsage.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ikd6xdrt.AssistantConversation) {
|
||||
return _ikd6xdrt.AssistantConversation.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5vmmfj2.AssistantTurn) {
|
||||
return _i5vmmfj2.AssistantTurn.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i6c5ny7n.Device) {
|
||||
return _i6c5ny7n.Device.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -2577,6 +2751,16 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
|
||||
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_ikd6xdrt.AssistantConversation?>()) {
|
||||
return (data != null
|
||||
? _ikd6xdrt.AssistantConversation.fromJson(data)
|
||||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_i5vmmfj2.AssistantTurn?>()) {
|
||||
return (data != null ? _i5vmmfj2.AssistantTurn.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
|
||||
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -2907,6 +3091,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||
_iurwo9k8.FileUsage => 'FileUsage',
|
||||
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
|
||||
_i5vmmfj2.AssistantTurn => 'AssistantTurn',
|
||||
_i6c5ny7n.Device => 'Device',
|
||||
_ipiw1cmx.Card => 'Card',
|
||||
_ipbyuef1.CardRevision => 'CardRevision',
|
||||
|
|
@ -2983,6 +3169,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return 'FileLimitExceededException';
|
||||
case _iurwo9k8.FileUsage():
|
||||
return 'FileUsage';
|
||||
case _ikd6xdrt.AssistantConversation():
|
||||
return 'AssistantConversation';
|
||||
case _i5vmmfj2.AssistantTurn():
|
||||
return 'AssistantTurn';
|
||||
case _i6c5ny7n.Device():
|
||||
return 'Device';
|
||||
case _ipiw1cmx.Card():
|
||||
|
|
@ -3133,6 +3323,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'FileUsage') {
|
||||
return deserialize<_iurwo9k8.FileUsage>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'AssistantConversation') {
|
||||
return deserialize<_ikd6xdrt.AssistantConversation>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'AssistantTurn') {
|
||||
return deserialize<_i5vmmfj2.AssistantTurn>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Device') {
|
||||
return deserialize<_i6c5ny7n.Device>(data['data']);
|
||||
}
|
||||
|
|
@ -3367,6 +3563,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
switch (t) {
|
||||
case _ipb7vkxr.Asset:
|
||||
return _ipb7vkxr.Asset.t;
|
||||
case _ikd6xdrt.AssistantConversation:
|
||||
return _ikd6xdrt.AssistantConversation.t;
|
||||
case _i5vmmfj2.AssistantTurn:
|
||||
return _i5vmmfj2.AssistantTurn.t;
|
||||
case _ipiw1cmx.Card:
|
||||
return _ipiw1cmx.Card.t;
|
||||
case _ipbyuef1.CardRevision:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
||||
import 'asset/asset.dart' as _ipb7vkxr;
|
||||
import 'assistant/assistant_conversation.dart' as _ikd6xdrt;
|
||||
import 'assistant/assistant_turn.dart' as _i5vmmfj2;
|
||||
import 'card/card.dart' as _ipiw1cmx;
|
||||
import 'card/card_revision.dart' as _ipbyuef1;
|
||||
import 'feedback/feedback_decision.dart' as _idsmz0xa;
|
||||
|
|
@ -39,6 +41,8 @@ import 'tag/tag.dart' as _ie8t3ar4;
|
|||
/// shared packages.
|
||||
final List<_isd.Table> syncTables = [
|
||||
_ipb7vkxr.Asset.t,
|
||||
_ikd6xdrt.AssistantConversation.t,
|
||||
_i5vmmfj2.AssistantTurn.t,
|
||||
_ipiw1cmx.Card.t,
|
||||
_ipbyuef1.CardRevision.t,
|
||||
_i6jz6nyq.CardTag.t,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.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/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.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_repository.dart';
|
||||
|
|
@ -495,6 +497,9 @@ Future<PersonDependencies> _openPersonData(
|
|||
pathChanges: paths.changes,
|
||||
);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy));
|
||||
final conversations = ConversationRepository(
|
||||
SyncConversationGateway(db, sync, privacy),
|
||||
);
|
||||
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
|
||||
// Count me and the copies' publications, which the instance can't read
|
||||
// any more (ADR 0019, D3).
|
||||
|
|
@ -588,6 +593,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
studysetProgress: studysetProgress,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
conversations: conversations,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
noteDeletions: noteDeletions,
|
||||
|
|
@ -636,6 +642,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
await conversations.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
|
||||
|
|
@ -65,6 +66,10 @@ class PersonDependencies({
|
|||
/// The person's settings, which follow them to every device.
|
||||
required final SettingsRepository settings,
|
||||
|
||||
/// The person's conversations with assistants, kept sealed (ADR 0021,
|
||||
/// D13).
|
||||
required final ConversationRepository conversations,
|
||||
|
||||
/// The files of the person's content: images, sounds and PDFs.
|
||||
required final AssetRepository assets,
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// A conversation's row as it opened on this device, with when it was
|
||||
/// started (from its UUID v7 id; null for an id that doesn't say).
|
||||
typedef ConversationRow = ({String id, ByteData payload, DateTime? startedAt});
|
||||
|
||||
/// A turn's row as it opened on this device, with when it was kept (from
|
||||
/// its UUID v7 id; null for an id that doesn't say).
|
||||
typedef TurnRow = ({String id, ByteData payload, DateTime? writtenAt});
|
||||
|
||||
/// The boundary to the person's conversations with assistants in their
|
||||
/// local, synced database (ADR 0021, D13, D51). Tests replace it with a
|
||||
/// fake.
|
||||
abstract interface class ConversationGateway() {
|
||||
/// The conversations that open here, unordered: one that isn't sealed
|
||||
/// with the person's key, or doesn't open here, is left out.
|
||||
Future<List<ConversationRow>> conversations();
|
||||
|
||||
/// The turns of the conversation [conversationId] that open here,
|
||||
/// unordered, as [conversations] leaves them out.
|
||||
Future<List<TurnRow>> turnsOf(String conversationId);
|
||||
|
||||
/// Whether this device seals what it writes with the person's key, as
|
||||
/// anything kept here must be (D52).
|
||||
Future<bool> seals();
|
||||
|
||||
/// Stores a new conversation, sealed; returns its id.
|
||||
Future<String> insertConversation(ByteData payload);
|
||||
|
||||
/// Appends a turn, sealed, to the conversation [conversationId]; returns
|
||||
/// its id.
|
||||
Future<String> insertTurn(String conversationId, ByteData payload);
|
||||
|
||||
/// Blanks the payload of the conversation [conversationId] and of each of
|
||||
/// its turns (ADR 0002, rule 2).
|
||||
Future<void> blankConversation(String conversationId);
|
||||
|
||||
/// Deletes the conversation [conversationId] and its turns.
|
||||
Future<void> deleteConversation(String conversationId);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
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.
|
||||
|
||||
/// The schema this app writes a conversation in.
|
||||
const conversationSchemaVersion = 1;
|
||||
|
||||
/// The schema this app writes a turn in.
|
||||
const turnSchemaVersion = 1;
|
||||
|
||||
/// What a kept turn holds in an image's place: images aren't kept (they
|
||||
/// would make a turn megabytes), so a follow-up goes without them.
|
||||
const imageNotKept = '[An image was sent here.]';
|
||||
|
||||
/// Encodes a conversation's [title], [assistant] and [scope].
|
||||
ByteData encodeConversationPayload({
|
||||
required String title,
|
||||
required AssistantRef assistant,
|
||||
required AssistantScope scope,
|
||||
}) => encodePayload(conversationSchemaVersion, {
|
||||
'title': title,
|
||||
'assistant': {'id': assistant.id, if (assistant.own) 'own': true},
|
||||
'scope': {
|
||||
'reach': scope.reach.name,
|
||||
'studyset': ?scope.studysetId,
|
||||
if (scope.includePrivate) 'private': true,
|
||||
},
|
||||
});
|
||||
|
||||
/// Decodes the conversation [id]'s payload.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
AssistantConversation decodeConversationPayload(
|
||||
String id,
|
||||
ByteData payload, {
|
||||
required DateTime startedAt,
|
||||
}) {
|
||||
final json = decodePayload(payload, newestSchema: conversationSchemaVersion);
|
||||
if (json case {
|
||||
'title': final String title,
|
||||
'assistant': {'id': final String assistant},
|
||||
'scope': {'reach': final String reach},
|
||||
}) {
|
||||
final scope = json['scope']! as Map<String, Object?>;
|
||||
return AssistantConversation(
|
||||
id: id,
|
||||
title: title,
|
||||
assistant: AssistantRef(
|
||||
id: assistant,
|
||||
own: (json['assistant']! as Map<String, Object?>)['own'] == true,
|
||||
),
|
||||
scope: AssistantScope(
|
||||
reach: AssistantReach.values.firstWhere(
|
||||
(each) => each.name == reach,
|
||||
orElse: () => throw FormatException('Not a reach: $reach'),
|
||||
),
|
||||
studysetId: switch (scope['studyset']) {
|
||||
final String studyset => studyset,
|
||||
_ => null,
|
||||
},
|
||||
includePrivate: scope['private'] == true,
|
||||
),
|
||||
startedAt: startedAt,
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a conversation payload.');
|
||||
}
|
||||
|
||||
/// Encodes a turn's [messages] and what they counted.
|
||||
ByteData encodeTurnPayload(List<LlmMessage> messages, LlmUsage usage) =>
|
||||
encodePayload(turnSchemaVersion, {
|
||||
'messages': [for (final message in messages) _messageToJson(message)],
|
||||
'usage': {'input': usage.inputTokens, 'output': usage.outputTokens},
|
||||
});
|
||||
|
||||
/// Decodes the turn [id]'s payload.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
AssistantTurn decodeTurnPayload(
|
||||
String id,
|
||||
ByteData payload, {
|
||||
required DateTime writtenAt,
|
||||
}) {
|
||||
final json = decodePayload(payload, newestSchema: turnSchemaVersion);
|
||||
if (json case {'messages': final List<Object?> messages}) {
|
||||
return AssistantTurn(
|
||||
id: id,
|
||||
messages: [for (final message in messages) _messageFromJson(message)],
|
||||
usage: switch (json['usage']) {
|
||||
{'input': final int input, 'output': final int output} => LlmUsage(
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
),
|
||||
_ => LlmUsage.none,
|
||||
},
|
||||
writtenAt: writtenAt,
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a turn payload.');
|
||||
}
|
||||
|
||||
Map<String, Object?> _messageToJson(LlmMessage message) => {
|
||||
'role': message.role.name,
|
||||
'parts': [
|
||||
for (final part in message.parts)
|
||||
switch (part) {
|
||||
LlmText(:final text, :final extra) => {
|
||||
'type': 'text',
|
||||
'text': text,
|
||||
if (extra.isNotEmpty) 'extra': extra,
|
||||
},
|
||||
LlmImage() => {'type': 'text', 'text': imageNotKept},
|
||||
LlmToolCall(:final id, :final name, :final input, :final extra) => {
|
||||
'type': 'call',
|
||||
'id': id,
|
||||
'name': name,
|
||||
'input': input,
|
||||
if (extra.isNotEmpty) 'extra': extra,
|
||||
},
|
||||
LlmToolResult(:final callId, :final content, :final isError) => {
|
||||
'type': 'result',
|
||||
'call': callId,
|
||||
'content': content,
|
||||
if (isError) 'error': true,
|
||||
},
|
||||
LlmProviderBlock(:final protocol, :final block) => {
|
||||
'type': 'provider',
|
||||
'protocol': protocol.key,
|
||||
'block': block,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
LlmMessage _messageFromJson(Object? json) {
|
||||
if (json case {'role': final String role, 'parts': final List<Object?> raw}) {
|
||||
final parts = [for (final part in raw) _partFromJson(part)];
|
||||
// What each role may hold, as LlmMessage allows it: anything else isn't
|
||||
// what this app wrote.
|
||||
final holds = switch (role) {
|
||||
'user' => (
|
||||
LlmPart part,
|
||||
) => part is! LlmToolCall && part is! LlmProviderBlock,
|
||||
'assistant' => (
|
||||
LlmPart part,
|
||||
) => part is! LlmImage && part is! LlmToolResult,
|
||||
_ => throw FormatException('Not a role: $role'),
|
||||
};
|
||||
if (parts.isEmpty || !parts.every(holds)) {
|
||||
throw FormatException('Not a turn of $role.');
|
||||
}
|
||||
return role == 'user'
|
||||
? LlmMessage.user(parts)
|
||||
: LlmMessage.assistant(parts);
|
||||
}
|
||||
throw const FormatException('Not a message.');
|
||||
}
|
||||
|
||||
LlmPart _partFromJson(Object? json) => switch (json) {
|
||||
{'type': 'text', 'text': final String text} => LlmText(
|
||||
text,
|
||||
extra: _object(json['extra']),
|
||||
),
|
||||
{
|
||||
'type': 'call',
|
||||
'id': final String id,
|
||||
'name': final String name,
|
||||
'input': final Map<String, Object?> input,
|
||||
} =>
|
||||
LlmToolCall(
|
||||
id: id,
|
||||
name: name,
|
||||
input: input,
|
||||
extra: _object(json['extra']),
|
||||
),
|
||||
{
|
||||
'type': 'result',
|
||||
'call': final String callId,
|
||||
'content': final String content,
|
||||
} =>
|
||||
LlmToolResult(
|
||||
callId: callId,
|
||||
content: content,
|
||||
isError: json['error'] == true,
|
||||
),
|
||||
{
|
||||
'type': 'provider',
|
||||
'protocol': final String protocol,
|
||||
'block': final Map<String, Object?> block,
|
||||
} =>
|
||||
LlmProviderBlock(
|
||||
protocol:
|
||||
LlmProtocol.named(protocol) ??
|
||||
(throw FormatException('Not a protocol: $protocol')),
|
||||
block: block,
|
||||
),
|
||||
_ => throw const FormatException('Not a part.'),
|
||||
};
|
||||
|
||||
Map<String, Object?> _object(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => map,
|
||||
_ => const {},
|
||||
};
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/watch_reads.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
/// A conversation not kept: this device can't seal it with the person's
|
||||
/// key, and nothing is kept unsealed (ADR 0021, D52).
|
||||
final class const ConversationNotKept() implements Exception;
|
||||
|
||||
/// The person's conversations with assistants (ADR 0021, D13, D51): sealed
|
||||
/// with their key, synced to their devices, a turn appended for each
|
||||
/// question and never changed, kept until they delete one.
|
||||
class ConversationRepository(final ConversationGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
late final StreamSubscription<void> _remoteChanges;
|
||||
|
||||
/// Every conversation, the newest first, now and after every change,
|
||||
/// local or synced. A read that fails arrives as an error; the next
|
||||
/// change reads again.
|
||||
Stream<List<AssistantConversation>> watch() =>
|
||||
watchReads(_changes.stream, all, what: 'conversations', logName: _log);
|
||||
|
||||
/// The turns of the conversation [id] in order, now and after every
|
||||
/// change, as [watch].
|
||||
Stream<List<AssistantTurn>> watchTurns(String id) => watchReads(
|
||||
_changes.stream,
|
||||
() => turnsOf(id),
|
||||
what: 'turns',
|
||||
logName: _log,
|
||||
);
|
||||
|
||||
/// Every conversation on this device now, the newest first.
|
||||
Future<List<AssistantConversation>> all() async => [
|
||||
for (final row in await _gateway.conversations()) ?_decodeConversation(row),
|
||||
]..sort((a, b) => b.id.compareTo(a.id));
|
||||
|
||||
/// The turns of the conversation [id] on this device now, in the order
|
||||
/// they were kept (their UUID v7 ids', ADR 0001, rule 7).
|
||||
Future<List<AssistantTurn>> turnsOf(String id) async => [
|
||||
for (final row in await _gateway.turnsOf(id)) ?_decodeTurn(row),
|
||||
]..sort((a, b) => a.id.compareTo(b.id));
|
||||
|
||||
/// Whether this device keeps conversations: only where it seals them.
|
||||
Future<bool> keeps() => _gateway.seals();
|
||||
|
||||
/// Starts a conversation with [assistant] in [scope], titled by its first
|
||||
/// [question]; returns its id. Throws [ConversationNotKept] where this
|
||||
/// device can't seal it, having written nothing.
|
||||
Future<String> start({
|
||||
required String question,
|
||||
required AssistantRef assistant,
|
||||
required AssistantScope scope,
|
||||
}) async {
|
||||
await _refuseUnsealed();
|
||||
final id = await _gateway.insertConversation(
|
||||
encodeConversationPayload(
|
||||
title: AssistantConversation.titleOf(question),
|
||||
assistant: assistant,
|
||||
scope: scope,
|
||||
),
|
||||
);
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Keeps a turn of the conversation [id]: its [messages], the question
|
||||
/// first, and what their requests counted. Throws [ConversationNotKept]
|
||||
/// as [start] does.
|
||||
Future<void> keep(
|
||||
String id,
|
||||
List<LlmMessage> messages,
|
||||
LlmUsage usage,
|
||||
) async {
|
||||
await _refuseUnsealed();
|
||||
await _gateway.insertTurn(id, encodeTurnPayload(messages, usage));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the conversation [id] with its turns, blanked first: the sync
|
||||
/// engine keeps deleted rows until their space goes (ADR 0002, rule 2).
|
||||
Future<void> delete(String id) async {
|
||||
await _gateway.blankConversation(id);
|
||||
await _gateway.deleteConversation(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Stops following remote changes.
|
||||
Future<void> dispose() async {
|
||||
await _remoteChanges.cancel();
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
Future<void> _refuseUnsealed() async {
|
||||
if (!await _gateway.seals()) {
|
||||
throw const ConversationNotKept();
|
||||
}
|
||||
}
|
||||
|
||||
static const _log = 'Conversations';
|
||||
|
||||
AssistantConversation? _decodeConversation(ConversationRow row) {
|
||||
try {
|
||||
return decodeConversationPayload(
|
||||
row.id,
|
||||
row.payload,
|
||||
startedAt: row.startedAt ?? DateTime.utc(1970),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped conversation ${row.id}: ${error.message}', name: _log);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
AssistantTurn? _decodeTurn(TurnRow row) {
|
||||
try {
|
||||
return decodeTurnPayload(
|
||||
row.id,
|
||||
row.payload,
|
||||
writtenAt: row.writtenAt ?? DateTime.utc(1970),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped turn ${row.id}: ${error.message}', name: _log);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [ConversationGateway] on the person's local database, which the sync
|
||||
/// engine keeps in step with their other devices. Everything is sealed with
|
||||
/// the person's key ([Privacy], ADR 0021, D13), a turn bound to its
|
||||
/// conversation, so the instance can't move it to another; a row that isn't
|
||||
/// sealed is never read, as nothing here writes one (D52).
|
||||
class SyncConversationGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements ConversationGateway {
|
||||
static const _conversations = 'assistant_conversation';
|
||||
static const _turns = 'assistant_turn';
|
||||
|
||||
@override
|
||||
Future<List<ConversationRow>> conversations() async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final row in await protocol.AssistantConversation.db.find(_db))
|
||||
if (_opened(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: _conversations,
|
||||
id: row.id!.uuid,
|
||||
bound: '',
|
||||
)
|
||||
case final payload?)
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
payload: payload,
|
||||
startedAt: uuidV7Time(row.id!.uuid),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TurnRow>> turnsOf(String conversationId) async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final row in await protocol.AssistantTurn.db.find(
|
||||
_db,
|
||||
where: (t) => t.conversationId.equals(_uuid(conversationId)),
|
||||
))
|
||||
if (_opened(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: _turns,
|
||||
id: row.id!.uuid,
|
||||
bound: 'conversation:$conversationId',
|
||||
)
|
||||
case final payload?)
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
payload: payload,
|
||||
writtenAt: uuidV7Time(row.id!.uuid),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// [envelope]'s payload where it is sealed and opens here; null for one
|
||||
/// that is readable (nothing here writes one: the instance made it up),
|
||||
/// blank (being deleted), or doesn't open.
|
||||
ByteData? _opened(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
required String bound,
|
||||
}) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_privacy.open(
|
||||
envelope,
|
||||
table: table,
|
||||
id: id,
|
||||
bound: bound,
|
||||
)) {
|
||||
Readable(:final payload) when payload.lengthInBytes > 0 => payload,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> seals() async {
|
||||
try {
|
||||
return await _privacy.personalKey() != null;
|
||||
} on PrivacyRefused {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The person's key, which everything here is sealed with; refused where
|
||||
/// this device has none (the repository asks [seals] first).
|
||||
Future<String> _key() async =>
|
||||
await _privacy.personalKey() ??
|
||||
(throw const PrivacyRefused(PrivacyRefusal.keysMissing));
|
||||
|
||||
@override
|
||||
Future<String> insertConversation(ByteData payload) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _key(),
|
||||
table: _conversations,
|
||||
id: id.uuid,
|
||||
bound: '',
|
||||
);
|
||||
await protocol.AssistantConversation.db.insertRow(
|
||||
_db,
|
||||
protocol.AssistantConversation(
|
||||
id: id,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return id.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertTurn(String conversationId, ByteData payload) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _key(),
|
||||
table: _turns,
|
||||
id: id.uuid,
|
||||
bound: 'conversation:$conversationId',
|
||||
);
|
||||
await protocol.AssistantTurn.db.insertRow(
|
||||
_db,
|
||||
protocol.AssistantTurn(
|
||||
id: id,
|
||||
conversationId: _uuid(conversationId),
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return id.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankConversation(String conversationId) async {
|
||||
final id = _uuid(conversationId);
|
||||
// Column-scoped updates, which sync like any other edit (ADR 0002,
|
||||
// rule 2).
|
||||
await protocol.AssistantTurn.db.update(
|
||||
_db,
|
||||
[
|
||||
for (final turn in await protocol.AssistantTurn.db.find(
|
||||
_db,
|
||||
where: (t) => t.conversationId.equals(id),
|
||||
))
|
||||
turn.copyWith(payload: redactedPayload),
|
||||
],
|
||||
columns: (t) => [t.payload],
|
||||
);
|
||||
if (await protocol.AssistantConversation.db.findById(_db, id)
|
||||
case final conversation?) {
|
||||
await protocol.AssistantConversation.db.updateRow(
|
||||
_db,
|
||||
conversation.copyWith(payload: redactedPayload),
|
||||
columns: (t) => [t.payload],
|
||||
);
|
||||
}
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteConversation(String conversationId) async {
|
||||
final id = _uuid(conversationId);
|
||||
await protocol.AssistantTurn.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.conversationId.equals(id),
|
||||
);
|
||||
await protocol.AssistantConversation.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(id),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
static protocol.UuidValue _uuid(String id) =>
|
||||
protocol.UuidValue.withValidation(id);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
/// Which assistant a conversation is with (ADR 0021): one the operator
|
||||
/// granted, by the instance's ID of it, or one with the person's own key,
|
||||
/// by its ID in their settings.
|
||||
final class const AssistantRef({
|
||||
/// Its ID.
|
||||
required final String id,
|
||||
|
||||
/// Whether it is one of the person's own.
|
||||
final bool own = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, own];
|
||||
}
|
||||
|
||||
/// How far a question reaches beyond the parts ticked (ADR 0021, D7, D49):
|
||||
/// nothing, the studyset asked on, its tree, or the whole library.
|
||||
enum AssistantReach() {
|
||||
/// Only the parts ticked; the model looks nothing up (D50).
|
||||
ticked,
|
||||
|
||||
/// The studyset asked on, with everything inside it.
|
||||
studyset,
|
||||
|
||||
/// The studyset asked on's tree, from its top studyset down.
|
||||
tree,
|
||||
|
||||
/// Every studyset of the person's.
|
||||
library,
|
||||
}
|
||||
|
||||
/// Where a conversation's questions are asked and how far they reach.
|
||||
final class const AssistantScope({
|
||||
/// How far they reach.
|
||||
final AssistantReach reach = AssistantReach.ticked,
|
||||
|
||||
/// The studyset asked on; null on Home or in the Library.
|
||||
final String? studysetId,
|
||||
|
||||
/// Whether private studysets are within [reach] (D8): never unless the
|
||||
/// person switches it on.
|
||||
final bool includePrivate = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [reach, studysetId, includePrivate];
|
||||
}
|
||||
|
||||
/// A conversation with an assistant, kept sealed (ADR 0021, D13, D51): its
|
||||
/// [title], who answers, and its scope; its turns are kept apart.
|
||||
final class const AssistantConversation({
|
||||
/// Its ID, a UUID v7.
|
||||
required final String id,
|
||||
|
||||
/// What it is called: the first question, cut short.
|
||||
required final String title,
|
||||
|
||||
/// Who answers.
|
||||
required final AssistantRef assistant,
|
||||
|
||||
/// Where it was asked and how far its questions reach.
|
||||
required final AssistantScope scope,
|
||||
|
||||
/// When it was started.
|
||||
required final DateTime startedAt,
|
||||
}) with Equatable {
|
||||
/// How long a [title] is at most.
|
||||
static const maxTitleLength = 80;
|
||||
|
||||
/// [question] as a title: its first line, cut to [maxTitleLength].
|
||||
static String titleOf(String question) {
|
||||
final line = question.trim().split('\n').first.trim();
|
||||
return line.length <= maxTitleLength
|
||||
? line
|
||||
: '${line.substring(0, maxTitleLength - 1)}…';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, title, assistant, scope, startedAt];
|
||||
}
|
||||
|
||||
/// One turn of a conversation, never changed once kept: the person's
|
||||
/// question and every message up to the model's answer, the lookups' calls
|
||||
/// and results among them, as they are sent back with a follow-up.
|
||||
final class const AssistantTurn({
|
||||
/// Its ID, a UUID v7, by which turns are in order.
|
||||
required final String id,
|
||||
|
||||
/// Its messages, in order: the question first.
|
||||
required final List<LlmMessage> messages,
|
||||
|
||||
/// What its requests counted, together.
|
||||
final LlmUsage usage = LlmUsage.none,
|
||||
|
||||
/// When it was kept.
|
||||
required final DateTime writtenAt,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, messages, usage, writtenAt];
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
// A conversation's and its turns' payloads (ADR 0021, D51): what a follow-up
|
||||
// sends back must come back from them as it was sent.
|
||||
|
||||
final _at = DateTime.utc(2026, 10, 26, 9);
|
||||
|
||||
void main() {
|
||||
group('a conversation', () {
|
||||
test('keeps its title, assistant and scope', () {
|
||||
const scope = AssistantScope(
|
||||
reach: AssistantReach.tree,
|
||||
studysetId: 'studyset-1',
|
||||
includePrivate: true,
|
||||
);
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
encodeConversationPayload(
|
||||
title: 'Why て-form here?',
|
||||
assistant: const AssistantRef(id: 'own-1', own: true),
|
||||
scope: scope,
|
||||
),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(
|
||||
conversation,
|
||||
AssistantConversation(
|
||||
id: 'c-1',
|
||||
title: 'Why て-form here?',
|
||||
assistant: const AssistantRef(id: 'own-1', own: true),
|
||||
scope: scope,
|
||||
startedAt: _at,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('starts with only what is ticked, and nothing private (D50, D8)', () {
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
encodeConversationPayload(
|
||||
title: 'Hi',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(),
|
||||
),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(conversation.scope, const AssistantScope());
|
||||
expect(conversation.assistant.own, isFalse);
|
||||
});
|
||||
|
||||
test("refuses one of a newer app's schema", () {
|
||||
expect(
|
||||
() => decodeConversationPayload(
|
||||
'c-1',
|
||||
encodePayload(conversationSchemaVersion + 1, {}),
|
||||
startedAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a reach it does not know', () {
|
||||
expect(
|
||||
() => decodeConversationPayload(
|
||||
'c-1',
|
||||
encodePayload(conversationSchemaVersion, {
|
||||
'title': 'Hi',
|
||||
'assistant': {'id': 'claude'},
|
||||
'scope': {'reach': 'everything'},
|
||||
}),
|
||||
startedAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('is titled by its first line, cut short', () {
|
||||
expect(AssistantConversation.titleOf(' Why?\nAnd how?'), 'Why?');
|
||||
final long = AssistantConversation.titleOf('a' * 200);
|
||||
expect(long, hasLength(AssistantConversation.maxTitleLength));
|
||||
expect(long, endsWith('…'));
|
||||
});
|
||||
});
|
||||
|
||||
group('a turn', () {
|
||||
test('keeps every part a follow-up sends back, as it came', () {
|
||||
final messages = [
|
||||
LlmMessage.user([const LlmText('Which verbs are irregular?')]),
|
||||
LlmMessage.assistant([
|
||||
const LlmProviderBlock(
|
||||
protocol: LlmProtocol.anthropic,
|
||||
block: {'type': 'thinking', 'thinking': '…', 'signature': 'sig'},
|
||||
),
|
||||
const LlmText('Let me look.', extra: {'phase': 'commentary'}),
|
||||
const LlmToolCall(
|
||||
id: 'call-1',
|
||||
name: 'find_cards',
|
||||
input: {'query': 'する'},
|
||||
extra: {
|
||||
'extra_content': {'google': 'x'},
|
||||
},
|
||||
),
|
||||
]),
|
||||
LlmMessage.user([
|
||||
const LlmToolResult(callId: 'call-1', content: '[]', isError: true),
|
||||
]),
|
||||
LlmMessage.assistant([const LlmText('する and 来る.')]),
|
||||
];
|
||||
|
||||
final turn = decodeTurnPayload(
|
||||
't-1',
|
||||
encodeTurnPayload(
|
||||
messages,
|
||||
const LlmUsage(inputTokens: 900, outputTokens: 120),
|
||||
),
|
||||
writtenAt: _at,
|
||||
);
|
||||
|
||||
expect(
|
||||
encodeTurnPayload(turn.messages, turn.usage),
|
||||
_same(
|
||||
encodeTurnPayload(
|
||||
messages,
|
||||
const LlmUsage(inputTokens: 900, outputTokens: 120),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(turn.usage, const LlmUsage(inputTokens: 900, outputTokens: 120));
|
||||
expect(turn.writtenAt, _at);
|
||||
final call = turn.messages[1].toolCalls.single;
|
||||
expect(call.input, {'query': 'する'});
|
||||
expect(call.extra, {
|
||||
'extra_content': {'google': 'x'},
|
||||
});
|
||||
expect((turn.messages[2].parts.single as LlmToolResult).isError, isTrue);
|
||||
});
|
||||
|
||||
test('keeps no image, only that one was sent', () {
|
||||
final turn = decodeTurnPayload(
|
||||
't-1',
|
||||
encodeTurnPayload([
|
||||
LlmMessage.user([
|
||||
const LlmText('What is this?'),
|
||||
LlmImage(mediaType: 'image/png', bytes: Uint8List(1024)),
|
||||
]),
|
||||
], LlmUsage.none),
|
||||
writtenAt: _at,
|
||||
);
|
||||
|
||||
expect(turn.messages.single.parts, hasLength(2));
|
||||
expect((turn.messages.single.parts.last as LlmText).text, imageNotKept);
|
||||
});
|
||||
|
||||
test('refuses a part it does not know', () {
|
||||
expect(
|
||||
() => decodeTurnPayload(
|
||||
't-1',
|
||||
encodePayload(turnSchemaVersion, {
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [
|
||||
{'type': 'audio'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
writtenAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a part its role can't hold", () {
|
||||
expect(
|
||||
() => decodeTurnPayload(
|
||||
't-1',
|
||||
encodePayload(turnSchemaVersion, {
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [
|
||||
{
|
||||
'type': 'call',
|
||||
'id': 'c',
|
||||
'name': 'n',
|
||||
'input': <String, Object?>{},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
writtenAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The same bytes as [expected].
|
||||
Matcher _same(ByteData expected) => predicate<ByteData>(
|
||||
(actual) =>
|
||||
Uint8List.sublistView(actual).toString() ==
|
||||
Uint8List.sublistView(expected).toString(),
|
||||
'the same payload',
|
||||
);
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
import '../../../support/fake_conversation_gateway.dart';
|
||||
|
||||
// The person's conversations with assistants, kept sealed, a turn appended
|
||||
// for each question (ADR 0021, D13, D51), and nothing kept where this
|
||||
// device can't seal (D52).
|
||||
|
||||
const _claude = AssistantRef(id: 'claude');
|
||||
const _scope = AssistantScope(studysetId: 'studyset-1');
|
||||
|
||||
List<LlmMessage> _turn(String question, String answer) => [
|
||||
LlmMessage.user([LlmText(question)]),
|
||||
LlmMessage.assistant([LlmText(answer)]),
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeConversationGateway gateway;
|
||||
late ConversationRepository conversations;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeConversationGateway();
|
||||
conversations = ConversationRepository(gateway);
|
||||
});
|
||||
tearDown(() => conversations.dispose());
|
||||
|
||||
test('starts a conversation titled by its first question', () async {
|
||||
final id = await conversations.start(
|
||||
question: 'Why て-form here?\nIn the second card.',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
final kept = await conversations.all();
|
||||
expect(kept.single.id, id);
|
||||
expect(kept.single.title, 'Why て-form here?');
|
||||
expect(kept.single.assistant, _claude);
|
||||
expect(kept.single.scope, _scope);
|
||||
});
|
||||
|
||||
test('keeps a turn for each question, in order', () async {
|
||||
final id = await conversations.start(
|
||||
question: 'First',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.keep(
|
||||
id,
|
||||
_turn('First', 'One'),
|
||||
const LlmUsage(inputTokens: 10, outputTokens: 2),
|
||||
);
|
||||
await conversations.keep(id, _turn('Then?', 'Two'), LlmUsage.none);
|
||||
|
||||
final turns = await conversations.turnsOf(id);
|
||||
expect([for (final turn in turns) turn.messages.last.text], ['One', 'Two']);
|
||||
expect(turns.first.usage, const LlmUsage(inputTokens: 10, outputTokens: 2));
|
||||
});
|
||||
|
||||
test('lists the newest first', () async {
|
||||
await conversations.start(
|
||||
question: 'Older',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.start(
|
||||
question: 'Newer',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final each in await conversations.all()) each.title],
|
||||
[
|
||||
'Newer',
|
||||
'Older',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('follows changes made here and synced from elsewhere', () async {
|
||||
final watched = expectLater(
|
||||
conversations.watch().map((all) => [for (final c in all) c.title]),
|
||||
emitsInOrder([
|
||||
isEmpty,
|
||||
['Here'],
|
||||
['There', 'Here'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await conversations.start(
|
||||
question: 'Here',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
gateway.receiveConversation(
|
||||
'conversation-900',
|
||||
encodeConversationPayload(
|
||||
title: 'There',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
),
|
||||
);
|
||||
await watched;
|
||||
});
|
||||
|
||||
test(
|
||||
'deleting one blanks it and its turns first (ADR 0002, rule 2)',
|
||||
() async {
|
||||
final id = await conversations.start(
|
||||
question: 'Gone',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.keep(id, _turn('Gone', 'Yes'), LlmUsage.none);
|
||||
|
||||
await conversations.delete(id);
|
||||
|
||||
expect(gateway.blanked, [id]);
|
||||
expect(await conversations.all(), isEmpty);
|
||||
expect(await conversations.turnsOf(id), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('skips what it cannot read, and keeps the rest', () async {
|
||||
gateway.receiveConversation(
|
||||
'conversation-900',
|
||||
encodeTurnPayload(
|
||||
_turn('a', 'b'),
|
||||
LlmUsage.none,
|
||||
),
|
||||
);
|
||||
await conversations.start(
|
||||
question: 'Readable',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final each in await conversations.all()) each.title],
|
||||
[
|
||||
'Readable',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
group("where this device can't seal (D52)", () {
|
||||
setUp(() => gateway.sealing = false);
|
||||
|
||||
test('says it keeps nothing', () async {
|
||||
expect(await conversations.keeps(), isFalse);
|
||||
});
|
||||
|
||||
test('refuses to start a conversation, writing nothing', () async {
|
||||
await expectLater(
|
||||
conversations.start(question: 'Hi', assistant: _claude, scope: _scope),
|
||||
throwsA(isA<ConversationNotKept>()),
|
||||
);
|
||||
expect(gateway.written, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses to keep a turn, writing nothing', () async {
|
||||
gateway.sealing = true;
|
||||
final id = await conversations.start(
|
||||
question: 'Hi',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
gateway.sealing = false;
|
||||
|
||||
await expectLater(
|
||||
conversations.keep(id, _turn('Hi', 'Hello'), LlmUsage.none),
|
||||
throwsA(isA<ConversationNotKept>()),
|
||||
);
|
||||
expect(gateway.written.keys, [id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The conversations on the device's own database (ADR 0021, D13, D51, D52):
|
||||
// sealed with the person's key, every row; nothing written where the device
|
||||
// can't seal; and what an instance could make up or move left unread.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
ByteData _text(String text) => ByteData.sublistView(utf8.encode(text));
|
||||
|
||||
/// [payload] as text; sealed bytes, which needn't be UTF-8, as far as they
|
||||
/// read.
|
||||
String _read(ByteData payload) =>
|
||||
utf8.decode(Uint8List.sublistView(payload), allowMalformed: true);
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
final opened = <({ContentKeys keys, Privacy privacy})>[];
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('conversations');
|
||||
db = await protocol.Client(
|
||||
'http://127.0.0.1:9/',
|
||||
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
for (final device in opened) {
|
||||
await device.privacy.dispose();
|
||||
await device.keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// The gateway of a device holding the account's keys, or of one without
|
||||
/// them ([account] null) where the register says the account [hasKeys].
|
||||
Future<SyncConversationGateway> device({
|
||||
UnlockedAccountKeys? account,
|
||||
bool hasKeys = true,
|
||||
}) async {
|
||||
final ledger = PrivacyLedger(MemoryPrivacyLedgerStore());
|
||||
await ledger.open();
|
||||
final keys = ContentKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
FakeContentCipher(),
|
||||
() async => account,
|
||||
seen: ledger,
|
||||
registerHasKeys: () async => hasKeys,
|
||||
);
|
||||
await keys.refresh();
|
||||
final privacy = Privacy(db, keys);
|
||||
opened.add((keys: keys, privacy: privacy));
|
||||
return SyncConversationGateway(db, sync, privacy);
|
||||
}
|
||||
|
||||
final alice = FakeAccountCrypto().keysNumbered(1);
|
||||
|
||||
test('writes every row sealed, and reads it back', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Why て-form?'));
|
||||
await gateway.insertTurn(id, _text('Because.'));
|
||||
|
||||
final row = (await protocol.AssistantConversation.db.find(db)).single;
|
||||
expect(row.keyId, isNotNull);
|
||||
expect(_read(row.payload), isNot(contains('て-form')));
|
||||
final turn = (await protocol.AssistantTurn.db.find(db)).single;
|
||||
expect(turn.keyId, isNotNull);
|
||||
|
||||
expect(
|
||||
_read((await gateway.conversations()).single.payload),
|
||||
'Why て-form?',
|
||||
);
|
||||
expect(_read((await gateway.turnsOf(id)).single.payload), 'Because.');
|
||||
expect(await gateway.seals(), isTrue);
|
||||
});
|
||||
|
||||
test("a device without the account's keys neither seals nor reads", () async {
|
||||
final laptop = await device(account: alice);
|
||||
final id = await laptop.insertConversation(_text('Mine'));
|
||||
final keyless = await device();
|
||||
|
||||
expect(await keyless.seals(), isFalse);
|
||||
expect(await keyless.conversations(), isEmpty);
|
||||
await expectLater(
|
||||
keyless.insertConversation(_text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
keyless.insertTurn(id, _text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
expect(await protocol.AssistantConversation.db.count(db), 1);
|
||||
expect(await protocol.AssistantTurn.db.count(db), 0);
|
||||
});
|
||||
|
||||
test('an account without keys keeps none (D52)', () async {
|
||||
final gateway = await device(hasKeys: false);
|
||||
|
||||
expect(await gateway.seals(), isFalse);
|
||||
await expectLater(
|
||||
gateway.insertConversation(_text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a readable row, which only an instance could have made, is never '
|
||||
'read', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Mine'));
|
||||
await protocol.AssistantConversation.db.insertRow(
|
||||
db,
|
||||
protocol.AssistantConversation(payload: _text('Made up')),
|
||||
);
|
||||
await protocol.AssistantTurn.db.insertRow(
|
||||
db,
|
||||
protocol.AssistantTurn(
|
||||
conversationId: protocol.UuidValue.withValidation(id),
|
||||
payload: _text('Made up'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final row in await gateway.conversations()) _read(row.payload),
|
||||
],
|
||||
['Mine'],
|
||||
);
|
||||
expect(await gateway.turnsOf(id), isEmpty);
|
||||
});
|
||||
|
||||
test('a turn moved to another conversation no longer opens', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final first = await gateway.insertConversation(_text('First'));
|
||||
final second = await gateway.insertConversation(_text('Second'));
|
||||
await gateway.insertTurn(first, _text('Of the first'));
|
||||
final turn = (await protocol.AssistantTurn.db.find(db)).single;
|
||||
await protocol.AssistantTurn.db.updateRow(
|
||||
db,
|
||||
turn.copyWith(conversationId: protocol.UuidValue.withValidation(second)),
|
||||
columns: (t) => [t.conversationId],
|
||||
);
|
||||
|
||||
expect(await gateway.turnsOf(second), isEmpty);
|
||||
expect(await gateway.turnsOf(first), isEmpty);
|
||||
});
|
||||
|
||||
test('blanks a conversation and its turns, then deletes them', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Gone'));
|
||||
await gateway.insertTurn(id, _text('Turn'));
|
||||
|
||||
await gateway.blankConversation(id);
|
||||
expect(
|
||||
(await protocol.AssistantTurn.db.find(db)).single.payload.lengthInBytes,
|
||||
0,
|
||||
);
|
||||
expect(await gateway.conversations(), isEmpty);
|
||||
|
||||
await gateway.deleteConversation(id);
|
||||
expect(await protocol.AssistantConversation.db.count(db), 0);
|
||||
expect(await protocol.AssistantTurn.db.count(db), 0);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue