feat(app,learn,todo): the assistants in Settings and on the operator's screen

ADR 0021, step 2's app half. halkyon_app asks the module's assistant
endpoints (ServerpodAssistantGateway, ServerpodAssistantOperatorGateway)
and a person's own provider directly (halkyon_llm), behind
AssistantRepository: an AssistantChoice is a GrantedAssistant, asked
through the instance, which counts it, or a PersonalAssistant with the
person's own key, which the instance never sees. assistant_messages.dart
turns a conversation into the endpoint's models and back.

AssistantsCard, for an app's settings: what is granted and left of its
limits, agreed to once on a sheet saying what goes where, the person's
use by UTC month, the instance's privacy page, and their own assistants
where the operator allows own keys (kept ones stay listed, unasked,
where it doesn't). AssistantGrantsCard on InstanceOperatorScreen: grants
by handle with their limits in the pricing's unit, changed and taken
back after asking, everyone's use by month; a found account's menu
grants too. Nothing shows where nothing is offered or can be read.
package:halkyon_app/assistant_models.dart holds the models in pure Dart.

Learn keeps own assistants in the person's settings (ownAssistants,
schema 12, SettingsOwnAssistants); SettingsRepository.update refuses
(OwnAssistantRefused) to write a key where the settings would not be
sealed (SettingsGateway.seals). Learn depends on halkyon_llm directly.
Todo wires the gateways; a member offers no assistant. No feature asks
an assistant yet (step 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 18:59:41 +01:00
commit 36899cdc3b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
40 changed files with 7505 additions and 46 deletions

View file

@ -67,6 +67,23 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
refused; who operates, named by handle and taken back (never the last);
accounts found by the start of their handle, suspended or named. Only the
register's service answers: on a member, nothing shows.
- **Assistants** (`lib/src/assistant/`, Halkyon Learn's ADR 0021, step 2):
`AssistantRepository` on `ServerpodAssistantGateway` (the module's
`assistant` endpoint; refusals as `AssistantFailed`) asks an
`AssistantChoice` in halkyon_llm's terms: a `GrantedAssistant` through the
instance, which counts it, or a `PersonalAssistant` with the person's own
key directly (`LlmClient`), the instance seeing neither key nor question;
`assistant_messages.dart` turns a conversation into the endpoint's models
and back, part by part. `AssistantsCard` is an app's settings' card: what
is granted and left of its limits, agreed to once (the consent sheet),
the person's use by UTC month, the instance's privacy page, and the
person's own assistants where the operator allows them, kept where the
app says (`OwnAssistantStore`: Learn's sealed settings). For the operator,
`AssistantOperatorRepository` (grants by exact handle) and
`AssistantGrantsCard` on `InstanceOperatorScreen`: grants with their
limits, granted, changed and taken back, and everyone's use by month.
`package:halkyon_app/assistant_models.dart` holds the models alone, in
pure Dart, for an app's own models.
- **About** (`showAppAbout`): the app's name (its frame's), its version,
icon and legalese where it gives them, where the source of what the
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;

View file

@ -0,0 +1,10 @@
/// The assistants' models alone (Halkyon Learn's ADR 0021, step 2), in pure
/// Dart, for an app's own models to hold: a person's own assistant in its
/// settings, without Flutter in the model layer.
library;
export 'src/assistant/model/assistant_amount.dart';
export 'src/assistant/model/assistant_failed.dart';
export 'src/assistant/model/assistant_grant.dart';
export 'src/assistant/model/assistant_offer.dart';
export 'src/assistant/model/own_assistant.dart';

View file

@ -18,6 +18,11 @@
/// operates the instance, on `OperatingCubit`, leading to
/// `InstanceOperatorScreen`, where accounts are approved, refused and
/// suspended and operators named (Halkyon Learn's ADR 0021, D21 to D24).
/// - **Assistants**: `AssistantsCard` for an app's settings, what the
/// operator grants a person and their own keys where allowed, on
/// `AssistantRepository`; `AssistantGrantsCard` on the operator's screen,
/// on `AssistantOperatorRepository` (Halkyon Learn's ADR 0021, step 2).
/// `package:halkyon_app/assistant_models.dart` holds the models alone.
/// - **Signing in**: with SimpleLogin (ADR 0020: `SimpleLoginRepository`,
/// its sheet and `SimpleLoginSection`), with a password and TOTP (ADR
/// 0017: `PasswordRepository`, its sheets and `AccountSecuritySection`),
@ -39,6 +44,29 @@ export 'src/app/app_locale.dart';
export 'src/app/app_scope.dart';
export 'src/app/instance_gate.dart';
export 'src/app/person_scope.dart';
export 'src/assistant/bloc/assistant_grants_cubit.dart';
export 'src/assistant/bloc/assistant_use_cubit.dart';
export 'src/assistant/bloc/assistants_cubit.dart';
export 'src/assistant/data/assistant_gateway.dart';
export 'src/assistant/data/assistant_messages.dart'
show
eventsOf,
failureOf,
llmMessageOf,
messageOf,
ownKeyFailureOf,
questionOf;
export 'src/assistant/data/assistant_repository.dart';
export 'src/assistant/data/serverpod_assistant_gateway.dart';
export 'src/assistant/model/assistant_amount.dart';
export 'src/assistant/model/assistant_failed.dart';
export 'src/assistant/model/assistant_grant.dart';
export 'src/assistant/model/assistant_offer.dart';
export 'src/assistant/model/own_assistant.dart';
export 'src/assistant/widget/assistant_grants_card.dart';
export 'src/assistant/widget/assistant_use_view.dart';
export 'src/assistant/widget/assistant_words.dart';
export 'src/assistant/widget/assistants_card.dart';
export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/bloc/wait_cubit.dart';

View file

@ -345,5 +345,87 @@
"operatorIsOperatorProblem": "Admins können nicht gesperrt werden. Entzieh zuerst die Rolle.",
"operatorLastOperator": "Die Instanz braucht mindestens einen Admin.",
"operatorNotAllowed": "Das können nur die Admins der Instanz.",
"operatorFailed": "Das hat nicht geklappt. Versuch es noch einmal."
"operatorFailed": "Das hat nicht geklappt. Versuch es noch einmal.",
"operatorAssistantNotSet": "Dieser Assistent ist hier nicht mehr eingerichtet.",
"operatorInvalidLimit": "Eine Grenze kann nicht unter null liegen.",
"assistantGrantsTitle": "Assistenten",
"assistantGrantsNone": "Noch hat niemand einen Assistenten.",
"assistantGrant": "Assistenten freigeben",
"assistantGrantSave": "Freigeben",
"assistantGrantChange": "Grenzen ändern",
"assistantGrantAssistant": "Assistent",
"assistantGrantDaily": "Grenze pro Tag",
"assistantGrantMonthly": "Grenze pro Monat",
"assistantGrantLimitHelper": "Leer für keine. Tage und Monate nach UTC.",
"assistantGrantLimitInvalid": "Keine Zahl, die das nimmt",
"assistantGrantNoHandle": "Kein Konto hat dieses Handle.",
"assistantGrantTokens": "Tokens",
"assistantGrantNote": "Vor der ersten Frage stimmen sie zu, was wohin geht. Du siehst, wie viel sie nutzen, nie, was sie fragen.",
"assistantPerDay": "{amount} pro Tag",
"assistantPerMonth": "{amount} pro Monat",
"assistantNoLimits": "Ohne Grenzen",
"assistantGrantAgreed": "Zugestimmt",
"assistantGrantNotAgreed": "Noch nicht zugestimmt",
"assistantGrantOutdated": "Die Preise haben sich geändert: Grenzen neu setzen",
"assistantGrantUnset": "Nicht mehr eingerichtet",
"assistantRevoke": "Zurücknehmen",
"assistantRevokeTitle": "{assistant} zurücknehmen?",
"assistantRevokeMessage": "Sie können ihn nicht mehr fragen. Was sie genutzt haben, bleibt gezählt.",
"assistantUseByMonth": "Nutzung nach Monat",
"assistantUseTitle": "Nutzung der Assistenten",
"assistantUseNone": "In diesem Monat nichts genutzt.",
"assistantUseKept": "Je UTC-Tag gezählt, aufbewahrt für diesen Monat und die zwei davor. Nie, was gefragt wurde.",
"assistantRequests": "{count, plural, =1{1 Anfrage} other{{count} Anfragen}}",
"assistantTokens": "{amount} Tokens",
"assistantsTitle": "Assistenten",
"assistantModelAt": "{model} bei {host}",
"assistantLeftToday": "{left} von {limit} heute übrig",
"assistantLeftThisMonth": "{left} von {limit} diesen Monat übrig",
"assistantAgree": "Lesen und zustimmen",
"assistantWaitsForLimits": "Wartet darauf, dass seine Grenzen neu gesetzt werden",
"assistantYourUse": "Deine Nutzung",
"assistantPrivacyPage": "Was auf {instance} wohin geht",
"assistantOwnTitle": "Mit deinem eigenen Schlüssel",
"assistantOwnAdd": "Assistenten mit eigenem Schlüssel hinzufügen",
"assistantOwnNote": "Die App fragt deinen Anbieter direkt: Diese Instanz sieht weder deinen Schlüssel noch deine Fragen und zählt nichts. Der Schlüssel liegt in deinen Einstellungen, auf deinen Geräten verschlüsselt.",
"assistantOwnEdit": "Dein eigener Schlüssel",
"assistantOwnName": "Name",
"assistantOwnProtocol": "API",
"assistantOwnBaseUrl": "Adresse",
"assistantOwnBaseUrlHelper": "Leer für die des Anbieters. Mistral, OpenRouter oder ein eigenes Modell: seine Adresse mit /v1.",
"assistantOwnBaseUrlInvalid": "Nur https, oder http im eigenen Netz",
"assistantOwnModel": "Modell",
"assistantOwnKey": "Schlüssel",
"assistantOwnKeyHelper": "Leer für ein eigenes Modell, das keinen braucht.",
"assistantOwnImages": "Liest Bilder",
"assistantOwnSave": "Speichern",
"assistantOwnRemove": "Entfernen",
"assistantOwnRemoveTitle": "{name} entfernen?",
"assistantOwnRemoveMessage": "Sein Schlüssel verschwindet auf allen Geräten aus deinen Einstellungen.",
"assistantOwnRefused": "Der Schlüssel wurde nicht gespeichert: Dieses Gerät kann deine Einstellungen noch nicht verschlüsseln. Öffne zuerst hier die Schlüssel deines Kontos.",
"assistantOwnNotAllowed": "Wer {instance} betreibt, erlaubt hier keine eigenen Schlüssel, also fragt die App diese nicht.",
"assistantConsentTitle": "Bevor du {name} fragst",
"assistantConsentModel": "Es antwortet das Modell {model} bei {host}.",
"assistantConsentWhatGoes": "Dorthin geht nur, was du sendest: deine Frage und was du davon auswählst, worum es geht. Für den Anbieter gelten seine eigenen Bedingungen.",
"assistantConsentImages": "Es liest die Bilder, die du hinzufügst.",
"assistantConsentInstance": "Es geht auf dem Weg durch {instance}, das nichts davon aufbewahrt.",
"assistantConsentOperator": "Wer {instance} betreibt, sieht, wie viel du es nutzt (Anfragen, Tokens, Kosten), nie, was du fragst.",
"assistantConsentConversations": "Deine Unterhaltungen bleiben auf deinen Geräten, verschlüsselt.",
"assistantConsentAgree": "Zustimmen",
"assistantConsentAgreedOn": "Du hast am {date} zugestimmt.",
"assistantProblemNotGranted": "Dieser Assistent ist für dich nicht mehr freigegeben.",
"assistantProblemNotAgreed": "Stimm zuerst zu, was wohin geht.",
"assistantProblemLimitReached": "Seine Grenze ist bis {time} erreicht.",
"assistantProblemLimitReachedNow": "Seine Grenze ist vorerst erreicht.",
"assistantProblemLimitOutdated": "Es wartet darauf, dass seine Grenzen neu gesetzt werden.",
"assistantProblemImagesNotRead": "Es liest keine Bilder.",
"assistantProblemMalformed": "Diese Frage kann nicht gesendet werden.",
"assistantProblemTooLarge": "Diese Frage ist zu lang oder enthält zu viele oder zu große Bilder.",
"assistantProblemTooManyAtOnce": "Warte zuerst auf die Antworten, die unterwegs sind.",
"assistantProblemProviderKey": "Der Anbieter hat den Schlüssel abgelehnt.",
"assistantProblemProviderBusy": "Der Anbieter ist ausgelastet. Versuch es gleich noch einmal.",
"assistantProblemProviderRefused": "Der Anbieter hat diese Frage abgelehnt.",
"assistantProblemProviderFailed": "Beim Anbieter ist etwas schiefgegangen. Versuch es noch einmal.",
"assistantProblemNotAllowed": "Das können nur die, für die er freigegeben ist.",
"assistantProblemFailed": "Das hat nicht geklappt. Versuch es noch einmal."
}

View file

@ -906,5 +906,431 @@
"operatorFailed": "That didn't work. Try again.",
"@operatorFailed": {
"description": "Any other failure."
},
"operatorAssistantNotSet": "That assistant isn't set up here any more.",
"@operatorAssistantNotSet": {
"description": "The assistant asked for is no longer in deploy/assistants.env."
},
"operatorInvalidLimit": "A limit can't be below zero.",
"@operatorInvalidLimit": {
"description": "A limit below 0 was refused."
},
"assistantGrantsTitle": "Assistants",
"@assistantGrantsTitle": {
"description": "The operator's card of the assistants granted (Halkyon Learn's ADR 0021)."
},
"assistantGrantsNone": "Nobody has an assistant yet.",
"@assistantGrantsNone": {
"description": "No assistant is granted to anyone."
},
"assistantGrant": "Grant an assistant",
"@assistantGrant": {
"description": "Opens the sheet granting an assistant to an account."
},
"assistantGrantSave": "Grant",
"@assistantGrantSave": {
"description": "The grant sheet's button."
},
"assistantGrantChange": "Change limits",
"@assistantGrantChange": {
"description": "A grant's menu item, and the sheet's title, changing its limits."
},
"assistantGrantAssistant": "Assistant",
"@assistantGrantAssistant": {
"description": "Above the choice of assistant in the grant sheet."
},
"assistantGrantDaily": "Limit a day",
"@assistantGrantDaily": {
"description": "The daily limit field."
},
"assistantGrantMonthly": "Limit a month",
"@assistantGrantMonthly": {
"description": "The monthly limit field."
},
"assistantGrantLimitHelper": "Empty for none. Days and months are UTC's.",
"@assistantGrantLimitHelper": {
"description": "Under the limit fields: the instance counts by UTC days and months (D42)."
},
"assistantGrantLimitInvalid": "Not a number this takes",
"@assistantGrantLimitInvalid": {
"description": "A limit typed that is no amount or number of tokens."
},
"assistantGrantNoHandle": "No account has this handle.",
"@assistantGrantNoHandle": {
"description": "The grant sheet: the handle typed is nobody's."
},
"assistantGrantTokens": "tokens",
"@assistantGrantTokens": {
"description": "After a limit in tokens, for an assistant without prices."
},
"assistantGrantNote": "They agree once to what goes where before they ask it. You see how much they use, never what they ask.",
"@assistantGrantNote": {
"description": "In the grant sheet: consent (D40) and what the operator sees (D3)."
},
"assistantPerDay": "{amount} a day",
"@assistantPerDay": {
"description": "A daily limit.",
"placeholders": {
"amount": {
"type": "String"
}
}
},
"assistantPerMonth": "{amount} a month",
"@assistantPerMonth": {
"description": "A monthly limit.",
"placeholders": {
"amount": {
"type": "String"
}
}
},
"assistantNoLimits": "No limits",
"@assistantNoLimits": {
"description": "A grant without limits."
},
"assistantGrantAgreed": "Agreed",
"@assistantGrantAgreed": {
"description": "A grant whose person agreed to what goes where."
},
"assistantGrantNotAgreed": "Not agreed yet",
"@assistantGrantNotAgreed": {
"description": "A grant whose person hasn't agreed yet."
},
"assistantGrantOutdated": "Its prices changed: set its limits again",
"@assistantGrantOutdated": {
"description": "A grant whose limits are in another unit than the pricing now; it answers nothing until set again."
},
"assistantGrantUnset": "No longer set up",
"@assistantGrantUnset": {
"description": "A grant of an assistant taken out of deploy/assistants.env."
},
"assistantRevoke": "Take back",
"@assistantRevoke": {
"description": "A grant's menu item."
},
"assistantRevokeTitle": "Take back {assistant}?",
"@assistantRevokeTitle": {
"description": "Asks before taking a grant back.",
"placeholders": {
"assistant": {
"type": "String"
}
}
},
"assistantRevokeMessage": "They can no longer ask it. What they used stays counted.",
"@assistantRevokeMessage": {
"description": "Under the question taking a grant back."
},
"assistantUseByMonth": "Use by month",
"@assistantUseByMonth": {
"description": "Opens everyone's figures (D3)."
},
"assistantUseTitle": "Use of assistants",
"@assistantUseTitle": {
"description": "The title of the figures' screen and sheet."
},
"assistantUseNone": "Nothing used in this month.",
"@assistantUseNone": {
"description": "A month without use."
},
"assistantUseKept": "Counted per UTC day, kept for this month and the two before. Never what was asked.",
"@assistantUseKept": {
"description": "Under the figures: what is counted and for how long (D41, D42)."
},
"assistantRequests": "{count, plural, =1{1 request} other{{count} requests}}",
"@assistantRequests": {
"description": "How many requests were counted.",
"placeholders": {
"count": {
"type": "int"
}
}
},
"assistantTokens": "{amount} tokens",
"@assistantTokens": {
"description": "How many tokens were counted, the number formatted.",
"placeholders": {
"amount": {
"type": "String"
}
}
},
"assistantsTitle": "Assistants",
"@assistantsTitle": {
"description": "Settings' card of the assistants a person may ask (Halkyon Learn's ADR 0021, point 9)."
},
"assistantModelAt": "{model} at {host}",
"@assistantModelAt": {
"description": "Which model answers, and the host that receives what is asked.",
"placeholders": {
"model": {
"type": "String"
},
"host": {
"type": "String"
}
}
},
"assistantLeftToday": "{left} of {limit} left today",
"@assistantLeftToday": {
"description": "What is left of a day's limit.",
"placeholders": {
"left": {
"type": "String"
},
"limit": {
"type": "String"
}
}
},
"assistantLeftThisMonth": "{left} of {limit} left this month",
"@assistantLeftThisMonth": {
"description": "What is left of a month's limit.",
"placeholders": {
"left": {
"type": "String"
},
"limit": {
"type": "String"
}
}
},
"assistantAgree": "Read and agree",
"@assistantAgree": {
"description": "Opens the consent sheet of an assistant not agreed to yet."
},
"assistantWaitsForLimits": "Waits for its limits to be set again",
"@assistantWaitsForLimits": {
"description": "An assistant whose limits are outdated: it answers nothing until the operator sets them again."
},
"assistantYourUse": "Your use",
"@assistantYourUse": {
"description": "Opens the person's own figures."
},
"assistantPrivacyPage": "What goes where on {instance}",
"@assistantPrivacyPage": {
"description": "Opens the instance's privacy page (D39).",
"placeholders": {
"instance": {
"type": "String"
}
}
},
"assistantOwnTitle": "With your own key",
"@assistantOwnTitle": {
"description": "Above a person's own assistants (point 10)."
},
"assistantOwnAdd": "Add an assistant with your own key",
"@assistantOwnAdd": {
"description": "Opens the sheet adding an assistant with a personal key."
},
"assistantOwnNote": "The app asks your provider directly: this instance sees neither your key nor your questions, and counts nothing. The key is kept in your settings, encrypted on your devices.",
"@assistantOwnNote": {
"description": "What a personal key means (D5, D6)."
},
"assistantOwnEdit": "Your own key",
"@assistantOwnEdit": {
"description": "The personal key sheet's title."
},
"assistantOwnName": "Name",
"@assistantOwnName": {
"description": "The name the person gives their assistant."
},
"assistantOwnProtocol": "API",
"@assistantOwnProtocol": {
"description": "Above the choice of protocol."
},
"assistantOwnBaseUrl": "Address",
"@assistantOwnBaseUrl": {
"description": "The provider's address field."
},
"assistantOwnBaseUrlHelper": "Empty for the provider's own. Mistral, OpenRouter or a model of your own: its address with /v1.",
"@assistantOwnBaseUrlHelper": {
"description": "Under the address field: which services take which address."
},
"assistantOwnBaseUrlInvalid": "Only https, or http on your own network",
"@assistantOwnBaseUrlInvalid": {
"description": "An address the app refuses: keys and questions would travel in the clear."
},
"assistantOwnModel": "Model",
"@assistantOwnModel": {
"description": "The model's name at the provider."
},
"assistantOwnKey": "Key",
"@assistantOwnKey": {
"description": "The API key field."
},
"assistantOwnKeyHelper": "Empty for a model of your own that takes none.",
"@assistantOwnKeyHelper": {
"description": "Under the key field."
},
"assistantOwnImages": "Reads images",
"@assistantOwnImages": {
"description": "Whether the model reads images (D12)."
},
"assistantOwnSave": "Save",
"@assistantOwnSave": {
"description": "The personal key sheet's button."
},
"assistantOwnRemove": "Remove",
"@assistantOwnRemove": {
"description": "Removes an assistant with a personal key."
},
"assistantOwnRemoveTitle": "Remove {name}?",
"@assistantOwnRemoveTitle": {
"description": "Asks before removing an assistant with a personal key.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"assistantOwnRemoveMessage": "Its key goes from your settings on every device.",
"@assistantOwnRemoveMessage": {
"description": "Under the question removing a personal key."
},
"assistantOwnRefused": "The key wasn't kept: this device can't encrypt your settings yet. Open your account's keys here first.",
"@assistantOwnRefused": {
"description": "A personal key refused, as the settings wouldn't be sealed."
},
"assistantOwnNotAllowed": "Whoever runs {instance} doesn't allow own keys here, so the app doesn't ask these.",
"@assistantOwnNotAllowed": {
"description": "Below the person's own assistants, where the operator doesn't allow them (ADR 0021, D6).",
"placeholders": {
"instance": {
"type": "String",
"example": "learn.halkyon.eu"
}
}
},
"assistantConsentTitle": "Before you ask {name}",
"@assistantConsentTitle": {
"description": "The consent sheet's title (D40).",
"placeholders": {
"name": {
"type": "String"
}
}
},
"assistantConsentModel": "It answers with the model {model} at {host}.",
"@assistantConsentModel": {
"description": "Who answers (D27).",
"placeholders": {
"model": {
"type": "String"
},
"host": {
"type": "String"
}
}
},
"assistantConsentWhatGoes": "Only what you send goes there: your question and what you pick of what it is about. The provider's own terms apply to it.",
"@assistantConsentWhatGoes": {
"description": "What goes to the provider (D27)."
},
"assistantConsentImages": "It reads the images you add.",
"@assistantConsentImages": {
"description": "For an assistant that reads images (D12)."
},
"assistantConsentInstance": "It passes through {instance} on its way, which keeps none of it.",
"@assistantConsentInstance": {
"description": "The instance sees it in passing and keeps nothing (D27).",
"placeholders": {
"instance": {
"type": "String"
}
}
},
"assistantConsentOperator": "Whoever runs {instance} sees how much you use it (requests, tokens, cost), never what you ask.",
"@assistantConsentOperator": {
"description": "The operator's figures (D3).",
"placeholders": {
"instance": {
"type": "String"
}
}
},
"assistantConsentConversations": "Your conversations are kept on your devices, encrypted.",
"@assistantConsentConversations": {
"description": "Conversations are sealed on the devices (D13)."
},
"assistantConsentAgree": "Agree",
"@assistantConsentAgree": {
"description": "The consent sheet's button."
},
"assistantConsentAgreedOn": "You agreed on {date}.",
"@assistantConsentAgreedOn": {
"description": "An assistant agreed to, and when.",
"placeholders": {
"date": {
"type": "String"
}
}
},
"assistantProblemNotGranted": "This assistant isn't granted to you any more.",
"@assistantProblemNotGranted": {
"description": "notGranted."
},
"assistantProblemNotAgreed": "Agree to what goes where first.",
"@assistantProblemNotAgreed": {
"description": "notAgreed."
},
"assistantProblemLimitReached": "Its limit is reached until {time}.",
"@assistantProblemLimitReached": {
"description": "limitReached, with when it resets.",
"placeholders": {
"time": {
"type": "String"
}
}
},
"assistantProblemLimitReachedNow": "Its limit is reached for now.",
"@assistantProblemLimitReachedNow": {
"description": "Refused: a limit is reached, the instance not saying until when."
},
"assistantProblemLimitOutdated": "It waits for its limits to be set again.",
"@assistantProblemLimitOutdated": {
"description": "limitOutdated."
},
"assistantProblemImagesNotRead": "It reads no images.",
"@assistantProblemImagesNotRead": {
"description": "imagesNotRead."
},
"assistantProblemMalformed": "This question can't be sent.",
"@assistantProblemMalformed": {
"description": "malformed."
},
"assistantProblemTooLarge": "This question is too long, or holds too many or too large images.",
"@assistantProblemTooLarge": {
"description": "tooLarge."
},
"assistantProblemTooManyAtOnce": "Wait for the answers on their way first.",
"@assistantProblemTooManyAtOnce": {
"description": "tooManyAtOnce."
},
"assistantProblemProviderKey": "The provider refused the key.",
"@assistantProblemProviderKey": {
"description": "providerKey."
},
"assistantProblemProviderBusy": "The provider is busy. Try again in a moment.",
"@assistantProblemProviderBusy": {
"description": "providerBusy."
},
"assistantProblemProviderRefused": "The provider refused this question.",
"@assistantProblemProviderRefused": {
"description": "providerRefused."
},
"assistantProblemProviderFailed": "The provider failed. Try again.",
"@assistantProblemProviderFailed": {
"description": "providerFailed."
},
"assistantProblemNotAllowed": "Only people it is granted to can do this.",
"@assistantProblemNotAllowed": {
"description": "notAllowed."
},
"assistantProblemFailed": "That didn't work. Try again.",
"@assistantProblemFailed": {
"description": "failed."
}
}

View file

@ -2178,6 +2178,498 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'That didn\'t work. Try again.'**
String get operatorFailed;
/// The assistant asked for is no longer in deploy/assistants.env.
///
/// In en, this message translates to:
/// **'That assistant isn\'t set up here any more.'**
String get operatorAssistantNotSet;
/// A limit below 0 was refused.
///
/// In en, this message translates to:
/// **'A limit can\'t be below zero.'**
String get operatorInvalidLimit;
/// The operator's card of the assistants granted (Halkyon Learn's ADR 0021).
///
/// In en, this message translates to:
/// **'Assistants'**
String get assistantGrantsTitle;
/// No assistant is granted to anyone.
///
/// In en, this message translates to:
/// **'Nobody has an assistant yet.'**
String get assistantGrantsNone;
/// Opens the sheet granting an assistant to an account.
///
/// In en, this message translates to:
/// **'Grant an assistant'**
String get assistantGrant;
/// The grant sheet's button.
///
/// In en, this message translates to:
/// **'Grant'**
String get assistantGrantSave;
/// A grant's menu item, and the sheet's title, changing its limits.
///
/// In en, this message translates to:
/// **'Change limits'**
String get assistantGrantChange;
/// Above the choice of assistant in the grant sheet.
///
/// In en, this message translates to:
/// **'Assistant'**
String get assistantGrantAssistant;
/// The daily limit field.
///
/// In en, this message translates to:
/// **'Limit a day'**
String get assistantGrantDaily;
/// The monthly limit field.
///
/// In en, this message translates to:
/// **'Limit a month'**
String get assistantGrantMonthly;
/// Under the limit fields: the instance counts by UTC days and months (D42).
///
/// In en, this message translates to:
/// **'Empty for none. Days and months are UTC\'s.'**
String get assistantGrantLimitHelper;
/// A limit typed that is no amount or number of tokens.
///
/// In en, this message translates to:
/// **'Not a number this takes'**
String get assistantGrantLimitInvalid;
/// The grant sheet: the handle typed is nobody's.
///
/// In en, this message translates to:
/// **'No account has this handle.'**
String get assistantGrantNoHandle;
/// After a limit in tokens, for an assistant without prices.
///
/// In en, this message translates to:
/// **'tokens'**
String get assistantGrantTokens;
/// In the grant sheet: consent (D40) and what the operator sees (D3).
///
/// In en, this message translates to:
/// **'They agree once to what goes where before they ask it. You see how much they use, never what they ask.'**
String get assistantGrantNote;
/// A daily limit.
///
/// In en, this message translates to:
/// **'{amount} a day'**
String assistantPerDay(String amount);
/// A monthly limit.
///
/// In en, this message translates to:
/// **'{amount} a month'**
String assistantPerMonth(String amount);
/// A grant without limits.
///
/// In en, this message translates to:
/// **'No limits'**
String get assistantNoLimits;
/// A grant whose person agreed to what goes where.
///
/// In en, this message translates to:
/// **'Agreed'**
String get assistantGrantAgreed;
/// A grant whose person hasn't agreed yet.
///
/// In en, this message translates to:
/// **'Not agreed yet'**
String get assistantGrantNotAgreed;
/// A grant whose limits are in another unit than the pricing now; it answers nothing until set again.
///
/// In en, this message translates to:
/// **'Its prices changed: set its limits again'**
String get assistantGrantOutdated;
/// A grant of an assistant taken out of deploy/assistants.env.
///
/// In en, this message translates to:
/// **'No longer set up'**
String get assistantGrantUnset;
/// A grant's menu item.
///
/// In en, this message translates to:
/// **'Take back'**
String get assistantRevoke;
/// Asks before taking a grant back.
///
/// In en, this message translates to:
/// **'Take back {assistant}?'**
String assistantRevokeTitle(String assistant);
/// Under the question taking a grant back.
///
/// In en, this message translates to:
/// **'They can no longer ask it. What they used stays counted.'**
String get assistantRevokeMessage;
/// Opens everyone's figures (D3).
///
/// In en, this message translates to:
/// **'Use by month'**
String get assistantUseByMonth;
/// The title of the figures' screen and sheet.
///
/// In en, this message translates to:
/// **'Use of assistants'**
String get assistantUseTitle;
/// A month without use.
///
/// In en, this message translates to:
/// **'Nothing used in this month.'**
String get assistantUseNone;
/// Under the figures: what is counted and for how long (D41, D42).
///
/// In en, this message translates to:
/// **'Counted per UTC day, kept for this month and the two before. Never what was asked.'**
String get assistantUseKept;
/// How many requests were counted.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 request} other{{count} requests}}'**
String assistantRequests(int count);
/// How many tokens were counted, the number formatted.
///
/// In en, this message translates to:
/// **'{amount} tokens'**
String assistantTokens(String amount);
/// Settings' card of the assistants a person may ask (Halkyon Learn's ADR 0021, point 9).
///
/// In en, this message translates to:
/// **'Assistants'**
String get assistantsTitle;
/// Which model answers, and the host that receives what is asked.
///
/// In en, this message translates to:
/// **'{model} at {host}'**
String assistantModelAt(String model, String host);
/// What is left of a day's limit.
///
/// In en, this message translates to:
/// **'{left} of {limit} left today'**
String assistantLeftToday(String left, String limit);
/// What is left of a month's limit.
///
/// In en, this message translates to:
/// **'{left} of {limit} left this month'**
String assistantLeftThisMonth(String left, String limit);
/// Opens the consent sheet of an assistant not agreed to yet.
///
/// In en, this message translates to:
/// **'Read and agree'**
String get assistantAgree;
/// An assistant whose limits are outdated: it answers nothing until the operator sets them again.
///
/// In en, this message translates to:
/// **'Waits for its limits to be set again'**
String get assistantWaitsForLimits;
/// Opens the person's own figures.
///
/// In en, this message translates to:
/// **'Your use'**
String get assistantYourUse;
/// Opens the instance's privacy page (D39).
///
/// In en, this message translates to:
/// **'What goes where on {instance}'**
String assistantPrivacyPage(String instance);
/// Above a person's own assistants (point 10).
///
/// In en, this message translates to:
/// **'With your own key'**
String get assistantOwnTitle;
/// Opens the sheet adding an assistant with a personal key.
///
/// In en, this message translates to:
/// **'Add an assistant with your own key'**
String get assistantOwnAdd;
/// What a personal key means (D5, D6).
///
/// In en, this message translates to:
/// **'The app asks your provider directly: this instance sees neither your key nor your questions, and counts nothing. The key is kept in your settings, encrypted on your devices.'**
String get assistantOwnNote;
/// The personal key sheet's title.
///
/// In en, this message translates to:
/// **'Your own key'**
String get assistantOwnEdit;
/// The name the person gives their assistant.
///
/// In en, this message translates to:
/// **'Name'**
String get assistantOwnName;
/// Above the choice of protocol.
///
/// In en, this message translates to:
/// **'API'**
String get assistantOwnProtocol;
/// The provider's address field.
///
/// In en, this message translates to:
/// **'Address'**
String get assistantOwnBaseUrl;
/// Under the address field: which services take which address.
///
/// In en, this message translates to:
/// **'Empty for the provider\'s own. Mistral, OpenRouter or a model of your own: its address with /v1.'**
String get assistantOwnBaseUrlHelper;
/// An address the app refuses: keys and questions would travel in the clear.
///
/// In en, this message translates to:
/// **'Only https, or http on your own network'**
String get assistantOwnBaseUrlInvalid;
/// The model's name at the provider.
///
/// In en, this message translates to:
/// **'Model'**
String get assistantOwnModel;
/// The API key field.
///
/// In en, this message translates to:
/// **'Key'**
String get assistantOwnKey;
/// Under the key field.
///
/// In en, this message translates to:
/// **'Empty for a model of your own that takes none.'**
String get assistantOwnKeyHelper;
/// Whether the model reads images (D12).
///
/// In en, this message translates to:
/// **'Reads images'**
String get assistantOwnImages;
/// The personal key sheet's button.
///
/// In en, this message translates to:
/// **'Save'**
String get assistantOwnSave;
/// Removes an assistant with a personal key.
///
/// In en, this message translates to:
/// **'Remove'**
String get assistantOwnRemove;
/// Asks before removing an assistant with a personal key.
///
/// In en, this message translates to:
/// **'Remove {name}?'**
String assistantOwnRemoveTitle(String name);
/// Under the question removing a personal key.
///
/// In en, this message translates to:
/// **'Its key goes from your settings on every device.'**
String get assistantOwnRemoveMessage;
/// A personal key refused, as the settings wouldn't be sealed.
///
/// In en, this message translates to:
/// **'The key wasn\'t kept: this device can\'t encrypt your settings yet. Open your account\'s keys here first.'**
String get assistantOwnRefused;
/// Below the person's own assistants, where the operator doesn't allow them (ADR 0021, D6).
///
/// In en, this message translates to:
/// **'Whoever runs {instance} doesn\'t allow own keys here, so the app doesn\'t ask these.'**
String assistantOwnNotAllowed(String instance);
/// The consent sheet's title (D40).
///
/// In en, this message translates to:
/// **'Before you ask {name}'**
String assistantConsentTitle(String name);
/// Who answers (D27).
///
/// In en, this message translates to:
/// **'It answers with the model {model} at {host}.'**
String assistantConsentModel(String model, String host);
/// What goes to the provider (D27).
///
/// In en, this message translates to:
/// **'Only what you send goes there: your question and what you pick of what it is about. The provider\'s own terms apply to it.'**
String get assistantConsentWhatGoes;
/// For an assistant that reads images (D12).
///
/// In en, this message translates to:
/// **'It reads the images you add.'**
String get assistantConsentImages;
/// The instance sees it in passing and keeps nothing (D27).
///
/// In en, this message translates to:
/// **'It passes through {instance} on its way, which keeps none of it.'**
String assistantConsentInstance(String instance);
/// The operator's figures (D3).
///
/// In en, this message translates to:
/// **'Whoever runs {instance} sees how much you use it (requests, tokens, cost), never what you ask.'**
String assistantConsentOperator(String instance);
/// Conversations are sealed on the devices (D13).
///
/// In en, this message translates to:
/// **'Your conversations are kept on your devices, encrypted.'**
String get assistantConsentConversations;
/// The consent sheet's button.
///
/// In en, this message translates to:
/// **'Agree'**
String get assistantConsentAgree;
/// An assistant agreed to, and when.
///
/// In en, this message translates to:
/// **'You agreed on {date}.'**
String assistantConsentAgreedOn(String date);
/// notGranted.
///
/// In en, this message translates to:
/// **'This assistant isn\'t granted to you any more.'**
String get assistantProblemNotGranted;
/// notAgreed.
///
/// In en, this message translates to:
/// **'Agree to what goes where first.'**
String get assistantProblemNotAgreed;
/// limitReached, with when it resets.
///
/// In en, this message translates to:
/// **'Its limit is reached until {time}.'**
String assistantProblemLimitReached(String time);
/// Refused: a limit is reached, the instance not saying until when.
///
/// In en, this message translates to:
/// **'Its limit is reached for now.'**
String get assistantProblemLimitReachedNow;
/// limitOutdated.
///
/// In en, this message translates to:
/// **'It waits for its limits to be set again.'**
String get assistantProblemLimitOutdated;
/// imagesNotRead.
///
/// In en, this message translates to:
/// **'It reads no images.'**
String get assistantProblemImagesNotRead;
/// malformed.
///
/// In en, this message translates to:
/// **'This question can\'t be sent.'**
String get assistantProblemMalformed;
/// tooLarge.
///
/// In en, this message translates to:
/// **'This question is too long, or holds too many or too large images.'**
String get assistantProblemTooLarge;
/// tooManyAtOnce.
///
/// In en, this message translates to:
/// **'Wait for the answers on their way first.'**
String get assistantProblemTooManyAtOnce;
/// providerKey.
///
/// In en, this message translates to:
/// **'The provider refused the key.'**
String get assistantProblemProviderKey;
/// providerBusy.
///
/// In en, this message translates to:
/// **'The provider is busy. Try again in a moment.'**
String get assistantProblemProviderBusy;
/// providerRefused.
///
/// In en, this message translates to:
/// **'The provider refused this question.'**
String get assistantProblemProviderRefused;
/// providerFailed.
///
/// In en, this message translates to:
/// **'The provider failed. Try again.'**
String get assistantProblemProviderFailed;
/// notAllowed.
///
/// In en, this message translates to:
/// **'Only people it is granted to can do this.'**
String get assistantProblemNotAllowed;
/// failed.
///
/// In en, this message translates to:
/// **'That didn\'t work. Try again.'**
String get assistantProblemFailed;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -1304,4 +1304,319 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get operatorFailed =>
'Das hat nicht geklappt. Versuch es noch einmal.';
@override
String get operatorAssistantNotSet =>
'Dieser Assistent ist hier nicht mehr eingerichtet.';
@override
String get operatorInvalidLimit =>
'Eine Grenze kann nicht unter null liegen.';
@override
String get assistantGrantsTitle => 'Assistenten';
@override
String get assistantGrantsNone => 'Noch hat niemand einen Assistenten.';
@override
String get assistantGrant => 'Assistenten freigeben';
@override
String get assistantGrantSave => 'Freigeben';
@override
String get assistantGrantChange => 'Grenzen ändern';
@override
String get assistantGrantAssistant => 'Assistent';
@override
String get assistantGrantDaily => 'Grenze pro Tag';
@override
String get assistantGrantMonthly => 'Grenze pro Monat';
@override
String get assistantGrantLimitHelper =>
'Leer für keine. Tage und Monate nach UTC.';
@override
String get assistantGrantLimitInvalid => 'Keine Zahl, die das nimmt';
@override
String get assistantGrantNoHandle => 'Kein Konto hat dieses Handle.';
@override
String get assistantGrantTokens => 'Tokens';
@override
String get assistantGrantNote =>
'Vor der ersten Frage stimmen sie zu, was wohin geht. Du siehst, wie viel sie nutzen, nie, was sie fragen.';
@override
String assistantPerDay(String amount) {
return '$amount pro Tag';
}
@override
String assistantPerMonth(String amount) {
return '$amount pro Monat';
}
@override
String get assistantNoLimits => 'Ohne Grenzen';
@override
String get assistantGrantAgreed => 'Zugestimmt';
@override
String get assistantGrantNotAgreed => 'Noch nicht zugestimmt';
@override
String get assistantGrantOutdated =>
'Die Preise haben sich geändert: Grenzen neu setzen';
@override
String get assistantGrantUnset => 'Nicht mehr eingerichtet';
@override
String get assistantRevoke => 'Zurücknehmen';
@override
String assistantRevokeTitle(String assistant) {
return '$assistant zurücknehmen?';
}
@override
String get assistantRevokeMessage =>
'Sie können ihn nicht mehr fragen. Was sie genutzt haben, bleibt gezählt.';
@override
String get assistantUseByMonth => 'Nutzung nach Monat';
@override
String get assistantUseTitle => 'Nutzung der Assistenten';
@override
String get assistantUseNone => 'In diesem Monat nichts genutzt.';
@override
String get assistantUseKept =>
'Je UTC-Tag gezählt, aufbewahrt für diesen Monat und die zwei davor. Nie, was gefragt wurde.';
@override
String assistantRequests(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Anfragen',
one: '1 Anfrage',
);
return '$_temp0';
}
@override
String assistantTokens(String amount) {
return '$amount Tokens';
}
@override
String get assistantsTitle => 'Assistenten';
@override
String assistantModelAt(String model, String host) {
return '$model bei $host';
}
@override
String assistantLeftToday(String left, String limit) {
return '$left von $limit heute übrig';
}
@override
String assistantLeftThisMonth(String left, String limit) {
return '$left von $limit diesen Monat übrig';
}
@override
String get assistantAgree => 'Lesen und zustimmen';
@override
String get assistantWaitsForLimits =>
'Wartet darauf, dass seine Grenzen neu gesetzt werden';
@override
String get assistantYourUse => 'Deine Nutzung';
@override
String assistantPrivacyPage(String instance) {
return 'Was auf $instance wohin geht';
}
@override
String get assistantOwnTitle => 'Mit deinem eigenen Schlüssel';
@override
String get assistantOwnAdd => 'Assistenten mit eigenem Schlüssel hinzufügen';
@override
String get assistantOwnNote =>
'Die App fragt deinen Anbieter direkt: Diese Instanz sieht weder deinen Schlüssel noch deine Fragen und zählt nichts. Der Schlüssel liegt in deinen Einstellungen, auf deinen Geräten verschlüsselt.';
@override
String get assistantOwnEdit => 'Dein eigener Schlüssel';
@override
String get assistantOwnName => 'Name';
@override
String get assistantOwnProtocol => 'API';
@override
String get assistantOwnBaseUrl => 'Adresse';
@override
String get assistantOwnBaseUrlHelper =>
'Leer für die des Anbieters. Mistral, OpenRouter oder ein eigenes Modell: seine Adresse mit /v1.';
@override
String get assistantOwnBaseUrlInvalid =>
'Nur https, oder http im eigenen Netz';
@override
String get assistantOwnModel => 'Modell';
@override
String get assistantOwnKey => 'Schlüssel';
@override
String get assistantOwnKeyHelper =>
'Leer für ein eigenes Modell, das keinen braucht.';
@override
String get assistantOwnImages => 'Liest Bilder';
@override
String get assistantOwnSave => 'Speichern';
@override
String get assistantOwnRemove => 'Entfernen';
@override
String assistantOwnRemoveTitle(String name) {
return '$name entfernen?';
}
@override
String get assistantOwnRemoveMessage =>
'Sein Schlüssel verschwindet auf allen Geräten aus deinen Einstellungen.';
@override
String get assistantOwnRefused =>
'Der Schlüssel wurde nicht gespeichert: Dieses Gerät kann deine Einstellungen noch nicht verschlüsseln. Öffne zuerst hier die Schlüssel deines Kontos.';
@override
String assistantOwnNotAllowed(String instance) {
return 'Wer $instance betreibt, erlaubt hier keine eigenen Schlüssel, also fragt die App diese nicht.';
}
@override
String assistantConsentTitle(String name) {
return 'Bevor du $name fragst';
}
@override
String assistantConsentModel(String model, String host) {
return 'Es antwortet das Modell $model bei $host.';
}
@override
String get assistantConsentWhatGoes =>
'Dorthin geht nur, was du sendest: deine Frage und was du davon auswählst, worum es geht. Für den Anbieter gelten seine eigenen Bedingungen.';
@override
String get assistantConsentImages =>
'Es liest die Bilder, die du hinzufügst.';
@override
String assistantConsentInstance(String instance) {
return 'Es geht auf dem Weg durch $instance, das nichts davon aufbewahrt.';
}
@override
String assistantConsentOperator(String instance) {
return 'Wer $instance betreibt, sieht, wie viel du es nutzt (Anfragen, Tokens, Kosten), nie, was du fragst.';
}
@override
String get assistantConsentConversations =>
'Deine Unterhaltungen bleiben auf deinen Geräten, verschlüsselt.';
@override
String get assistantConsentAgree => 'Zustimmen';
@override
String assistantConsentAgreedOn(String date) {
return 'Du hast am $date zugestimmt.';
}
@override
String get assistantProblemNotGranted =>
'Dieser Assistent ist für dich nicht mehr freigegeben.';
@override
String get assistantProblemNotAgreed => 'Stimm zuerst zu, was wohin geht.';
@override
String assistantProblemLimitReached(String time) {
return 'Seine Grenze ist bis $time erreicht.';
}
@override
String get assistantProblemLimitReachedNow =>
'Seine Grenze ist vorerst erreicht.';
@override
String get assistantProblemLimitOutdated =>
'Es wartet darauf, dass seine Grenzen neu gesetzt werden.';
@override
String get assistantProblemImagesNotRead => 'Es liest keine Bilder.';
@override
String get assistantProblemMalformed =>
'Diese Frage kann nicht gesendet werden.';
@override
String get assistantProblemTooLarge =>
'Diese Frage ist zu lang oder enthält zu viele oder zu große Bilder.';
@override
String get assistantProblemTooManyAtOnce =>
'Warte zuerst auf die Antworten, die unterwegs sind.';
@override
String get assistantProblemProviderKey =>
'Der Anbieter hat den Schlüssel abgelehnt.';
@override
String get assistantProblemProviderBusy =>
'Der Anbieter ist ausgelastet. Versuch es gleich noch einmal.';
@override
String get assistantProblemProviderRefused =>
'Der Anbieter hat diese Frage abgelehnt.';
@override
String get assistantProblemProviderFailed =>
'Beim Anbieter ist etwas schiefgegangen. Versuch es noch einmal.';
@override
String get assistantProblemNotAllowed =>
'Das können nur die, für die er freigegeben ist.';
@override
String get assistantProblemFailed =>
'Das hat nicht geklappt. Versuch es noch einmal.';
}

View file

@ -1283,6 +1283,314 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get operatorFailed => 'That didn\'t work. Try again.';
@override
String get operatorAssistantNotSet =>
'That assistant isn\'t set up here any more.';
@override
String get operatorInvalidLimit => 'A limit can\'t be below zero.';
@override
String get assistantGrantsTitle => 'Assistants';
@override
String get assistantGrantsNone => 'Nobody has an assistant yet.';
@override
String get assistantGrant => 'Grant an assistant';
@override
String get assistantGrantSave => 'Grant';
@override
String get assistantGrantChange => 'Change limits';
@override
String get assistantGrantAssistant => 'Assistant';
@override
String get assistantGrantDaily => 'Limit a day';
@override
String get assistantGrantMonthly => 'Limit a month';
@override
String get assistantGrantLimitHelper =>
'Empty for none. Days and months are UTC\'s.';
@override
String get assistantGrantLimitInvalid => 'Not a number this takes';
@override
String get assistantGrantNoHandle => 'No account has this handle.';
@override
String get assistantGrantTokens => 'tokens';
@override
String get assistantGrantNote =>
'They agree once to what goes where before they ask it. You see how much they use, never what they ask.';
@override
String assistantPerDay(String amount) {
return '$amount a day';
}
@override
String assistantPerMonth(String amount) {
return '$amount a month';
}
@override
String get assistantNoLimits => 'No limits';
@override
String get assistantGrantAgreed => 'Agreed';
@override
String get assistantGrantNotAgreed => 'Not agreed yet';
@override
String get assistantGrantOutdated =>
'Its prices changed: set its limits again';
@override
String get assistantGrantUnset => 'No longer set up';
@override
String get assistantRevoke => 'Take back';
@override
String assistantRevokeTitle(String assistant) {
return 'Take back $assistant?';
}
@override
String get assistantRevokeMessage =>
'They can no longer ask it. What they used stays counted.';
@override
String get assistantUseByMonth => 'Use by month';
@override
String get assistantUseTitle => 'Use of assistants';
@override
String get assistantUseNone => 'Nothing used in this month.';
@override
String get assistantUseKept =>
'Counted per UTC day, kept for this month and the two before. Never what was asked.';
@override
String assistantRequests(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count requests',
one: '1 request',
);
return '$_temp0';
}
@override
String assistantTokens(String amount) {
return '$amount tokens';
}
@override
String get assistantsTitle => 'Assistants';
@override
String assistantModelAt(String model, String host) {
return '$model at $host';
}
@override
String assistantLeftToday(String left, String limit) {
return '$left of $limit left today';
}
@override
String assistantLeftThisMonth(String left, String limit) {
return '$left of $limit left this month';
}
@override
String get assistantAgree => 'Read and agree';
@override
String get assistantWaitsForLimits => 'Waits for its limits to be set again';
@override
String get assistantYourUse => 'Your use';
@override
String assistantPrivacyPage(String instance) {
return 'What goes where on $instance';
}
@override
String get assistantOwnTitle => 'With your own key';
@override
String get assistantOwnAdd => 'Add an assistant with your own key';
@override
String get assistantOwnNote =>
'The app asks your provider directly: this instance sees neither your key nor your questions, and counts nothing. The key is kept in your settings, encrypted on your devices.';
@override
String get assistantOwnEdit => 'Your own key';
@override
String get assistantOwnName => 'Name';
@override
String get assistantOwnProtocol => 'API';
@override
String get assistantOwnBaseUrl => 'Address';
@override
String get assistantOwnBaseUrlHelper =>
'Empty for the provider\'s own. Mistral, OpenRouter or a model of your own: its address with /v1.';
@override
String get assistantOwnBaseUrlInvalid =>
'Only https, or http on your own network';
@override
String get assistantOwnModel => 'Model';
@override
String get assistantOwnKey => 'Key';
@override
String get assistantOwnKeyHelper =>
'Empty for a model of your own that takes none.';
@override
String get assistantOwnImages => 'Reads images';
@override
String get assistantOwnSave => 'Save';
@override
String get assistantOwnRemove => 'Remove';
@override
String assistantOwnRemoveTitle(String name) {
return 'Remove $name?';
}
@override
String get assistantOwnRemoveMessage =>
'Its key goes from your settings on every device.';
@override
String get assistantOwnRefused =>
'The key wasn\'t kept: this device can\'t encrypt your settings yet. Open your account\'s keys here first.';
@override
String assistantOwnNotAllowed(String instance) {
return 'Whoever runs $instance doesn\'t allow own keys here, so the app doesn\'t ask these.';
}
@override
String assistantConsentTitle(String name) {
return 'Before you ask $name';
}
@override
String assistantConsentModel(String model, String host) {
return 'It answers with the model $model at $host.';
}
@override
String get assistantConsentWhatGoes =>
'Only what you send goes there: your question and what you pick of what it is about. The provider\'s own terms apply to it.';
@override
String get assistantConsentImages => 'It reads the images you add.';
@override
String assistantConsentInstance(String instance) {
return 'It passes through $instance on its way, which keeps none of it.';
}
@override
String assistantConsentOperator(String instance) {
return 'Whoever runs $instance sees how much you use it (requests, tokens, cost), never what you ask.';
}
@override
String get assistantConsentConversations =>
'Your conversations are kept on your devices, encrypted.';
@override
String get assistantConsentAgree => 'Agree';
@override
String assistantConsentAgreedOn(String date) {
return 'You agreed on $date.';
}
@override
String get assistantProblemNotGranted =>
'This assistant isn\'t granted to you any more.';
@override
String get assistantProblemNotAgreed => 'Agree to what goes where first.';
@override
String assistantProblemLimitReached(String time) {
return 'Its limit is reached until $time.';
}
@override
String get assistantProblemLimitReachedNow => 'Its limit is reached for now.';
@override
String get assistantProblemLimitOutdated =>
'It waits for its limits to be set again.';
@override
String get assistantProblemImagesNotRead => 'It reads no images.';
@override
String get assistantProblemMalformed => 'This question can\'t be sent.';
@override
String get assistantProblemTooLarge =>
'This question is too long, or holds too many or too large images.';
@override
String get assistantProblemTooManyAtOnce =>
'Wait for the answers on their way first.';
@override
String get assistantProblemProviderKey => 'The provider refused the key.';
@override
String get assistantProblemProviderBusy =>
'The provider is busy. Try again in a moment.';
@override
String get assistantProblemProviderRefused =>
'The provider refused this question.';
@override
String get assistantProblemProviderFailed =>
'The provider failed. Try again.';
@override
String get assistantProblemNotAllowed =>
'Only people it is granted to can do this.';
@override
String get assistantProblemFailed => 'That didn\'t work. Try again.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,4 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/assistant/data/assistant_repository.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/files/file_saver.dart';
@ -39,6 +40,16 @@ abstract interface class AppDependencies<P extends Disposable>()
/// suspended, and who operates; refused to anyone else.
InstanceOperatorRepository get instanceOperators;
/// The assistants the operator granted the signed-in person, agreeing to
/// them, their use, and asking them or the person's own (Halkyon Learn's
/// ADR 0021, step 2).
AssistantRepository get assistants;
/// Granting assistants, with their limits and everyone's use, as the
/// instance's operators do (ADR 0021, points 2, 6 and 8); refused to
/// anyone else.
AssistantOperatorRepository get assistantOperators;
/// Keeps a file the app made where the person chooses (a recovery phrase,
/// recovery codes, an app's exports).
FileSaver get fileSaver;

