feat: followups

This commit is contained in:
cogwheel0
2025-09-25 18:25:39 +05:30
parent 637274133f
commit bfa5ff6363
6 changed files with 1404 additions and 13 deletions

View File

@@ -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<String>? attachmentIds,
List<Map<String, dynamic>>? files, // For generated images
Map<String, dynamic>? metadata,
List<Map<String, dynamic>>? sources,
@Default(<ChatStatusUpdate>[]) List<ChatStatusUpdate> statusHistory,
@Default(<String>[]) List<String> followUps,
@Default(<ChatCodeExecution>[]) List<ChatCodeExecution> codeExecutions,
@JsonKey(
name: 'sources',
fromJson: _sourceRefsFromJson,
toJson: _sourceRefsToJson,
)
@Default(<ChatSourceReference>[])
List<ChatSourceReference> sources,
Map<String, dynamic>? usage,
}) = _ChatMessage;
factory ChatMessage.fromJson(Map<String, dynamic> 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(<String>[])
List<String> queries,
@JsonKey(fromJson: _safeStringList, toJson: _stringListToJson)
@Default(<String>[])
List<String> urls,
@JsonKey(fromJson: _statusItemsFromJson, toJson: _statusItemsToJson)
@Default(<ChatStatusItem>[])
List<ChatStatusItem> items,
@JsonKey(
name: 'timestamp',
fromJson: _timestampFromJson,
toJson: _timestampToJson,
)
DateTime? occurredAt,
}) = _ChatStatusUpdate;
factory ChatStatusUpdate.fromJson(Map<String, dynamic> json) =>
_$ChatStatusUpdateFromJson(json);
}
@freezed
abstract class ChatStatusItem with _$ChatStatusItem {
const factory ChatStatusItem({
String? title,
String? link,
String? snippet,
Map<String, dynamic>? metadata,
}) = _ChatStatusItem;
factory ChatStatusItem.fromJson(Map<String, dynamic> 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<String, dynamic>? metadata,
}) = _ChatCodeExecution;
factory ChatCodeExecution.fromJson(Map<String, dynamic> json) =>
_$ChatCodeExecutionFromJson(json);
}
@freezed
abstract class ChatCodeExecutionResult with _$ChatCodeExecutionResult {
const factory ChatCodeExecutionResult({
String? output,
String? error,
@JsonKey(fromJson: _executionFilesFromJson, toJson: _executionFilesToJson)
@Default(<ChatExecutionFile>[])
List<ChatExecutionFile> files,
Map<String, dynamic>? metadata,
}) = _ChatCodeExecutionResult;
factory ChatCodeExecutionResult.fromJson(Map<String, dynamic> json) =>
_$ChatCodeExecutionResultFromJson(json);
}
@freezed
abstract class ChatExecutionFile with _$ChatExecutionFile {
const factory ChatExecutionFile({
@JsonKey(fromJson: _nullableString) String? name,
@JsonKey(fromJson: _nullableString) String? url,
Map<String, dynamic>? metadata,
}) = _ChatExecutionFile;
factory ChatExecutionFile.fromJson(Map<String, dynamic> 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<String, dynamic>? metadata,
}) = _ChatSourceReference;
factory ChatSourceReference.fromJson(Map<String, dynamic> json) =>
_$ChatSourceReferenceFromJson(json);
}
List<String> _safeStringList(dynamic value) {
if (value is List) {
return value
.whereType<dynamic>()
.map((e) => e?.toString().trim() ?? '')
.where((s) => s.isNotEmpty)
.toList(growable: false);
}
if (value is String && value.isNotEmpty) {
return [value];
}
return const [];
}
List<String> _stringListToJson(List<String> value) =>
List<String>.from(value, growable: false);
List<ChatStatusItem> _statusItemsFromJson(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map(
(item) => ChatStatusItem.fromJson(
item.map((key, v) => MapEntry(key.toString(), v)),
),
)
.toList(growable: false);
}
return const [];
}
List<Map<String, dynamic>> _statusItemsToJson(List<ChatStatusItem> value) {
return value.map((item) => item.toJson()).toList(growable: false);
}
List<ChatExecutionFile> _executionFilesFromJson(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map(
(item) => ChatExecutionFile.fromJson(
item.map((key, v) => MapEntry(key.toString(), v)),
),
)
.toList(growable: false);
}
return const [];
}
List<Map<String, dynamic>> _executionFilesToJson(
List<ChatExecutionFile> files,
) {
return files.map((file) => file.toJson()).toList(growable: false);
}
List<ChatSourceReference> _sourceRefsFromJson(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map(
(item) => ChatSourceReference.fromJson(
item.map((key, v) => MapEntry(key.toString(), v)),
),
)
.toList(growable: false);
}
return const [];
}
List<Map<String, dynamic>> _sourceRefsToJson(
List<ChatSourceReference> 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;
}

