From ab516337a5fc6055b01796ff544f0ab5671d5c31 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 31 Oct 2026 10:17:29 -0700 Subject: [PATCH] feat(mobile): open a prefilled Create agent form from Builderlab agent cards A printed Builderlab agent card's QR leads to its share page (app.builderlab.xyz/a/), whose "Add to Buzz" button opens buzz://agent/new?card=. Mobile now handles that link: it fetches the card's agent.json from the avatars CDN for this build's Builderlab environment and opens the Agents page with an editable, prefilled Create agent form. Opening a link never creates an agent. - shared/deeplink: strict parser for buzz://agent/new?card=; any other shape is not this link. - HostedEnvironment.avatarsCdnOrigin maps each account environment to its CDN; the link can never name the host. - AgentCreationDraft.fetch reads name, avatarUrl, instructions, model and reasoningEffort. Every field is optional: published cards have only name and avatar today. snake_case and a few synonyms are also read so a later agent.json revision degrades to a partial draft instead of failing. The avatar is used only from the same CDN; the body is capped at 256 KiB. - AgentsPage waits for sign-in, the registry load and remote-agent support, drops the link with a message if a setup is unfinished or the card can't be loaded, and replaces an open Agents stack rather than stacking one. - CreateAgentPage takes an optional draft; model and effort apply only if the live catalog offers them. - Table tests for the link and agent.json, and a journey test from link to Create against a fake CDN (needs BUZZ_AUTH_DOMAINS, like the other hosted agent tests). Co-authored-by: studio Signed-off-by: Tom Brow --- mobile/README.md | 38 +++ .../features/agents/agent_avatar_picker.dart | 44 ++-- .../features/agents/agent_creation_draft.dart | 171 ++++++++++++++ mobile/lib/features/agents/agents_page.dart | 162 +++++++++++-- .../features/agents/create_agent_page.dart | 43 +++- .../channels/deep_link_dispatcher.dart | 42 ++++ mobile/lib/main.dart | 7 + mobile/lib/shared/agents/agents_surface.dart | 13 ++ .../shared/deeplink/agent_creation_link.dart | 45 ++++ mobile/lib/shared/deeplink/deep_link.dart | 3 + .../lib/shared/hosted/hosted_environment.dart | 8 + .../agent_creation_link_journey_test.dart | 217 ++++++++++++++++++ .../test/features/agents/agents_harness.dart | 3 + .../deeplink/agent_creation_link_test.dart | 172 ++++++++++++++ 14 files changed, 917 insertions(+), 51 deletions(-) create mode 100644 mobile/lib/features/agents/agent_creation_draft.dart create mode 100644 mobile/lib/shared/agents/agents_surface.dart create mode 100644 mobile/lib/shared/deeplink/agent_creation_link.dart create mode 100644 mobile/test/features/agents/agent_creation_link_journey_test.dart create mode 100644 mobile/test/shared/deeplink/agent_creation_link_test.dart diff --git a/mobile/README.md b/mobile/README.md index 8dfae1a7cd..b46292f5c3 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -29,6 +29,44 @@ names, not URLs or wildcards. The list does not add trusted account servers: configured domains outside the supported Builderlab realm cannot use hosted setup. `BUZZ_BUILDERLAB_URL` still selects the trusted account environment. +## Agent creation links + +`buzz://agent/new?card=` opens the Agents page and an editable, pre-filled +Create agent form for the active community. Opening a link never creates an +agent. A Builderlab agent card's QR leads to its share page +(`app.builderlab.xyz/a/`), whose **Add to Buzz** button opens this link. + +The app fetches `//agent.json` from the CDN for this build's +Builderlab environment (`HostedEnvironment.avatarsCdnOrigin`), never from a +host named by the link. The link carries only the id, so instructions can be +any length while the QR stays small. + +```json +{ + "name": "Scout", + "avatarUrl": "https://///avatar.png", + "instructions": "Reply concisely.", + "model": "", + "reasoningEffort": "high" +} +``` + +- Every field is optional and left for the user when missing. Cards published + so far have only `name` and `avatarUrl`. snake_case and a few synonyms + (`system_prompt`, `model_id`, `effort`, a `model` object with an `id`) are + also read. Other fields are ignored. +- The avatar is used only if it is an `https` URL on the same CDN; otherwise the + form picks its usual default. It stays editable. +- `model` and `reasoningEffort` are pre-selected only if the live model catalog + offers them (the effort only for that model). +- The link waits for sign-in and community load. It needs Builderlab login and a + hosted community that supports remote agents, and is dropped with a message if + an unfinished agent setup exists (resume or discard that first) or the card + can't be loaded. + +The parser is `lib/shared/deeplink/agent_creation_link.dart`; the card reader +is `lib/features/agents/agent_creation_draft.dart`. + ## Hosted relay authentication `--dart-define=BUZZ_HOSTED_RELAY_AUTH=true` enables Builderlab relay assertions diff --git a/mobile/lib/features/agents/agent_avatar_picker.dart b/mobile/lib/features/agents/agent_avatar_picker.dart index fc192a10d2..7ecbd29eb3 100644 --- a/mobile/lib/features/agents/agent_avatar_picker.dart +++ b/mobile/lib/features/agents/agent_avatar_picker.dart @@ -57,6 +57,26 @@ class AgentAvatarSelection { } } +/// Renders an emoji on a solid [color] to the 512px PNG that creation uploads. +Future renderEmojiAvatarPng(String emoji, int color) async { + // Export pixels, as desktop does: registry pictures are ordinary HTTPS + // media URLs, not client-specific SVG data URLs. + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(Color(color), BlendMode.src); + final text = TextPainter( + text: TextSpan(text: emoji, style: const TextStyle(fontSize: 258)), + textDirection: TextDirection.ltr, + )..layout(); + text.paint(canvas, Offset((512 - text.width) / 2, (512 - text.height) / 2)); + final picture = recorder.endRecording(); + final raster = await picture.toImage(512, 512); + final data = await raster.toByteData(format: ui.ImageByteFormat.png); + raster.dispose(); + picture.dispose(); + return data?.buffer.asUint8List(); +} + /// Avatar preview with character, shared emoji, and image selection controls. class AgentAvatarPicker extends HookConsumerWidget { const AgentAvatarPicker({ @@ -121,30 +141,10 @@ class AgentAvatarPicker extends HookConsumerWidget { final locked = disabled || busy.value; Future selectEmoji() async { - // Export pixels, as desktop does: registry pictures are ordinary HTTPS - // media URLs, not client-specific SVG data URLs. - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.drawColor(Color(color.value), BlendMode.src); - final text = TextPainter( - text: TextSpan( - text: emoji.value, - style: const TextStyle(fontSize: 258), - ), - textDirection: TextDirection.ltr, - )..layout(); - text.paint( - canvas, - Offset((512 - text.width) / 2, (512 - text.height) / 2), - ); - final picture = recorder.endRecording(); - final raster = await picture.toImage(512, 512); - final data = await raster.toByteData(format: ui.ImageByteFormat.png); - raster.dispose(); - picture.dispose(); + final data = await renderEmojiAvatarPng(emoji.value, color.value); if (context.mounted && data != null) { final rendered = AgentAvatarSelection.emoji( - data.buffer.asUint8List(), + data, emoji.value, color.value, ); diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart new file mode 100644 index 0000000000..aebc1989ff --- /dev/null +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/hosted/hosted_environment.dart'; +import 'agent_avatar_picker.dart'; + +/// Creates the HTTP client that fetches a card's `agent.json`; overridden in +/// tests. +final agentCardClientProvider = Provider( + (ref) => http.Client.new, +); + +/// Largest `agent.json` accepted, far above any real card. +const maxAgentCardBytes = 256 * 1024; + +const _fetchTimeout = Duration(seconds: 15); + +/// Why a card's draft could not be loaded, worded for the user. +class AgentCardException implements Exception { + const AgentCardException(this.message); + + final String message; + + @override + String toString() => 'AgentCardException: $message'; +} + +/// Initial, fully editable values for [CreateAgentPage], from a link. +/// +/// Unlike an unfinished setup in `RemoteAgentController.pending`, a draft has +/// created nothing: every field stays editable and nothing is persisted. +class AgentCreationDraft { + const AgentCreationDraft({ + required this.name, + required this.instructions, + this.avatar, + this.modelId, + this.reasoningEffort, + }); + + final String name; + final String instructions; + + /// Card avatar, or null to let the form pick its usual default. + final AgentAvatarSelection? avatar; + + /// Requested model, applied only if the live catalog lists it. + final String? modelId; + + /// Requested effort, applied only if the chosen model supports it. + final String? reasoningEffort; + + /// Fetches [link]'s card from this build's avatars CDN and reads its draft. + static Future fetch( + AgentCreationDeepLink link, + http.Client client, + ) async { + final cdn = HostedEnvironment.avatarsCdnOrigin; + final url = cdn.replace(path: '/${link.cardId}/agent.json'); + final http.StreamedResponse response; + final List body; + try { + response = await client + .send( + http.Request('GET', url)..headers['accept'] = 'application/json', + ) + .timeout(_fetchTimeout); + if (response.statusCode == 403 || response.statusCode == 404) { + throw const AgentCardException('This agent card isn’t available.'); + } + if (response.statusCode != 200) { + throw AgentCardException( + 'Couldn’t load this agent card (HTTP ${response.statusCode}).', + ); + } + body = await _readBounded(response.stream).timeout(_fetchTimeout); + } on AgentCardException { + rethrow; + } on Exception { + throw const AgentCardException( + 'Couldn’t load this agent card. Check your connection and reopen the ' + 'link.', + ); + } + final Object? json; + try { + json = jsonDecode(utf8.decode(body)); + } on FormatException { + throw const AgentCardException('This agent card is invalid.'); + } + if (json is! Map) { + throw const AgentCardException('This agent card is invalid.'); + } + return parseAgentCard(json, cdn); + } + + /// Reads a card's draft. Only `name`, `avatarUrl`, `instructions`, `model` + /// and `reasoningEffort` are defined; cards published before instructions + /// and model existed have just the first two. Any field may be missing or + /// spelled in snake_case or a common synonym, and is then left for the user. + /// An avatar is used only from [cdn], the host the card itself came from. + static AgentCreationDraft parseAgentCard(Map json, Uri cdn) { + final model = json['model']; + final avatarUrl = _string(json, const [ + 'avatarUrl', + 'avatar_url', + 'avatar', + 'pictureUrl', + 'picture_url', + 'picture', + ]); + final avatar = Uri.tryParse(avatarUrl ?? ''); + return AgentCreationDraft( + name: _string(json, const ['name', 'displayName', 'display_name']) ?? '', + instructions: + _string(json, const [ + 'instructions', + 'systemPrompt', + 'system_prompt', + 'prompt', + ]) ?? + '', + avatar: + avatar != null && + avatar.scheme == 'https' && + avatar.host == cdn.host && + !avatar.hasPort && + avatar.userInfo.isEmpty + ? AgentAvatarSelection.url(avatarUrl!) + : null, + // `model` may also be an object such as {"id": ..., "reasoningEffort": ...}. + modelId: model is Map + ? _string(model, const ['id', 'modelId', 'model_id', 'name']) + : _string(json, const ['model', 'modelId', 'model_id']), + reasoningEffort: + _string(json, _effortKeys) ?? + (model is Map ? _string(model, _effortKeys) : null), + ); + } + + static const _effortKeys = [ + 'reasoningEffort', + 'reasoning_effort', + 'effort', + 'thinking', + ]; + + /// The first non-blank string among [keys], trimmed. + static String? _string(Map json, List keys) { + for (final key in keys) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + } + return null; + } + + static Future> _readBounded(Stream> stream) async { + final bytes = []; + await for (final chunk in stream) { + bytes.addAll(chunk); + if (bytes.length > maxAgentCardBytes) { + throw const AgentCardException('This agent card is too large.'); + } + } + return bytes; + } +} diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 7715b47aab..eeade78690 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -7,6 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/agents/owned_agent_directory.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/agents/remote_agents.dart'; +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/widgets/feedback_snackbar.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/me/default_agent.dart'; import '../../shared/hosted/hosted_flow.dart'; @@ -21,6 +23,7 @@ import '../../shared/widgets/agent_navigation_icon.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../profile/user_profile_sheet.dart'; +import 'agent_creation_draft.dart'; import 'create_agent_page.dart'; import 'agent_card.dart'; import 'remote_agent_model_control.dart'; @@ -32,16 +35,35 @@ import 'delete_agent_action.dart'; import 'agent_identity_sections.dart'; import 'bestie_agent_action.dart'; +Route? _openAgentsRoute; + /// Opens the active community's full-page Agents navigation stack. -Future showAgents(BuildContext context) async { +/// +/// A [draft] from a `buzz://agent/new` link opens [CreateAgentPage] pre-filled +/// once the account, registry and community gates allow it. +Future showAgents( + BuildContext context, { + AgentCreationDeepLink? draft, +}) async { final container = ProviderScope.containerOf(context); final controller = container.read(remoteAgentControllerProvider); unawaited(controller.run(controller.refresh)); - final channel = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _AgentsNavigation(controller: controller), - ), + final navigator = Navigator.of(context); + // A link replaces an Agents stack that is already open instead of stacking + // a second one on top of it. + final open = _openAgentsRoute; + if (draft != null && + open != null && + open.isActive && + open.navigator == navigator) { + navigator.removeRoute(open); + } + final route = MaterialPageRoute( + builder: (_) => _AgentsNavigation(controller: controller, draft: draft), ); + _openAgentsRoute = route; + final channel = await navigator.push(route); + if (identical(_openAgentsRoute, route)) _openAgentsRoute = null; if (channel != null && context.mounted) { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)), @@ -50,9 +72,10 @@ Future showAgents(BuildContext context) async { } class _AgentsNavigation extends HookConsumerWidget { - const _AgentsNavigation({required this.controller}); + const _AgentsNavigation({required this.controller, this.draft}); final RemoteAgentController controller; + final AgentCreationDeepLink? draft; @override Widget build(BuildContext context, WidgetRef ref) { @@ -75,6 +98,7 @@ class _AgentsNavigation extends HookConsumerWidget { color: context.colors.surface, child: AgentsPage( controller: controller, + draft: draft, onClose: () => Navigator.of(context).pop(), onSelect: (pubkey) async { final channel = await showBuzzModalBottomSheet( @@ -130,8 +154,12 @@ class AgentsPage extends HookConsumerWidget { required this.controller, required this.onClose, required this.onSelect, + this.draft, super.key, }); + + /// Link-supplied draft to open in [CreateAgentPage] when permitted. + final AgentCreationDeepLink? draft; final VoidCallback onClose; final ValueChanged onSelect; final RemoteAgentController controller; @@ -224,6 +252,28 @@ class AgentsPage extends HookConsumerWidget { } } + Future openCreate([AgentCreationDraft? initialDraft]) async { + if (community == null) return; + creating.value = true; + try { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CreateAgentPage( + controller: controller, + community: community, + initialDraft: initialDraft, + onCreated: () { + ref.invalidate(agentDirectoryProvider); + ref.invalidate(ownedAgentDirectoryProvider); + }, + ), + ), + ); + } finally { + if (context.mounted) creating.value = false; + } + } + final VoidCallback? addAgent = community == null || controller.busy || creating.value || signingIn ? null @@ -232,25 +282,88 @@ class AgentsPage extends HookConsumerWidget { await flow.reconnectAccount(); return; } - creating.value = true; - try { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CreateAgentPage( - controller: controller, - community: community, - onCreated: () { - ref.invalidate(agentDirectoryProvider); - ref.invalidate(ownedAgentDirectoryProvider); - }, - ), - ), - ); - } finally { - if (context.mounted) creating.value = false; - } + await openCreate(); }; + // Open a link's draft once: sign in first if needed, then wait for the + // registry so an unfinished setup is known before anything is offered. + final draftStep = useRef(draft == null ? _DraftStep.done : _DraftStep.wait); + // Looked up during build: effects cannot read inherited widgets, and + // snack bars cannot show while a frame is building. + final messenger = ScaffoldMessenger.maybeOf(context); + void drop(String message) { + draftStep.value = _DraftStep.done; + WidgetsBinding.instance.addPostFrameCallback((_) { + messenger?.showFeedbackSnackBar( + SnackBar(content: Text(message)), + kind: ToastKind.error, + ); + }); + } + + useEffect( + () { + final link = draft; + if (link == null || draftStep.value == _DraftStep.done) return null; + if (community == null || controller.busy || signingIn) return null; + if (!supportsRemoteAgents(community)) { + drop('Remote agents aren’t available in this community.'); + return null; + } + if (controller.account.account == null) { + if (draftStep.value == _DraftStep.wait) { + draftStep.value = _DraftStep.signingIn; + unawaited(flow.reconnectAccount()); + } else { + drop( + 'Sign in to Builderlab to create this agent. Reopen the link.', + ); + } + return null; + } + if (controller.listingFailed) { + drop('Couldn’t load your agents. Reopen the link to retry.'); + return null; + } + if (controller.pending != null) { + drop( + 'Finish your unfinished agent setup first, then reopen the link.', + ); + return null; + } + draftStep.value = _DraftStep.done; + final client = ref.read(agentCardClientProvider)(); + unawaited(() async { + // Hold Create (+) off while the card loads, like an open form does. + // Deferred past this build: hooks state can't change mid-build. + await Future.value(); + if (!context.mounted) return client.close(); + creating.value = true; + final AgentCreationDraft resolved; + try { + resolved = await AgentCreationDraft.fetch(link, client); + } on AgentCardException catch (error) { + if (context.mounted) { + creating.value = false; + drop(error.message); + } + return; + } finally { + client.close(); + } + if (context.mounted) await openCreate(resolved); + }()); + return null; + }, + [ + controller.busy, + signingIn, + flow.stage, + community?.id, + controller.account.account?.subject, + ], + ); + final headerStyle = context.textTheme.titleMedium?.copyWith( fontSize: 22, fontWeight: FontWeight.w600, @@ -391,3 +504,6 @@ class AgentsPage extends HookConsumerWidget { ); } } + +/// Progress of opening a link's draft: waiting on gates, in sign-in, or done. +enum _DraftStep { wait, signingIn, done } diff --git a/mobile/lib/features/agents/create_agent_page.dart b/mobile/lib/features/agents/create_agent_page.dart index bb53fb2b63..b849c60f33 100644 --- a/mobile/lib/features/agents/create_agent_page.dart +++ b/mobile/lib/features/agents/create_agent_page.dart @@ -11,6 +11,7 @@ import '../../shared/agents/remote_agents.dart'; import '../../shared/agents/agent_avatar_catalog.dart'; import '../../shared/relay/relay.dart'; import 'agent_avatar_picker.dart'; +import 'agent_creation_draft.dart'; import '../../shared/community/community.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/theme/theme.dart'; @@ -28,8 +29,13 @@ class CreateAgentPage extends HookConsumerWidget { required this.controller, required this.community, required this.onCreated, + this.initialDraft, super.key, }); + + /// Pre-fills a fresh setup (for example from a link). Ignored when an + /// unfinished setup is pending: that must be resumed as saved. + final AgentCreationDraft? initialDraft; final RemoteAgentController controller; final Community community; final VoidCallback onCreated; @@ -48,23 +54,48 @@ class CreateAgentPage extends HookConsumerWidget { () => controller.account.account?.subject, const [], ); + final draft = controller.pending == null ? initialDraft : null; final model = useState(controller.pending?['model_id'] as String?); final effort = useState(controller.pending?['reasoning_effort'] as String?); + // Draft model hints apply once, and only if the live catalog offers them. + final hintsApplied = useRef(false); + useEffect(() { + if (draft == null || + hintsApplied.value || + catalog.connectionState != ConnectionState.done) { + return null; + } + hintsApplied.value = true; + final match = catalog.data + ?.where((entry) => entry.id == draft.modelId) + .firstOrNull; + if (match == null) return null; + model.value = match.id; + effort.value = match.efforts.contains(draft.reasoningEffort) + ? draft.reasoningEffort + : null; + return null; + }, [catalog.connectionState]); final name = useTextEditingController( - text: controller.pending?['name'] as String? ?? '', + text: controller.pending?['name'] as String? ?? draft?.name ?? '', ); final instructions = useTextEditingController( - text: controller.pending?['instructions'] as String? ?? '', + text: + controller.pending?['instructions'] as String? ?? + draft?.instructions ?? + '', ); useListenable(name); final error = useState(null); final avatarHeroTag = useMemoized(Object.new); final editingAvatar = useState(false); final avatar = useState( - AgentAvatarSelection.url( - controller.pending?['picture'] as String? ?? - agentAvatarCatalog[Random().nextInt(agentAvatarCatalog.length)].url, - ), + draft?.avatar ?? + AgentAvatarSelection.url( + controller.pending?['picture'] as String? ?? + agentAvatarCatalog[Random().nextInt(agentAvatarCatalog.length)] + .url, + ), ); final preparing = useState(false); final busy = controller.busy || preparing.value; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 7867e29c41..33056fab53 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/agents/agents_surface.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; @@ -89,6 +90,12 @@ class _DeepLinkDispatcherState extends ConsumerState { void _maybeDispatch(BuzzDeepLink? link) { if (link == null || _preparingInvite || _handlingOnboarding) return; + if (link is AgentCreationDeepLink) { + // Held until signed in and the community is hydrated. + if (!widget.dispatchMessageLinks) return; + unawaited(_dispatchAgentCreation(link)); + return; + } if (widget.onboardingHandler != null && (link is InviteDeepLink || link is NostrBindDeepLink || @@ -112,6 +119,41 @@ class _DeepLinkDispatcherState extends ConsumerState { _dispatchNavigableLink(link); } + Future _dispatchAgentCreation(AgentCreationDeepLink link) async { + _handlingOnboarding = true; + try { + final handler = ref.read(agentCreationSurfaceProvider); + if (handler == null) { + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar( + content: Text('Agent creation is unavailable in this build.'), + ), + kind: ToastKind.error, + ); + } else { + await handler(context, link); + } + } catch (error) { + debugPrint('deep-link: agent draft failed: $error'); + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar( + content: Text('Couldn’t open this agent draft. Reopen the link.'), + ), + kind: ToastKind.error, + ); + } + } finally { + if (mounted) { + if (ref.read(pendingDeepLinkProvider) == link) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + } + _handlingOnboarding = false; + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } + } + } + Future _dispatchOnboarding(BuzzDeepLink link) async { _handlingOnboarding = true; var consumed = false; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f793b6a5d3..c771ef56b8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,10 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'features/age_gate/age_signal_push_bootstrap.dart'; +import 'features/agents/agents_page.dart'; import 'features/invites/invite_join_provider.dart'; +import 'shared/agents/agents_surface.dart'; import 'shared/push/push_bridge.dart'; import 'shared/relay/relay_closed_policy.dart'; import 'shared/theme/theme_provider.dart'; @@ -33,6 +37,9 @@ ProviderScope buildRootProviderScope({ // everything else. retry: relayProviderRetry, overrides: [ + agentCreationSurfaceProvider.overrideWithValue( + (context, link) async => unawaited(showAgents(context, draft: link)), + ), savedPrefsProvider.overrideWithValue(prefs), inviteJoinRecoveryProvider.overrideWith( (ref) => diff --git a/mobile/lib/shared/agents/agents_surface.dart b/mobile/lib/shared/agents/agents_surface.dart new file mode 100644 index 0000000000..2b22b69b51 --- /dev/null +++ b/mobile/lib/shared/agents/agents_surface.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../deeplink/deep_link.dart'; + +/// Opens the editable agent-creation draft for a `buzz://agent/new` link. +/// +/// Supplied by the app composition root so `features/channels` can route the +/// link without importing `features/agents`. Null when unavailable. +final agentCreationSurfaceProvider = + Provider Function(BuildContext, AgentCreationDeepLink)?>( + (ref) => null, + ); diff --git a/mobile/lib/shared/deeplink/agent_creation_link.dart b/mobile/lib/shared/deeplink/agent_creation_link.dart new file mode 100644 index 0000000000..5636ce48c2 --- /dev/null +++ b/mobile/lib/shared/deeplink/agent_creation_link.dart @@ -0,0 +1,45 @@ +part of 'deep_link.dart'; + +/// `buzz://agent/new?card=`: open an editable agent draft for a published +/// Builderlab agent card. The QR on a card leads to its share page, whose +/// "Add to Buzz" button opens this link. Opening it never creates an agent. +/// +/// The link carries only the card id. The draft itself comes from the card's +/// `agent.json` on this build's avatars CDN, so instructions can be any length +/// while the printed QR stays small. +class AgentCreationDeepLink extends BuzzDeepLink { + /// Creates a link for the published card [cardId]. + const AgentCreationDeepLink(this.cardId); + + /// Published card id, a plain URL-safe token such as `av_…`. + final String cardId; + + @override + bool operator ==(Object other) => + other is AgentCreationDeepLink && cardId == other.cardId; + + @override + int get hashCode => cardId.hashCode; + + @override + String toString() => 'AgentCreationDeepLink($cardId)'; +} + +final _cardId = RegExp(r'^[A-Za-z0-9_-]{1,64}$'); + +/// Parses `buzz://agent/new?card=`; anything else is not this link. +AgentCreationDeepLink? parseAgentCreationDeepLink(Uri uri) { + if (uri.scheme != 'buzz' || + uri.host != 'agent' || + uri.path != '/new' || + uri.hasPort || + uri.userInfo.isNotEmpty || + uri.hasFragment) { + return null; + } + final params = uri.queryParametersAll; + final card = params['card']; + if (params.length != 1 || card == null || card.length != 1) return null; + if (!_cardId.hasMatch(card.single)) return null; + return AgentCreationDeepLink(card.single); +} diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index b4a67b10f0..bb7825335e 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -10,6 +10,8 @@ library; import '../relay/relay_validation.dart'; import '../hosted/hosted_environment.dart'; +part 'agent_creation_link.dart'; + /// A parsed deep link supported by the app. sealed class BuzzDeepLink { const BuzzDeepLink(); @@ -297,6 +299,7 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { /// Parse any supported Buzz deep link. BuzzDeepLink? parseBuzzDeepLink(Uri uri) => + parseAgentCreationDeepLink(uri) ?? parseNostrBindDeepLink(uri) ?? parseAddCommunityDeepLink(uri) ?? parseInviteDeepLink(uri) ?? diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart index f0979342a2..8292738342 100644 --- a/mobile/lib/shared/hosted/hosted_environment.dart +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -10,6 +10,14 @@ abstract final class HostedEnvironment { 'https://app.builderlabstaging.xyz', }; + /// Public CDN serving each published Builderlab agent card's + /// `/agent.json` and images, for this build's account environment. + static Uri get avatarsCdnOrigin => Uri.parse(switch (origin.host) { + 'app.builderlab.xyz' => 'https://d2z0pbxrxbsiz5.cloudfront.net', + 'app.builderlabstaging.xyz' => 'https://d10hx6rb3fdt3l.cloudfront.net', + _ => throw StateError('Unsupported hosted account environment'), + }); + /// Shortens hostname fallbacks for display without changing relay identity. static String communityDisplayName(String name, String relayUrl) { final relay = Uri.tryParse(relayUrl); diff --git a/mobile/test/features/agents/agent_creation_link_journey_test.dart b/mobile/test/features/agents/agent_creation_link_journey_test.dart new file mode 100644 index 0000000000..ee8b06df02 --- /dev/null +++ b/mobile/test/features/agents/agent_creation_link_journey_test.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/agents/agent_creation_draft.dart'; +import 'package:buzz/features/agents/agents_page.dart'; +import 'package:buzz/features/agents/create_agent_page.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/deep_link_dispatcher.dart'; +import 'package:buzz/shared/agents/agents_surface.dart'; +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/widgets/preset_agent_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'agents_harness.dart'; + +class _NoChannels extends ChannelsNotifier { + @override + Future> build() async => const []; +} + +const _cdn = 'https://d2z0pbxrxbsiz5.cloudfront.net'; + +String _link(String card) => 'buzz://agent/new?card=$card'; + +/// Published `agent.json` files on the avatars CDN, by card id. +final _cards = >{ + 'av_full': { + 'v': 1, + 'id': 'av_full', + 'name': 'Scout', + 'avatarUrl': '$_cdn/av_full/r1/avatar.png', + 'instructions': 'Reply concisely.', + 'model': 'test-model', + 'reasoningEffort': 'high', + }, + // A card as published today: no instructions or model. + 'av_v1': { + 'v': 1, + 'id': 'av_v1', + 'collection': 'gloopie', + 'name': 'Owl', + 'avatarUrl': '$_cdn/av_v1/r1/avatar.png', + 'model_id': 'retired-model', + }, + 'av_third': {'name': 'Third'}, + 'av_fourth': {'name': 'Fourth'}, +}; + +/// Fakes only the CDN: unknown ids get S3's 403. +final _cdnRequests = []; +http.Client _cdnClient() => MockClient((request) async { + _cdnRequests.add(request.url); + final match = RegExp(r'^/([^/]+)/agent\.json$').firstMatch(request.url.path); + final card = request.url.origin == _cdn ? _cards[match?.group(1)] : null; + return card == null + ? http.Response('AccessDenied', 403) + : http.Response(jsonEncode(card), 200); +}); + +Finder _field(String label) => find.descendant( + of: find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, + ), + matching: find.byType(TextField), +); + +void main() { + testWidgets( + 'a buzz://agent/new?card= link opens an editable draft, never creates', + (tester) async { + final h = await AgentsHarness.create(); + if (!supportsRemoteAgents(h.community)) return; + final links = StreamController(); + PendingDeepLinkNotifier.debugUriStreamOverride = links.stream; + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + await links.close(); + }); + _cdnRequests.clear(); + await h.open( + tester, + directory: const [], + overrides: [ + channelsProvider.overrideWith(_NoChannels.new), + agentCardClientProvider.overrideWithValue(_cdnClient), + // Same wiring as buildRootProviderScope. + agentCreationSurfaceProvider.overrideWithValue( + (context, link) async => + unawaited(showAgents(context, draft: link)), + ), + ], + home: const DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ); + Future receive(String url) async { + links.add(Uri.parse(url)); + await tester.pumpAndSettle(); + } + + // A malformed link, or the retired inline form, opens nothing. + await receive('buzz://agent/new?card=../x'); + await receive('buzz://agent/new#e30'); + expect(find.byType(AgentsPage), findsNothing); + expect(_cdnRequests, isEmpty); + + // A card lands on Agents, then a form prefilled from its agent.json, + // fetched from this build's CDN. + await receive(_link('av_full')); + expect(_cdnRequests.single.toString(), '$_cdn/av_full/agent.json'); + expect(find.byType(CreateAgentPage), findsOneWidget); + expect(find.text('Scout'), findsOneWidget); + expect(find.text('Reply concisely.'), findsOneWidget); + expect(find.text('Test model'), findsOneWidget); + expect(find.text('high'), findsOneWidget); + expect( + tester + .widget(find.byType(PresetAgentAvatar)) + .imageUrl, + '$_cdn/av_full/r1/avatar.png', + ); + expect(h.registry.calls, isNot(contains('register-agent'))); + + // Everything stays editable, then Create submits the edited values. + await tester.enterText(_field('Name'), 'Scout Two'); + await tester.enterText(_field('Instructions'), 'Be formal.'); + await tester.ensureVisible(find.text('Thinking')); + await tester.tap(find.text('Thinking')); + await tester.pumpAndSettle(); + await tester.tap(find.text('low').last); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(h.controller.submittedName, 'Scout Two'); + expect(h.controller.submittedInstructions, 'Be formal.'); + final registered = h.registry.bodies['/v3/beekeeper/register-agent']!; + expect(registered['agent_name'], 'Scout Two'); + expect(registered['agent_picture_url'], '$_cdn/av_full/r1/avatar.png'); + expect( + h.registry.bodies['/v3/beekeeper/update-agent'], + containsPair('model_id', 'test-model'), + ); + expect( + h.registry.bodies['/v3/beekeeper/update-agent'], + containsPair('reasoning_effort', 'low'), + ); + expect(find.byType(CreateAgentPage), findsNothing); + await tester.tap(find.byTooltip('Close agents')); + await tester.pumpAndSettle(); + + // A card that is gone shows why and opens no form. + await receive(_link('av_missing')); + expect(find.byType(CreateAgentPage), findsNothing); + expect(find.text('This agent card isn’t available.'), findsOneWidget); + await tester.tap(find.byTooltip('Close agents')); + await tester.pumpAndSettle(); + + // A card as published today (no instructions, a model the catalog + // lacks, under another key): the draft opens with empty instructions + // and the model left for the user, so Create stays disabled. + await receive(_link('av_v1')); + expect(find.byType(CreateAgentPage), findsOneWidget); + expect(find.text('Owl'), findsOneWidget); + expect( + tester.widget(_field('Instructions')).controller!.text, + isEmpty, + ); + expect(find.text('Retired model'), findsNothing); + expect(find.text('Choose'), findsNWidgets(2)); + expect( + tester + .widget(find.widgetWithIcon(IconButton, Icons.check)) + .onPressed, + isNull, + ); + + // Pick a model by hand and fail registration to leave a setup unfinished. + h.registry.loseRegistration = true; + await tester.ensureVisible(find.text('Model')); + await tester.tap(find.text('Model')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Test model').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Thinking')); + await tester.pumpAndSettle(); + await tester.tap(find.text('high').last); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(h.controller.pending, isNotNull); + expect( + h.registry.bodies['/v3/beekeeper/register-agent']!['agent_picture_url'], + '$_cdn/av_v1/r1/avatar.png', + ); + await tester.tap(find.byTooltip('Cancel')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close agents')); + await tester.pumpAndSettle(); + + // With an unfinished setup, a new link must not open a draft. + await receive(_link('av_third')); + expect(find.byType(CreateAgentPage), findsNothing); + expect(find.byType(AgentsPage), findsOneWidget); + expect( + find.textContaining('Finish your unfinished agent setup'), + findsOne, + ); + + // A link that arrives while Agents is open replaces it, not stacks. + await receive(_link('av_fourth')); + expect(find.byType(AgentsPage, skipOffstage: false), findsOneWidget); + }, + ); +} diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 7ef98dc972..592d3604a6 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart' show Override; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/remote_agents_fixture.dart'; @@ -196,6 +197,7 @@ class AgentsHarness { WidgetTester tester, { required List directory, Widget? home, + List overrides = const [], }) async { registered = [...directory, oldAppAgentKeys.public]; final prefs = await SharedPreferences.getInstance(); @@ -207,6 +209,7 @@ class AgentsHarness { await tester.pumpWidget( ProviderScope( overrides: [ + ...overrides, ownedAgentDirectoryProvider.overrideWith( (ref) => loadOwnedAgentDirectory(fakeOwner.public, relay.fetchHistory), diff --git a/mobile/test/shared/deeplink/agent_creation_link_test.dart b/mobile/test/shared/deeplink/agent_creation_link_test.dart new file mode 100644 index 0000000000..fabee3bb75 --- /dev/null +++ b/mobile/test/shared/deeplink/agent_creation_link_test.dart @@ -0,0 +1,172 @@ +import 'package:buzz/features/agents/agent_creation_draft.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buzz://agent/new links', () { + const accepted = { + 'card id': ( + 'buzz://agent/new?card=av_MioTAG6dm7q3Zcm0UV2j8A', + 'av_MioTAG6dm7q3Zcm0UV2j8A', + ), + 'any URL-safe token': ( + 'buzz://agent/new?card=abc-DEF_123', + 'abc-DEF_123', + ), + }; + accepted.forEach((label, c) { + test(label, () { + expect(parseBuzzDeepLink(Uri.parse(c.$1)), AgentCreationDeepLink(c.$2)); + }); + }); + + const rejected = { + 'no card': 'buzz://agent/new', + 'empty card': 'buzz://agent/new?card=', + 'path in card': 'buzz://agent/new?card=..%2Fx', + 'host in card': 'buzz://agent/new?card=evil.example%2Fx', + 'card too long': + 'buzz://agent/new?card=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'two cards': 'buzz://agent/new?card=a&card=b', + 'extra parameter': 'buzz://agent/new?card=a&auto=true', + 'inline draft (retired format)': 'buzz://agent/new#e30', + 'card plus fragment': 'buzz://agent/new?card=a#e30', + 'wrong path': 'buzz://agent/other?card=a', + 'wrong host': 'buzz://agents/new?card=a', + 'https share page': 'https://app.builderlab.xyz/a/av_x', + }; + rejected.forEach((label, uri) { + test(label, () { + expect( + parseBuzzDeepLink(Uri.parse(uri)), + isNot(isA()), + ); + }); + }); + }); + + group('agent.json', () { + final cdn = Uri.parse('https://cdn.example'); + const avatar = 'https://cdn.example/av_1/rev/avatar.png'; + final cases = + < + String, + ({ + Map json, + String name, + String instructions, + String? avatar, + String? model, + String? effort, + }) + >{ + 'published v1 card: name and avatar only': ( + json: { + 'v': 1, + 'id': 'av_1', + 'collection': 'gloopie', + 'name': 'Nitrox', + 'number': 100, + 'avatarUrl': avatar, + 'cardUrl': 'https://cdn.example/av_1/rev/card.png', + 'shareUrl': 'https://app.builderlab.xyz/a/av_1', + }, + name: 'Nitrox', + instructions: '', + avatar: avatar, + model: null, + effort: null, + ), + 'canonical keys': ( + json: { + 'name': ' Scout ', + 'avatarUrl': avatar, + 'instructions': 'Reply concisely.', + 'model': 'sonnet-5-5', + 'reasoningEffort': 'high', + }, + name: 'Scout', + instructions: 'Reply concisely.', + avatar: avatar, + model: 'sonnet-5-5', + effort: 'high', + ), + 'snake_case and synonyms': ( + json: { + 'display_name': 'Scout', + 'avatar_url': avatar, + 'system_prompt': 'Be kind.', + 'model_id': 'opus-5-5', + 'reasoning_effort': 'low', + }, + name: 'Scout', + instructions: 'Be kind.', + avatar: avatar, + model: 'opus-5-5', + effort: 'low', + ), + 'model object': ( + json: { + 'name': 'Scout', + 'prompt': 'Hi.', + 'model': {'id': 'opus-5-5', 'effort': 'medium'}, + }, + name: 'Scout', + instructions: 'Hi.', + avatar: null, + model: 'opus-5-5', + effort: 'medium', + ), + 'wrong types and blanks are left for the user': ( + json: { + 'name': 7, + 'instructions': ['x'], + 'model': ' ', + 'reasoningEffort': true, + }, + name: '', + instructions: '', + avatar: null, + model: null, + effort: null, + ), + 'avatar on another host is not used': ( + json: {'name': 'Scout', 'avatarUrl': 'https://evil.example/a.png'}, + name: 'Scout', + instructions: '', + avatar: null, + model: null, + effort: null, + ), + 'http avatar is not used': ( + json: {'name': 'Scout', 'avatarUrl': 'http://cdn.example/a.png'}, + name: 'Scout', + instructions: '', + avatar: null, + model: null, + effort: null, + ), + 'avatar with credentials is not used': ( + json: { + 'name': 'Scout', + 'avatarUrl': 'https://u:p@cdn.example/a.png', + }, + name: 'Scout', + instructions: '', + avatar: null, + model: null, + effort: null, + ), + }; + cases.forEach((label, c) { + test(label, () { + final draft = AgentCreationDraft.parseAgentCard(c.json, cdn); + expect(draft.name, c.name); + expect(draft.instructions, c.instructions); + expect(draft.avatar?.url, c.avatar); + expect(draft.modelId, c.model); + expect(draft.reasoningEffort, c.effort); + }); + }); + }); +}