feat(assistant): a question opens in the AI assistant chosen, Proton's Lumo by default

Questions asked from the app went to Claude alone. Settings › Assistant
now chooses where they open (decided 2026-10-14): Lumo, the default, by
its guest chat with the question in the address's fragment (Lumo's
`prefill`, which needs no account and reaches no server; the address for
one's own account loses the question when nobody is signed in), ChatGPT,
Claude, Mistral, Perplexity, Duck.ai, Grok, Kagi Assistant, HuggingChat,
or an address of one's own with %s. Each link fills the question in for
the person to send (Perplexity answers at once, Grok asks first); Gemini,
which has no such link, and Copilot, whose stopped working, are left out.
The field names the assistant ("Ask Lumo…"). A device that sent questions
to Claude goes on asking an assistant, now Lumo. Connecting Claude to read
studysets stays as it was.

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-14 13:19:51 +02:00
commit 8dde0fa42a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
26 changed files with 701 additions and 207 deletions

View file

@ -161,7 +161,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use.
- **Asking Claude or searching the web** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): above Create studyset in the Library, above a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A switch at the field's start sends to Claude or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and a compact line per chosen part, and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
- **Asking an AI assistant or searching the web** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): above Create studyset in the Library, above a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A switch at the field's start sends to the AI assistant chosen in Settings, named in the field, or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `AssistantHandOff` opens the chosen assistant's new conversation with the question and a compact line per chosen part written in, for the person to send (`AiAssistant`, decided 2026-10-14: Proton's Lumo by default, by its guest chat `https://lumo.proton.me/guest#prefill=…`, the question in the fragment, which reaches no server, as Lumo's page reads it, undocumented; its address for one's own account loses the question when nobody is signed in; then ChatGPT, Claude, Mistral, Perplexity, which answers at once, Duck.ai, Grok, which asks first, Kagi Assistant, HuggingChat, or the person's own address with `%s`; only assistants whose address fills a question in, so not Gemini, which has none, nor Copilot, whose stopped working in November 2025; each cut at what it takes, 8,000 to 12,000 characters), and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, branch `halkyon/byte-payloads`, decided 2026-10-12): v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes. Move the fork along when upgrading, and drop it once a release has the fix.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.

View file

@ -1698,15 +1698,15 @@
"kanjiExampleWords": "Wörter damit",
"settingsAssistant": "Assistent",
"settingsAskClaude": "Claude fragen oder im Web suchen",
"settingsAskClaudeSubtitle": "Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in Claude oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.",
"assistantHint": "Frag Claude…",
"assistantHintStudyset": "Frag Claude zu {title}…",
"assistantHintCard": "Frag Claude zu dieser Karte…",
"assistantSend": "In Claude fragen",
"settingsAskAssistant": "Einen KI-Assistenten fragen oder im Web suchen",
"settingsAskAssistantSubtitle": "Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in dem KI-Assistenten, den du wählst, oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.",
"assistantHint": "Frag {assistant}…",
"assistantHintStudyset": "Frag {assistant} zu {title}…",
"assistantHintCard": "Frag {assistant} zu dieser Karte…",
"assistantSend": "In {assistant} fragen",
"assistantOpen": "Fragen oder suchen",
"assistantClose": "Schließen",
"assistantOpenFailed": "Claude ließ sich nicht öffnen.",
"assistantOpenFailed": "{assistant} ließ sich nicht öffnen.",
"assistantConnectTitle": "Claude verbinden",
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
@ -1725,7 +1725,7 @@
"assistantDisconnect": "Trennen",
"assistantDisconnectTitle": "{name} trennen?",
"assistantDisconnectMessage": "Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.",
"assistantNotOffered": "Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.",
"assistantNotOffered": "Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in dem KI-Assistenten, den du gewählt hast, mit dem, worum es geht.",
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
"assistantRetry": "Erneut versuchen",
@ -1737,14 +1737,15 @@
"assistantLineDescription": "Beschreibung: {text}",
"assistantChipStudyset": "Lernset: {title}",
"assistantContextLabel": "Mitsenden",
"assistantTargetClaude": "Claude",
"assistantTargetWeb": "Web",
"assistantSwitchToWeb": "Stattdessen im Web suchen",
"assistantSwitchToClaude": "Stattdessen Claude fragen",
"assistantSwitchToAssistant": "Stattdessen {assistant} fragen",
"assistantHintWeb": "Im Web suchen…",
"assistantSendWeb": "Suchen",
"assistantSearchFailed": "Die Suche ließ sich nicht öffnen.",
"settingsSearchEngine": "Suchmaschine",
"settingsAiAssistant": "KI-Assistent",
"aiAssistantCustomHint": "https://example.org/chat?q=%s",
"searchEngineDevice": "Suche des Geräts",
"searchEngineCustom": "Eigene Adresse",
"searchEngineCustomHint": "https://example.org/search?q=%s",

View file

@ -1992,16 +1992,20 @@
"kanjiExampleWords": "Words with it",
"settingsAssistant": "Assistant",
"settingsAskClaude": "Ask Claude or search the web",
"settingsAskClaudeSubtitle": "Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude or as a web search, with only the context you choose; nothing is sent from here.",
"assistantHint": "Ask Claude…",
"assistantHintStudyset": "Ask Claude about {title}…",
"@assistantHintStudyset": {"placeholders": {"title": {"type": "String"}}},
"assistantHintCard": "Ask Claude about this card…",
"assistantSend": "Ask in Claude",
"settingsAskAssistant": "Ask an AI assistant or search the web",
"settingsAskAssistantSubtitle": "Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in the AI assistant you choose or as a web search, with only the context you choose; nothing is sent from here.",
"assistantHint": "Ask {assistant}…",
"@assistantHint": {"placeholders": {"assistant": {"type": "String"}}},
"assistantHintStudyset": "Ask {assistant} about {title}…",
"@assistantHintStudyset": {"placeholders": {"assistant": {"type": "String"}, "title": {"type": "String"}}},
"assistantHintCard": "Ask {assistant} about this card…",
"@assistantHintCard": {"placeholders": {"assistant": {"type": "String"}}},
"assistantSend": "Ask in {assistant}",
"@assistantSend": {"placeholders": {"assistant": {"type": "String"}}},
"assistantOpen": "Ask or search",
"assistantClose": "Close",
"assistantOpenFailed": "Claude couldn't be opened.",
"assistantOpenFailed": "{assistant} couldn't be opened.",
"@assistantOpenFailed": {"placeholders": {"assistant": {"type": "String"}}},
"assistantConnectTitle": "Connect Claude",
"assistantConnectSubtitle": "Let Claude read your studysets",
@ -2025,12 +2029,12 @@
"assistantDisconnectTitle": "Disconnect {name}?",
"@assistantDisconnectTitle": {"placeholders": {"name": {"type": "String"}}},
"assistantDisconnectMessage": "It can't read your studysets any more. You can connect it again with a new code.",
"assistantNotOffered": "This instance doesn't offer a connector yet. Questions still open in Claude with what they are about.",
"assistantNotOffered": "This instance doesn't offer a connector yet. Questions still open in the AI assistant you chose, with what they are about.",
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
"assistantRetry": "Try again",
"assistantLineFront": "Front: {text}",
"@assistantLineFront": {"description": "A line of context handed to Claude after the question.", "placeholders": {"text": {"type": "String"}}},
"@assistantLineFront": {"description": "A line of context handed to the AI assistant after the question.", "placeholders": {"text": {"type": "String"}}},
"assistantLineBack": "Back: {text}",
"@assistantLineBack": {"placeholders": {"text": {"type": "String"}}},
"assistantLineHint": "Hint {number}: {text}",
@ -2044,14 +2048,16 @@
"assistantChipStudyset": "Studyset: {title}",
"@assistantChipStudyset": {"placeholders": {"title": {"type": "String"}}},
"assistantContextLabel": "Send along",
"assistantTargetClaude": "Claude",
"assistantTargetWeb": "Web",
"assistantSwitchToWeb": "Search the web instead",
"assistantSwitchToClaude": "Ask Claude instead",
"assistantSwitchToAssistant": "Ask {assistant} instead",
"@assistantSwitchToAssistant": {"placeholders": {"assistant": {"type": "String"}}},
"assistantHintWeb": "Search the web…",
"assistantSendWeb": "Search",
"assistantSearchFailed": "The search couldn't be opened.",
"settingsSearchEngine": "Search engine",
"settingsAiAssistant": "AI assistant",
"aiAssistantCustomHint": "https://example.org/chat?q=%s",
"searchEngineDevice": "The device's search",
"searchEngineCustom": "Your own address",
"searchEngineCustomHint": "https://example.org/search?q=%s",

View file

