refactor(base,learn): people's files kept by halkyon_base
ADR 0043, D2, D4, D6 and D7, the first step towards Todo's attachments: Learn's file store moves into the module, for every server that keeps files, and Learn becomes its first host. halkyon_base gets the store as Learn had it: uploads into the caller's own space by SHA-256, downloads by a row the caller may read, each person's room (FILE_LIMIT_IDENTIFIED_MB, FILE_LIMIT_INSTANCE_MB, the operator's file_allowance, which moves into the module unchanged) and the pruning of files no live row names. It stays free of the sync engine: a host hands it a FileStore saying which of its rows name a file and what it takes (HalkyonBase.files); without one the files endpoint refuses. Its tests, with refusals, run on a stand-in host. Learn hands it learnFiles: its asset rows, read by their space's members, a readable file over 20 MB only as a PDF, up to 100 MB. Its app calls the module; its own asset endpoint stays for released apps alone, forwarding to the module in Learn's own models, whose names are on the wire. Its file tests still pass through it, end to end. Migrations: the module's creates file_allowance. Learn's server migration holds version rows alone, as tables are matched by name (ADR 0014); its device migration creates the module's empty table, as every module's. Todo's, Train's and Meditate's create the empty table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
59fb9c4d6d
commit
b7f0aaaec7
96 changed files with 112084 additions and 348 deletions
|
|
@ -9,7 +9,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
|||
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`; the apps' icons are the root's `tool/app_icons.py`, ADR 0031). Learn is the app that runs; Train, Todo and Meditate run too (ADR 0030, ADR 0033, ADR 0038), Todo and Meditate not released yet.
|
||||
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the navigation bar flush with the foot with its fade, and the rail, a banner above every tab, `NavigationFrame.banner`, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
|
||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), people's files, kept by space and SHA-256 in the server's private storage with the room each may take and the pruning, for a host that keeps them (`HalkyonBase.files`, `FileStore`: which of its rows name a file and what it takes; Halkyon's ADR 0043; Learn's `asset` rows, Todo's attachments), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, held back on a metered network as the device's `MobileDataGate` allows (ADR 0042: Android's flag through halkyon_app's own Android plugin, `HalkyonAppPlugin.java`, in Java so that it applies no Kotlin Gradle Plugin, `FakeNetworkWatch` in `testing.dart`; `mobileDataBannerOf` above every tab in each app's frame; the setting in `SyncStatusCard`; an app's files ask `SyncService.filesAllowed`), the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput`: on Android the voice input app handling `RECOGNIZE_SPEECH` where one is installed, `VoiceInputApp` on each app's `VoiceInput.kt`, `app.halkyon/voice_input`, FUTO Voice Input for one, which records itself and returns text, else `speech_to_text`; `FakeSpeechInput` in `testing.dart`; none on Linux; Meditate's ADR 0038, D18) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
|
||||
- `halkyon_editor` (ADR 0033, D37, 6 Nov 2026): rich text, the general parts of Learn's editor on our fork of `appflowy_editor`: `RichContent` (the editor's document JSON, which Learn and Todo store), `RichContentEditor`, `RichContentView`, the format bar, links, the clipboard keeping formats, superscript and subscript, and `EditorBlocks`, through which an app adds blocks, marks within lines and their buttons (`basicBlocks` without any), with texts of its own (English, German). What is Learn's alone stays in Learn on top of it.
|
||||
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
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,71 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "createTable",
|
||||
"createTable": {
|
||||
"__className__": "serverpod.TableDefinition",
|
||||
"name": "file_allowance",
|
||||
"dartName": "FileAllowance",
|
||||
"module": "halkyon_base",
|
||||
"schema": "public",
|
||||
"columns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "id",
|
||||
"fieldName": "id",
|
||||
"columnType": 6,
|
||||
"isNullable": false,
|
||||
"columnDefault": "serial",
|
||||
"dartType": "int?"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "authUserId",
|
||||
"fieldName": "authUserId",
|
||||
"columnType": 7,
|
||||
"isNullable": false,
|
||||
"dartType": "UuidValue"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "maxBytes",
|
||||
"fieldName": "maxBytes",
|
||||
"columnType": 6,
|
||||
"isNullable": false,
|
||||
"dartType": "int"
|
||||
},
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "note",
|
||||
"fieldName": "note",
|
||||
"columnType": 0,
|
||||
"isNullable": true,
|
||||
"dartType": "String?"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [],
|
||||
"indexes": [
|
||||
{
|
||||
"__className__": "serverpod.IndexDefinition",
|
||||
"indexName": "file_allowance_person_idx",
|
||||
"elements": [
|
||||
{
|
||||
"__className__": "serverpod.IndexElementDefinition",
|
||||
"type": 0,
|
||||
"definition": "authUserId"
|
||||
}
|
||||
],
|
||||
"type": "btree",
|
||||
"isUnique": true,
|
||||
"isPrimary": false
|
||||
}
|
||||
],
|
||||
"managed": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
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,6 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ part '20261025135744595-waiting-accounts/migration.dart';
|
|||
part '20261025165102861-assistants/migration.dart';
|
||||
part '20261025184004860-assistant-conversations/migration.dart';
|
||||
part '20261026140329810-assistant-budgets/migration.dart';
|
||||
part '20261110225336783-file-store-in-halkyon-base/migration.dart';
|
||||
part '20261110225358224-file-store-in-halkyon-base/migration.dart';
|
||||
|
||||
/// Migration registry for the client-side database.
|
||||
class MigrationRegistry {
|
||||
|
|
@ -70,6 +72,8 @@ class MigrationRegistry {
|
|||
_Migration20261025165102861_assistants(),
|
||||
_Migration20261025184004860_assistant_conversations(),
|
||||
_Migration20261026140329810_assistant_budgets(),
|
||||
_Migration20261110225336783_file_store_in_halkyon_base(),
|
||||
_Migration20261110225358224_file_store_in_halkyon_base(),
|
||||
];
|
||||
|
||||
/// List of all client-side database migration versions.
|
||||
|
|
|
|||
|
|
@ -65,16 +65,12 @@ import 'protocol.dart' as _il2as5qe;
|
|||
import 'sync_tables.dart' as _ii3f3u05;
|
||||
import 'package:halkyon_learn_client/migrations/migration_registry.dart';
|
||||
|
||||
/// The files of people's content (ADR 0004, rule 5): images, sounds and
|
||||
/// PDFs, stored in the server's private storage at a path derived from the space
|
||||
/// and the file's SHA-256, so a file added twice is stored once.
|
||||
///
|
||||
/// Uploading goes into the caller's own space, by the file's hash, before or
|
||||
/// after its asset row syncs: the server asks for the file only when it
|
||||
/// lacks it, and keeps it only when its content has that hash. Downloading
|
||||
/// goes by the asset row, from a space the caller may read; any other asks
|
||||
/// as if the file weren't there. Files no asset row refers to any more are
|
||||
/// pruned ([pruneUnreferencedFiles]).
|
||||
/// The files of people's content (ADR 0004, rule 5), kept by halkyon_base
|
||||
/// since Halkyon's ADR 0043 (its `files` endpoint, Learn's rows named by
|
||||
/// `learnFiles`). This endpoint stays for Learn's released apps alone (D7):
|
||||
/// each call is the module's, its refusals and room in Learn's own models,
|
||||
/// whose names are on the wire. Dropped once `minimumAppBuild` passes the
|
||||
/// first build calling the module.
|
||||
/// {@category Endpoint}
|
||||
class EndpointAsset extends _isc.EndpointRef {
|
||||
EndpointAsset(_isc.EndpointCaller caller) : super(caller);
|
||||
|
|
@ -82,11 +78,9 @@ class EndpointAsset extends _isc.EndpointRef {
|
|||
@override
|
||||
String get name => 'asset';
|
||||
|
||||
/// How to upload the caller's file with [sha256] (lowercase hex) of
|
||||
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
|
||||
/// 0019), which may be larger by [encryptionOverhead]; null when the
|
||||
/// server has it already. Throws a [FileLimitExceededException] when it
|
||||
/// doesn't fit (see [FileLimits]).
|
||||
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
|
||||
/// [encrypted] where a private studyset's; null when the server has it
|
||||
/// already. Throws a [FileLimitExceededException] when it doesn't fit.
|
||||
_ida.Future<String?> startUpload(
|
||||
String sha256,
|
||||
int byteSize, {
|
||||
|
|
@ -102,12 +96,7 @@ class EndpointAsset extends _isc.EndpointRef {
|
|||
);
|
||||
|
||||
/// Completes the upload of the caller's file with [sha256]; whether it is
|
||||
/// stored now. A file whose content has another hash is deleted, and so is
|
||||
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
|
||||
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
|
||||
/// (ADR 0019) can't be looked into: it must start as a sealed file does
|
||||
/// (`HLKF`, version 1), or it is deleted, and its size alone counts, as
|
||||
/// the quotas' sizes do.
|
||||
/// stored now.
|
||||
_ida.Future<bool> finishUpload(
|
||||
String sha256, {
|
||||
bool? encrypted,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
|||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
||||
as _ipulbpi2;
|
||||
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;
|
||||
|
|
@ -101,7 +100,6 @@ import 'tag/learning_path_tag.dart' as _i31v2f8r;
|
|||
import 'tag/note_tag.dart' as _itkfn57x;
|
||||
import 'tag/tag.dart' as _ie8t3ar4;
|
||||
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';
|
||||
|
|
@ -2575,9 +2573,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _ipb7vkxr.Asset) {
|
||||
return _ipb7vkxr.Asset.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i9uv0f1n.FileAllowance) {
|
||||
return _i9uv0f1n.FileAllowance.fromJson(data) as T;
|
||||
}
|
||||
if (t == _it5d94hq.FileLimitExceededException) {
|
||||
return _it5d94hq.FileLimitExceededException.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -2758,10 +2753,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_ipb7vkxr.Asset?>()) {
|
||||
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_i9uv0f1n.FileAllowance?>()) {
|
||||
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_it5d94hq.FileLimitExceededException?>()) {
|
||||
return (data != null
|
||||
? _it5d94hq.FileLimitExceededException.fromJson(data)
|
||||
|
|
@ -3136,7 +3127,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_ipb7vkxr.Asset => 'Asset',
|
||||
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||
_iurwo9k8.FileUsage => 'FileUsage',
|
||||
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
|
||||
|
|
@ -3215,8 +3205,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
switch (data) {
|
||||
case _ipb7vkxr.Asset():
|
||||
return 'Asset';
|
||||
case _i9uv0f1n.FileAllowance():
|
||||
return 'FileAllowance';
|
||||
case _it5d94hq.FileLimitExceededException():
|
||||
return 'FileLimitExceededException';
|
||||
case _iurwo9k8.FileUsage():
|
||||
|
|
@ -3374,9 +3362,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Asset') {
|
||||
return deserialize<_ipb7vkxr.Asset>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'FileAllowance') {
|
||||
return deserialize<_i9uv0f1n.FileAllowance>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'FileLimitExceededException') {
|
||||
return deserialize<_it5d94hq.FileLimitExceededException>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as base;
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
|
||||
|
||||
/// [AssetServerGateway] on the server's asset endpoint, uploading through
|
||||
/// Serverpod's own upload route.
|
||||
/// [AssetServerGateway] on halkyon_base's `files` endpoint (Halkyon's ADR
|
||||
/// 0043), uploading through Serverpod's own upload route. Learn's own
|
||||
/// `asset` endpoint stays for released apps alone (D7).
|
||||
class ServerpodAssetServerGateway(final protocol.Client _client)
|
||||
implements AssetServerGateway {
|
||||
@override
|
||||
|
|
@ -13,7 +15,11 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
|||
int byteSize, {
|
||||
bool encrypted = false,
|
||||
}) => _refusing(
|
||||
() => _client.asset.startUpload(sha256, byteSize, encrypted: encrypted),
|
||||
() => _client.modules.base.files.startUpload(
|
||||
sha256,
|
||||
byteSize,
|
||||
encrypted: encrypted,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -24,12 +30,15 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
|||
@override
|
||||
Future<bool> finishUpload(String sha256, {bool encrypted = false}) =>
|
||||
_refusing(
|
||||
() => _client.asset.finishUpload(sha256, encrypted: encrypted),
|
||||
() => _client.modules.base.files.finishUpload(
|
||||
sha256,
|
||||
encrypted: encrypted,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<FileRoom> room() async {
|
||||
final usage = await _client.asset.usage();
|
||||
final usage = await _client.modules.base.files.usage();
|
||||
return (usedBytes: usage.usedBytes, limitBytes: usage.limitBytes);
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +46,7 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
|||
Future<T> _refusing<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await call();
|
||||
} on protocol.FileLimitExceededException catch (error) {
|
||||
} on base.FileLimitExceededException catch (error) {
|
||||
throw FileLimitReached(
|
||||
instanceFull: error.instanceFull,
|
||||
limitBytes: error.limitBytes,
|
||||
|
|
@ -49,9 +58,11 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
|
|||
Future<Uint8List?> download(String id) async {
|
||||
try {
|
||||
return Uint8List.sublistView(
|
||||
await _client.asset.download(protocol.UuidValue.withValidation(id)),
|
||||
await _client.modules.base.files.download(
|
||||
protocol.UuidValue.withValidation(id),
|
||||
),
|
||||
);
|
||||
} on protocol.NotFoundException {
|
||||
} on base.StoredFileNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,201 +1,96 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_limits.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
|
||||
import 'package:halkyon_base_server/base.dart' as base;
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// The files of people's content (ADR 0004, rule 5): images, sounds and
|
||||
/// PDFs, stored in the server's private storage at a path derived from the space
|
||||
/// and the file's SHA-256, so a file added twice is stored once.
|
||||
///
|
||||
/// Uploading goes into the caller's own space, by the file's hash, before or
|
||||
/// after its asset row syncs: the server asks for the file only when it
|
||||
/// lacks it, and keeps it only when its content has that hash. Downloading
|
||||
/// goes by the asset row, from a space the caller may read; any other asks
|
||||
/// as if the file weren't there. Files no asset row refers to any more are
|
||||
/// pruned ([pruneUnreferencedFiles]).
|
||||
/// The files of people's content (ADR 0004, rule 5), kept by halkyon_base
|
||||
/// since Halkyon's ADR 0043 (its `files` endpoint, Learn's rows named by
|
||||
/// `learnFiles`). This endpoint stays for Learn's released apps alone (D7):
|
||||
/// each call is the module's, its refusals and room in Learn's own models,
|
||||
/// whose names are on the wire. Dropped once `minimumAppBuild` passes the
|
||||
/// first build calling the module.
|
||||
class AssetEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// The largest file accepted: a PDF (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09, for the books of the old app's library).
|
||||
static const maxBytes = 100 * 1024 * 1024;
|
||||
/// The largest file accepted: a PDF.
|
||||
static const maxBytes = base.FileStore.defaultMaxBytes;
|
||||
|
||||
/// The largest file of any other kind, an image or a sound. A larger one
|
||||
/// is checked to be a PDF once stored, and deleted otherwise.
|
||||
static const maxOtherBytes = 20 * 1024 * 1024;
|
||||
/// What encrypting adds to a file at most (ADR 0019).
|
||||
static const encryptionOverhead = base.FileStore.encryptionOverhead;
|
||||
|
||||
/// What encrypting adds to a file at most (ADR 0019): its key's ID, the
|
||||
/// nonce, the signature and the cipher's tag, with room to spare.
|
||||
static const encryptionOverhead = 4 * 1024;
|
||||
|
||||
/// How to upload the caller's file with [sha256] (lowercase hex) of
|
||||
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
|
||||
/// 0019), which may be larger by [encryptionOverhead]; null when the
|
||||
/// server has it already. Throws a [FileLimitExceededException] when it
|
||||
/// doesn't fit (see [FileLimits]).
|
||||
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
|
||||
/// [encrypted] where a private studyset's; null when the server has it
|
||||
/// already. Throws a [FileLimitExceededException] when it doesn't fit.
|
||||
Future<String?> startUpload(
|
||||
Session session,
|
||||
String sha256,
|
||||
int byteSize, {
|
||||
bool? encrypted,
|
||||
}) async {
|
||||
_checkHash(sha256);
|
||||
final most = maxBytes + (encrypted ?? false ? encryptionOverhead : 0);
|
||||
if (byteSize <= 0 || byteSize > most) {
|
||||
throw ArgumentError.value(byteSize, 'byteSize', 'is out of range');
|
||||
}
|
||||
final space = _caller(session);
|
||||
final path = assetPath(space, sha256);
|
||||
if (await session.storage.fileExists(storageId: assetStorage, path: path)) {
|
||||
// The file is claimed anew, so pruning leaves it alone for a while
|
||||
// even if the row that claims it hasn't synced yet.
|
||||
// UPDATE "serverpod_cloud_storage" SET "addedTime" = $1
|
||||
// WHERE "storageId" = 'private' AND "path" = $2
|
||||
await CloudStorageEntry.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.addedTime(clock.now().toUtc())],
|
||||
where: (t) => t.storageId.equals(assetStorage) & t.path.equals(path),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
checkRoom(await roomOf(session, space), byteSize);
|
||||
return await session.storage.createUploadDescription(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
options: UploadOptions(contentLength: byteSize, maxFileSize: most),
|
||||
);
|
||||
}
|
||||
}) => _inLearnTerms(
|
||||
() => base.startFileUpload(
|
||||
session,
|
||||
sha256,
|
||||
byteSize,
|
||||
encrypted: encrypted ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
/// Completes the upload of the caller's file with [sha256]; whether it is
|
||||
/// stored now. A file whose content has another hash is deleted, and so is
|
||||
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
|
||||
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
|
||||
/// (ADR 0019) can't be looked into: it must start as a sealed file does
|
||||
/// (`HLKF`, version 1), or it is deleted, and its size alone counts, as
|
||||
/// the quotas' sizes do.
|
||||
/// stored now.
|
||||
Future<bool> finishUpload(
|
||||
Session session,
|
||||
String sha256, {
|
||||
bool? encrypted,
|
||||
}) async {
|
||||
_checkHash(sha256);
|
||||
final path = assetPath(_caller(session), sha256);
|
||||
if (!await session.storage.verifyUpload(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
final bytes = await session.storage.retrieveFile(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
);
|
||||
if (_hashOf(bytes) != sha256 ||
|
||||
(encrypted ?? false
|
||||
? !_isSealed(bytes)
|
||||
: bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes)) ||
|
||||
bytes.lengthInBytes > maxBytes + encryptionOverhead) {
|
||||
await session.storage.deleteFile(storageId: assetStorage, path: path);
|
||||
return false;
|
||||
}
|
||||
// Checked again now that it is stored: uploads started side by side each
|
||||
// fitted on their own.
|
||||
try {
|
||||
checkRoom(
|
||||
await roomOf(session, _caller(session), finishedOnly: true),
|
||||
0,
|
||||
);
|
||||
} on FileLimitExceededException {
|
||||
await session.storage.deleteFile(storageId: assetStorage, path: path);
|
||||
rethrow;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}) => _inLearnTerms(
|
||||
() => base.finishFileUpload(
|
||||
session,
|
||||
sha256,
|
||||
encrypted: encrypted ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
/// How much room the caller's files take, and may take.
|
||||
Future<FileUsage> usage(Session session) async {
|
||||
final room = await roomOf(session, _caller(session));
|
||||
return FileUsage(usedBytes: room.used, limitBytes: effectiveLimit(room));
|
||||
final usage = await base.fileUsageOf(session);
|
||||
return FileUsage(
|
||||
usedBytes: usage.usedBytes,
|
||||
limitBytes: usage.limitBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// The file of the asset [assetId]. Throws a [NotFoundException] when the
|
||||
/// asset isn't in a space the caller may read, or its file isn't uploaded
|
||||
/// yet.
|
||||
Future<ByteData> download(Session session, UuidValue assetId) async {
|
||||
final asset = await Asset.db.findById(session, assetId);
|
||||
final spaceId = asset?.spaceId;
|
||||
final space = spaceId == null
|
||||
? null
|
||||
: await OfflineSyncSpace.db.findById(session, spaceId);
|
||||
final caller = _caller(session);
|
||||
if (asset == null ||
|
||||
space == null ||
|
||||
!(space.uuidSpaceId == caller ||
|
||||
await OfflineSyncSpaces(
|
||||
session,
|
||||
).roleOf(user: caller, space: space.uuidSpaceId) !=
|
||||
null)) {
|
||||
try {
|
||||
return await base.downloadFile(session, assetId);
|
||||
} on base.StoredFileNotFoundException {
|
||||
throw NotFoundException(what: 'file');
|
||||
}
|
||||
}
|
||||
|
||||
/// [call], its refusal of room in Learn's model.
|
||||
Future<T> _inLearnTerms<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await session.storage.retrieveFile(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(space.uuidSpaceId, asset.sha256),
|
||||
return await call();
|
||||
} on base.FileLimitExceededException catch (refused) {
|
||||
throw FileLimitExceededException(
|
||||
instanceFull: refused.instanceFull,
|
||||
limitBytes: refused.limitBytes,
|
||||
usedBytes: refused.usedBytes,
|
||||
);
|
||||
} on CloudStorageFileNotFoundException {
|
||||
throw NotFoundException(what: 'file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the file with [sha256] of the space [space] is stored.
|
||||
String assetPath(UuidValue space, String sha256) =>
|
||||
'$assetPathPrefix${space.uuid}/$sha256';
|
||||
base.storedFilePath(space, sha256);
|
||||
|
||||
/// The start of every stored file's path; a space's files are below
|
||||
/// `<prefix><space>/`.
|
||||
const assetPathPrefix = 'spaces/';
|
||||
/// The start of every stored file's path.
|
||||
const assetPathPrefix = base.storedFilePathPrefix;
|
||||
|
||||
/// The storage of every person's files.
|
||||
const assetStorage = 'private';
|
||||
|
||||
UuidValue _caller(Session session) =>
|
||||
UuidValue.withValidation(session.authenticated!.userIdentifier);
|
||||
|
||||
void _checkHash(String sha256) {
|
||||
if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(sha256)) {
|
||||
throw ArgumentError.value(sha256, 'sha256', 'is no SHA-256 in hex');
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [bytes] are a PDF's: they start as every PDF does.
|
||||
bool _isPdf(ByteData bytes) {
|
||||
const signature = '%PDF-';
|
||||
return bytes.lengthInBytes >= signature.length &&
|
||||
String.fromCharCodes(
|
||||
Uint8List.sublistView(bytes, 0, signature.length),
|
||||
) ==
|
||||
signature;
|
||||
}
|
||||
|
||||
/// Whether [bytes] are a sealed file's (ADR 0019): they start as the app
|
||||
/// seals every file, `HLKF` and its format's version, 1. What follows can't
|
||||
/// be looked into, but a readable file said to be sealed can't skip the
|
||||
/// PDF check unnoticed.
|
||||
bool _isSealed(ByteData bytes) {
|
||||
const magic = 'HLKF';
|
||||
return bytes.lengthInBytes > magic.length + 1 &&
|
||||
String.fromCharCodes(Uint8List.sublistView(bytes, 0, magic.length)) ==
|
||||
magic &&
|
||||
bytes.getUint8(magic.length) == 1;
|
||||
}
|
||||
|
||||
String _hashOf(ByteData bytes) =>
|
||||
sha256.convert(Uint8List.sublistView(bytes)).toString();
|
||||
const assetStorage = base.fileStorage;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Deletes the stored files nobody can reach any more; the server schedules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// The largest readable file of any kind but a PDF, an image or a sound:
|
||||
/// 20 MB. A larger one is checked to be a PDF once stored, and deleted
|
||||
/// otherwise (ADR 0004, rule 5).
|
||||
const maxOtherBytes = 20 * 1024 * 1024;
|
||||
|
||||
/// Learn's files in halkyon_base's store (Halkyon's ADR 0043, D6): the
|
||||
/// `asset` rows name them, a file is read by the members of the space its
|
||||
/// row is in, and a readable file over [maxOtherBytes] must be a PDF; up to
|
||||
/// 100 MB, the module's default (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09, for the books of the old app's library).
|
||||
const learnFiles = FileStore(
|
||||
fileOfRow: _fileOfAsset,
|
||||
liveHashes: _liveAssetHashes,
|
||||
accepts: _accepts,
|
||||
);
|
||||
|
||||
/// The file the asset row [assetId] names, where the caller may read its
|
||||
/// space: its owner's, or one they were granted; null for a deleted row
|
||||
/// (the sync engine hides tombstones) and any other.
|
||||
Future<StoredFileRef?> _fileOfAsset(Session session, UuidValue assetId) async {
|
||||
// SELECT * FROM "asset" WHERE "id" = $1 (tombstones hidden)
|
||||
final asset = await Asset.db.findById(session, assetId);
|
||||
final spaceId = asset?.spaceId;
|
||||
// SELECT * FROM "offline_sync_spaces" WHERE "id" = $1
|
||||
final space = spaceId == null
|
||||
? null
|
||||
: await OfflineSyncSpace.db.findById(session, spaceId);
|
||||
final caller = UuidValue.withValidation(
|
||||
session.authenticated!.userIdentifier,
|
||||
);
|
||||
if (asset == null ||
|
||||
space == null ||
|
||||
!(space.uuidSpaceId == caller ||
|
||||
await OfflineSyncSpaces(
|
||||
session,
|
||||
).roleOf(user: caller, space: space.uuidSpaceId) !=
|
||||
null)) {
|
||||
return null;
|
||||
}
|
||||
return (space: space.uuidSpaceId, sha256: asset.sha256);
|
||||
}
|
||||
|
||||
/// Which of [hashes] (every one, where null) the live asset rows of [space]
|
||||
/// name.
|
||||
Future<Set<String>> _liveAssetHashes(
|
||||
Session session,
|
||||
UuidValue space,
|
||||
Set<String>? hashes,
|
||||
) async {
|
||||
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
|
||||
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidSpaceId.equals(space),
|
||||
))?.id;
|
||||
if (spaceId == null) {
|
||||
return const {};
|
||||
}
|
||||
// SELECT "sha256" FROM "asset" WHERE "spaceId" = $1 [AND "sha256" IN …]
|
||||
// (tombstones hidden)
|
||||
return {
|
||||
for (final asset in await Asset.db.find(
|
||||
session,
|
||||
where: (t) => hashes == null
|
||||
? t.spaceId.equals(spaceId)
|
||||
: t.spaceId.equals(spaceId) & t.sha256.inSet(hashes),
|
||||
))
|
||||
asset.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
/// A sealed file can't be looked into, and the module checked its header;
|
||||
/// a readable one over [maxOtherBytes] must be a PDF.
|
||||
bool _accepts(ByteData bytes, {required bool encrypted}) =>
|
||||
encrypted || bytes.lengthInBytes <= maxOtherBytes || _isPdf(bytes);
|
||||
|
||||
/// Whether [bytes] are a PDF's: they start as every PDF does.
|
||||
bool _isPdf(ByteData bytes) {
|
||||
const signature = '%PDF-';
|
||||
return bytes.lengthInBytes >= signature.length &&
|
||||
String.fromCharCodes(
|
||||
Uint8List.sublistView(bytes, 0, signature.length),
|
||||
) ==
|
||||
signature;
|
||||
}
|
||||
|
|
@ -75,12 +75,6 @@ Future<AfterDeletion?> deleteLearnData(
|
|||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "file_allowance" WHERE "authUserId" = $1
|
||||
await FileAllowance.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// Whether they chose to be counted, and the copies their device reported
|
||||
// (ADR 0019, D3).
|
||||
// DELETE FROM "counted_learner" WHERE "authUserId" = $1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/learn_files.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/app_compatibility.dart';
|
||||
|
||||
|
|
@ -17,7 +18,8 @@ const upstreamSourceCodeUrl =
|
|||
/// works with apps from [minimumAppBuild] on, deletes Learn's own data of a
|
||||
/// person who goes ([deleteLearnData]), profile pages name the app at their
|
||||
/// foot, and it holds the instance's register with [accounts]' members
|
||||
/// ([learnAccounts]). The server sets it up before it starts (`server.dart`),
|
||||
/// ([learnAccounts]), and keeps people's files, which its asset rows name
|
||||
/// ([learnFiles], Halkyon's ADR 0043). The server sets it up before it starts (`server.dart`),
|
||||
/// and so do tests that delete people or read profiles.
|
||||
HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
|
||||
HalkyonBase(
|
||||
|
|
@ -34,6 +36,7 @@ HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
|
|||
),
|
||||
privacyPagePoints: _privacyPagePoints,
|
||||
accounts: accounts,
|
||||
files: learnFiles,
|
||||
);
|
||||
|
||||
/// Learn's part in the instance's accounts (ADR 0016), from the operator's
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
||||
as _izehhkf5;
|
||||
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;
|
||||
|
|
@ -109,7 +108,6 @@ import 'tag/learning_path_tag.dart' as _i31v2f8r;
|
|||
import 'tag/note_tag.dart' as _itkfn57x;
|
||||
import 'tag/tag.dart' as _ie8t3ar4;
|
||||
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';
|
||||
|
|
@ -1930,56 +1928,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
],
|
||||
managed: true,
|
||||
),
|
||||
_isp.TableDefinition(
|
||||
name: 'file_allowance',
|
||||
dartName: 'FileAllowance',
|
||||
schema: 'public',
|
||||
module: 'halkyon_learn',
|
||||
columns: [
|
||||
_isp.ColumnDefinition(
|
||||
name: 'id',
|
||||
columnType: _isp.ColumnType.bigint,
|
||||
isNullable: false,
|
||||
dartType: 'int?',
|
||||
columnDefault: 'serial',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'authUserId',
|
||||
columnType: _isp.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'maxBytes',
|
||||
columnType: _isp.ColumnType.bigint,
|
||||
isNullable: false,
|
||||
dartType: 'int',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'note',
|
||||
columnType: _isp.ColumnType.text,
|
||||
isNullable: true,
|
||||
dartType: 'String?',
|
||||
),
|
||||
],
|
||||
foreignKeys: [],
|
||||
indexes: [
|
||||
_isp.IndexDefinition(
|
||||
indexName: 'file_allowance_person_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isp.IndexElementDefinition(
|
||||
type: _isp.IndexElementDefinitionType.column,
|
||||
definition: 'authUserId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: true,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
managed: true,
|
||||
),
|
||||
_isp.TableDefinition(
|
||||
name: 'learning_copy',
|
||||
dartName: 'LearningCopy',
|
||||
|
|
@ -3786,9 +3734,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _ipb7vkxr.Asset) {
|
||||
return _ipb7vkxr.Asset.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i9uv0f1n.FileAllowance) {
|
||||
return _i9uv0f1n.FileAllowance.fromJson(data) as T;
|
||||
}
|
||||
if (t == _it5d94hq.FileLimitExceededException) {
|
||||
return _it5d94hq.FileLimitExceededException.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -3993,10 +3938,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_ipb7vkxr.Asset?>()) {
|
||||
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_i9uv0f1n.FileAllowance?>()) {
|
||||
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_it5d94hq.FileLimitExceededException?>()) {
|
||||
return (data != null
|
||||
? _it5d94hq.FileLimitExceededException.fromJson(data)
|
||||
|
|
@ -4404,7 +4345,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_ipb7vkxr.Asset => 'Asset',
|
||||
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||
_iurwo9k8.FileUsage => 'FileUsage',
|
||||
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
|
||||
|
|
@ -4491,8 +4431,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (data) {
|
||||
case _ipb7vkxr.Asset():
|
||||
return 'Asset';
|
||||
case _i9uv0f1n.FileAllowance():
|
||||
return 'FileAllowance';
|
||||
case _it5d94hq.FileLimitExceededException():
|
||||
return 'FileLimitExceededException';
|
||||
case _iurwo9k8.FileUsage():
|
||||
|
|
@ -4670,9 +4608,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Asset') {
|
||||
return deserialize<_ipb7vkxr.Asset>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'FileAllowance') {
|
||||
return deserialize<_i9uv0f1n.FileAllowance>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'FileLimitExceededException') {
|
||||
return deserialize<_it5d94hq.FileLimitExceededException>(data['data']);
|
||||
}
|
||||
|
|
@ -4950,8 +4885,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (t) {
|
||||
case _ipb7vkxr.Asset:
|
||||
return _ipb7vkxr.Asset.t;
|
||||
case _i9uv0f1n.FileAllowance:
|
||||
return _i9uv0f1n.FileAllowance.t;
|
||||
case _ikd6xdrt.AssistantConversation:
|
||||
return _ikd6xdrt.AssistantConversation.t;
|
||||
case _i5vmmfj2.AssistantTurn:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue