Files
buzz/mobile/lib/features/agents/owned_agent_directory.dart
klopez4212 0cde1fe2f4 Make mobile Agents a full-page owned-agent directory (#8693)
Replace the mobile Agents sheet with a full-page directory of verified
owned agents. Add dedicated creation and avatar pages with
catalog-backed model selection, reuse the profile emoji/photo/camera
controls, and expose Builderbot model settings while labeling desktop
agents “Manage on desktop.” Share one account-scoped model catalog
across editable cards and retries. Choose Bestie from the agent profile
using the shared encrypted main-agent record (text-only action, filled
star once selected), and move confirmed deletion to a top-left trash
control with native iOS glass. Include uploaded avatars in owner
registrations so desktop can display them.

Validation: mobile analysis and all 3,958 hosted-enabled app tests
passed; default-build tests run by the push hook. Earlier iPhone build
installed and iterated with the user; the current iOS build succeeds,
but final changes still need a device pass. Independent review found no
new blockers. Actual camera capture and live Bestie sync remain
unconfirmed; the existing shared `main-agent` wire format and Bestie
terminology are retained.

### Snapshots

Widget-harness captures with test data (not native iOS chrome).

| Agents directory | Image avatar editor |
| --- | --- |
| ![Agents
directory](https://github.com/user-attachments/assets/c68a0505-90a5-41db-980f-e3197cac63ac)
| ![Image avatar
editor](https://github.com/user-attachments/assets/9b126186-1e7c-4169-9a74-8f7f252cfa93)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 18:30:17 +00:00

118 lines
4.0 KiB
Dart

import 'dart:convert';
import 'package:nostr/nostr.dart' as nostr;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/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,
});
final String? model;
}
/// Discovers owned identities even when their runtime has no kind:10100 entry.
/// Owner registrations nominate candidates; 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 || session.status != SessionStatus.connected) {
return const [];
}
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 keys = <String>{};
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;
for (final tag in event.tags) {
if (tag.length >= 2 &&
tag.first == 'd' &&
RegExp(r'^[0-9a-f]{64}$').hasMatch(tag[1])) {
keys.add(tag[1]);
final previous = registrations[tag[1]];
if (previous == null ||
event.createdAt > previous.createdAt ||
(event.createdAt == previous.createdAt &&
event.id.compareTo(previous.id) < 0)) {
registrations[tag[1]] = event;
}
}
}
}
if (events.length < 100 || fresh.isEmpty) break;
}
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;
try {
final registration = registrations[event.pubkey]!;
nostr.Event.fromJson(jsonEncode(registration.toJson()));
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,
),
);
}
}
}
return results;
}