feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/l10n/l10n.dart';
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../date_formatters.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
import 'transcript_item_widget.dart';
|
||||
|
||||
/// Full-screen modal bottom sheet showing the live agent activity transcript.
|
||||
class AgentActivitySheet extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final String agentPubkey;
|
||||
|
||||
const AgentActivitySheet({
|
||||
super.key,
|
||||
required this.channelId,
|
||||
required this.agentPubkey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final observerState = ref.watch(
|
||||
observerSubscriptionProvider((
|
||||
channelId: channelId,
|
||||
agentPubkey: agentPubkey,
|
||||
)),
|
||||
);
|
||||
final transcript = observerState.transcript;
|
||||
final connection = observerState.connection;
|
||||
|
||||
// Resolve bot name.
|
||||
final profile = ref.watch(
|
||||
userCacheProvider.select((cache) => cache[agentPubkey.toLowerCase()]),
|
||||
);
|
||||
final botName = profile?.label ?? shortPubkey(agentPubkey);
|
||||
|
||||
// Auto-scroll to bottom on new items.
|
||||
final sheetControllerRef = useRef<ScrollController?>(null);
|
||||
final previousLength = useRef(0);
|
||||
|
||||
useEffect(() {
|
||||
final sc = sheetControllerRef.value;
|
||||
if (transcript.length > previousLength.value &&
|
||||
sc != null &&
|
||||
sc.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (sc.hasClients) {
|
||||
sc.animateTo(
|
||||
sc.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
previousLength.value = transcript.length;
|
||||
return null;
|
||||
}, [transcript.length]);
|
||||
|
||||
// Preload the bot profile.
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload([agentPubkey]);
|
||||
return null;
|
||||
}, [agentPubkey]);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, sheetScrollController) {
|
||||
sheetControllerRef.value = sheetScrollController;
|
||||
final bottomPadding =
|
||||
MediaQuery.viewPaddingOf(context).bottom + Grid.sm;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bot,
|
||||
size: 18,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
botName,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
_ConnectionBadge(connection: connection),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
context.l10n.agentLiveActivityHint,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Divider(color: context.colors.outlineVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Transcript list
|
||||
Expanded(
|
||||
child: transcript.isEmpty
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding),
|
||||
child: _EmptyState(
|
||||
connection: connection,
|
||||
errorMessage: observerState.errorMessage,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: sheetScrollController,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
Grid.gutter,
|
||||
bottomPadding,
|
||||
),
|
||||
itemCount: transcript.length,
|
||||
itemBuilder: (context, index) {
|
||||
return TranscriptItemWidget(item: transcript[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final ObserverConnectionState connection;
|
||||
final String? errorMessage;
|
||||
|
||||
const _EmptyState({required this.connection, this.errorMessage});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (connection == ObserverConnectionState.error) {
|
||||
final localizedError =
|
||||
Localizations.localeOf(context).languageCode == 'en'
|
||||
? errorMessage
|
||||
: context.l10n.agentObserverFailed;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.circleX, size: 24, color: context.colors.error),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'${context.l10n.transcriptError}: '
|
||||
'${localizedError ?? context.l10n.agentUnknownError}',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (connection == ObserverConnectionState.idle) {
|
||||
return Center(
|
||||
child: Text(
|
||||
context.l10n.agentNotConnected,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// connecting or open — show spinner
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BuzzLoadingIndicator(
|
||||
size: 28,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
semanticLabel: context.l10n.waitingForAgent,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
context.l10n.agentWaitingActivity,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionBadge extends StatelessWidget {
|
||||
final ObserverConnectionState connection;
|
||||
|
||||
const _ConnectionBadge({required this.connection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = switch (connection) {
|
||||
ObserverConnectionState.idle => (
|
||||
context.colors.onSurfaceVariant,
|
||||
context.l10n.agentConnectionIdle,
|
||||
),
|
||||
ObserverConnectionState.connecting => (
|
||||
context.appColors.warning,
|
||||
context.l10n.agentConnectionConnecting,
|
||||
),
|
||||
ObserverConnectionState.open => (
|
||||
context.appColors.success,
|
||||
context.l10n.agentConnectionLive,
|
||||
),
|
||||
ObserverConnectionState.error => (
|
||||
context.colors.error,
|
||||
context.l10n.agentConnectionError,
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Connection state for the observer relay subscription.
|
||||
enum ObserverConnectionState { idle, connecting, open, error }
|
||||
|
||||
/// Status of a tool execution.
|
||||
enum ToolStatus { executing, completed, failed, pending }
|
||||
|
||||
/// A decrypted observer frame from a kind:24200 event.
|
||||
@immutable
|
||||
class ObserverFrame {
|
||||
final int seq;
|
||||
final String timestamp;
|
||||
final String kind;
|
||||
final int? agentIndex;
|
||||
final String? channelId;
|
||||
final String? sessionId;
|
||||
final String? turnId;
|
||||
final dynamic payload;
|
||||
|
||||
const ObserverFrame({
|
||||
required this.seq,
|
||||
required this.timestamp,
|
||||
required this.kind,
|
||||
this.agentIndex,
|
||||
this.channelId,
|
||||
this.sessionId,
|
||||
this.turnId,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
factory ObserverFrame.fromJson(Map<String, dynamic> json) => ObserverFrame(
|
||||
seq: json['seq'] as int? ?? 0,
|
||||
timestamp: json['timestamp'] as String? ?? '',
|
||||
kind: json['kind'] as String? ?? '',
|
||||
agentIndex: json['agentIndex'] as int?,
|
||||
channelId: json['channelId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
turnId: json['turnId'] as String?,
|
||||
payload: json['payload'],
|
||||
);
|
||||
}
|
||||
|
||||
/// A section within prompt context metadata.
|
||||
@immutable
|
||||
class PromptSection {
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
const PromptSection({required this.title, required this.body});
|
||||
}
|
||||
|
||||
/// A single item in the agent activity transcript.
|
||||
sealed class TranscriptItem {
|
||||
String get id;
|
||||
String get timestamp;
|
||||
}
|
||||
|
||||
class MessageItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String role;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
MessageItem({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class ThoughtItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
ThoughtItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class LifecycleItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
String text;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
LifecycleItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class MetadataItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
final String title;
|
||||
List<PromptSection> sections;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
MetadataItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.sections,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class ToolItem extends TranscriptItem {
|
||||
@override
|
||||
final String id;
|
||||
String title;
|
||||
String toolName;
|
||||
String? buzzToolName;
|
||||
ToolStatus status;
|
||||
Map<String, dynamic> args;
|
||||
String result;
|
||||
bool isError;
|
||||
@override
|
||||
final String timestamp;
|
||||
|
||||
ToolItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.toolName,
|
||||
this.buzzToolName,
|
||||
required this.status,
|
||||
required this.args,
|
||||
required this.result,
|
||||
required this.isError,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../../shared/crypto/nip44.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'transcript_builder.dart';
|
||||
|
||||
/// Maximum observer events to keep per agent.
|
||||
const _maxObserverEvents = 800;
|
||||
|
||||
/// Key for channel-scoped transcript reads.
|
||||
typedef ObserverKey = ({String channelId, String agentPubkey});
|
||||
|
||||
/// State emitted by the channel-scoped observer transcript provider.
|
||||
@immutable
|
||||
class ObserverState {
|
||||
final ObserverConnectionState connection;
|
||||
final List<TranscriptItem> transcript;
|
||||
final String? errorMessage;
|
||||
|
||||
const ObserverState({
|
||||
required this.connection,
|
||||
required this.transcript,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
const ObserverState.initial()
|
||||
: connection = ObserverConnectionState.idle,
|
||||
transcript = const [],
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ObserverRelayState {
|
||||
final ObserverConnectionState connection;
|
||||
final Map<String, List<ObserverFrame>> framesByAgent;
|
||||
final String? errorMessage;
|
||||
|
||||
const ObserverRelayState({
|
||||
required this.connection,
|
||||
required this.framesByAgent,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
const ObserverRelayState.initial()
|
||||
: connection = ObserverConnectionState.idle,
|
||||
framesByAgent = const {},
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
|
||||
final Map<String, List<ObserverFrame>> _framesByAgent = {};
|
||||
final Map<String, Set<String>> _dedupeKeysByAgent = {};
|
||||
final Map<String, Uint8List> _conversationKeysByAgent = {};
|
||||
|
||||
void Function()? _unsubscribe;
|
||||
Future<void>? _startFuture;
|
||||
String? _privHex;
|
||||
String? _ownerPubkey;
|
||||
String? _identityKey;
|
||||
String? _errorMessage;
|
||||
int _subscriptionEpoch = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ObserverRelayState build() {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
final identityKey = '${config.baseUrl}|${config.nsec ?? ''}';
|
||||
|
||||
_disposed = false;
|
||||
if (_identityKey != null && _identityKey != identityKey) {
|
||||
_reset();
|
||||
}
|
||||
_identityKey = identityKey;
|
||||
|
||||
ref.onDispose(() {
|
||||
_disposed = true;
|
||||
_reset();
|
||||
});
|
||||
|
||||
if (sessionState.status == SessionStatus.connected) {
|
||||
Future.microtask(_ensureSubscribed);
|
||||
}
|
||||
|
||||
final hasSigningKey = config.nsec?.isNotEmpty == true;
|
||||
return ObserverRelayState(
|
||||
connection: hasSigningKey
|
||||
? _connectionForSession(sessionState.status)
|
||||
: ObserverConnectionState.idle,
|
||||
framesByAgent: _snapshotFrames(),
|
||||
errorMessage: _errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureSubscribed() {
|
||||
if (_disposed) return Future.value();
|
||||
if (_unsubscribe != null) return Future.value();
|
||||
if (_startFuture != null) return _startFuture!;
|
||||
|
||||
_startFuture = _subscribe(_subscriptionEpoch);
|
||||
return _startFuture!;
|
||||
}
|
||||
|
||||
Future<void> _subscribe(int epoch) async {
|
||||
try {
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final nsec = config.nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
final privHex = _decodePrivkey(nsec);
|
||||
final ownerPubkey = _derivePubkey(privHex);
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
_privHex = privHex;
|
||||
_ownerPubkey = ownerPubkey;
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.connecting);
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final unsubscribe = await session.subscribe(
|
||||
NostrFilter(
|
||||
kinds: [EventKind.agentObserverFrame],
|
||||
tags: {
|
||||
'#p': [ownerPubkey],
|
||||
},
|
||||
limit: 0,
|
||||
),
|
||||
_handleEvent,
|
||||
onClosed: (message) {
|
||||
_unsubscribe = null;
|
||||
_errorMessage = 'Observer subscription closed: $message';
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
_unsubscribe = unsubscribe;
|
||||
_emit(connection: ObserverConnectionState.open);
|
||||
} catch (error) {
|
||||
if (_disposed || epoch != _subscriptionEpoch) {
|
||||
return;
|
||||
}
|
||||
_errorMessage = _observerErrorMessage(error);
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
} finally {
|
||||
if (epoch == _subscriptionEpoch) {
|
||||
_startFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(NostrEvent event) {
|
||||
final agentPubkey = event.getTagValue('agent');
|
||||
if (agentPubkey == null || event.getTagValue('frame') != 'telemetry') {
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedAgent = agentPubkey.toLowerCase();
|
||||
if (event.pubkey.toLowerCase() != normalizedAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ownerPubkey = _ownerPubkey;
|
||||
final privHex = _privHex;
|
||||
if (ownerPubkey == null || privHex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pTag = event.getTagValue('p');
|
||||
if (pTag?.toLowerCase() != ownerPubkey.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final frame = _decryptFrame(event, normalizedAgent, privHex);
|
||||
if (frame == null) return;
|
||||
|
||||
final dedupeKey = '${frame.seq}:${frame.timestamp}';
|
||||
final dedupeKeys = _dedupeKeysByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => <String>{},
|
||||
);
|
||||
if (!dedupeKeys.add(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final frames = _framesByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => <ObserverFrame>[],
|
||||
);
|
||||
frames.add(frame);
|
||||
frames.sort(_compareObserverFrames);
|
||||
|
||||
if (frames.length > _maxObserverEvents) {
|
||||
final removeCount = frames.length - _maxObserverEvents;
|
||||
for (final removed in frames.take(removeCount)) {
|
||||
dedupeKeys.remove('${removed.seq}:${removed.timestamp}');
|
||||
}
|
||||
frames.removeRange(0, removeCount);
|
||||
}
|
||||
|
||||
_errorMessage = null;
|
||||
_emit(connection: ObserverConnectionState.open);
|
||||
}
|
||||
|
||||
ObserverFrame? _decryptFrame(
|
||||
NostrEvent event,
|
||||
String normalizedAgent,
|
||||
String privHex,
|
||||
) {
|
||||
try {
|
||||
final conversationKey = _conversationKeysByAgent.putIfAbsent(
|
||||
normalizedAgent,
|
||||
() => getConversationKey(privHex, normalizedAgent),
|
||||
);
|
||||
final plaintext = nip44Decrypt(conversationKey, event.content);
|
||||
final json = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
return ObserverFrame.fromJson(json);
|
||||
} catch (error) {
|
||||
_errorMessage = 'Observer event decrypt failed: $error';
|
||||
_emit(connection: ObserverConnectionState.error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _emit({required ObserverConnectionState connection}) {
|
||||
if (_disposed) return;
|
||||
state = ObserverRelayState(
|
||||
connection: connection,
|
||||
framesByAgent: _snapshotFrames(),
|
||||
errorMessage: _errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<ObserverFrame>> _snapshotFrames() {
|
||||
return Map<String, List<ObserverFrame>>.unmodifiable({
|
||||
for (final entry in _framesByAgent.entries)
|
||||
entry.key: List<ObserverFrame>.unmodifiable(entry.value),
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_subscriptionEpoch += 1;
|
||||
_unsubscribe?.call();
|
||||
_unsubscribe = null;
|
||||
_startFuture = null;
|
||||
_privHex = null;
|
||||
_ownerPubkey = null;
|
||||
_errorMessage = null;
|
||||
_framesByAgent.clear();
|
||||
_dedupeKeysByAgent.clear();
|
||||
_conversationKeysByAgent.clear();
|
||||
}
|
||||
|
||||
static String _decodePrivkey(String nsec) {
|
||||
try {
|
||||
final privHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privHex.isEmpty) {
|
||||
throw const FormatException('empty private key');
|
||||
}
|
||||
return privHex;
|
||||
} catch (_) {
|
||||
throw const FormatException('failed to decode private key');
|
||||
}
|
||||
}
|
||||
|
||||
static String _derivePubkey(String privHex) {
|
||||
try {
|
||||
return nostr.Keys(privHex).public;
|
||||
} catch (_) {
|
||||
throw const FormatException('failed to derive pubkey');
|
||||
}
|
||||
}
|
||||
|
||||
ObserverConnectionState _connectionForSession(SessionStatus status) {
|
||||
if (_errorMessage != null && _unsubscribe == null && _startFuture == null) {
|
||||
return ObserverConnectionState.error;
|
||||
}
|
||||
return switch (status) {
|
||||
SessionStatus.connected =>
|
||||
_unsubscribe == null
|
||||
? ObserverConnectionState.connecting
|
||||
: ObserverConnectionState.open,
|
||||
SessionStatus.connecting ||
|
||||
SessionStatus.reconnecting => ObserverConnectionState.connecting,
|
||||
SessionStatus.disconnected => ObserverConnectionState.idle,
|
||||
};
|
||||
}
|
||||
|
||||
static String _observerErrorMessage(Object error) {
|
||||
if (error is FormatException) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Observer subscription failed: $error';
|
||||
}
|
||||
|
||||
static int _compareObserverFrames(ObserverFrame a, ObserverFrame b) {
|
||||
final tsA = DateTime.tryParse(a.timestamp)?.millisecondsSinceEpoch ?? 0;
|
||||
final tsB = DateTime.tryParse(b.timestamp)?.millisecondsSinceEpoch ?? 0;
|
||||
if (tsA != tsB) return tsA.compareTo(tsB);
|
||||
return a.seq.compareTo(b.seq);
|
||||
}
|
||||
}
|
||||
|
||||
final observerRelayProvider =
|
||||
NotifierProvider<ObserverRelayNotifier, ObserverRelayState>(
|
||||
ObserverRelayNotifier.new,
|
||||
);
|
||||
|
||||
final observerSubscriptionProvider =
|
||||
Provider.family<ObserverState, ObserverKey>((ref, key) {
|
||||
final relayState = ref.watch(observerRelayProvider);
|
||||
final normalizedAgent = key.agentPubkey.toLowerCase();
|
||||
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
|
||||
final channelFrames = [
|
||||
for (final frame in frames)
|
||||
if (frame.channelId == null || frame.channelId == key.channelId)
|
||||
frame,
|
||||
];
|
||||
|
||||
return ObserverState(
|
||||
connection: relayState.connection,
|
||||
transcript: buildTranscript(channelFrames),
|
||||
errorMessage: relayState.errorMessage,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,714 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'observer_models.dart';
|
||||
|
||||
const _buzzReadTools = <String>{
|
||||
'get_messages',
|
||||
'get_channel_history',
|
||||
'get_thread',
|
||||
'search',
|
||||
'get_feed',
|
||||
'get_reactions',
|
||||
'list_channels',
|
||||
'get_channel',
|
||||
'get_users',
|
||||
'get_presence',
|
||||
'list_channel_members',
|
||||
'list_dms',
|
||||
'get_canvas',
|
||||
'list_workflows',
|
||||
'get_workflow_runs',
|
||||
'get_event',
|
||||
'get_user_notes',
|
||||
'get_contact_list',
|
||||
};
|
||||
|
||||
const _buzzWriteTools = <String>{
|
||||
'send_message',
|
||||
'send_diff_message',
|
||||
'edit_message',
|
||||
'delete_message',
|
||||
'add_reaction',
|
||||
'remove_reaction',
|
||||
'join_channel',
|
||||
'leave_channel',
|
||||
'update_channel',
|
||||
'set_channel_topic',
|
||||
'set_channel_purpose',
|
||||
'open_dm',
|
||||
'set_profile',
|
||||
'set_presence',
|
||||
'trigger_workflow',
|
||||
'approve_step',
|
||||
'create_channel',
|
||||
'archive_channel',
|
||||
'unarchive_channel',
|
||||
'add_channel_member',
|
||||
'remove_channel_member',
|
||||
'add_dm_member',
|
||||
'hide_dm',
|
||||
'set_canvas',
|
||||
'create_workflow',
|
||||
'update_workflow',
|
||||
'delete_workflow',
|
||||
'set_channel_add_policy',
|
||||
'vote_on_post',
|
||||
'publish_note',
|
||||
'set_contact_list',
|
||||
};
|
||||
|
||||
final _buzzToolNames = <String>{..._buzzReadTools, ..._buzzWriteTools};
|
||||
|
||||
final _buzzToolNamesByLength = _buzzToolNames.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
final _buzzToolTitleAliases = <(RegExp, String)>[
|
||||
(RegExp(r'\bsending message to channel\b'), 'send_message'),
|
||||
(RegExp(r'\bretrieving recent messages from channel\b'), 'get_messages'),
|
||||
(RegExp(r'\bgetting channel details\b'), 'get_channel'),
|
||||
(RegExp(r'\bgetting user information\b'), 'get_users'),
|
||||
(RegExp(r'\bsearching relay history\b'), 'search'),
|
||||
(RegExp(r'\bgetting thread\b'), 'get_thread'),
|
||||
(RegExp(r'\badding reaction\b'), 'add_reaction'),
|
||||
(RegExp(r'\bremoving reaction\b'), 'remove_reaction'),
|
||||
];
|
||||
|
||||
Map<String, dynamic> _asRecord(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return value.cast<String, dynamic>();
|
||||
return const {};
|
||||
}
|
||||
|
||||
String? _asString(dynamic value) {
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
String _shorten(String value) {
|
||||
if (value.length > 14) {
|
||||
return '${value.substring(0, 8)}...${value.substring(value.length - 4)}';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _titleCase(String value) {
|
||||
return value
|
||||
.replaceAll(RegExp(r'[_-]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.replaceAllMapped(RegExp(r'\b\w'), (m) => m[0]!.toUpperCase());
|
||||
}
|
||||
|
||||
String _normalizeToolNameText(String value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9_]+'), '_')
|
||||
.replaceAll(RegExp(r'_+'), '_')
|
||||
.replaceAll(RegExp(r'^_+|_+$'), '');
|
||||
}
|
||||
|
||||
String? _findBuzzToolName(String value, bool includeShortNames) {
|
||||
final alias = _findBuzzToolAlias(value);
|
||||
if (alias != null) return alias;
|
||||
|
||||
final normalized = _normalizeToolNameText(value);
|
||||
for (final name in _buzzToolNamesByLength) {
|
||||
if ((!includeShortNames && name.length < 8) || !normalized.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _findBuzzToolAlias(String value) {
|
||||
final normalizedPhrase = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[_-]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
for (final (pattern, name) in _buzzToolTitleAliases) {
|
||||
if (pattern.hasMatch(normalizedPhrase)) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isGenericToolTitle(String value) {
|
||||
final normalized = _normalizeToolNameText(value);
|
||||
return normalized.isEmpty ||
|
||||
normalized == 'tool' ||
|
||||
normalized == 'tool_call' ||
|
||||
normalized == 'mcp_tool_call' ||
|
||||
normalized == 'unknown' ||
|
||||
normalized == 'read' ||
|
||||
normalized == 'write' ||
|
||||
normalized == 'execute' ||
|
||||
normalized == 'completed';
|
||||
}
|
||||
|
||||
String _normalizeToolName(String title) {
|
||||
final knownName = _findBuzzToolName(title, true);
|
||||
if (knownName != null) return knownName;
|
||||
|
||||
final normalized = _normalizeToolNameText(
|
||||
title,
|
||||
).replaceAll(RegExp(r'^buzz_'), '');
|
||||
return RegExp(r'[a-z][a-z0-9_]+').firstMatch(normalized)?[0] ?? normalized;
|
||||
}
|
||||
|
||||
ToolStatus _normalizeToolStatus(String status) {
|
||||
final normalized = status.toLowerCase();
|
||||
if (normalized.contains('complete') ||
|
||||
normalized.contains('success') ||
|
||||
normalized == 'done') {
|
||||
return ToolStatus.completed;
|
||||
}
|
||||
if (normalized.contains('fail') || normalized.contains('error')) {
|
||||
return ToolStatus.failed;
|
||||
}
|
||||
if (normalized.contains('pending')) {
|
||||
return ToolStatus.pending;
|
||||
}
|
||||
return ToolStatus.executing;
|
||||
}
|
||||
|
||||
String _extractContentText(dynamic value) {
|
||||
if (value is String) return value;
|
||||
if (value is List) return value.map(_extractBlockText).join('\n');
|
||||
return _extractBlockText(value);
|
||||
}
|
||||
|
||||
String _extractBlockText(dynamic value) {
|
||||
if (value is String) return value;
|
||||
if (value is List) return value.map(_extractBlockText).join('\n');
|
||||
final record = _asRecord(value);
|
||||
final nestedContent = record['content'];
|
||||
final rawOutput = record['rawOutput'];
|
||||
final nestedText = nestedContent != null && nestedContent is! String
|
||||
? _extractBlockText(nestedContent)
|
||||
: '';
|
||||
final rawOutputText = rawOutput == null
|
||||
? ''
|
||||
: rawOutput is String
|
||||
? rawOutput
|
||||
: _safeJsonEncode(rawOutput);
|
||||
final directText = _asString(record['text']) ?? _asString(record['content']);
|
||||
if (directText != null && directText.isNotEmpty) return directText;
|
||||
if (nestedText.isNotEmpty) return nestedText;
|
||||
if (rawOutputText.isNotEmpty) return rawOutputText;
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractPromptText(Map<String, dynamic> payload) {
|
||||
final params = _asRecord(payload['params']);
|
||||
final prompt = params['prompt'];
|
||||
if (prompt is! List) return '';
|
||||
return (prompt).map(_extractBlockText).where((s) => s.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
({List<PromptSection> sections, String userText, String userTitle})
|
||||
_parsePromptText(String text) {
|
||||
final sections = _parsePromptSections(text);
|
||||
if (sections.isEmpty) {
|
||||
return (
|
||||
sections: <PromptSection>[],
|
||||
userText: text.trim(),
|
||||
userTitle: 'Prompt',
|
||||
);
|
||||
}
|
||||
|
||||
PromptSection? eventSection;
|
||||
for (final section in sections) {
|
||||
if (section.title.toLowerCase().startsWith('buzz event')) {
|
||||
eventSection = section;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final eventContent = eventSection != null
|
||||
? _extractEventContent(eventSection.body)
|
||||
: '';
|
||||
final eventKind = eventSection?.title.split(':').skip(1).join(':').trim();
|
||||
|
||||
return (
|
||||
sections: sections,
|
||||
userText: eventContent,
|
||||
userTitle: eventKind != null && eventKind.isNotEmpty
|
||||
? _titleCase(eventKind)
|
||||
: 'Buzz event',
|
||||
);
|
||||
}
|
||||
|
||||
List<PromptSection> _parsePromptSections(String text) {
|
||||
final sections = <PromptSection>[];
|
||||
final headerPattern = RegExp(r'^\[([^\]]+)]\s*$');
|
||||
String? currentTitle;
|
||||
final currentBody = StringBuffer();
|
||||
final preamble = <String>[];
|
||||
|
||||
for (final line in text.split(RegExp(r'\r?\n'))) {
|
||||
final header = headerPattern.firstMatch(line);
|
||||
if (header != null) {
|
||||
if (currentTitle != null) {
|
||||
sections.add(
|
||||
PromptSection(
|
||||
title: currentTitle,
|
||||
body: currentBody.toString().trim(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final pre = preamble.join('\n').trim();
|
||||
if (pre.isNotEmpty) {
|
||||
sections.add(PromptSection(title: 'Prompt', body: pre));
|
||||
}
|
||||
}
|
||||
currentTitle = header.group(1)!;
|
||||
currentBody.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTitle != null) {
|
||||
if (currentBody.isNotEmpty) currentBody.write('\n');
|
||||
currentBody.write(line);
|
||||
} else {
|
||||
preamble.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTitle != null) {
|
||||
sections.add(
|
||||
PromptSection(title: currentTitle, body: currentBody.toString().trim()),
|
||||
);
|
||||
} else {
|
||||
final pre = preamble.join('\n').trim();
|
||||
if (pre.isNotEmpty) {
|
||||
sections.add(PromptSection(title: 'Prompt', body: pre));
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
String _extractEventContent(String body) {
|
||||
final match = RegExp(r'^Content:\s*(.*)$', multiLine: true).firstMatch(body);
|
||||
return match?.group(1)?.trim() ?? '';
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractToolArgs(Map<String, dynamic> update) {
|
||||
final candidates = [
|
||||
update['args'],
|
||||
update['arguments'],
|
||||
update['input'],
|
||||
update['rawInput'],
|
||||
];
|
||||
for (final candidate in candidates) {
|
||||
if (candidate is Map && candidate is! List) {
|
||||
return Map<String, dynamic>.from(candidate);
|
||||
}
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
({String title, String toolName, String? buzzToolName}) _extractToolIdentity(
|
||||
Map<String, dynamic> update,
|
||||
) {
|
||||
final candidates = _collectToolNameCandidates(update);
|
||||
String? knownName;
|
||||
for (final c in candidates) {
|
||||
knownName = _findBuzzToolName(c, true);
|
||||
if (knownName != null) break;
|
||||
}
|
||||
knownName ??= _findBuzzToolName(_safeJsonEncode(update), false);
|
||||
String? firstSpecific;
|
||||
for (final candidate in candidates) {
|
||||
if (!_isGenericToolTitle(candidate)) {
|
||||
firstSpecific = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final title =
|
||||
_asString(update['title']) ?? knownName ?? firstSpecific ?? 'Tool call';
|
||||
return (
|
||||
title: title,
|
||||
toolName: knownName ?? _normalizeToolName(firstSpecific ?? title),
|
||||
buzzToolName: knownName,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _collectToolNameCandidates(Map<String, dynamic> update) {
|
||||
final args = _extractToolArgs(update);
|
||||
final tool = _asRecord(update['tool']);
|
||||
final input = _asRecord(update['input']);
|
||||
final rawInput = _asRecord(update['rawInput']);
|
||||
final sources = <dynamic>[
|
||||
update['toolName'],
|
||||
update['tool_name'],
|
||||
update['name'],
|
||||
update['title'],
|
||||
update['kind'],
|
||||
tool['name'],
|
||||
tool['toolName'],
|
||||
args['toolName'],
|
||||
args['tool_name'],
|
||||
args['name'],
|
||||
args['method'],
|
||||
input['toolName'],
|
||||
input['tool_name'],
|
||||
input['name'],
|
||||
rawInput['toolName'],
|
||||
rawInput['tool_name'],
|
||||
rawInput['name'],
|
||||
];
|
||||
return [
|
||||
for (final s in sources)
|
||||
if (s is String && s.isNotEmpty) s,
|
||||
];
|
||||
}
|
||||
|
||||
String _extractToolResult(Map<String, dynamic> update) {
|
||||
final contentText = _extractContentText(update['content']);
|
||||
if (contentText.isNotEmpty) return contentText;
|
||||
return _extractBlockText(update['rawOutput']);
|
||||
}
|
||||
|
||||
String _describeTurnStarted(dynamic payload) {
|
||||
final record = _asRecord(payload);
|
||||
final ids = record['triggeringEventIds'];
|
||||
if (ids is List) {
|
||||
final stringIds = ids.whereType<String>().toList();
|
||||
if (stringIds.isNotEmpty) {
|
||||
return 'Triggered by ${stringIds.map(_shorten).join(', ')}.';
|
||||
}
|
||||
}
|
||||
return 'Heartbeat or internal turn.';
|
||||
}
|
||||
|
||||
String _describeSessionResolved(dynamic payload) {
|
||||
final record = _asRecord(payload);
|
||||
final sessionId = _asString(record['sessionId']);
|
||||
final isNewSession = record['isNewSession'] == true;
|
||||
if (sessionId == null) return 'Using existing ACP session.';
|
||||
return '${isNewSession ? 'Created' : 'Using'} session ${_shorten(sessionId)}.';
|
||||
}
|
||||
|
||||
String _safeJsonEncode(dynamic value) {
|
||||
try {
|
||||
return jsonEncode(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
List<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
|
||||
final items = <TranscriptItem>[];
|
||||
final itemsById = <String, TranscriptItem>{};
|
||||
|
||||
// Maps a logical message ID to the actual key currently being appended to.
|
||||
final activeMessageKey = <String, String>{};
|
||||
final sealedKeys = <String>{};
|
||||
var continuationSeq = 0;
|
||||
|
||||
void sealOpenMessages() {
|
||||
for (final currentKey in activeMessageKey.values) {
|
||||
sealedKeys.add(currentKey);
|
||||
}
|
||||
}
|
||||
|
||||
void upsertMessage(
|
||||
String id,
|
||||
String role,
|
||||
String title,
|
||||
String text,
|
||||
String timestamp,
|
||||
) {
|
||||
final currentKey = activeMessageKey[id];
|
||||
|
||||
if (currentKey != null && !sealedKeys.contains(currentKey)) {
|
||||
final existing = itemsById[currentKey];
|
||||
if (existing is MessageItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
continuationSeq += 1;
|
||||
final newKey = currentKey != null ? '$id:c$continuationSeq' : id;
|
||||
final item = MessageItem(
|
||||
id: newKey,
|
||||
role: role,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[newKey] = item;
|
||||
activeMessageKey[id] = newKey;
|
||||
}
|
||||
|
||||
void upsertTextItem(
|
||||
String id,
|
||||
String type,
|
||||
String title,
|
||||
String text,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
if (existing != null) {
|
||||
if (type == 'thought' && existing is ThoughtItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
if (type == 'lifecycle' && existing is LifecycleItem) {
|
||||
existing.text += text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
sealOpenMessages();
|
||||
final TranscriptItem item;
|
||||
if (type == 'thought') {
|
||||
item = ThoughtItem(
|
||||
id: id,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
} else {
|
||||
item = LifecycleItem(
|
||||
id: id,
|
||||
title: title,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
void upsertMetadata(
|
||||
String id,
|
||||
String title,
|
||||
List<PromptSection> sections,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
if (existing is MetadataItem) {
|
||||
existing.sections = sections;
|
||||
return;
|
||||
}
|
||||
sealOpenMessages();
|
||||
final item = MetadataItem(
|
||||
id: id,
|
||||
title: title,
|
||||
sections: sections,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
void upsertTool(
|
||||
String id,
|
||||
String title,
|
||||
String toolName,
|
||||
String? buzzToolName,
|
||||
ToolStatus status,
|
||||
Map<String, dynamic> args,
|
||||
String result,
|
||||
bool isError,
|
||||
String timestamp,
|
||||
) {
|
||||
final existing = itemsById[id];
|
||||
final canonicalBuzzToolName =
|
||||
buzzToolName ?? _findBuzzToolName(toolName, true);
|
||||
if (existing is ToolItem) {
|
||||
if (!_isGenericToolTitle(title)) {
|
||||
existing.title = title;
|
||||
}
|
||||
if (canonicalBuzzToolName != null) {
|
||||
existing.buzzToolName = canonicalBuzzToolName;
|
||||
existing.toolName = canonicalBuzzToolName;
|
||||
} else if (existing.buzzToolName == null &&
|
||||
!_isGenericToolTitle(toolName)) {
|
||||
existing.toolName = toolName;
|
||||
}
|
||||
existing.status = status;
|
||||
existing.args = args.isNotEmpty ? args : existing.args;
|
||||
if (result.isNotEmpty) existing.result = result;
|
||||
existing.isError = isError || existing.isError;
|
||||
return;
|
||||
}
|
||||
sealOpenMessages();
|
||||
final item = ToolItem(
|
||||
id: id,
|
||||
title: title,
|
||||
toolName: canonicalBuzzToolName ?? toolName,
|
||||
buzzToolName: canonicalBuzzToolName,
|
||||
status: status,
|
||||
args: args,
|
||||
result: result,
|
||||
isError: isError,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
items.add(item);
|
||||
itemsById[id] = item;
|
||||
}
|
||||
|
||||
for (final event in events) {
|
||||
if (event.kind == 'turn_started') {
|
||||
upsertTextItem(
|
||||
'turn:${event.turnId ?? '${event.seq}'}',
|
||||
'lifecycle',
|
||||
'Turn started',
|
||||
_describeTurnStarted(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == 'session_resolved') {
|
||||
upsertTextItem(
|
||||
'session:${event.turnId ?? '${event.seq}'}',
|
||||
'lifecycle',
|
||||
'Session ready',
|
||||
_describeSessionResolved(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind == 'acp_parse_error') {
|
||||
upsertTextItem(
|
||||
'parse-error:${event.seq}',
|
||||
'lifecycle',
|
||||
'Wire parse error',
|
||||
_extractBlockText(event.payload),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind != 'acp_read' && event.kind != 'acp_write') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final payload = _asRecord(event.payload);
|
||||
final method = _asString(payload['method']);
|
||||
|
||||
if (event.kind == 'acp_write' && method == 'session/prompt') {
|
||||
final promptText = _extractPromptText(payload);
|
||||
if (promptText.isNotEmpty) {
|
||||
final parsedPrompt = _parsePromptText(promptText);
|
||||
if (parsedPrompt.userText.isNotEmpty) {
|
||||
upsertMessage(
|
||||
'prompt:${event.turnId ?? '${event.seq}'}',
|
||||
'user',
|
||||
parsedPrompt.userTitle,
|
||||
parsedPrompt.userText,
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
if (parsedPrompt.sections.isNotEmpty) {
|
||||
upsertMetadata(
|
||||
'prompt-context:${event.turnId ?? '${event.seq}'}',
|
||||
'Prompt context',
|
||||
parsedPrompt.sections,
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind != 'acp_read' || method != 'session/update') {
|
||||
continue;
|
||||
}
|
||||
|
||||
final params = _asRecord(payload['params']);
|
||||
final update = _asRecord(params['update']);
|
||||
final updateType = _asString(update['sessionUpdate']) ?? 'unknown';
|
||||
final turnKey = event.turnId ?? event.sessionId ?? 'unknown';
|
||||
final messageId = _asString(update['messageId']);
|
||||
|
||||
if (updateType == 'agent_message_chunk') {
|
||||
upsertMessage(
|
||||
'assistant:${messageId ?? turnKey}',
|
||||
'assistant',
|
||||
'Assistant',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'user_message_chunk') {
|
||||
upsertMessage(
|
||||
'user:${messageId ?? turnKey}',
|
||||
'user',
|
||||
'User',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'agent_thought_chunk') {
|
||||
upsertTextItem(
|
||||
'thinking:${messageId ?? turnKey}',
|
||||
'thought',
|
||||
'Thinking',
|
||||
_extractContentText(update['content']),
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'tool_call') {
|
||||
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
|
||||
final identity = _extractToolIdentity(update);
|
||||
upsertTool(
|
||||
'tool:$toolId',
|
||||
identity.title,
|
||||
identity.toolName,
|
||||
identity.buzzToolName,
|
||||
_normalizeToolStatus(_asString(update['status']) ?? 'executing'),
|
||||
_extractToolArgs(update),
|
||||
_extractToolResult(update),
|
||||
false,
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'tool_call_update') {
|
||||
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
|
||||
final status = _normalizeToolStatus(
|
||||
_asString(update['status']) ?? 'completed',
|
||||
);
|
||||
final identity = _extractToolIdentity(update);
|
||||
upsertTool(
|
||||
'tool:$toolId',
|
||||
identity.title,
|
||||
identity.toolName,
|
||||
identity.buzzToolName,
|
||||
status,
|
||||
_extractToolArgs(update),
|
||||
_extractToolResult(update),
|
||||
status == ToolStatus.failed,
|
||||
event.timestamp,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateType == 'plan') {
|
||||
final content = _extractContentText(update['content']);
|
||||
upsertTextItem(
|
||||
'plan:$turnKey',
|
||||
'thought',
|
||||
'Plan',
|
||||
content.isNotEmpty ? content : _safeJsonEncode(update),
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/l10n/l10n.dart';
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import 'observer_models.dart';
|
||||
|
||||
/// Renders a single [TranscriptItem] in the agent activity transcript.
|
||||
class TranscriptItemWidget extends StatelessWidget {
|
||||
final TranscriptItem item;
|
||||
|
||||
const TranscriptItemWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (item) {
|
||||
final MessageItem i => _MessageItemWidget(item: i),
|
||||
final ThoughtItem i => _ThoughtItemWidget(item: i),
|
||||
final LifecycleItem i => _LifecycleItemWidget(item: i),
|
||||
final MetadataItem i => _MetadataItemWidget(item: i),
|
||||
final ToolItem i => _ToolItemWidget(item: i),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageItemWidget extends StatelessWidget {
|
||||
final MessageItem item;
|
||||
|
||||
const _MessageItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAssistant = item.role == 'assistant';
|
||||
final badgeColor = isAssistant
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant;
|
||||
final badgeLabel = isAssistant
|
||||
? context.l10n.transcriptAssistant
|
||||
: context.l10n.transcriptUser;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
if (item.text.isNotEmpty)
|
||||
GptMarkdown(
|
||||
item.text,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThoughtItemWidget extends HookWidget {
|
||||
final ThoughtItem item;
|
||||
|
||||
const _ThoughtItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = useState(item.text.length <= 200);
|
||||
|
||||
useEffect(() {
|
||||
expanded.value = item.text.length <= 200;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () => expanded.value = !expanded.value,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.brain,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_localizedTranscriptLabel(context, item.title),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expanded.value && item.text.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GptMarkdown(
|
||||
item.text,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LifecycleItemWidget extends StatelessWidget {
|
||||
final LifecycleItem item;
|
||||
|
||||
const _LifecycleItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizedText = _localizedTranscriptText(context, item.text);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${_localizedTranscriptLabel(context, item.title)}'
|
||||
'${localizedText.isNotEmpty ? ' \u2014 $localizedText' : ''}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataItemWidget extends HookWidget {
|
||||
final MetadataItem item;
|
||||
|
||||
const _MetadataItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
expanded.value = false;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () => expanded.value = !expanded.value,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.fileText,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_localizedTranscriptLabel(context, item.title)} '
|
||||
'(${context.l10n.transcriptSections(item.sections.length)})',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
expanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expanded.value)
|
||||
for (final section in item.sections) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
_localizedTranscriptLabel(context, section.title),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (section.body.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Text(
|
||||
section.body.length > 500
|
||||
? '${section.body.substring(0, 500)}\u2026'
|
||||
: section.body,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolItemWidget extends HookWidget {
|
||||
final ToolItem item;
|
||||
|
||||
const _ToolItemWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final argsExpanded = useState(false);
|
||||
final resultExpanded = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
argsExpanded.value = false;
|
||||
resultExpanded.value = false;
|
||||
return null;
|
||||
}, [item.id]);
|
||||
|
||||
final (statusColor, statusLabel, statusIcon) = _toolStatusDisplay(
|
||||
item.status,
|
||||
item.isError,
|
||||
context,
|
||||
);
|
||||
|
||||
final displayName = _formatToolName(item.toolName);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: item.isError
|
||||
? Border.all(color: context.colors.error.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: tool name + status badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.wrench, size: 14, color: statusColor),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(statusIcon, size: 10, color: statusColor),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text(
|
||||
statusLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Args section
|
||||
if (item.args.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GestureDetector(
|
||||
onTap: () => argsExpanded.value = !argsExpanded.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.transcriptArguments,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
argsExpanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (argsExpanded.value) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
_CodeBlock(text: _prettyJson(item.args)),
|
||||
],
|
||||
],
|
||||
// Result section
|
||||
if (item.result.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
GestureDetector(
|
||||
onTap: () => resultExpanded.value = !resultExpanded.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.transcriptResult,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: item.isError
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
resultExpanded.value
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: item.isError
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (resultExpanded.value) ...[
|
||||
const SizedBox(height: Grid.quarter),
|
||||
_CodeBlock(
|
||||
text: item.result.length > 2000
|
||||
? '${item.result.substring(0, 2000)}\n\n'
|
||||
'${context.l10n.transcriptTruncated}'
|
||||
: item.result,
|
||||
isError: item.isError,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(Color, String, IconData) _toolStatusDisplay(
|
||||
ToolStatus status,
|
||||
bool isError,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (isError || status == ToolStatus.failed) {
|
||||
return (
|
||||
context.colors.error,
|
||||
context.l10n.transcriptError,
|
||||
LucideIcons.circleX,
|
||||
);
|
||||
}
|
||||
if (status == ToolStatus.completed) {
|
||||
return (
|
||||
context.appColors.success,
|
||||
context.l10n.transcriptDone,
|
||||
LucideIcons.circleCheck,
|
||||
);
|
||||
}
|
||||
if (status == ToolStatus.pending) {
|
||||
return (
|
||||
context.colors.onSurfaceVariant,
|
||||
context.l10n.transcriptPending,
|
||||
LucideIcons.circleDot,
|
||||
);
|
||||
}
|
||||
return (
|
||||
context.appColors.warning,
|
||||
context.l10n.transcriptRunning,
|
||||
LucideIcons.clock3,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatToolName(String toolName) {
|
||||
return toolName
|
||||
.split('_')
|
||||
.map(
|
||||
(part) =>
|
||||
part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
String _localizedTranscriptLabel(BuildContext context, String label) {
|
||||
final l10n = context.l10n;
|
||||
return switch (label) {
|
||||
'Assistant' => l10n.transcriptAssistant,
|
||||
'Base' => l10n.transcriptBase,
|
||||
'Buzz event' => l10n.transcriptBuzzEvent,
|
||||
'Channel Canvas' => l10n.transcriptChannelCanvas,
|
||||
'Core Memory' => l10n.transcriptCoreMemory,
|
||||
'Plan' => l10n.transcriptPlan,
|
||||
'Prompt' => l10n.prompt,
|
||||
'Prompt context' => l10n.transcriptPromptContext,
|
||||
'Session ready' => l10n.transcriptSessionReady,
|
||||
'System' => l10n.transcriptSystem,
|
||||
'System prompt' => l10n.transcriptSystemPrompt,
|
||||
'Team Instructions' => l10n.transcriptTeamInstructions,
|
||||
'Thinking' => l10n.transcriptThinking,
|
||||
'Tool call' => l10n.transcriptToolCall,
|
||||
'Turn started' => l10n.transcriptTurnStarted,
|
||||
'User' => l10n.transcriptUser,
|
||||
'Wire parse error' => l10n.transcriptWireParseError,
|
||||
_ => label,
|
||||
};
|
||||
}
|
||||
|
||||
String _localizedTranscriptText(BuildContext context, String text) {
|
||||
final l10n = context.l10n;
|
||||
if (text == 'Heartbeat or internal turn.') {
|
||||
return l10n.transcriptInternalTurn;
|
||||
}
|
||||
if (text == 'Using existing ACP session.') {
|
||||
return l10n.transcriptUsingExistingSession;
|
||||
}
|
||||
if (text.startsWith('Triggered by ') && text.endsWith('.')) {
|
||||
return l10n.transcriptTriggeredBy(
|
||||
text.substring('Triggered by '.length, text.length - 1),
|
||||
);
|
||||
}
|
||||
if (text.startsWith('Created session ') && text.endsWith('.')) {
|
||||
return l10n.transcriptCreatedSession(
|
||||
text.substring('Created session '.length, text.length - 1),
|
||||
);
|
||||
}
|
||||
if (text.startsWith('Using session ') && text.endsWith('.')) {
|
||||
return l10n.transcriptUsingSession(
|
||||
text.substring('Using session '.length, text.length - 1),
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
String _prettyJson(Map<String, dynamic> value) {
|
||||
try {
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeBlock extends StatelessWidget {
|
||||
final String text;
|
||||
final bool isError;
|
||||
|
||||
const _CodeBlock({required this.text, this.isError = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: isError
|
||||
? context.colors.error.withValues(alpha: 0.06)
|
||||
: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
text,
|
||||
softWrap: false,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: isError
|
||||
? context.colors.error
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../channel_management_provider.dart';
|
||||
import '../channel_typing_provider.dart';
|
||||
|
||||
/// Derived provider that computes which bot members in a channel are currently
|
||||
/// typing (i.e. "working"). Returns a set of lowercase pubkeys.
|
||||
///
|
||||
/// Used by both the members button badge and the members sheet to avoid
|
||||
/// duplicating the bot-typing cross-reference logic.
|
||||
final workingBotPubkeysProvider = Provider.autoDispose
|
||||
.family<Set<String>, String>((ref, channelId) {
|
||||
final typingEntries = ref.watch(channelTypingProvider(channelId));
|
||||
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
||||
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
|
||||
|
||||
final botPubkeys = <String>{
|
||||
for (final m in allMembers)
|
||||
if (m.isBot) m.pubkey.toLowerCase(),
|
||||
};
|
||||
|
||||
return <String>{
|
||||
for (final e in typingEntries)
|
||||
if (botPubkeys.contains(e.pubkey.toLowerCase()))
|
||||
e.pubkey.toLowerCase(),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user