View File

@@ -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<ChatStatusUpdate> _parseStatusHistoryField(dynamic raw) {
if (raw is List) {
return raw
.whereType<Map>()
.map(
(entry) => ChatStatusUpdate.fromJson(
entry.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false);
}
return const <ChatStatusUpdate>[];
}
List<String> _parseFollowUpsField(dynamic raw) {
if (raw is List) {
return raw
.whereType<dynamic>()
.map((value) => value?.toString().trim() ?? '')
.where((value) => value.isNotEmpty)
.toList(growable: false);
}
if (raw is String && raw.trim().isNotEmpty) {
return [raw.trim()];
}
return const <String>[];
}
List<ChatCodeExecution> _parseCodeExecutionsField(dynamic raw) {
if (raw is List) {
return raw
.whereType<Map>()
.map(
(entry) => ChatCodeExecution.fromJson(
entry.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false);
}
return const <ChatCodeExecution>[];
}
List<ChatSourceReference> _parseSourcesField(dynamic raw) {
if (raw is List) {
return raw
.whereType<Map>()
.map(
(entry) => ChatSourceReference.fromJson(
entry.map((key, value) => MapEntry(key.toString(), value)),
),
)
.toList(growable: false);
}
return const <ChatSourceReference>[];
}
// Create new conversation using OpenWebUI API
Future<Conversation> createConversation({
required String title,

View File

@@ -142,27 +142,67 @@ class SocketService {
}
}
void onChatEvents(void Function(Map<String, dynamic> event) handler) {
_socket?.on('chat-events', (data) {
void onChatEvents(
void Function(
Map<String, dynamic> event,
void Function(dynamic response)? ack,
)
handler,
) {
_socket?.on('chat-events', (dynamic data, [dynamic ack]) {
try {
Map<String, dynamic>? map;
if (data is Map<String, dynamic>) {
handler(data);
map = data;
} else if (data is Map) {
handler(Map<String, dynamic>.from(data));
map = Map<String, dynamic>.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<String, dynamic> event) handler) {
_socket?.on('channel-events', (data) {
void onChannelEvents(
void Function(
Map<String, dynamic> event,
void Function(dynamic response)? ack,
)
handler,
) {
_socket?.on('channel-events', (dynamic data, [dynamic ack]) {
try {
Map<String, dynamic>? map;
if (data is Map<String, dynamic>) {
handler(data);
map = data;
} else if (data is Map) {
handler(Map<String, dynamic>.from(data));
map = Map<String, dynamic>.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 (_) {}
});
}

View File

@@ -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<String> 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<String> 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<ChatMessage> Function() getMessages,
}) {
@@ -330,12 +347,16 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
});
}
void chatHandler(Map<String, dynamic> ev) {
void chatHandler(
Map<String, dynamic> 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<String> 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<String> 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<String> attachUnifiedChunkedStreaming({
} catch (_) {}
}
void channelEventsHandler(Map<String, dynamic> ev) {
void channelEventsHandler(
Map<String, dynamic> ev,
void Function(dynamic response)? ack,
) {
try {
final data = ev['data'];
if (data == null) return;
@@ -851,3 +998,173 @@ List<Map<String, dynamic>> _extractFilesFromResult(dynamic resp) {
}
return results;
}
Map<String, dynamic>? _asStringMap(dynamic value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, val) => MapEntry(key.toString(), val));
}
return null;
}
String? _resolveTargetMessageId(
String? messageId,
List<ChatMessage> Function() getMessages,
) {
if (messageId != null && messageId.isNotEmpty) {
return messageId;
}
final messages = getMessages();
if (messages.isEmpty) {
return null;
}
return messages.last.id;
}
List<String> _parseFollowUpsField(dynamic raw) {
if (raw is List) {
return raw
.whereType<dynamic>()
.map((value) => value?.toString().trim() ?? '')
.where((value) => value.isNotEmpty)
.toList(growable: false);
}
if (raw is String && raw.trim().isNotEmpty) {
return [raw.trim()];
}
return const <String>[];
}
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<bool> _showConfirmationDialog(Map<String, dynamic> 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<String?> _showInputDialog(Map<String, dynamic> 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<String>(
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;
}