Files
buzz/mobile/lib/features/agents/agent_instructions.dart
klopez4212 a12ad1cdd2 feat(mobile): refine agent profiles and model selection (#8773)
## Summary
- Simplify agent profiles with Message/Bestie actions, relay-authored
published instructions (unavailable without a config channel),
management details, public-key copying, and a separate final Delete
section.
- Sort the Agents directory by name and mark the selected Bestie with a
muted filled star.
- Place Model and Thinking together below Name. Use native iOS Liquid
Glass menus and preserve selection callbacks as controls become enabled.

Related: #8685.

## Validation
Full mobile checks passed. Regression coverage includes relay-author
filtering, unavailable instructions, metadata retry, resizing native
pickers, accurate deletion state, selection after loading, and Bestie
switching. Real iOS simulator taps verified both pickers; the user
confirmed the earlier iPhone build works. Review fixes were checked with
widget journeys and the native UIKit resize/selection probe.

<details>
<summary>Previews</summary>

Synthetic fixture renders:

| Profile | Create agent | Bestie card |
| --- | --- | --- |
| ![Profile with separate Delete
section](https://github.com/user-attachments/assets/4b6e7cf5-b152-43a3-99d9-7c1ee65d0c70)
| ![Model and Thinking
settings](https://github.com/user-attachments/assets/c81dc138-d5d3-43bb-873b-a64171155a7d)
| ![Muted Bestie
star](https://github.com/user-attachments/assets/5ce95e3b-2403-41c5-b6d6-52b8b60cc7d6)
|

Native iOS simulator interaction with a synthetic catalog:

![Both picker values updated after native menu
selections](https://github.com/user-attachments/assets/a3ee6d4d-91e0-4df6-b4cf-b66d5c1a2e87)

</details>

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 11:28:14 -06:00

144 lines
4.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/relay/relay_author_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
/// Reads only the selected owner's community-scoped agent configuration.
final agentInstructionsProvider = FutureProvider.autoDispose
.family<String?, String>((ref, pubkey) async {
final community = ref.watch(activeCommunityProvider).asData?.value;
final owner = community?.pubkey;
if (owner == null) return null;
final relay = ref.watch(relaySessionProvider.notifier);
Future<List<NostrEvent>> query(NostrFilter filter) =>
relay.queryRelay([filter]);
final relayAuthor = await ref.watch(
relayAuthorProvider(community!.relayUrl).future,
);
if (relayAuthor == null) return null;
return loadAgentInstructions(pubkey, owner, relayAuthor, query);
});
/// Only published config is authoritative; local desktop overrides cannot be
/// inferred from legacy registration or definition prompts.
Future<String?> loadAgentInstructions(
String pubkey,
String owner,
String relayAuthor,
Future<List<NostrEvent>> Function(NostrFilter) query,
) async {
final channels = await query(
NostrFilter(
kinds: const [39000],
authors: [relayAuthor],
tags: {
'#P': [owner],
'#t': [pubkey],
},
),
);
final configs = channels.where((event) {
final channel = ChannelData.fromEvent(event);
return event.kind == 39000 &&
event.pubkey == relayAuthor &&
channel.creator == owner &&
channel.channelType == 'system' &&
channel.labels.contains('agent-config') &&
channel.labels.contains(pubkey);
}).toList();
if (channels.length >= 100) {
throw const FormatException('Too many config channels');
}
configs.sort((a, b) {
final left = int.tryParse(a.getTagValue('created_at') ?? '');
final right = int.tryParse(b.getTagValue('created_at') ?? '');
final order = left == null
? (right == null ? 0 : 1)
: (right == null ? -1 : left.compareTo(right));
return order != 0
? order
: (a.getTagValue('d') ?? '').compareTo(b.getTagValue('d') ?? '');
});
if (configs.isNotEmpty) {
final channel = ChannelData.fromEvent(configs.first);
if (channel.isArchived) return null;
final id = const Uuid().v5(channel.id, 'buzz.agent.instructions');
final artifacts = await query(
NostrFilter(
kinds: const [45010],
extensions: const {'artifact': 'current'},
tags: {
'#d': [id],
'#h': [channel.id],
},
),
);
if (artifacts.length != 1) return null;
final event = artifacts.single;
if (event.kind != 45010 ||
event.getTagValue('d') != id ||
event.getTagValue('h') != channel.id ||
event.getTagValue('type') != 'buzz.agent.instructions' ||
event.getTagValue('ar') != '1' ||
!['create', 'update', 'restore'].contains(event.getTagValue('op'))) {
return null;
}
final data = jsonDecode(event.content);
if (data is! Map ||
data['version'] != 1 ||
data['text'] is! String ||
(data['text'] as String).contains('\u0000')) {
throw const FormatException('Invalid instructions');
}
return data['text'] as String;
}
return null;
}
/// Read-only instructions, with explicit unavailable and retry states.
class AgentInstructions extends ConsumerWidget {
const AgentInstructions({required this.pubkey, super.key});
final String pubkey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final instructions = ref.watch(agentInstructionsProvider(pubkey));
return instructions.when(
loading: () => const Center(
child: BuzzLoadingIndicator(semanticLabel: 'Loading instructions'),
),
error: (_, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Could not load instructions.'),
TextButton(
onPressed: () {
final community = ref.read(activeCommunityProvider).asData?.value;
if (community != null) {
ref.invalidate(relayAuthorProvider(community.relayUrl));
}
ref.invalidate(agentInstructionsProvider(pubkey));
},
child: const Text('Retry'),
),
],
),
data: (text) => SelectableText(
text == null
? 'Instructions aren’t available on mobile for this agent. View them on desktop.'
: text.trim().isEmpty
? 'No instructions set.'
: text,
style: context.textTheme.bodyMedium,
),
);
}
}