View file

@ -0,0 +1,170 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/assistant/data/assistant_repository.dart';
import 'package:halkyon_app/src/assistant/model/assistant_grant.dart';
import 'package:halkyon_app/src/operator/model/operated_account.dart';
/// What the operator's screen shows of the assistants.
final class const AssistantGrantsState({
/// Reading the assistants set and the grants.
final bool loading = true,
/// Why they couldn't be read; null while they could.
final OperatorFailed? unreadable,
/// The assistants set in `deploy/assistants.env`, without their keys.
final AssistantsSetUp setUp = const AssistantsSetUp(),
/// Every grant, by handle.
final List<AssistantGrant> grants = const [],
/// What is under way: a grant's key ([keyOf]), or `grant` for a new one;
/// null for nothing.
final String? busy,
/// Why the last action failed, nothing changed; null for none.
final OperatorFailed? problem,
}) with Equatable {
/// Whether there is anything to show: an assistant set, or a grant left
/// of one no longer set.
bool get showsAnything => setUp.assistants.isNotEmpty || grants.isNotEmpty;
/// What names [grant] while it is acted on.
static String keyOf(AssistantGrant grant) =>
'${grant.account}/${grant.assistant}';
AssistantGrantsState copyWith({
bool? loading,
OperatorFailed? unreadable,
bool readable = false,
AssistantsSetUp? setUp,
List<AssistantGrant>? grants,
String? busy,
bool clearBusy = false,
OperatorFailed? problem,
bool clearProblem = false,
}) => AssistantGrantsState(
loading: loading ?? this.loading,
unreadable: readable ? null : unreadable ?? this.unreadable,
setUp: setUp ?? this.setUp,
grants: grants ?? this.grants,
busy: clearBusy ? null : busy ?? this.busy,
problem: clearProblem ? null : problem ?? this.problem,
);
@override
List<Object?> get props => [
loading,
unreadable,
setUp,
grants,
busy,
problem,
];
}
/// The assistants the operator grants (Halkyon Learn's ADR 0021, points 2,
/// 6 and 8): granted by handle with their limits, the limits changed, taken
/// back. One action at a time, the grants read anew after each.
class AssistantGrantsCubit(final AssistantOperatorRepository _operators)
extends Cubit<AssistantGrantsState> {
this : super(const AssistantGrantsState());
/// Reads the assistants set and the grants.
Future<void> load() async {
try {
final setUp = await _operators.setUp();
final grants = await _operators.grants();
if (!isClosed) {
emit(
state.copyWith(
loading: false,
readable: true,
setUp: setUp,
grants: grants,
),
);
}
} on Object catch (error) {
log('Reading the grants failed: ${error.runtimeType}', name: 'AI');
if (!isClosed) {
emit(state.copyWith(loading: false, unreadable: _failure(error)));
}
}
}
/// Grants [assistant] to the account with [handle], with [dailyLimit] and
/// [monthlyLimit] in its pricing's unit; why it failed, or null once
/// granted. The sheet asking says it, so it stays out of `problem`.
Future<OperatorFailed?> grant({
required String handle,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) => _act(
'grant',
() => _operators.grant(
handle: handle,
assistant: assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
),
);
/// Sets [grant]'s limits; why it failed, or null once set.
Future<OperatorFailed?> changeLimits(
AssistantGrant grant, {
required int? dailyLimit,
required int? monthlyLimit,
}) => _act(
AssistantGrantsState.keyOf(grant),
() => _operators.changeLimits(
account: grant.account,
assistant: grant.assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
),
);
/// Takes [grant] back; a failure is the state's `problem`.
Future<void> revoke(AssistantGrant grant) async {
final failed = await _act(
AssistantGrantsState.keyOf(grant),
() => _operators.revoke(
account: grant.account,
assistant: grant.assistant,
),
);
if (failed != null && !isClosed) {
emit(state.copyWith(problem: failed));
}
}
Future<OperatorFailed?> _act(
String what,
Future<void> Function() action,
) async {
if (state.busy != null) {
return const OperatorFailed(OperatorProblem.failed);
}
emit(state.copyWith(busy: what, clearProblem: true));
OperatorFailed? failed;
try {
await action();
} on Object catch (error) {
log('An assistant grant failed: ${error.runtimeType}', name: 'AI');
failed = _failure(error);
}
await load();
if (!isClosed) {
emit(state.copyWith(clearBusy: true));
}
return failed;
}
static OperatorFailed _failure(Object error) => error is OperatorFailed
? error
: const OperatorFailed(OperatorProblem.failed);
}

View file

@ -0,0 +1,84 @@
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
/// What a list of use shows: one UTC month's.
final class const AssistantUseState({
/// The month shown, its first day in UTC.
required final DateTime month,
/// The months the instance keeps, the latest first: this one and the
/// two before (Halkyon Learn's ADR 0021, D41).
required final List<DateTime> months,
/// Reading it.
final bool loading = true,
/// Whether it couldn't be read.
final bool failed = false,
/// What was used, by person and assistant.
final List<AssistantUse> use = const [],
}) with Equatable {
@override
List<Object?> get props => [month, months, loading, failed, use];
}
/// One UTC month's use of assistants, as [_read] gives it: the person's
/// own, or everyone's for the operator (Halkyon Learn's ADR 0021, D3).
class AssistantUseCubit(
final Future<List<AssistantUse>> Function(DateTime month) _read,
) extends Cubit<AssistantUseState> {
this : super(_first(clock.now()));
static AssistantUseState _first(DateTime now) {
final month = utcMonthOf(now);
return AssistantUseState(
month: month,
months: [
for (var back = 0; back < 3; back++)
DateTime.utc(month.year, month.month - back),
],
);
}
/// Reads [month]'s use, or the month shown's.
Future<void> show([DateTime? month]) async {
final shown = month ?? state.month;
emit(
AssistantUseState(
month: shown,
months: state.months,
use: shown == state.month ? state.use : const [],
),
);
try {
final use = await _read(shown);
if (!isClosed && state.month == shown) {
emit(
AssistantUseState(
month: shown,
months: state.months,
loading: false,
use: use,
),
);
}
} on Object catch (error) {
log('Reading the use failed: ${error.runtimeType}', name: 'AI');
if (!isClosed && state.month == shown) {
emit(
AssistantUseState(
month: shown,
months: state.months,
loading: false,
failed: true,
),
);
}
}
}
}

View file

@ -0,0 +1,177 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/assistant/data/assistant_repository.dart';
import 'package:halkyon_app/src/assistant/model/assistant_failed.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
import 'package:halkyon_app/src/assistant/model/own_assistant.dart';
/// What an app's settings show of the person's assistants.
final class const AssistantsState({
/// Reading what the instance offers.
final bool loading = true,
/// Why it couldn't be read; null while it could.
final AssistantFailed? unreadable,
/// What the instance offers; nothing while it couldn't be read.
final AssistantsOffer offer = AssistantsOffer.none,
/// The person's own assistants, in the order added.
final List<OwnAssistant> own = const [],
/// The ID of the assistant being agreed to; null for none.
final String? agreeing,
/// Why the last agreement failed; null for none.
final AssistantFailed? problem,
}) with Equatable {
/// Whether there is anything to show: an assistant granted, own keys
/// allowed, or own assistants kept.
bool get showsAnything =>
offer.assistants.isNotEmpty || offer.ownKeys || own.isNotEmpty;
AssistantsState copyWith({
bool? loading,
AssistantFailed? unreadable,
bool readable = false,
AssistantsOffer? offer,
List<OwnAssistant>? own,
String? agreeing,
bool clearAgreeing = false,
AssistantFailed? problem,
bool clearProblem = false,
}) => AssistantsState(
loading: loading ?? this.loading,
unreadable: readable ? null : unreadable ?? this.unreadable,
offer: offer ?? this.offer,
own: own ?? this.own,
agreeing: clearAgreeing ? null : agreeing ?? this.agreeing,
problem: clearProblem ? null : problem ?? this.problem,
);
@override
List<Object?> get props => [
loading,
unreadable,
offer,
own,
agreeing,
problem,
];
}
/// How saving an own assistant ended.
enum OwnAssistantSaved() {
/// It is kept.
kept,
/// This device can't seal the settings, so its key wasn't kept.
refused,
/// Keeping it failed.
failed,
}
/// The person's assistants (Halkyon Learn's ADR 0021, points 6, 9 and 10):
/// those the operator granted, read anew after every agreement, and those
/// with their own key from [ownAssistants], where the app keeps them.
class AssistantsCubit(
final AssistantRepository _assistants, {
/// Where the app keeps the person's own assistants; none where it keeps
/// none.
final OwnAssistantStore? ownAssistants,
}) extends Cubit<AssistantsState> {
this : super(const AssistantsState()) {
_changes = _assistants.changes.listen((_) => unawaited(load()));
_own = ownAssistants?.watch().listen((own) {
if (!isClosed) {
emit(state.copyWith(own: own));
}
});
}
late final StreamSubscription<void> _changes;
late final StreamSubscription<List<OwnAssistant>>? _own;
/// Reads what the instance offers.
Future<void> load() async {
try {
final offer = await _assistants.offered();
if (!isClosed) {
emit(state.copyWith(loading: false, readable: true, offer: offer));
}
} on Object catch (error) {
log('Reading the assistants failed: ${error.runtimeType}', name: 'AI');
if (!isClosed) {
emit(
state.copyWith(
loading: false,
offer: AssistantsOffer.none,
unreadable: error is AssistantFailed
? error
: const AssistantFailed(AssistantProblem.failed),
),
);
}
}
}
/// Agrees to what goes where with [assistant]; the offer is read anew.
Future<void> agree(String assistant) async {
if (state.agreeing != null) {
return;
}
emit(state.copyWith(agreeing: assistant, clearProblem: true));
AssistantFailed? failed;
try {
await _assistants.agree(assistant);
} on Object catch (error) {
log('Agreeing failed: ${error.runtimeType}', name: 'AI');
failed = error is AssistantFailed
? error
: const AssistantFailed(AssistantProblem.failed);
}
await load();
if (!isClosed) {
emit(state.copyWith(clearAgreeing: true, problem: failed));
}
}
/// Keeps [assistant] among the person's own, in place of the one with
/// its ID.
Future<OwnAssistantSaved> saveOwn(OwnAssistant assistant) async {
final store = ownAssistants;
if (store == null) {
return OwnAssistantSaved.failed;
}
try {
await store.save(assistant);
return OwnAssistantSaved.kept;
} on OwnAssistantRefused {
return OwnAssistantSaved.refused;
} on Object catch (error) {
log('Keeping an own assistant failed: ${error.runtimeType}', name: 'AI');
return OwnAssistantSaved.failed;
}
}
/// Removes the person's own assistant [id] names.
Future<void> removeOwn(String id) async {
try {
await ownAssistants?.remove(id);
} on Object catch (error) {
log('Removing an own assistant failed: ${error.runtimeType}', name: 'AI');
}
}
@override
Future<void> close() async {
await _changes.cancel();
await _own?.cancel();
await super.close();
}
}

View file

@ -0,0 +1,52 @@
import 'package:halkyon_app/src/assistant/model/assistant_grant.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// The boundary to the assistants the instance's operator grants (Halkyon
/// Learn's ADR 0021, points 6 to 8), on the signed-in person's session:
/// failures are `AssistantFailed`s. Tests replace it with a fake.
abstract interface class AssistantGateway() {
/// The assistants granted to the person, with what is left of their
/// limits; none on a member of another's register (D43).
Future<AssistantsOffer> offered();
/// Records that the person agrees to what goes where with [assistant],
/// its provider as it is now (D40).
Future<void> agree(String assistant);
/// Sends [request] to [assistant] through the instance, the answer
/// streamed as halkyon_llm streams a provider's: the text in pieces, then
/// the whole turn. A refusal or failure ends it as an `AssistantFailed`;
/// cancelling stops the answer coming, which the instance still counts.
Stream<LlmEvent> ask(String assistant, LlmRequest request);
/// What the person used, month by month, the latest first.
Future<List<AssistantUse>> figures();
}
/// The boundary to granting assistants (Halkyon Learn's ADR 0021, points 2,
/// 6 and 8), on the operator's session: refusals are `OperatorFailed`s.
abstract interface class AssistantOperatorGateway() {
/// The assistants set in `deploy/assistants.env`, without their keys.
Future<AssistantsSetUp> setUp();
/// Every grant, by handle.
Future<List<AssistantGrant>> grants();
/// Grants [assistant] to [account] with [dailyLimit] and [monthlyLimit]
/// (null: none) in its pricing's unit; granted already, the limits change
/// and the agreement stays.
Future<void> grant({
required String account,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
});
/// Takes [assistant] back from [account].
Future<void> revoke({required String account, required String assistant});
/// Everyone's use in the UTC month [month] is in, per person and
/// assistant.
Future<List<AssistantUse>> figures(DateTime month);
}

View file

@ -0,0 +1,266 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_app/src/assistant/model/assistant_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:serverpod_client/serverpod_client.dart';
// A conversation as halkyon_llm holds it, and as the module's assistant
// endpoint carries it (Halkyon Learn's ADR 0021, point 7): the app's
// features speak halkyon_llm alone, whether the operator's assistant
// answers through the instance or a person's own key answers directly, so
// a feature never knows which. The module turns them back on its side
// (`requestOf`, `messageOf`), each part as it was.
/// [request] to [assistant] as the module's endpoint takes it; the person's
/// ID is the instance's to give (D30), so [LlmRequest.personId] stays here.
protocol.AssistantQuestion questionOf(String assistant, LlmRequest request) =>
protocol.AssistantQuestion(
assistant: assistant,
system: request.system,
messages: [for (final message in request.messages) messageOf(message)],
tools: [
for (final tool in request.tools)
protocol.AssistantTool(
name: tool.name,
description: tool.description,
inputSchema: jsonEncode(tool.inputSchema),
),
],
maxOutputTokens: request.maxOutputTokens,
);
/// [message] as the module's endpoint carries it.
protocol.AssistantMessage messageOf(LlmMessage message) =>
protocol.AssistantMessage(
role: switch (message.role) {
LlmRole.user => protocol.AssistantRole.user,
LlmRole.assistant => protocol.AssistantRole.assistant,
},
parts: [
for (final part in message.parts)
switch (part) {
LlmText(:final text, :final extra) => protocol.AssistantPart(
kind: protocol.AssistantPartKind.text,
text: text,
extra: extra.isEmpty ? null : jsonEncode(extra),
),
LlmImage(:final mediaType, :final bytes) => protocol.AssistantPart(
kind: protocol.AssistantPartKind.image,
mediaType: mediaType,
bytes: ByteData.sublistView(bytes),
),
LlmToolCall(:final id, :final name, :final input, :final extra) =>
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolCall,
callId: id,
name: name,
json: jsonEncode(input),
extra: extra.isEmpty ? null : jsonEncode(extra),
),
LlmToolResult(:final callId, :final content, :final isError) =>
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolResult,
callId: callId,
text: content,
isError: isError,
),
LlmProviderBlock(protocol: final of, :final block) =>
protocol.AssistantPart(
kind: protocol.AssistantPartKind.providerBlock,
protocol: of.key,
json: jsonEncode(block),
),
},
],
);
/// The model's turn [message], as the module's endpoint answers it, in
/// halkyon_llm's terms; a [FormatException] for a turn without parts or a
/// part that isn't one, or out of its turn.
LlmMessage llmMessageOf(protocol.AssistantMessage message) {
final parts = [for (final part in message.parts) _partOf(part)];
final byPerson = message.role == protocol.AssistantRole.user;
if (parts.isEmpty ||
parts.any(
(part) => byPerson
? part is LlmToolCall || part is LlmProviderBlock
: part is LlmImage || part is LlmToolResult,
)) {
throw const FormatException('Not a turn of its role');
}
return byPerson ? LlmMessage.user(parts) : LlmMessage.assistant(parts);
}
LlmPart _partOf(protocol.AssistantPart part) => switch (part) {
protocol.AssistantPart(
kind: protocol.AssistantPartKind.text,
:final text?,
) =>
LlmText(text, extra: _objectOrNone(part.extra)),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.image,
:final mediaType?,
:final bytes?,
) =>
LlmImage(mediaType: mediaType, bytes: Uint8List.sublistView(bytes)),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolCall,
:final callId?,
:final name?,
:final json?,
) =>
LlmToolCall(
id: callId,
name: name,
input: _object(json),
extra: _objectOrNone(part.extra),
),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.toolResult,
:final callId?,
:final text?,
) =>
LlmToolResult(
callId: callId,
content: text,
isError: part.isError ?? false,
),
protocol.AssistantPart(
kind: protocol.AssistantPartKind.providerBlock,
protocol: final key?,
:final json?,
) =>
LlmProviderBlock(
protocol:
LlmProtocol.named(key) ??
(throw FormatException('No protocol of this name', key)),
block: _object(json),
),
_ => throw FormatException('Not a part of its kind', part.kind.name),
};
Map<String, Object?> _object(String json) => switch (jsonDecode(json)) {
final Map<String, Object?> object => object,
_ => throw FormatException('Not a JSON object', json),
};
Map<String, Object?> _objectOrNone(String? json) =>
json == null ? const {} : _object(json);
/// Why the model stopped, in halkyon_llm's terms.
LlmStop stopOf(protocol.AssistantStop? stop) => switch (stop) {
protocol.AssistantStop.end => LlmStop.end,
protocol.AssistantStop.maxTokens => LlmStop.maxTokens,
protocol.AssistantStop.toolCalls => LlmStop.toolCalls,
protocol.AssistantStop.refused => LlmStop.refused,
protocol.AssistantStop.other || null => LlmStop.other,
};
/// The module's [answers] as halkyon_llm's events: each piece of text, then
/// the whole turn; its refusals, and failing to reach the instance, as
/// [AssistantFailed]. An answer that ends without its turn fails too, and
/// nothing comes after the first failure.
Stream<LlmEvent> eventsOf(Stream<protocol.AssistantAnswer> answers) {
var ended = false;
return answers.transform(
StreamTransformer<protocol.AssistantAnswer, LlmEvent>.fromHandlers(
handleData: (answer, sink) {
if (ended) {
return;
}
if (answer.delta case final delta? when delta.isNotEmpty) {
sink.add(LlmTextDelta(delta));
}
if (answer.message case final message?) {
ended = true;
try {
sink.add(
LlmFinished(
message: llmMessageOf(message),
stop: stopOf(answer.stop),
usage: LlmUsage(
inputTokens: answer.inputTokens ?? 0,
outputTokens: answer.outputTokens ?? 0,
),
),
);
} on FormatException {
sink.addError(
const AssistantFailed(AssistantProblem.providerFailed),
);
}
}
},
handleError: (error, st, sink) {
if (!ended) {
ended = true;
sink.addError(failureOf(error), st);
}
},
handleDone: (sink) {
if (!ended) {
sink.addError(const AssistantFailed(AssistantProblem.failed));
}
sink.close();
},
),
);
}
/// [error] from the instance as an [AssistantFailed].
AssistantFailed failureOf(Object error) => switch (error) {
final AssistantFailed failed => failed,
protocol.AssistantRefusedException(
:final refusal,
:final resetsAt,
:final retryAfterSeconds,
) =>
AssistantFailed(
switch (refusal) {
protocol.AssistantRefusal.notGranted => AssistantProblem.notGranted,
protocol.AssistantRefusal.notAgreed => AssistantProblem.notAgreed,
protocol.AssistantRefusal.limitReached => AssistantProblem.limitReached,
protocol.AssistantRefusal.limitOutdated =>
AssistantProblem.limitOutdated,
protocol.AssistantRefusal.imagesNotRead =>
AssistantProblem.imagesNotRead,
protocol.AssistantRefusal.malformed => AssistantProblem.malformed,
protocol.AssistantRefusal.tooLarge => AssistantProblem.tooLarge,
protocol.AssistantRefusal.tooManyAtOnce =>
AssistantProblem.tooManyAtOnce,
protocol.AssistantRefusal.providerKey => AssistantProblem.providerKey,
protocol.AssistantRefusal.providerBusy => AssistantProblem.providerBusy,
protocol.AssistantRefusal.providerRefused =>
AssistantProblem.providerRefused,
protocol.AssistantRefusal.providerFailed =>
AssistantProblem.providerFailed,
},
resetsAt: resetsAt,
retryAfter: switch (retryAfterSeconds) {
final seconds? => Duration(seconds: seconds),
null => null,
},
),
// A session without the scope, or none.
ServerpodClientUnauthorized() || ServerpodClientForbidden() =>
const AssistantFailed(AssistantProblem.notAllowed),
_ => const AssistantFailed(AssistantProblem.failed),
};
/// A provider's [failure], answering a person's own key, as an
/// [AssistantFailed]: the key's, busy, the question refused, or failed.
AssistantFailed ownKeyFailureOf(LlmFailure failure) => AssistantFailed(
switch (failure.kind) {
LlmFailureKind.key => AssistantProblem.providerKey,
LlmFailureKind.rateLimited ||
LlmFailureKind.unavailable => AssistantProblem.providerBusy,
LlmFailureKind.request => AssistantProblem.providerRefused,
LlmFailureKind.network => AssistantProblem.failed,
LlmFailureKind.provider ||
LlmFailureKind.malformed => AssistantProblem.providerFailed,
},
retryAfter: failure.retryAfter,
);