@ -10047,41 +10047,41 @@ abstract class AppLocalizations {
/// **'Assistant'**
String get settingsAssistant;
/// No description provided for @settingsAskClaude.
/// No description provided for @settingsAskAssistant.
///
/// In en, this message translates to:
/// **'Ask Claude or search the web'**
String get settingsAskClaude;
/// **'Ask an AI assistant or search the web'**
String get settingsAskAssistant;
/// No description provided for @settingsAskClaudeSubtitle.
/// No description provided for @settingsAskAssistantSubtitle.
///
/// In en, this message translates to:
/// **'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude or as a web search, with only the context you choose; nothing is sent from here.'**
String get settingsAskClaudeSubtitle;
/// **'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in the AI assistant you choose or as a web search, with only the context you choose; nothing is sent from here.'**
String get settingsAskAssistantSubtitle;
/// No description provided for @assistantHint.
///
/// In en, this message translates to:
/// **'Ask Claude…'**
String get assistantHint;
/// **'Ask {assistant}…'**
String assistantHint(String assistant);
/// No description provided for @assistantHintStudyset.
///
/// In en, this message translates to:
/// **'Ask Claude about {title}…'**
String assistantHintStudyset(String title);
/// **'Ask {assistant} about {title}…'**
String assistantHintStudyset(String assistant, String title);
/// No description provided for @assistantHintCard.
///
/// In en, this message translates to:
/// **'Ask Claude about this card…'**
String get assistantHintCard;
/// **'Ask {assistant} about this card…'**
String assistantHintCard(String assistant);
/// No description provided for @assistantSend.
///
/// In en, this message translates to:
/// **'Ask in Claude'**
String get assistantSend;
/// **'Ask in {assistant}'**
String assistantSend(String assistant);
/// No description provided for @assistantOpen.
///
@ -10098,8 +10098,8 @@ abstract class AppLocalizations {
/// No description provided for @assistantOpenFailed.
///
/// In en, this message translates to:
/// **'Claude couldn\'t be opened.'**
String get assistantOpenFailed;
/// **'{assistant} couldn\'t be opened.'**
String assistantOpenFailed(String assistant);
/// No description provided for @assistantConnectTitle.
///
@ -10206,7 +10206,7 @@ abstract class AppLocalizations {
/// No description provided for @assistantNotOffered.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer a connector yet. Questions still open in Claude with what they are about.'**
/// **'This instance doesn\'t offer a connector yet. Questions still open in the AI assistant you chose, with what they are about.'**
String get assistantNotOffered;
/// No description provided for @assistantUnreachable.
@ -10221,7 +10221,7 @@ abstract class AppLocalizations {
/// **'Try again'**
String get assistantRetry;
/// A line of context handed to Claude after the question.
/// A line of context handed to the AI assistant after the question.
///
/// In en, this message translates to:
/// **'Front: {text}'**
@ -10269,12 +10269,6 @@ abstract class AppLocalizations {
/// **'Send along'**
String get assistantContextLabel;
/// No description provided for @assistantTargetClaude.
///
/// In en, this message translates to:
/// **'Claude'**
String get assistantTargetClaude;
/// No description provided for @assistantTargetWeb.
///
/// In en, this message translates to:
@ -10287,11 +10281,11 @@ abstract class AppLocalizations {
/// **'Search the web instead'**
String get assistantSwitchToWeb;
/// No description provided for @assistantSwitchToClaude.
/// No description provided for @assistantSwitchToAssistant.
///
/// In en, this message translates to:
/// **'Ask Claude instead'**
String get assistantSwitchToClaude;
/// **'Ask {assistant} instead'**
String assistantSwitchToAssistant(String assistant);
/// No description provided for @assistantHintWeb.
///
@ -10317,6 +10311,18 @@ abstract class AppLocalizations {
/// **'Search engine'**
String get settingsSearchEngine;
/// No description provided for @settingsAiAssistant.
///
/// In en, this message translates to:
/// **'AI assistant'**
String get settingsAiAssistant;
/// No description provided for @aiAssistantCustomHint.
///
/// In en, this message translates to:
/// **'https://example.org/chat?q=%s'**
String get aiAssistantCustomHint;
/// No description provided for @searchEngineDevice.
///
/// In en, this message translates to:

View file

@ -6308,25 +6308,32 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsAssistant => 'Assistent';
@override
String get settingsAskClaude => 'Claude fragen oder im Web suchen';
String get settingsAskAssistant =>
'Einen KI-Assistenten fragen oder im Web suchen';
@override
String get settingsAskClaudeSubtitle =>
'Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in Claude oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.';
String get settingsAskAssistantSubtitle =>
'Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in dem KI-Assistenten, den du wählst, oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.';
@override
String get assistantHint => 'Frag Claude…';
@override
String assistantHintStudyset(String title) {
return 'Frag Claude zu $title…';
String assistantHint(String assistant) {
return 'Frag $assistant…';
}
@override
String get assistantHintCard => 'Frag Claude zu dieser Karte…';
String assistantHintStudyset(String assistant, String title) {
return 'Frag $assistant zu $title…';
}
@override
String get assistantSend => 'In Claude fragen';
String assistantHintCard(String assistant) {
return 'Frag $assistant zu dieser Karte…';
}
@override
String assistantSend(String assistant) {
return 'In $assistant fragen';
}
@override
String get assistantOpen => 'Fragen oder suchen';
@ -6335,7 +6342,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get assistantClose => 'Schließen';
@override
String get assistantOpenFailed => 'Claude ließ sich nicht öffnen.';
String assistantOpenFailed(String assistant) {
return '$assistant ließ sich nicht öffnen.';
}
@override
String get assistantConnectTitle => 'Claude verbinden';
@ -6403,7 +6412,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantNotOffered =>
'Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.';
'Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in dem KI-Assistenten, den du gewählt hast, mit dem, worum es geht.';
@override
String get assistantUnreachable =>
@ -6450,9 +6459,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantContextLabel => 'Mitsenden';
@override
String get assistantTargetClaude => 'Claude';
@override
String get assistantTargetWeb => 'Web';
@ -6460,7 +6466,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get assistantSwitchToWeb => 'Stattdessen im Web suchen';
@override
String get assistantSwitchToClaude => 'Stattdessen Claude fragen';
String assistantSwitchToAssistant(String assistant) {
return 'Stattdessen $assistant fragen';
}
@override
String get assistantHintWeb => 'Im Web suchen…';
@ -6474,6 +6482,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsSearchEngine => 'Suchmaschine';
@override
String get settingsAiAssistant => 'KI-Assistent';
@override
String get aiAssistantCustomHint => 'https://example.org/chat?q=%s';
@override
String get searchEngineDevice => 'Suche des Geräts';

View file

@ -6250,25 +6250,31 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsAssistant => 'Assistant';
@override
String get settingsAskClaude => 'Ask Claude or search the web';
String get settingsAskAssistant => 'Ask an AI assistant or search the web';
@override
String get settingsAskClaudeSubtitle =>
'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude or as a web search, with only the context you choose; nothing is sent from here.';
String get settingsAskAssistantSubtitle =>
'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in the AI assistant you choose or as a web search, with only the context you choose; nothing is sent from here.';
@override
String get assistantHint => 'Ask Claude…';
@override
String assistantHintStudyset(String title) {
return 'Ask Claude about $title…';
String assistantHint(String assistant) {
return 'Ask $assistant…';
}
@override
String get assistantHintCard => 'Ask Claude about this card…';
String assistantHintStudyset(String assistant, String title) {
return 'Ask $assistant about $title…';
}
@override
String get assistantSend => 'Ask in Claude';
String assistantHintCard(String assistant) {
return 'Ask $assistant about this card…';
}
@override
String assistantSend(String assistant) {
return 'Ask in $assistant';
}
@override
String get assistantOpen => 'Ask or search';
@ -6277,7 +6283,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get assistantClose => 'Close';
@override
String get assistantOpenFailed => 'Claude couldn\'t be opened.';
String assistantOpenFailed(String assistant) {
return '$assistant couldn\'t be opened.';
}
@override
String get assistantConnectTitle => 'Connect Claude';
@ -6345,7 +6353,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantNotOffered =>
'This instance doesn\'t offer a connector yet. Questions still open in Claude with what they are about.';
'This instance doesn\'t offer a connector yet. Questions still open in the AI assistant you chose, with what they are about.';
@override
String get assistantUnreachable =>
@ -6392,9 +6400,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantContextLabel => 'Send along';
@override
String get assistantTargetClaude => 'Claude';
@override
String get assistantTargetWeb => 'Web';
@ -6402,7 +6407,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get assistantSwitchToWeb => 'Search the web instead';
@override
String get assistantSwitchToClaude => 'Ask Claude instead';
String assistantSwitchToAssistant(String assistant) {
return 'Ask $assistant instead';
}
@override
String get assistantHintWeb => 'Search the web…';
@ -6416,6 +6423,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsSearchEngine => 'Search engine';
@override
String get settingsAiAssistant => 'AI assistant';
@override
String get aiAssistantCustomHint => 'https://example.org/chat?q=%s';
@override
String get searchEngineDevice => 'The device\'s search';

View file

@ -26,8 +26,8 @@ import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.da
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_hand_off.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
@ -158,8 +158,8 @@ class Dependencies({
/// What the system shows of the Pomodoro timer, and its alerts.
required final PomodoroPlatform pomodoro,
/// Hands questions to Claude.
required final ClaudeHandOff claude,
/// Hands questions to the AI assistant the person chose.
required final AssistantHandOff assistantHandOff,
/// Searches the web for questions.
required final WebSearch webSearch,
@ -231,7 +231,7 @@ class Dependencies({
deviceSettings: await device.deviceSettingsStore.read(),
appVersion: appVersion,
pomodoro: device.pomodoro,
claude: ClaudeHandOff(),
assistantHandOff: AssistantHandOff(),
webSearch: WebSearch(),
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
jellyfin: device.jellyfin,

View file

@ -39,6 +39,12 @@ class DeviceSettingsBloc(
state.withSearchEngine(event.name, customUrl: event.customUrl),
),
);
on<DeviceSettingsEventAiAssistantChosen>(
(event, emit) => _apply(
emit,
state.withAiAssistant(event.name, customUrl: event.customUrl),
),
);
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
@ -99,7 +105,7 @@ final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
List<Object?> get props => [shown];
}
/// The person turned asking Claude [on] or off.
/// The person turned asking an assistant [on] or off.
final class const DeviceSettingsEventAssistantChosen(final bool on)
extends DeviceSettingsEvent {
@override
@ -124,6 +130,16 @@ final class const DeviceSettingsEventSearchEngineChosen(
List<Object?> get props => [name, customUrl];
}
/// The person chose the AI assistant [name] (null for the default), at
/// [customUrl] for a custom one.
final class const DeviceSettingsEventAiAssistantChosen(
final String? name, {
final String? customUrl,
}) extends DeviceSettingsEvent {
@override
List<Object?> get props => [name, customUrl];
}
/// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -52,9 +52,12 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
for (final id in ids.whereType<String>()) id,
},
assistant: json['assistant'] == true,
assistantTarget:
AssistantTarget.values.asNameMap()[json['assistantTarget']] ??
AssistantTarget.claude,
// Asking Claude was the only assistant before 2026-10-14
// (`claude`).
assistantTarget: switch (json['assistantTarget']) {
'web' => AssistantTarget.web,
_ => AssistantTarget.assistant,
},
searchEngine: switch (json['searchEngine']) {
final String name => name,
_ => null,
@ -63,6 +66,14 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String url => url,
_ => null,
},
aiAssistant: switch (json['aiAssistant']) {
final String name => name,
_ => null,
},
customAssistantUrl: switch (json['customAssistantUrl']) {
final String url => url,
_ => null,
},
pdfSidebarShare: switch (json['pdfSidebarShare']) {
final num share when share > 0 && share < 1 => share.toDouble(),
_ => null,
@ -89,6 +100,8 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'assistantTarget': settings.assistantTarget.name,
'searchEngine': ?settings.searchEngine,
'customSearchUrl': ?settings.customSearchUrl,
'aiAssistant': ?settings.aiAssistant,
'customAssistantUrl': ?settings.customAssistantUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
}),
flush: true,

View file

@ -8,10 +8,11 @@ enum Appearance() {
dark,
}
/// Where a question asked from the app goes: to Claude, or into a web
/// search; the field remembers the last choice (decided 2026-10-10).
/// Where a question asked from the app goes: to the AI assistant the
/// person chose, or into a web search; the field remembers the last choice
/// (decided 2026-10-10).
enum AssistantTarget() {
claude,
assistant,
web,
}
@ -19,7 +20,7 @@ enum AssistantTarget() {
/// app looks, the language it speaks, the style it cites sources in,
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, and whether and how the person asks
/// Claude or searches the web from the app.
/// an AI assistant or searches the web from the app.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -39,13 +40,21 @@ final class const DeviceSettings({
/// list (detail) rather than a grid, as last left.
final Set<String> detailedBrowsers = const {},
/// Whether the app offers to ask Claude or search the web (decided
/// 2026-10-10): a field on Home, a button in the Library, on a studyset
/// and while studying. Off unless the person turns it on.
/// Whether the app offers to ask an AI assistant or search the web
/// (decided 2026-10-10): a field on Home, a button in the Library, on a
/// studyset and while studying. Off unless the person turns it on.
final bool assistant = false,
/// Where the field sends a question, as last chosen.
final AssistantTarget assistantTarget = AssistantTarget.claude,
final AssistantTarget assistantTarget = AssistantTarget.assistant,
/// The AI assistant a question opens in, by its name in the assistant's
/// list; null for the default, Proton's Lumo (decided 2026-10-14).
final String? aiAssistant,
/// The address a custom assistant opens, with `%s` for the question;
/// null without.
final String? customAssistantUrl,
/// The search engine a web search uses, by its name in the assistant's
/// list; null for the platform's default (the device's own search on
@ -84,7 +93,8 @@ final class const DeviceSettings({
: ({...detailedBrowsers}..remove(studysetId)),
);
/// These settings with asking Claude or searching the web [on] or off.
/// These settings with asking an assistant or searching the web [on] or
/// off.
DeviceSettings withAssistant(bool on) => _copy(assistant: on);
/// These settings with questions going to [target].
@ -96,6 +106,11 @@ final class const DeviceSettings({
DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
_copy(searchEngine: name, customSearchUrl: customUrl);
/// These settings asking the assistant [name] (null for the default), at
/// [customUrl] for a custom one.
DeviceSettings withAiAssistant(String? name, {String? customUrl}) =>
_copy(aiAssistant: name, customAssistantUrl: customUrl);
/// These settings with the editor beside a PDF taking [share] of the
/// room.
DeviceSettings withPdfSidebarShare(double share) =>
@ -111,6 +126,8 @@ final class const DeviceSettings({
AssistantTarget? assistantTarget,
Object? searchEngine = _keep,
Object? customSearchUrl = _keep,
Object? aiAssistant = _keep,
Object? customAssistantUrl = _keep,
double? pdfSidebarShare,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
@ -126,6 +143,12 @@ final class const DeviceSettings({
customSearchUrl: identical(customSearchUrl, _keep)
? this.customSearchUrl
: customSearchUrl as String?,
aiAssistant: identical(aiAssistant, _keep)
? this.aiAssistant
: aiAssistant as String?,
customAssistantUrl: identical(customAssistantUrl, _keep)
? this.customAssistantUrl
: customAssistantUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
);
@ -140,6 +163,8 @@ final class const DeviceSettings({
assistantTarget,
searchEngine,
customSearchUrl,
aiAssistant,
customAssistantUrl,
pdfSidebarShare,
];
}

View file

@ -0,0 +1,55 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart';
import 'package:url_launcher/url_launcher.dart';
/// Hands a question to the AI assistant the person chose (decided
/// 2026-10-10, more than Claude since 2026-10-14): opens a new
/// conversation on its site with the question written in, for the person
/// to read and send. An assistant's own app opens it where it handles the
/// site's links, a browser elsewhere.
///
/// Halkyon Learn can't answer inside the app with someone's plan: no
/// assistant lets another app sign in with it, and API keys stay out
/// (their pricing is opaque and open to abuse). Claude reads the person's
/// studysets instead through the instance's connector, which the person
/// adds to Claude once (`ConnectClaudeScreen`).
class AssistantHandOff({
final Future<bool> Function(Uri url) _open = _openOutside,
}) {
/// The link that opens [prompt] at [assistant] ([customUrl] for a custom
/// one), cut to what it takes; Lumo's for a custom address that won't do.
static Uri urlFor(
String prompt,
AiAssistant assistant, {
String? customUrl,
}) {
final (template, max) = switch (assistant) {
AiAssistant.custom
when customUrl != null && AiAssistant.validTemplate(customUrl) =>
(customUrl, AiAssistant.custom.maxPromptLength),
AiAssistant.custom => (
AiAssistant.lumo.template!,
AiAssistant.lumo.maxPromptLength,
),
_ => (assistant.template!, assistant.maxPromptLength),
};
// Encoded as a component, so it reads alike in a query and in a
// fragment (Lumo's).
return Uri.parse(
template.replaceAll('%s', Uri.encodeComponent(clip(prompt, max))),
);
}
/// [prompt], cut to [max] with an ellipsis where it was cut.
static String clip(String prompt, int max) =>
prompt.length <= max ? prompt : '${prompt.substring(0, max - 1)}…';
/// Opens [prompt] at [assistant]; false if nothing could open it.
Future<bool> ask(
String prompt, {
required AiAssistant assistant,
String? customUrl,
}) => _open(urlFor(prompt, assistant, customUrl: customUrl));
}
Future<bool> _openOutside(Uri url) =>
launchUrl(url, mode: LaunchMode.externalApplication);

View file

@ -1,33 +0,0 @@
import 'package:url_launcher/url_launcher.dart';
/// Hands a question to Claude: opens a new conversation at claude.ai with
/// the question written in, for the person to read and send. Claude's own
/// app opens it where it handles claude.ai's links, a browser elsewhere.
///
/// Halkyon Learn can't answer inside the app with someone's Claude plan:
/// Anthropic allows no other app to sign in with it. Claude reads the
/// person's studysets instead through the instance's connector, which the
/// person adds to Claude once (`ConnectClaudeScreen`).
class ClaudeHandOff({
final Future<bool> Function(Uri url) _open = _openOutside,
}) {
/// How long a prompt may be: claude.ai cuts what it is given at about
/// 14,000 characters, and a link that long stays well within what
/// browsers accept.
static const maxPromptLength = 12000;
/// The link that opens [prompt] in a new conversation.
static Uri urlFor(String prompt) =>
Uri.https('claude.ai', '/new', {'q': clip(prompt)});
/// [prompt], cut to [maxPromptLength] with an ellipsis where it was cut.
static String clip(String prompt) => prompt.length <= maxPromptLength
? prompt
: '${prompt.substring(0, maxPromptLength - 1)}…';
/// Opens [prompt] in Claude; false if nothing could open it.
Future<bool> ask(String prompt) => _open(urlFor(prompt));
}
Future<bool> _openOutside(Uri url) =>
launchUrl(url, mode: LaunchMode.externalApplication);

View file

@ -0,0 +1,113 @@
/// The AI assistants a question asked from the app can open in (decided
/// 2026-10-14), each by the address that opens a new conversation with the
/// question written in, `%s` where it goes, and how long a question it
/// takes. The app never answers itself: the person reads the question
/// there and sends it (Perplexity answers at once, as a search; Grok asks
/// first). Only assistants whose address fills in a question are offered:
/// Gemini has none, and Copilot's stopped filling it in (checked
/// 2026-10-14).
enum AiAssistant(
/// What it is called, as its maker names it; none for [custom], which
/// its address names.
final String? title,
/// Who makes it.
final String? maker,
/// The address, with `%s` for the question; none for [custom].
final String? template, {
/// The longest question it takes: what its page reads, or what a link
/// through the services in front of it may carry.
final int maxPromptLength = 8000,
}) {
/// Proton's Lumo, the default: its guest chat, which needs no account
/// (the address for one's own account loses the question when nobody is
/// signed in). In the address's fragment, which reaches no server, as
/// Lumo's page reads it (`prefill`, undocumented, checked 2026-10-14).
lumo(
'Lumo',
'Proton',
'https://lumo.proton.me/guest#prefill=%s',
maxPromptLength: 12000,
),
/// OpenAI's ChatGPT (`prompt` fills in; `q` would send).
chatGpt('ChatGPT', 'OpenAI', 'https://chatgpt.com/?prompt=%s'),
/// Anthropic's Claude, which cuts what it is given at about 14,000
/// characters.
claude(
'Claude',
'Anthropic',
'https://claude.ai/new?q=%s',
maxPromptLength: 12000,
),
/// Mistral's chat.
mistral(
'Mistral',
'Mistral AI',
'https://chat.mistral.ai/chat?q=%s',
maxPromptLength: 12000,
),
/// Perplexity, which answers at once, as its search does.
perplexity(
'Perplexity',
'Perplexity',
'https://www.perplexity.ai/search?q=%s',
),
/// DuckDuckGo's Duck.ai.
duckAi('Duck.ai', 'DuckDuckGo', 'https://duck.ai/?q=%s'),
/// xAI's Grok, which asks before sending.
grok('Grok', 'xAI', 'https://grok.com/?q=%s'),
/// Kagi Assistant, for Kagi's members (`qvalue` fills in; `q` would
/// send).
kagi(
'Kagi Assistant',
'Kagi',
'https://kagi.com/assistant?qvalue=%s',
maxPromptLength: 12000,
),
/// Hugging Face's HuggingChat (`prompt` fills in).
huggingChat(
'HuggingChat',
'Hugging Face',
'https://huggingface.co/chat/?prompt=%s',
),
/// An address of the person's own, with `%s` for the question.
custom(null, null, null);
/// What it is called in the app's hints and buttons: its [title], a
/// custom one by the site of its address ([customUrl]), Lumo's for one
/// whose address won't do (as a question then opens in Lumo).
String titleWith(String? customUrl) => switch (this) {
custom when customUrl != null && validTemplate(customUrl) =>
Uri.parse(customUrl.replaceAll('%s', 'q')).host.replaceFirst(
RegExp(r'^www\.'),
'',
),
custom => lumo.title!,
_ => title!,
};
/// The assistant stored as [name]; Lumo for none or an unknown one.
static AiAssistant byName(String? name) =>
AiAssistant.values.asNameMap()[name] ?? lumo;
/// Whether [template] is an address a custom assistant can open: http or
/// https, with `%s` for the question.
static bool validTemplate(String template) {
final uri = Uri.tryParse(template.replaceAll('%s', 'q'));
return template.contains('%s') &&
uri != null &&
(uri.scheme == 'https' || uri.scheme == 'http') &&
uri.host.isNotEmpty;
}
}

View file

@ -104,9 +104,10 @@ final class const AssistantPart({
List<Object?> get props => [kind, text, number];
}
/// The text handed to Claude: [question], then each part [chosen], a line
/// each, as compact as it reads; the studyset by name and [instance], so a
/// connected Claude knows where to look. The question alone without any.
/// The text handed to the AI assistant: [question], then each part
/// [chosen], a line each, as compact as it reads; the studyset by name and
/// [instance], so a connected Claude knows where to look. The question
/// alone without any.
String assistantPrompt(
AppLocalizations l10n, {
required String question,

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -17,8 +18,8 @@ bool assistantOn(BuildContext context) =>
context.select<DeviceSettingsBloc, bool>((bloc) => bloc.state.assistant);
/// Sends [question], with the parts [chosen] of what it is about, to
/// [target]: Claude, or a web search with the device's chosen engine. Says
/// so if nothing could open it.
/// [target]: the AI assistant the person chose, or a web search with the
/// device's chosen engine. Says so if nothing could open it.
Future<void> sendQuestion(
BuildContext context, {
required String question,
@ -29,14 +30,17 @@ Future<void> sendQuestion(
final settings = context.read<DeviceSettingsBloc>().state;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final assistant = AiAssistant.byName(settings.aiAssistant);
final opened = switch (target) {
AssistantTarget.claude => await dependencies.claude.ask(
AssistantTarget.assistant => await dependencies.assistantHandOff.ask(
assistantPrompt(
l10n,
question: question,
chosen: chosen,
instance: dependencies.serverName,
),
assistant: assistant,
customUrl: settings.customAssistantUrl,
),
AssistantTarget.web => await dependencies.webSearch.search(
searchQuery(question, chosen),
@ -48,7 +52,9 @@ Future<void> sendQuestion(
messenger.showSnackBar(
SnackBar(
content: Text(switch (target) {
AssistantTarget.claude => l10n.assistantOpenFailed,
AssistantTarget.assistant => l10n.assistantOpenFailed(
assistant.titleWith(settings.customAssistantUrl),
),
AssistantTarget.web => l10n.assistantSearchFailed,
}),
),
@ -66,7 +72,8 @@ const _ring = LinearGradient(
const _ringWidth = 2.0;
/// A field to ask about [topic], in [_ring]: the question, where it goes
/// (Claude or the web, switched at the field's start and remembered), and
/// (the AI assistant chosen in Settings or the web, switched at the field's
/// start and remembered), and
/// above it, where the topic has parts, a chip for each to send along;
/// none is chosen, so only what matters goes (decided 2026-10-10). Empty
/// again once sent.
@ -106,7 +113,7 @@ class _AssistantFieldState() extends State<AssistantField> {
/// The parts [target] can take, in the topic's order.
List<AssistantPart> _offered(AssistantTarget target) => [
for (final part in widget.topic.parts)
if (target == AssistantTarget.claude || part.searchable) part,
if (target == AssistantTarget.assistant || part.searchable) part,
];
Future<void> _send(AssistantTarget target) async {
@ -133,8 +140,8 @@ class _AssistantFieldState() extends State<AssistantField> {
void _switch(AssistantTarget target) =>
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAssistantTargetChosen(switch (target) {
AssistantTarget.claude => AssistantTarget.web,
AssistantTarget.web => AssistantTarget.claude,
AssistantTarget.assistant => AssistantTarget.web,
AssistantTarget.web => AssistantTarget.assistant,
}),
);
@ -145,14 +152,20 @@ class _AssistantFieldState() extends State<AssistantField> {
final target = context.select<DeviceSettingsBloc, AssistantTarget>(
(bloc) => bloc.state.assistantTarget,
);
final assistant = context.select<DeviceSettingsBloc, String>(
(bloc) => AiAssistant.byName(
bloc.state.aiAssistant,
).titleWith(bloc.state.customAssistantUrl),
);
final offered = _offered(target);
final hint = switch ((target, widget.topic)) {
(AssistantTarget.web, _) => l10n.assistantHintWeb,
(_, AssistantTopicLearning()) => l10n.assistantHint,
(_, AssistantTopicLearning()) => l10n.assistantHint(assistant),
(_, AssistantTopicStudyset(:final title)) => l10n.assistantHintStudyset(
assistant,
title,
),
(_, AssistantTopicCard()) => l10n.assistantHintCard,
(_, AssistantTopicCard()) => l10n.assistantHintCard(assistant),
};
final shape = offered.isEmpty
? const StadiumBorder()
@ -165,7 +178,11 @@ class _AssistantFieldState() extends State<AssistantField> {
icon: const Icon(LucideIcons.x),
onPressed: close,
),
_TargetSwitch(target: target, onSwitch: () => _switch(target)),
_TargetSwitch(
target: target,
assistant: assistant,
onSwitch: () => _switch(target),
),
Expanded(
child: TextField(
maxLength: TextLimits.message,
@ -175,7 +192,7 @@ class _AssistantFieldState() extends State<AssistantField> {
minLines: 1,
maxLines: 4,
textInputAction: switch (target) {
AssistantTarget.claude => TextInputAction.send,
AssistantTarget.assistant => TextInputAction.send,
AssistantTarget.web => TextInputAction.search,
},
textCapitalization: TextCapitalization.sentences,
@ -194,11 +211,11 @@ class _AssistantFieldState() extends State<AssistantField> {
listenable: _question,
builder: (context, _) => IconButton.filled(
tooltip: switch (target) {
AssistantTarget.claude => l10n.assistantSend,
AssistantTarget.assistant => l10n.assistantSend(assistant),
AssistantTarget.web => l10n.assistantSendWeb,
},
icon: Icon(switch (target) {
AssistantTarget.claude => LucideIcons.arrowUp,
AssistantTarget.assistant => LucideIcons.arrowUp,
AssistantTarget.web => LucideIcons.search,
}),
onPressed:
@ -248,10 +265,12 @@ class _AssistantFieldState() extends State<AssistantField> {
}
}
/// Where a question goes, at the field's start: Claude or the web; a tap
/// switches, and the field remembers it.
/// Where a question goes, at the field's start: the AI assistant chosen,
/// named [assistant], or the web; a tap switches, and the field remembers
/// it.
class const _TargetSwitch({
required final AssistantTarget target,
required final String assistant,
required final VoidCallback onSwitch,
}) extends StatelessWidget {
@override
@ -260,8 +279,8 @@ class const _TargetSwitch({
final theme = Theme.of(context);
return Tooltip(
message: switch (target) {
AssistantTarget.claude => l10n.assistantSwitchToWeb,
AssistantTarget.web => l10n.assistantSwitchToClaude,
AssistantTarget.assistant => l10n.assistantSwitchToWeb,
AssistantTarget.web => l10n.assistantSwitchToAssistant(assistant),
},
child: TextButton.icon(
style: TextButton.styleFrom(
@ -271,11 +290,11 @@ class const _TargetSwitch({
),
onPressed: onSwitch,
icon: Icon(switch (target) {
AssistantTarget.claude => LucideIcons.sparkles,
AssistantTarget.assistant => LucideIcons.sparkles,
AssistantTarget.web => LucideIcons.globe,
}, size: 18),
label: Text(switch (target) {
AssistantTarget.claude => l10n.assistantTargetClaude,
AssistantTarget.assistant => assistant,
AssistantTarget.web => l10n.assistantTargetWeb,
}),
),

View file

@ -10,14 +10,17 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/field_label.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Settings' Assistant card: whether this device asks Claude or searches
/// the web from the app, off unless the person turns it on (decided
/// 2026-10-10); once on, which search engine a web search uses, and
/// connecting Claude to read the person's studysets (ADR 0006).
/// Settings' Assistant card: whether this device asks an AI assistant or
/// searches the web from the app, off unless the person turns it on
/// (decided 2026-10-10); once on, which assistant a question opens in
/// (Lumo unless chosen otherwise, decided 2026-10-14), which search engine
/// a web search uses, and connecting Claude to read the person's studysets
/// (ADR 0006).
class const AssistantSettingsCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -26,6 +29,7 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
final settings = context.watch<DeviceSettingsBloc>().state;
final on = settings.assistant;
final engine = SearchEngine.byName(settings.searchEngine);
final assistant = AiAssistant.byName(settings.aiAssistant);
return SectionCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
@ -39,8 +43,8 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsAskClaude),
subtitle: Text(l10n.settingsAskClaudeSubtitle),
title: Text(l10n.settingsAskAssistant),
subtitle: Text(l10n.settingsAskAssistantSubtitle),
isThreeLine: true,
value: on,
onChanged: (on) => context.read<DeviceSettingsBloc>().add(
@ -48,6 +52,18 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
),
),
if (on) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.sparkles),
title: Text(l10n.settingsAiAssistant),
subtitle: Text(
assistant == AiAssistant.custom
? settings.customAssistantUrl ?? l10n.searchEngineCustom
: assistant.title!,
),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => unawaited(_chooseAssistant(context, settings)),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.globe),
@ -74,6 +90,28 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
);
}
Future<void> _chooseAssistant(
BuildContext context,
DeviceSettings settings,
) async {
final bloc = context.read<DeviceSettingsBloc>();
final chosen = await showAdaptiveSheet<(AiAssistant, String?)>(
context,
builder: (context) => _AssistantSheet(
assistant: AiAssistant.byName(settings.aiAssistant),
customUrl: settings.customAssistantUrl,
),
);
if (chosen case (final assistant, final customUrl)) {
bloc.add(
DeviceSettingsEventAiAssistantChosen(
assistant == AiAssistant.lumo ? null : assistant.name,
customUrl: customUrl,
),
);
}
}
Future<void> _chooseEngine(
BuildContext context,
DeviceSettings settings,
@ -199,3 +237,95 @@ class _EngineSheetState() extends State<_EngineSheet> {
);
}
}
/// The AI assistants a question can open in, each with its maker, the
/// chosen one marked; your own address takes one with `%s` where the
/// question goes. Closes with the choice.
class const _AssistantSheet({
required final AiAssistant assistant,
required final String? customUrl,
}) extends StatefulWidget {
@override
State<_AssistantSheet> createState() => _AssistantSheetState();
}
class _AssistantSheetState() extends State<_AssistantSheet> {
late var _assistant = widget.assistant;
late final _url = TextEditingController(text: widget.customUrl ?? '');
@override
void dispose() {
_url.dispose();
super.dispose();
}
void _choose(AiAssistant assistant) {
if (assistant == AiAssistant.custom) {
setState(() => _assistant = assistant);
} else {
Navigator.of(context).pop((assistant, null));
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final valid = AiAssistant.validTemplate(_url.text.trim());
return SheetScaffold(
title: l10n.settingsAiAssistant,
body: RadioGroup<AiAssistant>(
groupValue: _assistant,
onChanged: (assistant) => _choose(assistant!),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final assistant in AiAssistant.values)
RadioListTile<AiAssistant>(
value: assistant,
title: Text(assistant.title ?? l10n.searchEngineCustom),
subtitle: switch (assistant.maker) {
final maker? => Text(maker),
null => null,
},
),
if (_assistant == AiAssistant.custom)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
controller: _url,
autofocus: true,
keyboardType: TextInputType.url,
onChanged: (_) => setState(() {}),
decoration: requiredDecoration(
context,
InputDecoration(
hintText: l10n.aiAssistantCustomHint,
errorText: _url.text.trim().isEmpty || valid
? null
: l10n.searchEngineCustomInvalid,
),
missing: _url.text.trim().isEmpty,
),
),
),
],
),
),
footer: _assistant == AiAssistant.custom
? Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: valid
? () => Navigator.of(
context,
).pop((AiAssistant.custom, _url.text.trim()))
: null,
child: Text(l10n.searchEngineCustomSave),
),
)
: null,
);
}
}

View file

@ -26,7 +26,7 @@ import 'package:material_ui/material_ui.dart';
/// The home tab, where the app opens: a study dashboard. The streak sits in
/// the app bar; below it the study goal, the week and a year of activity,
/// in the order the person arranged them. Cobalt washes down from the top
/// ([TopGradient]). Where the person asks Claude, a field to ask sits at the
/// ([TopGradient]). Where the person asks an assistant, a field sits at the
/// foot ([AssistantBar]).
///
/// Long-pressing a widget arranges the dashboard: a handle to drag each

View file

@ -23,7 +23,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Settings, over the tabs: where sync stands, how this device shows the app,
/// whether it asks Claude, the account (signing out, deleting it), what
/// whether it asks an assistant, the account (signing out, deleting it), what
/// Halkyon Learn is, and at the bottom which release this is.
class const SettingsScreen({super.key}) extends StatelessWidget {
@override

View file

@ -327,7 +327,7 @@ class const _Showing(
final StudySessionStateShowing state, {
required final Widget header,
/// The studyset's title, for questions to Claude about the card.
/// The studyset's title, for questions to an assistant about the card.
required final String title,
}) extends StatelessWidget {
/// How far inside the card's edges the assistant's button floats, and

View file

@ -188,7 +188,7 @@ class _LibraryState() extends State<_Library> {
child: const Icon(LucideIcons.plus),
);
return ScrollHidingFab(
// Asking Claude stands above Create studyset, smaller.
// Asking an assistant stands above Create studyset, smaller.
fab: assistantOn(context)
? AssistantButton(
topic: const AssistantTopicLearning(),

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