From bfa5ff636362adb2fbdfc8ba358b027f001b885d Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 25 Sep 2025 18:25:39 +0530 Subject: [PATCH] feat: followups --- lib/core/models/chat_message.dart | 223 ++++++- lib/core/services/api_service.dart | 83 +++ lib/core/services/socket_service.dart | 56 +- lib/core/services/streaming_helper.dart | 325 +++++++++- .../chat/providers/chat_providers.dart | 135 ++++ .../widgets/assistant_message_widget.dart | 595 ++++++++++++++++++ 6 files changed, 1404 insertions(+), 13 deletions(-) diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index 353a42f..e879121 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -1,5 +1,9 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +// Freezed applies JsonKey to constructor parameters which triggers +// invalid_annotation_target; suppress it for this data model file. +// ignore_for_file: invalid_annotation_target + part 'chat_message.freezed.dart'; part 'chat_message.g.dart'; @@ -15,10 +19,227 @@ sealed class ChatMessage with _$ChatMessage { List? attachmentIds, List>? files, // For generated images Map? metadata, - List>? sources, + @Default([]) List statusHistory, + @Default([]) List followUps, + @Default([]) List codeExecutions, + @JsonKey( + name: 'sources', + fromJson: _sourceRefsFromJson, + toJson: _sourceRefsToJson, + ) + @Default([]) + List sources, Map? usage, }) = _ChatMessage; factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); } + +@freezed +abstract class ChatStatusUpdate with _$ChatStatusUpdate { + const factory ChatStatusUpdate({ + String? action, + String? description, + bool? done, + bool? hidden, + int? count, + String? query, + @JsonKey(fromJson: _safeStringList, toJson: _stringListToJson) + @Default([]) + List queries, + @JsonKey(fromJson: _safeStringList, toJson: _stringListToJson) + @Default([]) + List urls, + @JsonKey(fromJson: _statusItemsFromJson, toJson: _statusItemsToJson) + @Default([]) + List items, + @JsonKey( + name: 'timestamp', + fromJson: _timestampFromJson, + toJson: _timestampToJson, + ) + DateTime? occurredAt, + }) = _ChatStatusUpdate; + + factory ChatStatusUpdate.fromJson(Map json) => + _$ChatStatusUpdateFromJson(json); +} + +@freezed +abstract class ChatStatusItem with _$ChatStatusItem { + const factory ChatStatusItem({ + String? title, + String? link, + String? snippet, + Map? metadata, + }) = _ChatStatusItem; + + factory ChatStatusItem.fromJson(Map json) => + _$ChatStatusItemFromJson(json); +} + +@freezed +abstract class ChatCodeExecution with _$ChatCodeExecution { + const factory ChatCodeExecution({ + @JsonKey(fromJson: _requiredString) required String id, + @JsonKey(fromJson: _nullableString) String? name, + @JsonKey(fromJson: _nullableString) String? language, + @JsonKey(fromJson: _nullableString) String? code, + ChatCodeExecutionResult? result, + Map? metadata, + }) = _ChatCodeExecution; + + factory ChatCodeExecution.fromJson(Map json) => + _$ChatCodeExecutionFromJson(json); +} + +@freezed +abstract class ChatCodeExecutionResult with _$ChatCodeExecutionResult { + const factory ChatCodeExecutionResult({ + String? output, + String? error, + @JsonKey(fromJson: _executionFilesFromJson, toJson: _executionFilesToJson) + @Default([]) + List files, + Map? metadata, + }) = _ChatCodeExecutionResult; + + factory ChatCodeExecutionResult.fromJson(Map json) => + _$ChatCodeExecutionResultFromJson(json); +} + +@freezed +abstract class ChatExecutionFile with _$ChatExecutionFile { + const factory ChatExecutionFile({ + @JsonKey(fromJson: _nullableString) String? name, + @JsonKey(fromJson: _nullableString) String? url, + Map? metadata, + }) = _ChatExecutionFile; + + factory ChatExecutionFile.fromJson(Map json) => + _$ChatExecutionFileFromJson(json); +} + +@freezed +abstract class ChatSourceReference with _$ChatSourceReference { + const factory ChatSourceReference({ + @JsonKey(fromJson: _nullableString) String? id, + @JsonKey(fromJson: _nullableString) String? title, + @JsonKey(fromJson: _nullableString) String? url, + @JsonKey(fromJson: _nullableString) String? snippet, + @JsonKey(fromJson: _nullableString) String? type, + Map? metadata, + }) = _ChatSourceReference; + + factory ChatSourceReference.fromJson(Map json) => + _$ChatSourceReferenceFromJson(json); +} + +List _safeStringList(dynamic value) { + if (value is List) { + return value + .whereType() + .map((e) => e?.toString().trim() ?? '') + .where((s) => s.isNotEmpty) + .toList(growable: false); + } + if (value is String && value.isNotEmpty) { + return [value]; + } + return const []; +} + +List _stringListToJson(List value) => + List.from(value, growable: false); + +List _statusItemsFromJson(dynamic value) { + if (value is List) { + return value + .whereType() + .map( + (item) => ChatStatusItem.fromJson( + item.map((key, v) => MapEntry(key.toString(), v)), + ), + ) + .toList(growable: false); + } + return const []; +} + +List> _statusItemsToJson(List value) { + return value.map((item) => item.toJson()).toList(growable: false); +} + +List _executionFilesFromJson(dynamic value) { + if (value is List) { + return value + .whereType() + .map( + (item) => ChatExecutionFile.fromJson( + item.map((key, v) => MapEntry(key.toString(), v)), + ), + ) + .toList(growable: false); + } + return const []; +} + +List> _executionFilesToJson( + List files, +) { + return files.map((file) => file.toJson()).toList(growable: false); +} + +List _sourceRefsFromJson(dynamic value) { + if (value is List) { + return value + .whereType() + .map( + (item) => ChatSourceReference.fromJson( + item.map((key, v) => MapEntry(key.toString(), v)), + ), + ) + .toList(growable: false); + } + return const []; +} + +List> _sourceRefsToJson( + List references, +) { + return references.map((ref) => ref.toJson()).toList(growable: false); +} + +DateTime? _timestampFromJson(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is int) { + // Heuristics: treat seconds vs milliseconds + final isSeconds = value < 1000000000000; + final millis = isSeconds ? value * 1000 : value; + return DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true).toLocal(); + } + if (value is double) { + final millis = value < 1000000000 ? (value * 1000).toInt() : value.toInt(); + return DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true).toLocal(); + } + if (value is String && value.isNotEmpty) { + return DateTime.tryParse(value)?.toLocal(); + } + return null; +} + +String? _timestampToJson(DateTime? value) => value?.toIso8601String(); + +String _requiredString(dynamic value, {String fallback = ''}) { + if (value == null) return fallback; + final str = value.toString(); + return str.isEmpty ? fallback : str; +} + +String? _nullableString(dynamic value) { + if (value == null) return null; + final str = value.toString(); + return str.isEmpty ? null : str; +} diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 648a38c..7d9119c 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -838,6 +838,29 @@ class ApiService { files = allFiles.isNotEmpty ? allFiles : null; } + final dynamic statusRaw = + historyMsg != null && historyMsg.containsKey('statusHistory') + ? historyMsg['statusHistory'] + : msgData['statusHistory']; + final statusHistory = _parseStatusHistoryField(statusRaw); + + final dynamic followUpsRaw = + historyMsg != null && historyMsg.containsKey('followUps') + ? historyMsg['followUps'] + : msgData['followUps'] ?? msgData['follow_ups']; + final followUps = _parseFollowUpsField(followUpsRaw); + + final dynamic codeExecRaw = historyMsg != null + ? (historyMsg['code_executions'] ?? historyMsg['codeExecutions']) + : (msgData['code_executions'] ?? msgData['codeExecutions']); + final codeExecutions = _parseCodeExecutionsField(codeExecRaw); + + final dynamic sourcesRaw = + historyMsg != null && historyMsg.containsKey('sources') + ? historyMsg['sources'] + : msgData['sources']; + final sources = _parseSourcesField(sourcesRaw); + return ChatMessage( id: msgData['id']?.toString() ?? uuid.v4(), role: role, @@ -846,6 +869,10 @@ class ApiService { model: msgData['model'] as String?, attachmentIds: attachmentIds, files: files, + statusHistory: statusHistory, + followUps: followUps, + codeExecutions: codeExecutions, + sources: sources, ); } @@ -1029,6 +1056,62 @@ class ApiService { return buf.toString().trim(); } + List _parseStatusHistoryField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map( + (entry) => ChatStatusUpdate.fromJson( + entry.map((key, value) => MapEntry(key.toString(), value)), + ), + ) + .toList(growable: false); + } + return const []; + } + + List _parseFollowUpsField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((value) => value?.toString().trim() ?? '') + .where((value) => value.isNotEmpty) + .toList(growable: false); + } + if (raw is String && raw.trim().isNotEmpty) { + return [raw.trim()]; + } + return const []; + } + + List _parseCodeExecutionsField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map( + (entry) => ChatCodeExecution.fromJson( + entry.map((key, value) => MapEntry(key.toString(), value)), + ), + ) + .toList(growable: false); + } + return const []; + } + + List _parseSourcesField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map( + (entry) => ChatSourceReference.fromJson( + entry.map((key, value) => MapEntry(key.toString(), value)), + ), + ) + .toList(growable: false); + } + return const []; + } + // Create new conversation using OpenWebUI API Future createConversation({ required String title, diff --git a/lib/core/services/socket_service.dart b/lib/core/services/socket_service.dart index 6c6f422..77b5ede 100644 --- a/lib/core/services/socket_service.dart +++ b/lib/core/services/socket_service.dart @@ -142,27 +142,67 @@ class SocketService { } } - void onChatEvents(void Function(Map event) handler) { - _socket?.on('chat-events', (data) { + void onChatEvents( + void Function( + Map event, + void Function(dynamic response)? ack, + ) + handler, + ) { + _socket?.on('chat-events', (dynamic data, [dynamic ack]) { try { + Map? map; if (data is Map) { - handler(data); + map = data; } else if (data is Map) { - handler(Map.from(data)); + map = Map.from(data); } + if (map == null) return; + final ackFn = ack is Function + ? (dynamic payload) { + if (payload is List) { + Function.apply(ack, payload); + } else if (payload == null) { + Function.apply(ack, const []); + } else { + Function.apply(ack, [payload]); + } + } + : null; + handler(map, ackFn); } catch (_) {} }); } // Subscribe to general channel events (server-broadcasted channel updates) - void onChannelEvents(void Function(Map event) handler) { - _socket?.on('channel-events', (data) { + void onChannelEvents( + void Function( + Map event, + void Function(dynamic response)? ack, + ) + handler, + ) { + _socket?.on('channel-events', (dynamic data, [dynamic ack]) { try { + Map? map; if (data is Map) { - handler(data); + map = data; } else if (data is Map) { - handler(Map.from(data)); + map = Map.from(data); } + if (map == null) return; + final ackFn = ack is Function + ? (dynamic payload) { + if (payload is List) { + Function.apply(ack, payload); + } else if (payload == null) { + Function.apply(ack, const []); + } else { + Function.apply(ack, [payload]); + } + } + : null; + handler(map, ackFn); } catch (_) {} }); } diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index debe2c2..c368c7d 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import '../../core/models/chat_message.dart'; import '../../core/services/persistent_streaming_service.dart'; @@ -9,6 +9,9 @@ import '../../core/services/socket_service.dart'; import '../../core/utils/inactivity_watchdog.dart'; import '../../core/utils/stream_chunker.dart'; import '../../core/utils/tool_calls_parser.dart'; +import 'navigation_service.dart'; +import '../../shared/widgets/themed_dialogs.dart'; +import '../../shared/theme/theme_extensions.dart'; // Keep local verbosity toggle for socket logs const bool kSocketVerboseLogging = false; @@ -36,6 +39,20 @@ StreamSubscription attachUnifiedChunkedStreaming({ required void Function(String) replaceLastMessageContent, required void Function(ChatMessage Function(ChatMessage)) updateLastMessageWith, + required void Function(String messageId, ChatStatusUpdate update) + appendStatusUpdate, + required void Function(String messageId, List followUps) setFollowUps, + required void Function(String messageId, ChatCodeExecution execution) + upsertCodeExecution, + required void Function(String messageId, ChatSourceReference reference) + appendSourceReference, + required void Function( + String messageId, + ChatMessage Function(ChatMessage current), + ) + updateMessageById, + void Function(String newTitle)? onChatTitleUpdated, + void Function()? onChatTagsUpdated, required void Function() finishStreaming, required List Function() getMessages, }) { @@ -330,12 +347,16 @@ StreamSubscription attachUnifiedChunkedStreaming({ }); } - void chatHandler(Map ev) { + void chatHandler( + Map ev, + void Function(dynamic response)? ack, + ) { try { final data = ev['data']; if (data == null) return; final type = data['type']; final payload = data['data']; + final messageId = ev['message_id']?.toString(); socketWatchdog?.ping(); if (type == 'chat:completion' && payload != null) { @@ -504,6 +525,121 @@ StreamSubscription attachUnifiedChunkedStreaming({ socketWatchdog?.stop(); } } + } else if (type == 'status' && payload != null) { + final statusMap = _asStringMap(payload); + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (statusMap != null && targetId != null) { + try { + final statusUpdate = ChatStatusUpdate.fromJson(statusMap); + appendStatusUpdate(targetId, statusUpdate); + updateMessageById(targetId, (current) { + final metadata = { + ...?current.metadata, + 'status': statusUpdate.toJson(), + }; + return current.copyWith(metadata: metadata); + }); + } catch (_) {} + } + } else if (type == 'chat:tasks:cancel') { + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (targetId != null) { + updateMessageById(targetId, (current) { + final metadata = {...?current.metadata, 'tasksCancelled': true}; + return current.copyWith(metadata: metadata, isStreaming: false); + }); + } + finishStreaming(); + } else if (type == 'chat:message:follow_ups' && payload != null) { + final followMap = _asStringMap(payload); + if (followMap != null) { + final followUpsRaw = + followMap['follow_ups'] ?? followMap['followUps']; + final suggestions = _parseFollowUpsField(followUpsRaw); + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (targetId != null) { + setFollowUps(targetId, suggestions); + updateMessageById(targetId, (current) { + final metadata = {...?current.metadata, 'followUps': suggestions}; + return current.copyWith(metadata: metadata); + }); + } + } + } else if (type == 'chat:title' && payload != null) { + final title = payload.toString(); + if (title.isNotEmpty) { + onChatTitleUpdated?.call(title); + } + } else if (type == 'chat:tags') { + onChatTagsUpdated?.call(); + } else if ((type == 'source' || type == 'citation') && payload != null) { + final map = _asStringMap(payload); + if (map != null) { + if (map['type']?.toString() == 'code_execution') { + try { + final exec = ChatCodeExecution.fromJson(map); + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (targetId != null) { + upsertCodeExecution(targetId, exec); + } + } catch (_) {} + } else { + try { + final source = ChatSourceReference.fromJson(map); + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (targetId != null) { + appendSourceReference(targetId, source); + } + } catch (_) {} + } + } + } else if (type == 'notification' && payload != null) { + final map = _asStringMap(payload); + if (map != null) { + final notifType = map['type']?.toString() ?? 'info'; + final content = map['content']?.toString() ?? ''; + _showSocketNotification(notifType, content); + } + } else if (type == 'confirmation' && payload != null) { + if (ack != null) { + final map = _asStringMap(payload); + if (map != null) { + () async { + final confirmed = await _showConfirmationDialog(map); + try { + ack(confirmed); + } catch (_) {} + }(); + } else { + ack(false); + } + } + } else if (type == 'execute' && payload != null) { + if (ack != null) { + final map = _asStringMap(payload); + final description = map?['description']?.toString(); + final errorMsg = description?.isNotEmpty == true + ? description! + : 'Client-side execute events are not supported.'; + try { + ack({'error': errorMsg}); + } catch (_) {} + _showSocketNotification('warning', errorMsg); + } + } else if (type == 'input' && payload != null) { + if (ack != null) { + final map = _asStringMap(payload); + if (map != null) { + () async { + final response = await _showInputDialog(map); + try { + ack(response); + } catch (_) {} + }(); + } else { + ack(null); + } + } } else if (type == 'chat:message:error' && payload != null) { // Server reports an error for the current assistant message try { @@ -641,12 +777,20 @@ StreamSubscription attachUnifiedChunkedStreaming({ } } catch (_) {} } else if (type == 'event:status' && payload != null) { - final status = payload['status']?.toString() ?? ''; + final map = _asStringMap(payload); + final status = map?['status']?.toString() ?? ''; if (status.isNotEmpty) { updateLastMessageWith( (m) => m.copyWith(metadata: {...?m.metadata, 'status': status}), ); } + final targetId = _resolveTargetMessageId(messageId, getMessages); + if (map != null && targetId != null) { + try { + final statusUpdate = ChatStatusUpdate.fromJson(map); + appendStatusUpdate(targetId, statusUpdate); + } catch (_) {} + } } else if (type == 'event:tool' && payload != null) { // Accept files from both 'result' and 'files' final files = [ @@ -672,7 +816,10 @@ StreamSubscription attachUnifiedChunkedStreaming({ } catch (_) {} } - void channelEventsHandler(Map ev) { + void channelEventsHandler( + Map ev, + void Function(dynamic response)? ack, + ) { try { final data = ev['data']; if (data == null) return; @@ -851,3 +998,173 @@ List> _extractFilesFromResult(dynamic resp) { } return results; } + +Map? _asStringMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, val) => MapEntry(key.toString(), val)); + } + return null; +} + +String? _resolveTargetMessageId( + String? messageId, + List Function() getMessages, +) { + if (messageId != null && messageId.isNotEmpty) { + return messageId; + } + final messages = getMessages(); + if (messages.isEmpty) { + return null; + } + return messages.last.id; +} + +List _parseFollowUpsField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((value) => value?.toString().trim() ?? '') + .where((value) => value.isNotEmpty) + .toList(growable: false); + } + if (raw is String && raw.trim().isNotEmpty) { + return [raw.trim()]; + } + return const []; +} + +void _showSocketNotification(String type, String content) { + if (content.isEmpty) return; + final ctx = NavigationService.context; + if (ctx == null) return; + final theme = Theme.of(ctx); + Color background; + Color foreground; + switch (type) { + case 'success': + background = theme.colorScheme.primary; + foreground = theme.colorScheme.onPrimary; + break; + case 'error': + background = theme.colorScheme.error; + foreground = theme.colorScheme.onError; + break; + case 'warning': + case 'warn': + background = theme.colorScheme.tertiary; + foreground = theme.colorScheme.onTertiary; + break; + default: + background = theme.colorScheme.secondary; + foreground = theme.colorScheme.onSecondary; + } + + final snackBar = SnackBar( + content: Text(content, style: TextStyle(color: foreground)), + backgroundColor: background, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 4), + ); + + ScaffoldMessenger.of(ctx) + ..removeCurrentSnackBar() + ..showSnackBar(snackBar); +} + +Future _showConfirmationDialog(Map data) async { + final ctx = NavigationService.context; + if (ctx == null) return false; + final title = data['title']?.toString() ?? 'Confirm'; + final message = data['message']?.toString() ?? ''; + final confirmText = data['confirm_text']?.toString() ?? 'Confirm'; + final cancelText = data['cancel_text']?.toString() ?? 'Cancel'; + + return ThemedDialogs.confirm( + ctx, + title: title, + message: message, + confirmText: confirmText, + cancelText: cancelText, + barrierDismissible: false, + ); +} + +Future _showInputDialog(Map data) async { + final ctx = NavigationService.context; + if (ctx == null) return null; + final title = data['title']?.toString() ?? 'Input Required'; + final message = data['message']?.toString() ?? ''; + final placeholder = data['placeholder']?.toString() ?? ''; + final initialValue = data['value']?.toString() ?? ''; + final controller = TextEditingController(text: initialValue); + + final result = await showDialog( + context: ctx, + barrierDismissible: false, + builder: (dialogCtx) { + return ThemedDialogs.buildBase( + context: dialogCtx, + title: title, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.isNotEmpty) ...[ + Text( + message, + style: TextStyle(color: dialogCtx.conduitTheme.textSecondary), + ), + const SizedBox(height: Spacing.md), + ], + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: placeholder.isNotEmpty + ? placeholder + : 'Enter a value', + ), + onSubmitted: (value) { + Navigator.of( + dialogCtx, + ).pop(value.trim().isEmpty ? null : value.trim()); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(null), + child: Text( + data['cancel_text']?.toString() ?? 'Cancel', + style: TextStyle(color: dialogCtx.conduitTheme.textSecondary), + ), + ), + TextButton( + onPressed: () { + final trimmed = controller.text.trim(); + if (trimmed.isEmpty) { + Navigator.of(dialogCtx).pop(null); + } else { + Navigator.of(dialogCtx).pop(trimmed); + } + }, + child: Text( + data['confirm_text']?.toString() ?? 'Submit', + style: TextStyle(color: dialogCtx.conduitTheme.buttonPrimary), + ), + ), + ], + ); + }, + ); + + controller.dispose(); + if (result == null) return null; + final trimmed = result.trim(); + return trimmed.isEmpty ? null : trimmed; +} diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 115c486..5618255 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -422,6 +422,67 @@ class ChatMessagesNotifier extends Notifier> { } } + void updateMessageById( + String messageId, + ChatMessage Function(ChatMessage current) updater, + ) { + final index = state.indexWhere((m) => m.id == messageId); + if (index == -1) return; + final original = state[index]; + final updated = updater(original); + if (identical(updated, original)) { + return; + } + final next = [...state]; + next[index] = updated; + state = next; + } + + void appendStatusUpdate(String messageId, ChatStatusUpdate update) { + updateMessageById(messageId, (current) { + final history = [...current.statusHistory, update]; + return current.copyWith(statusHistory: history); + }); + } + + void setFollowUps(String messageId, List followUps) { + updateMessageById(messageId, (current) { + return current.copyWith(followUps: List.from(followUps)); + }); + } + + void upsertCodeExecution(String messageId, ChatCodeExecution execution) { + updateMessageById(messageId, (current) { + final existing = current.codeExecutions; + final idx = existing.indexWhere((e) => e.id == execution.id); + if (idx == -1) { + return current.copyWith(codeExecutions: [...existing, execution]); + } + final next = [...existing]; + next[idx] = execution; + return current.copyWith(codeExecutions: next); + }); + } + + void appendSourceReference(String messageId, ChatSourceReference reference) { + updateMessageById(messageId, (current) { + final existing = current.sources; + final alreadyPresent = existing.any((source) { + if (reference.id != null && reference.id!.isNotEmpty) { + return source.id == reference.id; + } + if (reference.url != null && reference.url!.isNotEmpty) { + return source.url == reference.url; + } + return false; + }); + if (alreadyPresent) { + return current; + } + return current.copyWith(sources: [...existing, reference]); + }); + } + void appendToLastMessage(String content) { if (state.isEmpty) { return; @@ -1214,6 +1275,43 @@ Future regenerateMessage( updateLastMessageWith: (updater) => ref .read(chatMessagesProvider.notifier) .updateLastMessageWithFunction(updater), + appendStatusUpdate: (messageId, update) => ref + .read(chatMessagesProvider.notifier) + .appendStatusUpdate(messageId, update), + setFollowUps: (messageId, followUps) => ref + .read(chatMessagesProvider.notifier) + .setFollowUps(messageId, followUps), + upsertCodeExecution: (messageId, execution) => ref + .read(chatMessagesProvider.notifier) + .upsertCodeExecution(messageId, execution), + appendSourceReference: (messageId, reference) => ref + .read(chatMessagesProvider.notifier) + .appendSourceReference(messageId, reference), + updateMessageById: (messageId, updater) => ref + .read(chatMessagesProvider.notifier) + .updateMessageById(messageId, updater), + onChatTitleUpdated: (newTitle) { + final active = ref.read(activeConversationProvider); + if (active != null) { + ref + .read(activeConversationProvider.notifier) + .set(active.copyWith(title: newTitle)); + } + ref.invalidate(conversationsProvider); + }, + onChatTagsUpdated: () { + ref.invalidate(conversationsProvider); + final active = ref.read(activeConversationProvider); + final api = ref.read(apiServiceProvider); + if (active != null && api != null) { + Future.microtask(() async { + try { + final refreshed = await api.getConversation(active.id); + ref.read(activeConversationProvider.notifier).set(refreshed); + } catch (_) {} + }); + } + }, finishStreaming: () => ref.read(chatMessagesProvider.notifier).finishStreaming(), getMessages: () => ref.read(chatMessagesProvider), @@ -1731,6 +1829,43 @@ Future _sendMessageInternal( updateLastMessageWith: (updater) => ref .read(chatMessagesProvider.notifier) .updateLastMessageWithFunction(updater), + appendStatusUpdate: (messageId, update) => ref + .read(chatMessagesProvider.notifier) + .appendStatusUpdate(messageId, update), + setFollowUps: (messageId, followUps) => ref + .read(chatMessagesProvider.notifier) + .setFollowUps(messageId, followUps), + upsertCodeExecution: (messageId, execution) => ref + .read(chatMessagesProvider.notifier) + .upsertCodeExecution(messageId, execution), + appendSourceReference: (messageId, reference) => ref + .read(chatMessagesProvider.notifier) + .appendSourceReference(messageId, reference), + updateMessageById: (messageId, updater) => ref + .read(chatMessagesProvider.notifier) + .updateMessageById(messageId, updater), + onChatTitleUpdated: (newTitle) { + final active = ref.read(activeConversationProvider); + if (active != null) { + ref + .read(activeConversationProvider.notifier) + .set(active.copyWith(title: newTitle)); + } + ref.invalidate(conversationsProvider); + }, + onChatTagsUpdated: () { + ref.invalidate(conversationsProvider); + final active = ref.read(activeConversationProvider); + final api = ref.read(apiServiceProvider); + if (active != null && api != null) { + Future.microtask(() async { + try { + final refreshed = await api.getConversation(active.id); + ref.read(activeConversationProvider.notifier).set(refreshed); + } catch (_) {} + }); + } + }, finishStreaming: () => ref.read(chatMessagesProvider.notifier).finishStreaming(), getMessages: () => ref.read(chatMessagesProvider), diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 5a1db02..f2f35a3 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -10,12 +10,15 @@ import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../core/utils/reasoning_parser.dart'; import '../../../core/utils/message_segments.dart'; import '../../../core/utils/tool_calls_parser.dart'; +import '../../../core/models/chat_message.dart'; import '../providers/text_to_speech_provider.dart'; import 'enhanced_image_attachment.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'enhanced_attachment.dart'; import 'package:conduit/shared/widgets/chat_action_button.dart'; import '../../../shared/widgets/model_avatar.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import '../providers/chat_providers.dart' show sendMessage; class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -58,6 +61,19 @@ class _AssistantMessageWidgetState extends ConsumerState String _ttsPlainText = ''; // press state handled by shared ChatActionButton + Future _handleFollowUpTap(String suggestion) async { + final trimmed = suggestion.trim(); + if (trimmed.isEmpty || widget.isStreaming) { + return; + } + try { + await sendMessage(ref, trimmed, null); + } catch (err, stack) { + debugPrint('Failed to send follow-up: $err'); + debugPrintStack(stackTrace: stack); + } + } + @override void initState() { super.initState(); @@ -540,6 +556,15 @@ class _AssistantMessageWidgetState extends ConsumerState } Widget _buildDocumentationMessage() { + final visibleStatusHistory = widget.message.statusHistory + .where((status) => status.hidden != true) + .toList(growable: false); + final hasStatusTimeline = visibleStatusHistory.isNotEmpty; + final hasCodeExecutions = widget.message.codeExecutions.isNotEmpty; + final hasFollowUps = + widget.message.followUps.isNotEmpty && !widget.isStreaming; + final hasSources = widget.message.sources.isNotEmpty; + return Container( width: double.infinity, margin: const EdgeInsets.only( @@ -572,6 +597,11 @@ class _AssistantMessageWidgetState extends ConsumerState const SizedBox(height: Spacing.md), ], + if (hasStatusTimeline) ...[ + StatusHistoryTimeline(updates: visibleStatusHistory), + const SizedBox(height: Spacing.md), + ], + // Tool calls are rendered inline via segmented content // Smoothly crossfade between typing indicator and content AnimatedSwitcher( @@ -611,6 +641,27 @@ class _AssistantMessageWidgetState extends ConsumerState child: _buildSegmentedContent(), ), ), + + if (hasCodeExecutions) ...[ + const SizedBox(height: Spacing.md), + CodeExecutionListView( + executions: widget.message.codeExecutions, + ), + ], + + if (hasSources) ...[ + const SizedBox(height: Spacing.md), + CitationListView(sources: widget.message.sources), + ], + + if (hasFollowUps) ...[ + const SizedBox(height: Spacing.md), + FollowUpSuggestionBar( + suggestions: widget.message.followUps, + onSelected: _handleFollowUpTap, + isBusy: widget.isStreaming, + ), + ], ], ), ), @@ -1224,3 +1275,547 @@ class _AssistantMessageWidgetState extends ConsumerState ); } } + +class StatusHistoryTimeline extends StatelessWidget { + const StatusHistoryTimeline({super.key, required this.updates}); + + final List updates; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + if (updates.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.6), + width: BorderWidth.thin, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Status updates', + style: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), + ), + const SizedBox(height: Spacing.sm), + ...List.generate(updates.length, (index) { + final update = updates[index]; + final isLast = index == updates.length - 1; + return _StatusHistoryEntry(update: update, isLast: isLast); + }), + ], + ), + ); + } +} + +class _StatusHistoryEntry extends StatelessWidget { + const _StatusHistoryEntry({required this.update, required this.isLast}); + + final ChatStatusUpdate update; + final bool isLast; + + Color _indicatorColor(ConduitThemeExtension theme) { + if (update.done == false) { + return theme.buttonPrimary; + } + if (update.done == true) { + return theme.success; + } + return theme.textSecondary; + } + + IconData _indicatorIcon() { + if (update.done == false) { + return Icons.timelapse; + } + if (update.done == true) { + return Icons.check_circle; + } + return Icons.radio_button_unchecked; + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final indicatorColor = _indicatorColor(theme); + final description = update.description?.trim().isNotEmpty == true + ? update.description!.trim() + : (update.action?.isNotEmpty == true + ? update.action!.replaceAll('_', ' ') + : 'Processing'); + final timestamp = update.occurredAt; + final queries = [...update.queries]; + if (update.query != null && update.query!.trim().isNotEmpty) { + if (!queries.contains(update.query)) { + queries.add(update.query!.trim()); + } + } + + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Icon(_indicatorIcon(), size: 18, color: indicatorColor), + if (!isLast) + Container( + margin: const EdgeInsets.only(top: Spacing.xxs), + width: 2, + height: 32, + color: theme.dividerColor.withValues(alpha: 0.5), + ), + ], + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description, + style: TextStyle( + fontSize: AppTypography.bodyMedium, + color: theme.textPrimary, + fontWeight: update.done == true + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + if (update.count != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Text( + update.count == 1 + ? 'Retrieved 1 source' + : 'Retrieved ${update.count} sources', + style: TextStyle( + color: theme.textSecondary, + fontSize: AppTypography.labelSmall, + ), + ), + ), + if (timestamp != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Text( + _formatTimestamp(timestamp), + style: TextStyle( + color: theme.textSecondary, + fontSize: AppTypography.labelSmall, + ), + ), + ), + if (queries.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: queries.map((query) { + return ActionChip( + label: Text(query), + avatar: const Icon(Icons.search, size: 16), + onPressed: () { + _launchUri( + 'https://www.google.com/search?q=${Uri.encodeComponent(query)}', + ); + }, + ); + }).toList(), + ), + ), + if (update.urls.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: update.urls.map((url) { + return OutlinedButton.icon( + onPressed: () => _launchUri(url), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text( + Uri.tryParse(url)?.host ?? 'Link', + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + if (update.items.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: update.items.map((item) { + final title = item.title?.isNotEmpty == true + ? item.title! + : item.link ?? 'Result'; + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.xxs), + child: InkWell( + onTap: item.link != null + ? () => _launchUri(item.link!) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.link, size: 16), + const SizedBox(width: Spacing.xxs), + Expanded( + child: Text( + title, + style: TextStyle( + color: item.link != null + ? theme.buttonPrimary + : theme.textSecondary, + decoration: item.link != null + ? TextDecoration.underline + : TextDecoration.none, + ), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _formatTimestamp(DateTime timestamp) { + final local = timestamp.toLocal(); + final now = DateTime.now(); + final difference = now.difference(local); + if (difference.inMinutes < 1) { + return 'Just now'; + } + if (difference.inHours < 1) { + final minutes = difference.inMinutes; + return minutes == 1 ? '1 minute ago' : '$minutes minutes ago'; + } + return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } +} + +class CodeExecutionListView extends StatelessWidget { + const CodeExecutionListView({super.key, required this.executions}); + + final List executions; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + if (executions.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Code executions', + style: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), + ), + const SizedBox(height: Spacing.xs), + Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: executions.map((execution) { + final hasError = execution.result?.error != null; + final hasOutput = execution.result?.output != null; + IconData icon; + Color iconColor; + if (hasError) { + icon = Icons.error_outline; + iconColor = theme.error; + } else if (hasOutput) { + icon = Icons.check_circle_outline; + iconColor = theme.success; + } else { + icon = Icons.sync; + iconColor = theme.textSecondary; + } + final label = execution.name?.isNotEmpty == true + ? execution.name! + : 'Execution'; + return ActionChip( + avatar: Icon(icon, size: 16, color: iconColor), + label: Text(label), + onPressed: () => _showCodeExecutionDetails(context, execution), + ); + }).toList(), + ), + ], + ); + } + + Future _showCodeExecutionDetails( + BuildContext context, + ChatCodeExecution execution, + ) async { + final theme = context.conduitTheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: theme.surfaceBackground, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.dialog), + ), + ), + builder: (ctx) { + final result = execution.result; + return DraggableScrollableSheet( + initialChildSize: 0.6, + maxChildSize: 0.95, + expand: false, + builder: (_, controller) { + return Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: ListView( + controller: controller, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + execution.name ?? 'Code execution', + style: TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w600, + color: theme.textPrimary, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: Spacing.sm), + if (execution.language != null) + Text( + 'Language: ${execution.language}', + style: TextStyle(color: theme.textSecondary), + ), + const SizedBox(height: Spacing.sm), + if (execution.code != null && execution.code!.isNotEmpty) ...[ + Text( + 'Code', + style: TextStyle( + fontWeight: FontWeight.w600, + color: theme.textPrimary, + ), + ), + const SizedBox(height: Spacing.xs), + Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: theme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: SelectableText( + execution.code!, + style: const TextStyle( + fontFamily: 'monospace', + height: 1.4, + ), + ), + ), + const SizedBox(height: Spacing.md), + ], + if (result?.error != null) ...[ + Text( + 'Error', + style: TextStyle( + fontWeight: FontWeight.w600, + color: theme.error, + ), + ), + const SizedBox(height: Spacing.xs), + SelectableText(result!.error!), + const SizedBox(height: Spacing.md), + ], + if (result?.output != null) ...[ + Text( + 'Output', + style: TextStyle( + fontWeight: FontWeight.w600, + color: theme.textPrimary, + ), + ), + const SizedBox(height: Spacing.xs), + SelectableText(result!.output!), + const SizedBox(height: Spacing.md), + ], + if (result?.files.isNotEmpty == true) ...[ + Text( + 'Files', + style: TextStyle( + fontWeight: FontWeight.w600, + color: theme.textPrimary, + ), + ), + const SizedBox(height: Spacing.xs), + ...result!.files.map((file) { + final name = file.name ?? file.url ?? 'Download'; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(name), + onTap: file.url != null + ? () => _launchUri(file.url!) + : null, + trailing: file.url != null + ? const Icon(Icons.open_in_new) + : null, + ); + }), + ], + ], + ), + ); + }, + ); + }, + ); + } +} + +class CitationListView extends StatelessWidget { + const CitationListView({super.key, required this.sources}); + + final List sources; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + if (sources.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sources.length == 1 ? 'Source' : 'Sources', + style: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), + ), + const SizedBox(height: Spacing.xs), + ...sources.map((source) { + final title = source.title?.isNotEmpty == true + ? source.title! + : source.url ?? 'Citation'; + final subtitle = source.snippet?.isNotEmpty == true + ? source.snippet! + : source.url; + + return Card( + margin: const EdgeInsets.only(bottom: Spacing.xs), + color: theme.surfaceContainer, + child: ListTile( + onTap: source.url != null ? () => _launchUri(source.url!) : null, + title: Text(title, style: TextStyle(color: theme.textPrimary)), + subtitle: subtitle != null + ? Text(subtitle, style: TextStyle(color: theme.textSecondary)) + : null, + trailing: source.url != null + ? const Icon(Icons.open_in_new, size: 18) + : null, + ), + ); + }), + ], + ); + } +} + +class FollowUpSuggestionBar extends StatelessWidget { + const FollowUpSuggestionBar({ + super.key, + required this.suggestions, + required this.onSelected, + required this.isBusy, + }); + + final List suggestions; + final ValueChanged onSelected; + final bool isBusy; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + if (suggestions.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Try next', + style: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), + ), + const SizedBox(height: Spacing.xs), + Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: suggestions.map((suggestion) { + return FilledButton.tonal( + onPressed: isBusy ? null : () => onSelected(suggestion), + child: Text(suggestion), + ); + }).toList(), + ), + ], + ); + } +} + +Future _launchUri(String url) async { + if (url.isEmpty) return; + try { + await launchUrlString(url, mode: LaunchMode.externalApplication); + } catch (err) { + debugPrint('Unable to open url $url: $err'); + } +}