mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
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/<id>), whose "Add to Buzz" button opens buzz://agent/new?card=<id>. 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=<token>; 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 <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
@@ -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=<id>` 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/<id>`), whose **Add to Buzz** button opens this link.
|
||||
|
||||
The app fetches `<avatars CDN>/<id>/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://<avatars CDN>/<id>/<revision>/avatar.png",
|
||||
"instructions": "Reply concisely.",
|
||||
"model": "<model catalog id>",
|
||||
"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
|
||||
|
||||
@@ -57,6 +57,26 @@ class AgentAvatarSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an emoji on a solid [color] to the 512px PNG that creation uploads.
|
||||
Future<Uint8List?> 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<void> 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,
|
||||
);
|
||||
|
||||
171
mobile/lib/features/agents/agent_creation_draft.dart
Normal file
171
mobile/lib/features/agents/agent_creation_draft.dart
Normal file
@@ -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<http.Client Function()>(
|
||||
(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<AgentCreationDraft> 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<int> 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<String, dynamic>) {
|
||||
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<String, dynamic> 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, dynamic>
|
||||
? _string(model, const ['id', 'modelId', 'model_id', 'name'])
|
||||
: _string(json, const ['model', 'modelId', 'model_id']),
|
||||
reasoningEffort:
|
||||
_string(json, _effortKeys) ??
|
||||
(model is Map<String, dynamic> ? _string(model, _effortKeys) : null),
|
||||
);
|
||||
}
|
||||
|
||||
static const _effortKeys = [
|
||||
'reasoningEffort',
|
||||
'reasoning_effort',
|
||||
'effort',
|
||||
'thinking',
|
||||
];
|
||||
|
||||
/// The first non-blank string among [keys], trimmed.
|
||||
static String? _string(Map<String, dynamic> json, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<int>> _readBounded(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
if (bytes.length > maxAgentCardBytes) {
|
||||
throw const AgentCardException('This agent card is too large.');
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -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<Channel>? _openAgentsRoute;
|
||||
|
||||
/// Opens the active community's full-page Agents navigation stack.
|
||||
Future<void> 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<void> 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<Channel>(
|
||||
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<Channel>(
|
||||
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<void>(
|
||||
MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)),
|
||||
@@ -50,9 +72,10 @@ Future<void> 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<Channel>(
|
||||
@@ -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<String> onSelect;
|
||||
final RemoteAgentController controller;
|
||||
@@ -224,6 +252,28 @@ class AgentsPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openCreate([AgentCreationDraft? initialDraft]) async {
|
||||
if (community == null) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await Navigator.of(context).push<bool>(
|
||||
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<bool>(
|
||||
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<void>.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 }
|
||||
|
||||
@@ -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<String?>(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;
|
||||
|
||||
@@ -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<DeepLinkDispatcher> {
|
||||
|
||||
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<DeepLinkDispatcher> {
|
||||
_dispatchNavigableLink(link);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _dispatchOnboarding(BuzzDeepLink link) async {
|
||||
_handlingOnboarding = true;
|
||||
var consumed = false;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
13
mobile/lib/shared/agents/agents_surface.dart
Normal file
13
mobile/lib/shared/agents/agents_surface.dart
Normal file
@@ -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<Future<void> Function(BuildContext, AgentCreationDeepLink)?>(
|
||||
(ref) => null,
|
||||
);
|
||||
45
mobile/lib/shared/deeplink/agent_creation_link.dart
Normal file
45
mobile/lib/shared/deeplink/agent_creation_link.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
part of 'deep_link.dart';
|
||||
|
||||
/// `buzz://agent/new?card=<id>`: 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=<id>`; 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);
|
||||
}
|
||||
@@ -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) ??
|
||||
|
||||
@@ -10,6 +10,14 @@ abstract final class HostedEnvironment {
|
||||
'https://app.builderlabstaging.xyz',
|
||||
};
|
||||
|
||||
/// Public CDN serving each published Builderlab agent card's
|
||||
/// `<id>/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);
|
||||
|
||||
@@ -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<List<Channel>> 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 = <String, Map<String, Object?>>{
|
||||
'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 = <Uri>[];
|
||||
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('<Error>AccessDenied</Error>', 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<Uri>();
|
||||
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<void> 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<PresetAgentAvatar>(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<TextField>(_field('Instructions')).controller!.text,
|
||||
isEmpty,
|
||||
);
|
||||
expect(find.text('Retired model'), findsNothing);
|
||||
expect(find.text('Choose'), findsNWidgets(2));
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<String> directory,
|
||||
Widget? home,
|
||||
List<Override> 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),
|
||||
|
||||
172
mobile/test/shared/deeplink/agent_creation_link_test.dart
Normal file
172
mobile/test/shared/deeplink/agent_creation_link_test.dart
Normal file
@@ -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<AgentCreationDeepLink>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('agent.json', () {
|
||||
final cdn = Uri.parse('https://cdn.example');
|
||||
const avatar = 'https://cdn.example/av_1/rev/avatar.png';
|
||||
final cases =
|
||||
<
|
||||
String,
|
||||
({
|
||||
Map<String, dynamic> 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user