View file

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:halkyon_app/src/assistant/data/assistant_gateway.dart';
import 'package:halkyon_app/src/assistant/data/assistant_messages.dart';
import 'package:halkyon_app/src/assistant/model/assistant_failed.dart';
import 'package:halkyon_app/src/assistant/model/assistant_grant.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
import 'package:halkyon_app/src/assistant/model/own_assistant.dart';
import 'package:halkyon_app/src/operator/data/instance_operator_gateway.dart';
import 'package:halkyon_app/src/operator/model/operated_account.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// Who answers a question (Halkyon Learn's ADR 0021, point 7): an assistant
/// the operator granted, asked through the instance, or one with the
/// person's own key, asked directly (point 10). Features ask either alike.
sealed class const AssistantChoice() {
/// The name people see.
String get name;
/// The host that receives what is asked.
String get host;
/// Whether it reads images.
bool get images;
}
/// An assistant the operator granted, asked through the instance.
final class const GrantedAssistant(final OfferedAssistant assistant)
extends AssistantChoice {
@override
String get name => assistant.name;
@override
String get host => assistant.host;
@override
bool get images => assistant.images;
}
/// An assistant with the person's own key, asked directly: the instance
/// sees neither the key nor what is asked, and counts nothing.
final class const PersonalAssistant(final OwnAssistant assistant)
extends AssistantChoice {
@override
String get name => assistant.name;
@override
String get host => assistant.host;
@override
bool get images => assistant.images;
}
/// The assistants the signed-in person may ask (Halkyon Learn's ADR 0021):
/// those the operator granted, agreed to once, their use, and asking
/// either kind. Failures are [AssistantFailed]s.
class AssistantRepository(
final AssistantGateway _gateway, {
/// What asks a personal key's provider; one over HTTP unless given.
LlmClient? llm,
}) {
final LlmClient _llm = llm ?? LlmClient();
final _changes = StreamController<void>.broadcast();
/// Every agreement made through this repository, for what shows the
/// assistants to read them anew.
Stream<void> get changes => _changes.stream;
/// The assistants granted to the person, with what is left of their
/// limits.
Future<AssistantsOffer> offered() => _gateway.offered();
/// Records that the person agrees to what goes where with [assistant].
Future<void> agree(String assistant) async {
await _gateway.agree(assistant);
if (!_changes.isClosed) {
_changes.add(null);
}
}
/// What the person used, month by month, the latest first.
Future<List<AssistantUse>> figures() => _gateway.figures();
/// Asks [choice] [request], the answer streamed: the text in pieces, then
/// the whole turn; a failure ends it as an [AssistantFailed]. A personal
/// key's provider gets no person's ID: it knows its key's owner.
Stream<LlmEvent> ask(AssistantChoice choice, LlmRequest request) =>
switch (choice) {
GrantedAssistant(:final assistant) => _gateway.ask(
assistant.id,
request,
),
PersonalAssistant(:final assistant) => _askDirectly(
assistant,
request,
),
};
Stream<LlmEvent> _askDirectly(OwnAssistant assistant, LlmRequest request) {
final LlmEndpoint endpoint;
try {
endpoint = assistant.endpoint;
} on FormatException {
return Stream.error(
const AssistantFailed(AssistantProblem.providerFailed),
);
}
return _llm
.send(
endpoint,
LlmRequest(
messages: request.messages,
maxOutputTokens: request.maxOutputTokens,
system: request.system,
tools: request.tools,
),
)
.handleError(
(Object error) => throw switch (error) {
final LlmFailure failure => ownKeyFailureOf(failure),
_ => const AssistantFailed(AssistantProblem.failed),
},
);
}
/// Closes [changes] and the HTTP client; the owner calls it as it closes.
Future<void> dispose() async {
// Not awaited: a broadcast stream's close completes in the root zone,
// which a widget test's fake clock never reaches.
unawaited(_changes.close());
_llm.close();
}
}
/// Granting assistants (Halkyon Learn's ADR 0021, points 2, 6 and 8), as
/// the operator's screen uses it. Refusals are [OperatorFailed]s.
class AssistantOperatorRepository(
final AssistantOperatorGateway _gateway,
/// Where accounts are found by their handles.
final InstanceOperatorGateway _accounts,
) {
final _changes = StreamController<void>.broadcast();
/// Every change made through this repository.
Stream<void> get changes => _changes.stream;
/// The assistants set, without their keys.
Future<AssistantsSetUp> setUp() => _gateway.setUp();
/// Every grant, by handle.
Future<List<AssistantGrant>> grants() => _gateway.grants();
/// Grants [assistant] to the account with [handle] (an `@` before it
/// left out, whatever its case), with [dailyLimit] and [monthlyLimit] in
/// its pricing's unit (null: none); refused as `notFound` for a handle
/// nobody has.
Future<void> grant({
required String handle,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) async {
final typed = handle.trim().replaceFirst(RegExp('^@'), '');
final account = typed.isEmpty
? null
: (await _accounts.accountsStartingWith(typed))
.where(
(account) =>
account.handle?.toLowerCase() == typed.toLowerCase(),
)
.firstOrNull;
if (account == null) {
throw const OperatorFailed(OperatorProblem.notFound);
}
await changeLimits(
account: account.id,
assistant: assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
);
}
/// Sets the limits of [assistant]'s grant to [account], granting it where
/// it isn't; the person's agreement stays.
Future<void> changeLimits({
required String account,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) => _changing(
_gateway.grant(
account: account,
assistant: assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
),
);
/// Takes [assistant] back from [account].
Future<void> revoke({required String account, required String assistant}) =>
_changing(_gateway.revoke(account: account, assistant: assistant));
/// Everyone's use in the UTC month [month] is in.
Future<List<AssistantUse>> figures(DateTime month) =>
_gateway.figures(utcMonthOf(month));
Future<void> _changing(Future<void> change) async {
await change;
if (!_changes.isClosed) {
_changes.add(null);
}
}
/// Closes [changes]; the owner calls it as it closes.
Future<void> dispose() async {
unawaited(_changes.close());
}
}
/// Where a person's own assistants are kept (Halkyon Learn's ADR 0021,
/// point 10): an app's settings, sealed with the person's key, which each
/// app keeps in its own way.
abstract interface class OwnAssistantStore() {
/// The person's own assistants now and after every change, in the order
/// added.
Stream<List<OwnAssistant>> watch();
/// Keeps [assistant], in place of the one with its ID where there is one;
/// refused ([OwnAssistantRefused]) where the settings wouldn't be sealed.
Future<void> save(OwnAssistant assistant);
/// Removes the assistant [id] names.
Future<void> remove(String id);
}
/// A key wasn't kept: this device can't seal the person's settings, so the
/// instance could read it.
final class const OwnAssistantRefused() implements Exception {
@override
String toString() => 'OwnAssistantRefused()';
}

View file

@ -0,0 +1,162 @@
import 'package:halkyon_app/src/assistant/data/assistant_gateway.dart';
import 'package:halkyon_app/src/assistant/data/assistant_messages.dart';
import 'package:halkyon_app/src/assistant/model/assistant_grant.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
import 'package:halkyon_app/src/operator/data/serverpod_instance_operator_gateway.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:serverpod_client/serverpod_client.dart';
/// [AssistantGateway] on the module's `assistant` endpoint of an app's
/// client ([_base], `client.modules.base`), as the device's person.
class ServerpodAssistantGateway(final protocol.Caller _base)
implements AssistantGateway {
@override
Future<AssistantsOffer> offered() => _failing(() async {
final offered = await _base.assistant.offered();
return AssistantsOffer(
assistants: [
for (final offer in offered.assistants)
OfferedAssistant(
id: offer.id,
name: offer.name,
protocol: offer.protocol,
host: offer.host,
model: offer.model,
images: offer.images,
agreedAt: offer.agreedAt,
unit: unitOf(offer.limitUnit),
dailyLimit: offer.dailyLimit,
monthlyLimit: offer.monthlyLimit,
usedToday: offer.usedToday,
usedThisMonth: offer.usedThisMonth,
outdated: offer.outdated,
),
],
ownKeys: offered.ownKeys,
currency: offered.currency,
);
});
@override
Future<void> agree(String assistant) =>
_failing(() => _base.assistant.agree(assistant: assistant));
@override
Stream<LlmEvent> ask(String assistant, LlmRequest request) => eventsOf(
_base.assistant.ask(question: questionOf(assistant, request)),
);
@override
Future<List<AssistantUse>> figures() => _failing(
() async => [...(await _base.assistant.figures()).map(useOf)],
);
}
/// [AssistantOperatorGateway] on the module's `assistantOperator` endpoint
/// of an app's client ([_base]), as the operator; refusals as
/// `OperatorFailed`, as the operator's other calls.
class ServerpodAssistantOperatorGateway(final protocol.Caller _base)
implements AssistantOperatorGateway {
@override
Future<AssistantsSetUp> setUp() => operatorCall(() async {
final setUp = await _base.assistantOperator.setUp();
return AssistantsSetUp(
assistants: [
for (final info in setUp.assistants)
SetUpAssistant(
id: info.id,
name: info.name,
protocol: info.protocol,
host: info.host,
model: info.model,
images: info.images,
priceInput: info.priceInput,
priceOutput: info.priceOutput,
),
],
currency: setUp.currency,
ownKeys: setUp.ownKeys,
);
});
@override
Future<List<AssistantGrant>> grants() => operatorCall(
() async => [
for (final grant in await _base.assistantOperator.grants())
AssistantGrant(
account: grant.account.uuid,
handle: grant.handle,
assistant: grant.assistant,
assistantSet: grant.assistantSet,
grantedAt: grant.grantedAt,
unit: unitOf(grant.limitUnit),
dailyLimit: grant.dailyLimit,
monthlyLimit: grant.monthlyLimit,
agreedAt: grant.agreedAt,
outdated: grant.outdated,
),
],
);
@override
Future<void> grant({
required String account,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) => operatorCall(
() => _base.assistantOperator.grant(
account: UuidValue.withValidation(account),
assistant: assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
),
);
@override
Future<void> revoke({required String account, required String assistant}) =>
operatorCall(
() => _base.assistantOperator.revoke(
account: UuidValue.withValidation(account),
assistant: assistant,
),
);
@override
Future<List<AssistantUse>> figures(DateTime month) => operatorCall(
() async => [
...(await _base.assistantOperator.figures(month: month.toUtc())).map(
useOf,
),
],
);
}
/// [unit] in the app's terms.
AssistantLimitUnit unitOf(protocol.AssistantLimitUnit unit) => switch (unit) {
protocol.AssistantLimitUnit.costMicros => AssistantLimitUnit.money,
protocol.AssistantLimitUnit.tokens => AssistantLimitUnit.tokens,
};
/// A month's [figures] in the app's terms.
AssistantUse useOf(protocol.AssistantMonthFigures figures) => AssistantUse(
assistant: figures.assistant,
month: figures.month,
requests: figures.requests,
inputTokens: figures.inputTokens,
outputTokens: figures.outputTokens,
costMicros: figures.costMicros,
account: figures.account.uuid,
handle: figures.handle,
);
/// What [call] gives; the instance's refusals, and failing to reach it, as
/// `AssistantFailed`.
Future<T> _failing<T>(Future<T> Function() call) async {
try {
return await call();
} on Exception catch (error) {
throw failureOf(error);
}
}

View file

@ -0,0 +1,54 @@
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
/// A limit as the operator types it, in [unit]: an amount of money with up
/// to six decimals (a point or a comma before them), as millionths; tokens
/// as a whole number, its thousands grouped by points or commas, or not
/// (`1000000`, `1,000,000`); spaces and underscores left out. Empty means
/// no limit (null); anything else that isn't a number of at most 12 digits
/// (before the decimals) is a [FormatException].
int? limitFromText(String text, AssistantLimitUnit unit) {
final typed = text.trim().replaceAll(RegExp(r'[\s_]'), '');
if (typed.isEmpty) {
return null;
}
switch (unit) {
case AssistantLimitUnit.tokens:
final grouped = RegExp(r'^\d{1,3}([,.])\d{3}(?:\1\d{3})*$');
final digits = typed.replaceAll(RegExp('[,.]'), '');
if ((!RegExp(r'^\d+$').hasMatch(typed) && !grouped.hasMatch(typed)) ||
digits.length > 12) {
throw FormatException('Not a number of tokens', text);
}
return int.parse(digits);
case AssistantLimitUnit.money:
final match = RegExp(r'^(\d{1,12})(?:[.,](\d{1,6}))?$').firstMatch(typed);
if (match == null) {
throw FormatException('Not an amount', text);
}
final whole = int.parse(match[1]!);
final fraction = (match[2] ?? '').padRight(6, '0');
return whole * 1000000 + int.parse(fraction);
}
}
/// [limit], in [unit], as [limitFromText] reads it back: money with a
/// point and no more decimals than it has, tokens as digits; empty for no
/// limit.
String limitText(int? limit, AssistantLimitUnit unit) {
if (limit == null) {
return '';
}
return switch (unit) {
AssistantLimitUnit.tokens => '$limit',
AssistantLimitUnit.money => _decimal(limit),
};
}
String _decimal(int micros) {
final whole = micros ~/ 1000000;
final fraction = (micros % 1000000)
.toString()
.padLeft(6, '0')
.replaceFirst(RegExp(r'0+$'), '');
return fraction.isEmpty ? '$whole' : '$whole.$fraction';
}

View file

@ -0,0 +1,62 @@
import 'package:equatable/equatable.dart';
/// Why an assistant didn't answer, or why agreeing or reading figures
/// failed (Halkyon Learn's ADR 0021): the instance's refusals, its
/// provider's failures, and a personal key's provider's.
enum AssistantProblem() {
/// The operator granted it to nobody of this name (any more).
notGranted,
/// The person hasn't agreed to what goes where, as it is now.
notAgreed,
/// The day's or the month's limit is used up (`AssistantFailed.resetsAt`).
limitReached,
/// Its limits wait for the operator to set them again.
limitOutdated,
/// It reads no images.
imagesNotRead,
/// The question isn't one it can be sent.
malformed,
/// The question is too long, or holds too many or too large images.
tooLarge,
/// Too many questions are on their way at once.
tooManyAtOnce,
/// The provider refused the key: the operator's, or one's own.
providerKey,
/// The provider is busy or down (`AssistantFailed.retryAfter`).
providerBusy,
/// The provider refused the question.
providerRefused,
/// The provider failed otherwise.
providerFailed,
/// The signed-in person may not do this.
notAllowed,
/// Anything else, such as no connection.
failed,
}
/// An assistant failed as [problem] says, with when its limit resets or
/// when to try again where the instance or provider says so.
final class const AssistantFailed(
final AssistantProblem problem, {
final DateTime? resetsAt,
final Duration? retryAfter,
}) with Equatable implements Exception {
@override
List<Object?> get props => [problem, resetsAt, retryAfter];
@override
String toString() => 'AssistantFailed(${problem.name})';
}

View file

@ -0,0 +1,106 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_app/src/assistant/model/assistant_offer.dart';
/// An assistant the operator set in `deploy/assistants.env`, as the
/// operator's screen shows it (Halkyon Learn's ADR 0021, point 4): never
/// its key.
final class const SetUpAssistant({
required final String id,
required final String name,
required final String protocol,
required final String host,
required final String model,
final bool images = false,
/// What a million of the request's tokens cost, in the instance's
/// currency; null without prices.
final double? priceInput,
/// What a million of the answer's tokens cost; null without prices.
final double? priceOutput,
}) with Equatable {
/// What its limits are counted in: money with prices, else tokens (D38).
AssistantLimitUnit get unit => priceInput != null && priceOutput != null
? AssistantLimitUnit.money
: AssistantLimitUnit.tokens;
@override
List<Object?> get props => [
id,
name,
protocol,
host,
model,
images,
priceInput,
priceOutput,
];
}
/// The assistants the operator set, for their screen.
final class const AssistantsSetUp({
/// Each, in the operator's order; none where none is set.
final List<SetUpAssistant> assistants = const [],
/// The currency of the prices (ISO 4217).
final String currency = 'USD',
/// Whether people may add their own keys.
final bool ownKeys = false,
}) with Equatable {
/// The assistant [id] names; null for one not set (any more).
SetUpAssistant? named(String id) {
for (final assistant in assistants) {
if (assistant.id == id) {
return assistant;
}
}
return null;
}
@override
List<Object?> get props => [assistants, currency, ownKeys];
}
/// An assistant granted to an account, as the operator sees it (D2, D4).
final class const AssistantGrant({
/// The account's ID.
required final String account,
/// Its handle; null for one without.
final String? handle,
/// The assistant's ID.
required final String assistant,
/// Whether the assistant is still set; a grant of one taken out of the
/// file stays, unused.
final bool assistantSet = true,
required final DateTime grantedAt,
/// What [dailyLimit] and [monthlyLimit] are counted in.
final AssistantLimitUnit unit = AssistantLimitUnit.tokens,
final int? dailyLimit,
final int? monthlyLimit,
/// When the person agreed to what goes where; null until they do.
final DateTime? agreedAt,
/// Whether its limits are in another unit than the pricing's now, to be
/// set again.
final bool outdated = false,
}) with Equatable {
@override
List<Object?> get props => [
account,
handle,
assistant,
assistantSet,
grantedAt,
unit,
dailyLimit,
monthlyLimit,
agreedAt,
outdated,
];
}

View file

@ -0,0 +1,154 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
/// What an assistant's limits and figures are counted in (Halkyon Learn's
/// ADR 0021, D38): money where the operator set its prices, else tokens.
enum AssistantLimitUnit() {
/// Millionths of the instance's currency (`AssistantsOffer.currency`).
money,
/// Tokens, the request's and the answer's together.
tokens,
}
/// An assistant the operator granted to the signed-in person (Halkyon
/// Learn's ADR 0021, points 6 and 9): who answers, what is left of its
/// limits, and whether the person agreed to what goes where.
final class const OfferedAssistant({
/// The ID the operator gave it, which questions name.
required final String id,
/// The name people see.
required final String name,
/// The protocol it is asked in (`anthropic`, `openai`,
/// `openai-responses`).
required final String protocol,
/// The provider's host, which receives what is asked.
required final String host,
/// The model that answers.
required final String model,
/// Whether it reads images.
final bool images = false,
/// When the person agreed to what goes where, as it is now (D40); null
/// until they do, or once it moved to another provider.
final DateTime? agreedAt,
/// What its limits and use are counted in.
final AssistantLimitUnit unit = AssistantLimitUnit.tokens,
/// The most a UTC day may use, in [unit]; null for no daily limit.
final int? dailyLimit,
/// The most a UTC month may use, in [unit]; null for no monthly limit.
final int? monthlyLimit,
/// What today used, in [unit].
final int usedToday = 0,
/// What this month used, in [unit].
final int usedThisMonth = 0,
/// Whether its limits were set in another unit than its pricing's now:
/// it answers nothing until the operator sets them again.
final bool outdated = false,
}) with Equatable {
/// Whether the person agreed to what goes where.
bool get agreed => agreedAt != null;
/// What is left of today's limit; null without one.
int? get leftToday => switch (dailyLimit) {
final limit? => max(0, limit - usedToday),
null => null,
};
/// What is left of this month's limit; null without one.
int? get leftThisMonth => switch (monthlyLimit) {
final limit? => max(0, limit - usedThisMonth),
null => null,
};
@override
List<Object?> get props => [
id,
name,
protocol,
host,
model,
images,
agreedAt,
unit,
dailyLimit,
monthlyLimit,
usedToday,
usedThisMonth,
outdated,
];
}
/// The assistants the instance offers the signed-in person.
final class const AssistantsOffer({
/// Those granted to them, in the operator's order.
final List<OfferedAssistant> assistants = const [],
/// Whether they may add assistants with their own keys (D5, D6).
final bool ownKeys = false,
/// The currency of what is counted in money (ISO 4217).
final String currency = 'USD',
}) with Equatable {
/// Nothing offered: an instance without assistants, or a member (D43).
static const none = AssistantsOffer();
@override
List<Object?> get props => [assistants, ownKeys, currency];
}
/// What a person used of one assistant in one UTC month: their own, or,
/// for the operator, anyone's (D3).
final class const AssistantUse({
/// The assistant's ID.
required final String assistant,
/// The month's first day, in UTC.
required final DateTime month,
final int requests = 0,
final int inputTokens = 0,
final int outputTokens = 0,
/// What it cost, in millionths of the instance's currency, at the prices
/// of when it was counted; 0 without prices.
final int costMicros = 0,
/// Whose: the account's ID.
final String? account,
/// Their handle; null for one without.
final String? handle,
}) with Equatable {
/// The tokens of the requests and the answers together.
int get tokens => inputTokens + outputTokens;
@override
List<Object?> get props => [
assistant,
month,
requests,
inputTokens,
outputTokens,
costMicros,
account,
handle,
];
}
/// The first day of [at]'s UTC month, in UTC.
DateTime utcMonthOf(DateTime at) {
final utc = at.toUtc();
return DateTime.utc(utc.year, utc.month);
}

Some files were not shown because too many files have changed in this diff Show more