feat(assistant): Claude reads studysets through a connector on the instance
A Claude plan can't drive a chat inside another app, but Claude's apps (every plan, the phone apps too) can add a remote MCP server as a custom connector. The instance now is one (ADR 0006): Serverpod's web server serves the MCP endpoint (/mcp, Streamable HTTP, JSON answers, no sessions) with five read-only tools over the person's own synced rows (their studysets, a studyset's cards and notes a page at a time, a search across cards, a note, their progress), and the OAuth the specification asks for: protected resource and authorization server metadata, dynamic client registration, authorization code with PKCE (S256 only), tokens bound to the resource, iss in the response, refresh tokens that rotate. Caddy forwards these paths alone to the web server, which production now runs on 8082. People connect with a code from the app (Settings › Assistant › Connect Claude): eight Crockford characters, valid for ten minutes and once, typed on the authorization page, which names where it returns to, loads nothing from elsewhere and may not be framed. Anonymous people can connect too. Codes and tokens are kept only as SHA-256 hashes; an access token lives an hour, a refresh token 30 days. The app lists what is connected and disconnects it, which ends its tokens at once; deleting the account deletes it; a daily job forgets what expired and clients nobody connected within a week. InstanceInfo says where the connector is (additive); the migration only adds server-only tables, so devices and older apps are untouched. The handlers take plain requests, so the integration tests drive the whole flow (registration, the code, tokens, refresh, the tools, another person's content, disconnecting, expiry, pruning, account deletion) without a web server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f4baf053b3
commit
56c5b36084
70 changed files with 24646 additions and 22 deletions
|
|
@ -154,7 +154,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.
|
- **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.
|
- **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.
|
- **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** (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 (`AskClaudeBar`) and a button that unfolds into one (`AskClaudeButton`): above Create studyset in the Library, in the right lane of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, never answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and what it is about (`AssistantTopic`: the person's learning, a studyset by name, or the studied card's text), for the person to send in Claude. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude is to read studysets through a connector on the instance instead.
|
- **Asking Claude** (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 (`AskClaudeBar`) and a button that unfolds into one (`AskClaudeButton`): above Create studyset in the Library, in the right lane of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, never answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and what it is about (`AssistantTopic`: the person's learning, a studyset by name, or the studied card's text), for the person to send in Claude. 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).
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ The sync tests need a running server (`serverpod start`); run them in
|
||||||
`deploy/` holds a Docker Compose setup: Caddy (HTTPS), the server and PostgreSQL. Copy
|
`deploy/` holds a Docker Compose setup: Caddy (HTTPS), the server and PostgreSQL. Copy
|
||||||
`deploy/.env.example` to `deploy/.env` and fill it in; it explains every value. The
|
`deploy/.env.example` to `deploy/.env` and fill it in; it explains every value. The
|
||||||
licences require offering the source of what the instance runs to the people who use it.
|
licences require offering the source of what the instance runs to the people who use it.
|
||||||
|
Assistants such as Claude read people's studysets through the instance's MCP connector at
|
||||||
|
`/mcp` ([ADR 0006](docs/adr/0006-assistant-connector.md)): `webServer` in
|
||||||
|
`halkyon_learn_server/config/production.yaml` names its public address, as `apiServer`
|
||||||
|
does for the app.
|
||||||
|
|
||||||
learn.halkyon.eu itself deploys from `main` once CI is green: `deploy/redeploy.sh`, run by
|
learn.halkyon.eu itself deploys from `main` once CI is green: `deploy/redeploy.sh`, run by
|
||||||
the CI runner described in `deploy/runner/`, deploys only commits signed by a key in
|
the CI runner described in `deploy/runner/`, deploys only commits signed by a key in
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
# Caddy forwards to the API server only. Insights (8081) and a web server (8082) don't
|
# Caddy forwards to the API server, and the assistant connector's paths (ADR 0006) to
|
||||||
# run in production; if Insights is ever re-enabled, reach it through an SSH tunnel,
|
# the web server (8082), which serves nothing else. Insights (8081) doesn't run in
|
||||||
# never through this file.
|
# production; if it is ever re-enabled, reach it through an SSH tunnel, never through
|
||||||
|
# this file.
|
||||||
#
|
#
|
||||||
# {$DOMAIN} is `learn.halkyon.eu` on sahaquiel, where Caddy obtains and renews a
|
# {$DOMAIN} is `learn.halkyon.eu` on sahaquiel, where Caddy obtains and renews a
|
||||||
# Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal.
|
# Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal.
|
||||||
{$DOMAIN} {
|
{$DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# The assistant connector: the MCP endpoint, its OAuth pages, and their metadata.
|
||||||
|
@connector path /mcp /oauth/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/* /.well-known/oauth-authorization-server
|
||||||
|
handle @connector {
|
||||||
|
reverse_proxy server:8082
|
||||||
|
}
|
||||||
|
|
||||||
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
|
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
|
||||||
reverse_proxy server:8080
|
handle {
|
||||||
|
reverse_proxy server:8080
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
docs/adr/0006-assistant-connector.md
Normal file
81
docs/adr/0006-assistant-connector.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# ADR 0006: Claude reads studysets through a connector on the instance
|
||||||
|
|
||||||
|
- **Status:** accepted
|
||||||
|
- **Date:** 2026-10-10
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
People want to ask an assistant about what they learn: explain a card, quiz them on a
|
||||||
|
studyset, say what to repeat. The assistant should be able to read their material,
|
||||||
|
reading only for now, writing perhaps later. The first one is Claude; others, such as
|
||||||
|
ChatGPT, should be possible.
|
||||||
|
|
||||||
|
Most people who use Claude pay for a plan (Free, Pro, Max), not for the API. A plan
|
||||||
|
can't drive a chat inside another app: Anthropic allows no third party to offer
|
||||||
|
sign-in with Claude or to use a plan's limits ("Unless previously approved, Anthropic
|
||||||
|
does not allow third party developers to offer claude.ai login or rate limits for their
|
||||||
|
products", Agent SDK documentation, enforced since spring 2026). An in-app chat would
|
||||||
|
need everyone to buy API access and paste a key.
|
||||||
|
|
||||||
|
Claude's apps, on every plan and on phones too, can add a **custom connector**: a remote
|
||||||
|
MCP server that Claude calls from Anthropic's cloud, signed in with OAuth. The
|
||||||
|
conversation then happens in Claude, and Claude reads through the connector's tools
|
||||||
|
whatever it needs. ChatGPT and other clients speak MCP as well.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **The instance is the connector.** It already holds every person's content in
|
||||||
|
plaintext (ADR 0002) and is reachable from the internet, which a phone isn't. Its web
|
||||||
|
server serves the MCP endpoint (`/mcp`, Streamable HTTP, answering with JSON, no
|
||||||
|
sessions) and the OAuth it needs. Caddy forwards those paths to it; the API server is
|
||||||
|
untouched.
|
||||||
|
2. **Read-only tools**, the person's own content only, whoever's token it is:
|
||||||
|
studysets, their cards and notes, a search across cards, and progress. Each tool is
|
||||||
|
marked read-only. Writing comes later, as tools of its own behind a scope of its
|
||||||
|
own.
|
||||||
|
3. **Connecting by code.** Anonymous accounts can't sign in on a web page, so the
|
||||||
|
authorization page asks for a code the app shows (Settings › Assistant): eight
|
||||||
|
characters, valid for ten minutes, used once, kept only as a hash. The page names
|
||||||
|
where the person returns to (the client's redirect host), so a look-alike client
|
||||||
|
can't pass as Claude. The code is the consent; a wrong one is refused without
|
||||||
|
saying more.
|
||||||
|
4. **OAuth as MCP specifies (2026-07-28), the parts Claude uses:** protected resource
|
||||||
|
metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client
|
||||||
|
registration (RFC 7591; a public client, or one with a secret in the form or as
|
||||||
|
Basic), authorization code with PKCE (S256 only), the `resource` parameter bound
|
||||||
|
into every token (RFC 8707), `iss` in the response (RFC 9207), refresh tokens that
|
||||||
|
rotate. Redirect URIs are https, or http on the device itself, and compared
|
||||||
|
exactly; a request naming an unknown client or URI gets an error page, never a
|
||||||
|
redirect. Client ID metadata documents are not supported yet: fetching a client's
|
||||||
|
URL from the server needs care against requests into the instance's own network,
|
||||||
|
and clients fall back to registration.
|
||||||
|
5. **Tokens are kept as hashes** (SHA-256 of 256 random bits): an access token lives
|
||||||
|
an hour, a refresh token 30 days from its last use and only once. A connection is
|
||||||
|
the person's grant to one client, one per client, renewed by connecting again;
|
||||||
|
the app lists them and disconnects one, which ends its tokens at once. Deleting
|
||||||
|
the account deletes them; a daily job deletes what expired and clients nobody
|
||||||
|
connected within a week.
|
||||||
|
6. **The pages and the endpoint:** the authorization page loads nothing from
|
||||||
|
elsewhere, runs no script, and may not be framed (`frame-ancestors 'none'`); it
|
||||||
|
speaks English or German as the browser asks. The MCP endpoint takes POST only,
|
||||||
|
refuses pages from plain-http origins (DNS rebinding), and every request needs a
|
||||||
|
token; the tools are built per request for the token's person.
|
||||||
|
7. **In the app, an opt-in hand-off.** A device setting, off unless turned on, shows a
|
||||||
|
field on Home and a button in the Library, on a studyset and in a study session.
|
||||||
|
Each opens `claude.ai/new?q=…` with the question and what it is about written in
|
||||||
|
(the studyset's name, a card's text), for the person to read and send in Claude.
|
||||||
|
Nothing is sent from the app; what the conversation contains is Claude's, under
|
||||||
|
Anthropic's terms.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- People use the plan they have; Halkyon Learn pays for nothing and holds no API key.
|
||||||
|
- The answer appears in Claude, not inside Halkyon Learn. An in-app chat for people with
|
||||||
|
an API key can follow behind the same opt-in.
|
||||||
|
- The instance gains an OAuth authorization server, small but security-relevant: codes
|
||||||
|
and tokens hashed, redirect URIs matched exactly, PKCE required, the pages not
|
||||||
|
framable, every path tested.
|
||||||
|
- What Claude reads leaves the instance for Anthropic, at the person's request; the
|
||||||
|
privacy policy says so.
|
||||||
|
- Another instance offers the same once it runs this version; the app shows the
|
||||||
|
connector's address from what the instance says about itself.
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// A code for connecting an assistant, as the app shows it (ADR 0006, rule 3).
|
||||||
|
abstract class AssistantCode
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
AssistantCode._({
|
||||||
|
required this.code,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssistantCode({
|
||||||
|
required String code,
|
||||||
|
required DateTime expiresAt,
|
||||||
|
}) = _AssistantCodeImpl;
|
||||||
|
|
||||||
|
factory AssistantCode.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return AssistantCode(
|
||||||
|
code: jsonSerialization['code'] as String,
|
||||||
|
expiresAt: _isc.DateTimeJsonExtension.fromJson(
|
||||||
|
jsonSerialization['expiresAt'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The code, as `K7QX-M2PD`.
|
||||||
|
String code;
|
||||||
|
|
||||||
|
/// When it stops working, in UTC.
|
||||||
|
DateTime expiresAt;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [AssistantCode]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
AssistantCode copyWith({
|
||||||
|
String? code,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'AssistantCode',
|
||||||
|
'code': code,
|
||||||
|
'expiresAt': expiresAt.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'AssistantCode',
|
||||||
|
'code': code,
|
||||||
|
'expiresAt': expiresAt.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistantCodeImpl extends AssistantCode {
|
||||||
|
_AssistantCodeImpl({
|
||||||
|
required String code,
|
||||||
|
required DateTime expiresAt,
|
||||||
|
}) : super._(
|
||||||
|
code: code,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [AssistantCode]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
AssistantCode copyWith({
|
||||||
|
String? code,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
}) {
|
||||||
|
return AssistantCode(
|
||||||
|
code: code ?? this.code,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// A connected assistant, as the app lists it.
|
||||||
|
abstract class AssistantConnectionInfo
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
AssistantConnectionInfo._({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.host,
|
||||||
|
required this.connectedAt,
|
||||||
|
this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssistantConnectionInfo({
|
||||||
|
required int id,
|
||||||
|
required String name,
|
||||||
|
required String host,
|
||||||
|
required DateTime connectedAt,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) = _AssistantConnectionInfoImpl;
|
||||||
|
|
||||||
|
factory AssistantConnectionInfo.fromJson(
|
||||||
|
Map<String, dynamic> jsonSerialization,
|
||||||
|
) {
|
||||||
|
return AssistantConnectionInfo(
|
||||||
|
id: jsonSerialization['id'] as int,
|
||||||
|
name: jsonSerialization['name'] as String,
|
||||||
|
host: jsonSerialization['host'] as String,
|
||||||
|
connectedAt: _isc.DateTimeJsonExtension.fromJson(
|
||||||
|
jsonSerialization['connectedAt'],
|
||||||
|
),
|
||||||
|
lastUsedAt: jsonSerialization['lastUsedAt'] == null
|
||||||
|
? null
|
||||||
|
: _isc.DateTimeJsonExtension.fromJson(
|
||||||
|
jsonSerialization['lastUsedAt'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection's id, to disconnect it.
|
||||||
|
int id;
|
||||||
|
|
||||||
|
/// What the assistant calls itself.
|
||||||
|
String name;
|
||||||
|
|
||||||
|
/// The host the person was sent back to on connecting, e.g. `claude.ai`.
|
||||||
|
String host;
|
||||||
|
|
||||||
|
/// When the person connected it.
|
||||||
|
DateTime connectedAt;
|
||||||
|
|
||||||
|
/// When it last read something; null before.
|
||||||
|
DateTime? lastUsedAt;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [AssistantConnectionInfo]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
AssistantConnectionInfo copyWith({
|
||||||
|
int? id,
|
||||||
|
String? name,
|
||||||
|
String? host,
|
||||||
|
DateTime? connectedAt,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'AssistantConnectionInfo',
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'host': host,
|
||||||
|
'connectedAt': connectedAt.toJson(),
|
||||||
|
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'AssistantConnectionInfo',
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'host': host,
|
||||||
|
'connectedAt': connectedAt.toJson(),
|
||||||
|
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _AssistantConnectionInfoImpl extends AssistantConnectionInfo {
|
||||||
|
_AssistantConnectionInfoImpl({
|
||||||
|
required int id,
|
||||||
|
required String name,
|
||||||
|
required String host,
|
||||||
|
required DateTime connectedAt,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) : super._(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
host: host,
|
||||||
|
connectedAt: connectedAt,
|
||||||
|
lastUsedAt: lastUsedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [AssistantConnectionInfo]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
AssistantConnectionInfo copyWith({
|
||||||
|
int? id,
|
||||||
|
String? name,
|
||||||
|
String? host,
|
||||||
|
DateTime? connectedAt,
|
||||||
|
Object? lastUsedAt = _Undefined,
|
||||||
|
}) {
|
||||||
|
return AssistantConnectionInfo(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
host: host ?? this.host,
|
||||||
|
connectedAt: connectedAt ?? this.connectedAt,
|
||||||
|
lastUsedAt: lastUsedAt is DateTime? ? lastUsedAt : this.lastUsedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,10 @@ import 'dart:async' as _ida;
|
||||||
import 'dart:typed_data' as _idt;
|
import 'dart:typed_data' as _idt;
|
||||||
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
|
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
|
||||||
as _iimptq0q;
|
as _iimptq0q;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_code.dart'
|
||||||
|
as _itx3eecd;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
|
||||||
|
as _iplrdti2;
|
||||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||||
as _ieqh4yr9;
|
as _ieqh4yr9;
|
||||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||||
|
|
@ -109,6 +113,43 @@ class EndpointAsset extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connecting assistants to the signed-in person's account (ADR 0006): a
|
||||||
|
/// code to type on an assistant's authorization page, and the assistants
|
||||||
|
/// connected, to disconnect. Anonymous people too: the code is how they
|
||||||
|
/// connect without signing in anywhere.
|
||||||
|
/// {@category Endpoint}
|
||||||
|
class EndpointAssistant extends _isc.EndpointRef {
|
||||||
|
EndpointAssistant(_isc.EndpointCaller caller) : super(caller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'assistant';
|
||||||
|
|
||||||
|
/// A new code for connecting an assistant, valid for ten minutes and
|
||||||
|
/// once; the person's earlier codes stop working. Only its hash is kept.
|
||||||
|
_ida.Future<_itx3eecd.AssistantCode> connectCode() =>
|
||||||
|
caller.callServerEndpoint<_itx3eecd.AssistantCode>(
|
||||||
|
'assistant',
|
||||||
|
'connectCode',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The assistants connected to the person's account, the newest first.
|
||||||
|
_ida.Future<List<_iplrdti2.AssistantConnectionInfo>> connections() =>
|
||||||
|
caller.callServerEndpoint<List<_iplrdti2.AssistantConnectionInfo>>(
|
||||||
|
'assistant',
|
||||||
|
'connections',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Disconnects the person's assistant [id]: its tokens stop working at
|
||||||
|
/// once. Someone else's connection is one that doesn't exist.
|
||||||
|
_ida.Future<void> disconnect(int id) => caller.callServerEndpoint<void>(
|
||||||
|
'assistant',
|
||||||
|
'disconnect',
|
||||||
|
{'id': id},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The signed-in person's account.
|
/// The signed-in person's account.
|
||||||
/// {@category Endpoint}
|
/// {@category Endpoint}
|
||||||
class EndpointAccount extends _isc.EndpointRef {
|
class EndpointAccount extends _isc.EndpointRef {
|
||||||
|
|
@ -510,8 +551,8 @@ class EndpointInstance extends _isc.EndpointRef {
|
||||||
String get name => 'instance';
|
String get name => 'instance';
|
||||||
|
|
||||||
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
||||||
/// app, where its source is, the oldest app build it works with, and the
|
/// app, where its source is, the oldest app build it works with, the
|
||||||
/// domain its people's handles are on.
|
/// domain its people's handles are on, and where assistants connect.
|
||||||
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
||||||
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
||||||
'instance',
|
'instance',
|
||||||
|
|
@ -718,6 +759,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
httpClientOverride: httpClientOverride,
|
httpClientOverride: httpClientOverride,
|
||||||
) {
|
) {
|
||||||
asset = EndpointAsset(this);
|
asset = EndpointAsset(this);
|
||||||
|
assistant = EndpointAssistant(this);
|
||||||
account = EndpointAccount(this);
|
account = EndpointAccount(this);
|
||||||
anonymousIdp = EndpointAnonymousIdp(this);
|
anonymousIdp = EndpointAnonymousIdp(this);
|
||||||
devices = EndpointDevices(this);
|
devices = EndpointDevices(this);
|
||||||
|
|
@ -736,6 +778,8 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
|
|
||||||
late final EndpointAsset asset;
|
late final EndpointAsset asset;
|
||||||
|
|
||||||
|
late final EndpointAssistant assistant;
|
||||||
|
|
||||||
late final EndpointAccount account;
|
late final EndpointAccount account;
|
||||||
|
|
||||||
late final EndpointAnonymousIdp anonymousIdp;
|
late final EndpointAnonymousIdp anonymousIdp;
|
||||||
|
|
@ -767,6 +811,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
@override
|
@override
|
||||||
Map<String, _isc.EndpointRef> get endpointRefLookup => {
|
Map<String, _isc.EndpointRef> get endpointRefLookup => {
|
||||||
'asset': asset,
|
'asset': asset,
|
||||||
|
'assistant': assistant,
|
||||||
'account': account,
|
'account': account,
|
||||||
'anonymousIdp': anonymousIdp,
|
'anonymousIdp': anonymousIdp,
|
||||||
'devices': devices,
|
'devices': devices,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ abstract class InstanceInfo
|
||||||
required this.sourceCodeUrl,
|
required this.sourceCodeUrl,
|
||||||
this.minimumAppBuild,
|
this.minimumAppBuild,
|
||||||
this.accountDomain,
|
this.accountDomain,
|
||||||
|
this.assistantConnectorUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InstanceInfo({
|
factory InstanceInfo({
|
||||||
|
|
@ -28,6 +29,7 @@ abstract class InstanceInfo
|
||||||
required String sourceCodeUrl,
|
required String sourceCodeUrl,
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
|
String? assistantConnectorUrl,
|
||||||
}) = _InstanceInfoImpl;
|
}) = _InstanceInfoImpl;
|
||||||
|
|
||||||
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -36,6 +38,8 @@ abstract class InstanceInfo
|
||||||
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
|
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
|
||||||
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
|
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
|
||||||
accountDomain: jsonSerialization['accountDomain'] as String?,
|
accountDomain: jsonSerialization['accountDomain'] as String?,
|
||||||
|
assistantConnectorUrl:
|
||||||
|
jsonSerialization['assistantConnectorUrl'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +61,11 @@ abstract class InstanceInfo
|
||||||
/// it.
|
/// it.
|
||||||
String? accountDomain;
|
String? accountDomain;
|
||||||
|
|
||||||
|
/// Where assistants such as Claude connect to read people's studysets (ADR
|
||||||
|
/// 0006): the MCP endpoint, as `https://learn.halkyon.eu/mcp`. Null where
|
||||||
|
/// the instance offers none, and from servers that predate it.
|
||||||
|
String? assistantConnectorUrl;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
|
|
@ -65,6 +74,7 @@ abstract class InstanceInfo
|
||||||
String? sourceCodeUrl,
|
String? sourceCodeUrl,
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
|
String? assistantConnectorUrl,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -74,6 +84,8 @@ abstract class InstanceInfo
|
||||||
'sourceCodeUrl': sourceCodeUrl,
|
'sourceCodeUrl': sourceCodeUrl,
|
||||||
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
||||||
if (accountDomain != null) 'accountDomain': accountDomain,
|
if (accountDomain != null) 'accountDomain': accountDomain,
|
||||||
|
if (assistantConnectorUrl != null)
|
||||||
|
'assistantConnectorUrl': assistantConnectorUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +97,8 @@ abstract class InstanceInfo
|
||||||
'sourceCodeUrl': sourceCodeUrl,
|
'sourceCodeUrl': sourceCodeUrl,
|
||||||
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
||||||
if (accountDomain != null) 'accountDomain': accountDomain,
|
if (accountDomain != null) 'accountDomain': accountDomain,
|
||||||
|
if (assistantConnectorUrl != null)
|
||||||
|
'assistantConnectorUrl': assistantConnectorUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,11 +116,13 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
required String sourceCodeUrl,
|
required String sourceCodeUrl,
|
||||||
int? minimumAppBuild,
|
int? minimumAppBuild,
|
||||||
String? accountDomain,
|
String? accountDomain,
|
||||||
|
String? assistantConnectorUrl,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
gitHubClientId: gitHubClientId,
|
gitHubClientId: gitHubClientId,
|
||||||
sourceCodeUrl: sourceCodeUrl,
|
sourceCodeUrl: sourceCodeUrl,
|
||||||
minimumAppBuild: minimumAppBuild,
|
minimumAppBuild: minimumAppBuild,
|
||||||
accountDomain: accountDomain,
|
accountDomain: accountDomain,
|
||||||
|
assistantConnectorUrl: assistantConnectorUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
|
|
@ -118,6 +134,7 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
String? sourceCodeUrl,
|
String? sourceCodeUrl,
|
||||||
Object? minimumAppBuild = _Undefined,
|
Object? minimumAppBuild = _Undefined,
|
||||||
Object? accountDomain = _Undefined,
|
Object? accountDomain = _Undefined,
|
||||||
|
Object? assistantConnectorUrl = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
return InstanceInfo(
|
return InstanceInfo(
|
||||||
gitHubClientId: gitHubClientId is String?
|
gitHubClientId: gitHubClientId is String?
|
||||||
|
|
@ -130,6 +147,9 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
accountDomain: accountDomain is String?
|
accountDomain: accountDomain is String?
|
||||||
? accountDomain
|
? accountDomain
|
||||||
: this.accountDomain,
|
: this.accountDomain,
|
||||||
|
assistantConnectorUrl: assistantConnectorUrl is String?
|
||||||
|
? assistantConnectorUrl
|
||||||
|
: this.assistantConnectorUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
// ignore_for_file: dead_code, unnecessary_type_check
|
// ignore_for_file: dead_code, unnecessary_type_check
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
|
||||||
|
as _iplrdti2;
|
||||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||||
as _ieqh4yr9;
|
as _ieqh4yr9;
|
||||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||||
|
|
@ -33,6 +35,8 @@ import 'asset/asset.dart' as _ipb7vkxr;
|
||||||
import 'asset/file_allowance.dart' as _i9uv0f1n;
|
import 'asset/file_allowance.dart' as _i9uv0f1n;
|
||||||
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
|
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
|
||||||
import 'asset/file_usage.dart' as _iurwo9k8;
|
import 'asset/file_usage.dart' as _iurwo9k8;
|
||||||
|
import 'assistant/assistant_code.dart' as _i8xi9jqo;
|
||||||
|
import 'assistant/assistant_connection_info.dart' as _icukzjbz;
|
||||||
import 'auth/device.dart' as _i6c5ny7n;
|
import 'auth/device.dart' as _i6c5ny7n;
|
||||||
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
||||||
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||||
|
|
@ -80,6 +84,8 @@ export 'asset/asset.dart';
|
||||||
export 'asset/file_allowance.dart';
|
export 'asset/file_allowance.dart';
|
||||||
export 'asset/file_limit_exceeded_exception.dart';
|
export 'asset/file_limit_exceeded_exception.dart';
|
||||||
export 'asset/file_usage.dart';
|
export 'asset/file_usage.dart';
|
||||||
|
export 'assistant/assistant_code.dart';
|
||||||
|
export 'assistant/assistant_connection_info.dart';
|
||||||
export 'auth/device.dart';
|
export 'auth/device.dart';
|
||||||
export 'auth/github_sign_in_refusal.dart';
|
export 'auth/github_sign_in_refusal.dart';
|
||||||
export 'auth/github_sign_in_refused_exception.dart';
|
export 'auth/github_sign_in_refused_exception.dart';
|
||||||
|
|
@ -2277,6 +2283,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _iurwo9k8.FileUsage) {
|
if (t == _iurwo9k8.FileUsage) {
|
||||||
return _iurwo9k8.FileUsage.fromJson(data) as T;
|
return _iurwo9k8.FileUsage.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _i8xi9jqo.AssistantCode) {
|
||||||
|
return _i8xi9jqo.AssistantCode.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _icukzjbz.AssistantConnectionInfo) {
|
||||||
|
return _icukzjbz.AssistantConnectionInfo.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _i6c5ny7n.Device) {
|
if (t == _i6c5ny7n.Device) {
|
||||||
return _i6c5ny7n.Device.fromJson(data) as T;
|
return _i6c5ny7n.Device.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -2422,6 +2434,16 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
|
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
|
||||||
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
|
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _isc.getType<_i8xi9jqo.AssistantCode?>()) {
|
||||||
|
return (data != null ? _i8xi9jqo.AssistantCode.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_icukzjbz.AssistantConnectionInfo?>()) {
|
||||||
|
return (data != null
|
||||||
|
? _icukzjbz.AssistantConnectionInfo.fromJson(data)
|
||||||
|
: null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
|
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
|
||||||
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -2616,6 +2638,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
.toList()
|
.toList()
|
||||||
as T;
|
as T;
|
||||||
}
|
}
|
||||||
|
if (t == List<_iplrdti2.AssistantConnectionInfo>) {
|
||||||
|
return (data as List)
|
||||||
|
.map((e) => deserialize<_iplrdti2.AssistantConnectionInfo>(e))
|
||||||
|
.toList()
|
||||||
|
as T;
|
||||||
|
}
|
||||||
if (t == List<_ieqh4yr9.Device>) {
|
if (t == List<_ieqh4yr9.Device>) {
|
||||||
return (data as List)
|
return (data as List)
|
||||||
.map((e) => deserialize<_ieqh4yr9.Device>(e))
|
.map((e) => deserialize<_ieqh4yr9.Device>(e))
|
||||||
|
|
@ -2677,6 +2705,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||||
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||||
_iurwo9k8.FileUsage => 'FileUsage',
|
_iurwo9k8.FileUsage => 'FileUsage',
|
||||||
|
_i8xi9jqo.AssistantCode => 'AssistantCode',
|
||||||
|
_icukzjbz.AssistantConnectionInfo => 'AssistantConnectionInfo',
|
||||||
_i6c5ny7n.Device => 'Device',
|
_i6c5ny7n.Device => 'Device',
|
||||||
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
||||||
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
||||||
|
|
@ -2745,6 +2775,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return 'FileLimitExceededException';
|
return 'FileLimitExceededException';
|
||||||
case _iurwo9k8.FileUsage():
|
case _iurwo9k8.FileUsage():
|
||||||
return 'FileUsage';
|
return 'FileUsage';
|
||||||
|
case _i8xi9jqo.AssistantCode():
|
||||||
|
return 'AssistantCode';
|
||||||
|
case _icukzjbz.AssistantConnectionInfo():
|
||||||
|
return 'AssistantConnectionInfo';
|
||||||
case _i6c5ny7n.Device():
|
case _i6c5ny7n.Device():
|
||||||
return 'Device';
|
return 'Device';
|
||||||
case _ir1gd9p5.GitHubSignInRefusal():
|
case _ir1gd9p5.GitHubSignInRefusal():
|
||||||
|
|
@ -2871,6 +2905,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (dataClassName == 'FileUsage') {
|
if (dataClassName == 'FileUsage') {
|
||||||
return deserialize<_iurwo9k8.FileUsage>(data['data']);
|
return deserialize<_iurwo9k8.FileUsage>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'AssistantCode') {
|
||||||
|
return deserialize<_i8xi9jqo.AssistantCode>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'AssistantConnectionInfo') {
|
||||||
|
return deserialize<_icukzjbz.AssistantConnectionInfo>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'Device') {
|
if (dataClassName == 'Device') {
|
||||||
return deserialize<_i6c5ny7n.Device>(data['data']);
|
return deserialize<_i6c5ny7n.Device>(data['data']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1543,5 +1543,26 @@
|
||||||
"assistantOpenFailed": "Claude ließ sich nicht öffnen.",
|
"assistantOpenFailed": "Claude ließ sich nicht öffnen.",
|
||||||
"assistantPromptLearning": "{question}\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf {instance} lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)",
|
"assistantPromptLearning": "{question}\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf {instance} lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)",
|
||||||
"assistantPromptStudyset": "{question}\n\n(Es geht um mein Lernset „{title}“ in Halkyon Learn auf {instance}. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)",
|
"assistantPromptStudyset": "{question}\n\n(Es geht um mein Lernset „{title}“ in Halkyon Learn auf {instance}. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)",
|
||||||
"assistantPromptCard": "{question}\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „{title}“ in Halkyon Learn auf {instance}.\nVorderseite: {front}\nRückseite: {back})"
|
"assistantPromptCard": "{question}\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „{title}“ in Halkyon Learn auf {instance}.\nVorderseite: {front}\nRückseite: {back})",
|
||||||
|
|
||||||
|
"assistantConnectTitle": "Claude verbinden",
|
||||||
|
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
|
||||||
|
"assistantConnectIntro": "Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf {instance} lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.",
|
||||||
|
"assistantStepAdd": "Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.",
|
||||||
|
"assistantStepCode": "Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.",
|
||||||
|
"assistantShowCode": "Code anzeigen",
|
||||||
|
"assistantNewCode": "Neuer Code",
|
||||||
|
"assistantCodeValid": "Gültig bis {time}",
|
||||||
|
"assistantCopy": "Kopieren",
|
||||||
|
"assistantCopied": "Kopiert",
|
||||||
|
"assistantConnected": "Verbunden",
|
||||||
|
"assistantNoneConnected": "Noch nichts verbunden.",
|
||||||
|
"assistantConnectedOn": "verbunden am {date}",
|
||||||
|
"assistantLastRead": "zuletzt gelesen am {date}",
|
||||||
|
"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.",
|
||||||
|
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
|
||||||
|
"assistantRetry": "Erneut versuchen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1804,5 +1804,31 @@
|
||||||
"assistantPromptStudyset": "{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)",
|
"assistantPromptStudyset": "{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)",
|
||||||
"@assistantPromptStudyset": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "instance": {"type": "String"}}},
|
"@assistantPromptStudyset": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "instance": {"type": "String"}}},
|
||||||
"assistantPromptCard": "{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})",
|
"assistantPromptCard": "{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})",
|
||||||
"@assistantPromptCard": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "front": {"type": "String"}, "back": {"type": "String"}, "instance": {"type": "String"}}}
|
"@assistantPromptCard": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "front": {"type": "String"}, "back": {"type": "String"}, "instance": {"type": "String"}}},
|
||||||
|
|
||||||
|
"assistantConnectTitle": "Connect Claude",
|
||||||
|
"assistantConnectSubtitle": "Let Claude read your studysets",
|
||||||
|
"assistantConnectIntro": "Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn's connector, and answer about them. It can't change anything. What it reads goes to Anthropic, under their privacy policy.",
|
||||||
|
"@assistantConnectIntro": {"placeholders": {"instance": {"type": "String"}}},
|
||||||
|
"assistantStepAdd": "In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude's apps too.",
|
||||||
|
"assistantStepCode": "Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.",
|
||||||
|
"assistantShowCode": "Show a code",
|
||||||
|
"assistantNewCode": "New code",
|
||||||
|
"assistantCodeValid": "Valid until {time}",
|
||||||
|
"@assistantCodeValid": {"placeholders": {"time": {"type": "String"}}},
|
||||||
|
"assistantCopy": "Copy",
|
||||||
|
"assistantCopied": "Copied",
|
||||||
|
"assistantConnected": "Connected",
|
||||||
|
"assistantNoneConnected": "Nothing connected yet.",
|
||||||
|
"assistantConnectedOn": "connected {date}",
|
||||||
|
"@assistantConnectedOn": {"placeholders": {"date": {"type": "String"}}},
|
||||||
|
"assistantLastRead": "last read {date}",
|
||||||
|
"@assistantLastRead": {"placeholders": {"date": {"type": "String"}}},
|
||||||
|
"assistantDisconnect": "Disconnect",
|
||||||
|
"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.",
|
||||||
|
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
|
||||||
|
"assistantRetry": "Try again"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9162,6 +9162,126 @@ abstract class AppLocalizations {
|
||||||
String back,
|
String back,
|
||||||
String instance,
|
String instance,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// No description provided for @assistantConnectTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect Claude'**
|
||||||
|
String get assistantConnectTitle;
|
||||||
|
|
||||||
|
/// No description provided for @assistantConnectSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Let Claude read your studysets'**
|
||||||
|
String get assistantConnectSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @assistantConnectIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.'**
|
||||||
|
String assistantConnectIntro(String instance);
|
||||||
|
|
||||||
|
/// No description provided for @assistantStepAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.'**
|
||||||
|
String get assistantStepAdd;
|
||||||
|
|
||||||
|
/// No description provided for @assistantStepCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.'**
|
||||||
|
String get assistantStepCode;
|
||||||
|
|
||||||
|
/// No description provided for @assistantShowCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show a code'**
|
||||||
|
String get assistantShowCode;
|
||||||
|
|
||||||
|
/// No description provided for @assistantNewCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New code'**
|
||||||
|
String get assistantNewCode;
|
||||||
|
|
||||||
|
/// No description provided for @assistantCodeValid.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Valid until {time}'**
|
||||||
|
String assistantCodeValid(String time);
|
||||||
|
|
||||||
|
/// No description provided for @assistantCopy.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copy'**
|
||||||
|
String get assistantCopy;
|
||||||
|
|
||||||
|
/// No description provided for @assistantCopied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copied'**
|
||||||
|
String get assistantCopied;
|
||||||
|
|
||||||
|
/// No description provided for @assistantConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connected'**
|
||||||
|
String get assistantConnected;
|
||||||
|
|
||||||
|
/// No description provided for @assistantNoneConnected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nothing connected yet.'**
|
||||||
|
String get assistantNoneConnected;
|
||||||
|
|
||||||
|
/// No description provided for @assistantConnectedOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'connected {date}'**
|
||||||
|
String assistantConnectedOn(String date);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLastRead.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'last read {date}'**
|
||||||
|
String assistantLastRead(String date);
|
||||||
|
|
||||||
|
/// No description provided for @assistantDisconnect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disconnect'**
|
||||||
|
String get assistantDisconnect;
|
||||||
|
|
||||||
|
/// No description provided for @assistantDisconnectTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disconnect {name}?'**
|
||||||
|
String assistantDisconnectTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @assistantDisconnectMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'It can\'t read your studysets any more. You can connect it again with a new code.'**
|
||||||
|
String get assistantDisconnectMessage;
|
||||||
|
|
||||||
|
/// 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.'**
|
||||||
|
String get assistantNotOffered;
|
||||||
|
|
||||||
|
/// No description provided for @assistantUnreachable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The instance can\'t be reached. Try again once you are online.'**
|
||||||
|
String get assistantUnreachable;
|
||||||
|
|
||||||
|
/// No description provided for @assistantRetry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Try again'**
|
||||||
|
String get assistantRetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -5734,4 +5734,79 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
) {
|
) {
|
||||||
return '$question\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „$title“ in Halkyon Learn auf $instance.\nVorderseite: $front\nRückseite: $back)';
|
return '$question\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „$title“ in Halkyon Learn auf $instance.\nVorderseite: $front\nRückseite: $back)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnectTitle => 'Claude verbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnectSubtitle => 'Claude deine Lernsets lesen lassen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantConnectIntro(String instance) {
|
||||||
|
return 'Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf $instance lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantStepAdd =>
|
||||||
|
'Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantStepCode =>
|
||||||
|
'Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantShowCode => 'Code anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNewCode => 'Neuer Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantCodeValid(String time) {
|
||||||
|
return 'Gültig bis $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantCopy => 'Kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantCopied => 'Kopiert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnected => 'Verbunden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNoneConnected => 'Noch nichts verbunden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantConnectedOn(String date) {
|
||||||
|
return 'verbunden am $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLastRead(String date) {
|
||||||
|
return 'zuletzt gelesen am $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantDisconnect => 'Trennen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantDisconnectTitle(String name) {
|
||||||
|
return '$name trennen?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantDisconnectMessage =>
|
||||||
|
'Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNotOffered =>
|
||||||
|
'Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantUnreachable =>
|
||||||
|
'Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantRetry => 'Erneut versuchen';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5685,6 +5685,81 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
) {
|
) {
|
||||||
return '$question\n\n(About a flashcard I am studying, in my studyset “$title” in Halkyon Learn on $instance.\nFront: $front\nBack: $back)';
|
return '$question\n\n(About a flashcard I am studying, in my studyset “$title” in Halkyon Learn on $instance.\nFront: $front\nBack: $back)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnectTitle => 'Connect Claude';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnectSubtitle => 'Let Claude read your studysets';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantConnectIntro(String instance) {
|
||||||
|
return 'Claude can read your studysets, cards, notes and progress on $instance through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantStepAdd =>
|
||||||
|
'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantStepCode =>
|
||||||
|
'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantShowCode => 'Show a code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNewCode => 'New code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantCodeValid(String time) {
|
||||||
|
return 'Valid until $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantCopy => 'Copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantCopied => 'Copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantConnected => 'Connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNoneConnected => 'Nothing connected yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantConnectedOn(String date) {
|
||||||
|
return 'connected $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLastRead(String date) {
|
||||||
|
return 'last read $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantDisconnect => 'Disconnect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantDisconnectTitle(String name) {
|
||||||
|
return 'Disconnect $name?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantDisconnectMessage =>
|
||||||
|
'It can\'t read your studysets any more. You can connect it again with a new code.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantNotOffered =>
|
||||||
|
'This instance doesn\'t offer a connector yet. Questions still open in Claude with what they are about.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantUnreachable =>
|
||||||
|
'The instance can\'t be reached. Try again once you are online.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantRetry => 'Try again';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
|
||||||
|
|
@ -231,6 +232,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.settings,
|
path: RoutePaths.settings,
|
||||||
builder: (context, state) => const SettingsScreen(),
|
builder: (context, state) => const SettingsScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'assistant',
|
||||||
|
builder: (context, state) => const ConnectClaudeScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
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/anki/data/anki_package_reader.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/claude_hand_off.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
|
|
@ -146,6 +148,9 @@ class Dependencies({
|
||||||
|
|
||||||
/// Hands questions to Claude.
|
/// Hands questions to Claude.
|
||||||
required final ClaudeHandOff claude,
|
required final ClaudeHandOff claude,
|
||||||
|
|
||||||
|
/// Connects assistants to the account on the instance (ADR 0006).
|
||||||
|
required final AssistantRepository assistant,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) {
|
}) {
|
||||||
/// Builds the production graph on [instance], around one Serverpod
|
/// Builds the production graph on [instance], around one Serverpod
|
||||||
|
|
@ -201,6 +206,7 @@ class Dependencies({
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
pomodoro: device.pomodoro,
|
pomodoro: device.pomodoro,
|
||||||
claude: ClaudeHandOff(),
|
claude: ClaudeHandOff(),
|
||||||
|
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ abstract final class RoutePaths() {
|
||||||
/// Settings, over the tabs.
|
/// Settings, over the tabs.
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
|
|
||||||
|
/// Connecting Claude to the account (Settings › Assistant), over Settings.
|
||||||
|
static const connectAssistant = '/settings/assistant';
|
||||||
|
|
||||||
/// The reading deadlines on a line of days, over the tabs.
|
/// The reading deadlines on a line of days, over the tabs.
|
||||||
static const readingTimeline = '/reading-timeline';
|
static const readingTimeline = '/reading-timeline';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
|
||||||
|
|
||||||
|
/// Connecting Claude to the person's account: where the instance's
|
||||||
|
/// connector is, a code to type on Claude's page, and what is connected.
|
||||||
|
class ConnectAssistantCubit(final AssistantRepository _assistant)
|
||||||
|
extends Cubit<ConnectAssistantState> {
|
||||||
|
this : super(const ConnectAssistantState());
|
||||||
|
|
||||||
|
/// Asks the instance where its connector is and what is connected.
|
||||||
|
Future<void> load() async {
|
||||||
|
emit(state.copyWith(loading: true, failed: false));
|
||||||
|
try {
|
||||||
|
final url = await _assistant.connectorUrl();
|
||||||
|
final connections = url == null
|
||||||
|
? const <ConnectedAssistant>[]
|
||||||
|
: await _assistant.connections();
|
||||||
|
_emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
offered: url != null,
|
||||||
|
connectorUrl: url,
|
||||||
|
connections: connections,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Asking about the connector failed', name: 'Assistant', error: error);
|
||||||
|
_emit(state.copyWith(loading: false, failed: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a new code; the one shown before stops working.
|
||||||
|
Future<void> newCode() async {
|
||||||
|
try {
|
||||||
|
final code = await _assistant.connectCode();
|
||||||
|
_emit(state.copyWith(code: code, failed: false));
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Getting a code failed', name: 'Assistant', error: error);
|
||||||
|
_emit(state.copyWith(failed: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnects [assistant], then lists what is still connected.
|
||||||
|
Future<void> disconnect(ConnectedAssistant assistant) async {
|
||||||
|
try {
|
||||||
|
await _assistant.disconnect(assistant.id);
|
||||||
|
_emit(state.copyWith(connections: await _assistant.connections()));
|
||||||
|
} on Exception catch (error) {
|
||||||
|
log('Disconnecting failed', name: 'Assistant', error: error);
|
||||||
|
_emit(state.copyWith(failed: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit(ConnectAssistantState next) {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the screen shows of connecting Claude.
|
||||||
|
final class const ConnectAssistantState({
|
||||||
|
/// Whether the instance is being asked.
|
||||||
|
final bool loading = true,
|
||||||
|
|
||||||
|
/// Whether the instance offers a connector.
|
||||||
|
final bool offered = false,
|
||||||
|
|
||||||
|
/// The connector's address, to add in Claude.
|
||||||
|
final String? connectorUrl,
|
||||||
|
|
||||||
|
/// The assistants connected.
|
||||||
|
final List<ConnectedAssistant> connections = const [],
|
||||||
|
|
||||||
|
/// The last code got; null before.
|
||||||
|
final ConnectCode? code,
|
||||||
|
|
||||||
|
/// Whether the last request to the instance failed.
|
||||||
|
final bool failed = false,
|
||||||
|
}) with Equatable {
|
||||||
|
/// This state with the given fields replaced.
|
||||||
|
ConnectAssistantState copyWith({
|
||||||
|
bool? loading,
|
||||||
|
bool? offered,
|
||||||
|
String? connectorUrl,
|
||||||
|
List<ConnectedAssistant>? connections,
|
||||||
|
ConnectCode? code,
|
||||||
|
bool? failed,
|
||||||
|
}) => ConnectAssistantState(
|
||||||
|
loading: loading ?? this.loading,
|
||||||
|
offered: offered ?? this.offered,
|
||||||
|
connectorUrl: connectorUrl ?? this.connectorUrl,
|
||||||
|
connections: connections ?? this.connections,
|
||||||
|
code: code ?? this.code,
|
||||||
|
failed: failed ?? this.failed,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
loading,
|
||||||
|
offered,
|
||||||
|
connectorUrl,
|
||||||
|
connections,
|
||||||
|
code,
|
||||||
|
failed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
|
||||||
|
|
||||||
|
/// The instance's side of connecting assistants (ADR 0006).
|
||||||
|
abstract interface class AssistantGateway() {
|
||||||
|
/// Where assistants connect on this instance (its MCP endpoint); null
|
||||||
|
/// where it offers none.
|
||||||
|
Future<String?> connectorUrl();
|
||||||
|
|
||||||
|
/// A new code for connecting an assistant; the last one stops working.
|
||||||
|
Future<ConnectCode> connectCode();
|
||||||
|
|
||||||
|
/// The assistants connected to the person's account, newest first.
|
||||||
|
Future<List<ConnectedAssistant>> connections();
|
||||||
|
|
||||||
|
/// Disconnects the assistant [id]: it can't read anything from then on.
|
||||||
|
Future<void> disconnect(int id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
|
||||||
|
|
||||||
|
/// Connecting assistants such as Claude to the person's account on the
|
||||||
|
/// instance, so they can read their studysets (ADR 0006).
|
||||||
|
class AssistantRepository(final AssistantGateway _gateway) {
|
||||||
|
/// Where assistants connect on this instance; null where it offers none.
|
||||||
|
Future<String?> connectorUrl() => _gateway.connectorUrl();
|
||||||
|
|
||||||
|
/// A new code to type on an assistant's authorization page.
|
||||||
|
Future<ConnectCode> connectCode() => _gateway.connectCode();
|
||||||
|
|
||||||
|
/// The assistants connected, newest first.
|
||||||
|
Future<List<ConnectedAssistant>> connections() => _gateway.connections();
|
||||||
|
|
||||||
|
/// Disconnects the assistant [id].
|
||||||
|
Future<void> disconnect(int id) => _gateway.disconnect(id);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue