mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and Figgies every five seconds, without creating video players. The next poster is precached; reduced motion and hidden-screen pauses are preserved. The launcher uses the neutral “Bestie” label until a selection is available, then “Chat with …”. For supported communities with no owned agents, tapping opens Create agent directly. A successful first-agent setup saves that agent as Bestie without replacing an existing preference; existing-agent and unsupported-community paths keep the Agents list. Verified Builderlab registrations can show sleeping remote agents as Online. Owner-scoped saved runtime availability now prevents a known absent or deleted runtime from returning Online after Builderlab sign-out or a fresh load, even when another community retains its registration. Validation: mobile static checks; 4,274 Flutter tests passed with hosted flows enabled; launcher → cancel → create → Home “Chat with Scout”, including encrypted persisted selection; sign-out, fresh-load, owner isolation and deletion regressions. Restoring the old carousel produced three video-player requests; removing the saved-availability guard reproduced incorrect Online after sign-out. The earlier iPhone recording below shows the original carousel and entry flow and stops before creation. The new poster-only behavior and completed creation are covered by widget tests, not a new device recording. https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
161 lines
5.7 KiB
Dart
161 lines
5.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../crypto/event_signing.dart';
|
|
import '../crypto/nip_oa.dart';
|
|
import '../mentions/agent_identity_provider.dart';
|
|
import '../relay/relay.dart';
|
|
|
|
/// Public model information accompanying a verified owned agent profile.
|
|
class OwnedAgentDirectoryEntry extends AgentDirectoryEntry {
|
|
const OwnedAgentDirectoryEntry({
|
|
required super.pubkey,
|
|
super.displayName,
|
|
super.avatarUrl,
|
|
this.model,
|
|
this.runtime,
|
|
});
|
|
final String? model;
|
|
|
|
/// Runtime from the verified owner-signed registration in this community.
|
|
final String? runtime;
|
|
}
|
|
|
|
/// The owner's agents in the active community, as desktop's Agents tab lists
|
|
/// them: each agent's newest kind:30177 registration must name it and carry
|
|
/// the `runtime` tag of its agent type. Registrations an earlier Buzz wrote
|
|
/// have no `runtime` until the agent is imported, so they are not listed.
|
|
/// Signed profiles prove ownership.
|
|
final ownedAgentDirectoryProvider =
|
|
FutureProvider<List<OwnedAgentDirectoryEntry>>((ref) async {
|
|
ref.watch(relayConfigProvider);
|
|
final owner = ref.watch(myPubkeyProvider);
|
|
final session = ref.watch(relaySessionProvider);
|
|
if (owner == null) return const [];
|
|
// Stay loading until connected: an empty list would read as "no
|
|
// listed agents" and hide the owner's agents from mentions. The
|
|
// provider rebuilds when the session status changes.
|
|
if (session.status != SessionStatus.connected) {
|
|
return Completer<List<OwnedAgentDirectoryEntry>>().future;
|
|
}
|
|
final relay = ref.read(relaySessionProvider.notifier);
|
|
return loadOwnedAgentDirectory(
|
|
owner,
|
|
(filter) => relay.queryRelay([filter]),
|
|
);
|
|
});
|
|
|
|
/// Uses the active community's authenticated query boundary and paginates it.
|
|
Future<List<OwnedAgentDirectoryEntry>> loadOwnedAgentDirectory(
|
|
String owner,
|
|
Future<List<NostrEvent>> Function(NostrFilter) query,
|
|
) async {
|
|
final registrations = <String, NostrEvent>{};
|
|
final seen = <String>{};
|
|
for (var page = 1; ; page++) {
|
|
final events = await query(
|
|
NostrFilter(
|
|
kinds: const [30177],
|
|
authors: [owner],
|
|
limit: 100,
|
|
extensions: {'page': page},
|
|
),
|
|
);
|
|
final fresh = events.where((event) => seen.add(event.id)).toList();
|
|
for (final event in fresh) {
|
|
if (event.kind != 30177 || event.pubkey != owner) continue;
|
|
final key = _tagValue(event, 'd');
|
|
if (key == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) continue;
|
|
final previous = registrations[key];
|
|
if (previous == null ||
|
|
event.createdAt > previous.createdAt ||
|
|
(event.createdAt == previous.createdAt &&
|
|
event.id.compareTo(previous.id) < 0)) {
|
|
registrations[key] = event;
|
|
}
|
|
}
|
|
if (events.length < 100 || fresh.isEmpty) break;
|
|
}
|
|
final keys = {
|
|
for (final MapEntry(:key, :value) in registrations.entries)
|
|
if (isListedAgentRegistration(value)) key,
|
|
};
|
|
final results = <OwnedAgentDirectoryEntry>[];
|
|
final ordered = keys.toList();
|
|
for (var offset = 0; offset < ordered.length; offset += 50) {
|
|
final batch = ordered.skip(offset).take(50).toList();
|
|
final events = await query(NostrFilters.profilesBatch(batch));
|
|
final latest = <String, NostrEvent>{};
|
|
for (final event in events) {
|
|
if (event.kind != 0 || !batch.contains(event.pubkey)) continue;
|
|
final previous = latest[event.pubkey];
|
|
if (previous == null ||
|
|
event.createdAt > previous.createdAt ||
|
|
(event.createdAt == previous.createdAt &&
|
|
event.id.compareTo(previous.id) < 0)) {
|
|
latest[event.pubkey] = event;
|
|
}
|
|
}
|
|
for (final event in latest.values) {
|
|
if (verifiedProfileOaOwnerPubkey(event) == owner) {
|
|
final profile = AgentDirectoryEntry.fromEvent(event);
|
|
String? model;
|
|
String? runtime;
|
|
try {
|
|
final registration = registrations[event.pubkey]!;
|
|
// Native libsecp256k1 verification; the pure-Dart codec took
|
|
// several milliseconds per registration on the UI isolate.
|
|
verifiedNostrEventFromJson(jsonEncode(registration.toJson()));
|
|
runtime = _tagValue(registration, 'runtime');
|
|
final data = jsonDecode(registration.content);
|
|
if (data is Map && data['model'] is String) {
|
|
final value = (data['model'] as String).trim();
|
|
if (value.isNotEmpty) model = value;
|
|
}
|
|
} catch (_) {
|
|
/* Older registrations may have no metadata. */
|
|
}
|
|
results.add(
|
|
OwnedAgentDirectoryEntry(
|
|
pubkey: profile.pubkey,
|
|
displayName: profile.displayName,
|
|
avatarUrl: profile.avatarUrl,
|
|
model: model,
|
|
runtime: runtime,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/// An agent type key, `pluginId/typeId`, as desktop's `RUNTIME` pattern
|
|
/// (buzz-app `src/features/agents/service.ts`) accepts it.
|
|
final _runtimePattern = RegExp(r'^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$');
|
|
|
|
/// Whether desktop lists this kind:30177 registration as one of the owner's
|
|
/// agents: it names the agent and carries a valid `runtime` tag.
|
|
bool isListedAgentRegistration(NostrEvent event) {
|
|
if (!_runtimePattern.hasMatch(_tagValue(event, 'runtime') ?? '')) {
|
|
return false;
|
|
}
|
|
try {
|
|
final content = jsonDecode(event.content);
|
|
return content is Map &&
|
|
content['name'] is String &&
|
|
(content['name'] as String).trim().isNotEmpty;
|
|
} on FormatException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
String? _tagValue(NostrEvent event, String name) {
|
|
for (final tag in event.tags) {
|
|
if (tag.length >= 2 && tag.first == name) return tag[1];
|
|
}
|
|
return null;
|
|
}
|