feat: followups
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
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.freezed.dart';
|
||||||
part 'chat_message.g.dart';
|
part 'chat_message.g.dart';
|
||||||
|
|
||||||
@@ -15,10 +19,227 @@ sealed class ChatMessage with _$ChatMessage {
|
|||||||
List<String>? attachmentIds,
|
List<String>? attachmentIds,
|
||||||
List<Map<String, dynamic>>? files, // For generated images
|
List<Map<String, dynamic>>? files, // For generated images
|
||||||
Map<String, dynamic>? metadata,
|
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,
|
Map<String, dynamic>? usage,
|
||||||
}) = _ChatMessage;
|
}) = _ChatMessage;
|
||||||
|
|
||||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ChatMessageFromJson(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -838,6 +838,29 @@ class ApiService {
|
|||||||
files = allFiles.isNotEmpty ? allFiles : null;
|
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(
|
return ChatMessage(
|
||||||
id: msgData['id']?.toString() ?? uuid.v4(),
|
id: msgData['id']?.toString() ?? uuid.v4(),
|
||||||
role: role,
|
role: role,
|
||||||
@@ -846,6 +869,10 @@ class ApiService {
|
|||||||
model: msgData['model'] as String?,
|
model: msgData['model'] as String?,
|
||||||
attachmentIds: attachmentIds,
|
attachmentIds: attachmentIds,
|
||||||
files: files,
|
files: files,
|
||||||
|
statusHistory: statusHistory,
|
||||||
|
followUps: followUps,
|
||||||
|
codeExecutions: codeExecutions,
|
||||||
|
sources: sources,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,6 +1056,62 @@ class ApiService {
|
|||||||
return buf.toString().trim();
|
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
|
// Create new conversation using OpenWebUI API
|
||||||
Future<Conversation> createConversation({
|
Future<Conversation> createConversation({
|
||||||
required String title,
|
required String title,
|
||||||
|
|||||||
@@ -142,27 +142,67 @@ class SocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onChatEvents(void Function(Map<String, dynamic> event) handler) {
|
void onChatEvents(
|
||||||
_socket?.on('chat-events', (data) {
|
void Function(
|
||||||
|
Map<String, dynamic> event,
|
||||||
|
void Function(dynamic response)? ack,
|
||||||
|
)
|
||||||
|
handler,
|
||||||
|
) {
|
||||||
|
_socket?.on('chat-events', (dynamic data, [dynamic ack]) {
|
||||||
try {
|
try {
|
||||||
|
Map<String, dynamic>? map;
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
handler(data);
|
map = data;
|
||||||
} else if (data is Map) {
|
} 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 (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to general channel events (server-broadcasted channel updates)
|
// Subscribe to general channel events (server-broadcasted channel updates)
|
||||||
void onChannelEvents(void Function(Map<String, dynamic> event) handler) {
|
void onChannelEvents(
|
||||||
_socket?.on('channel-events', (data) {
|
void Function(
|
||||||
|
Map<String, dynamic> event,
|
||||||
|
void Function(dynamic response)? ack,
|
||||||
|
)
|
||||||
|
handler,
|
||||||
|
) {
|
||||||
|
_socket?.on('channel-events', (dynamic data, [dynamic ack]) {
|
||||||
try {
|
try {
|
||||||
|
Map<String, dynamic>? map;
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
handler(data);
|
map = data;
|
||||||
} else if (data is Map) {
|
} 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 (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/models/chat_message.dart';
|
import '../../core/models/chat_message.dart';
|
||||||
import '../../core/services/persistent_streaming_service.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/inactivity_watchdog.dart';
|
||||||
import '../../core/utils/stream_chunker.dart';
|
import '../../core/utils/stream_chunker.dart';
|
||||||
import '../../core/utils/tool_calls_parser.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
|
// Keep local verbosity toggle for socket logs
|
||||||
const bool kSocketVerboseLogging = false;
|
const bool kSocketVerboseLogging = false;
|
||||||
@@ -36,6 +39,20 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
required void Function(String) replaceLastMessageContent,
|
required void Function(String) replaceLastMessageContent,
|
||||||
required void Function(ChatMessage Function(ChatMessage))
|
required void Function(ChatMessage Function(ChatMessage))
|
||||||
updateLastMessageWith,
|
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 void Function() finishStreaming,
|
||||||
required List<ChatMessage> Function() getMessages,
|
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 {
|
try {
|
||||||
final data = ev['data'];
|
final data = ev['data'];
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
final type = data['type'];
|
final type = data['type'];
|
||||||
final payload = data['data'];
|
final payload = data['data'];
|
||||||
|
final messageId = ev['message_id']?.toString();
|
||||||
socketWatchdog?.ping();
|
socketWatchdog?.ping();
|
||||||
|
|
||||||
if (type == 'chat:completion' && payload != null) {
|
if (type == 'chat:completion' && payload != null) {
|
||||||
@@ -504,6 +525,121 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
socketWatchdog?.stop();
|
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) {
|
} else if (type == 'chat:message:error' && payload != null) {
|
||||||
// Server reports an error for the current assistant message
|
// Server reports an error for the current assistant message
|
||||||
try {
|
try {
|
||||||
@@ -641,12 +777,20 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
} else if (type == 'event:status' && payload != null) {
|
} else if (type == 'event:status' && payload != null) {
|
||||||
final status = payload['status']?.toString() ?? '';
|
final map = _asStringMap(payload);
|
||||||
|
final status = map?['status']?.toString() ?? '';
|
||||||
if (status.isNotEmpty) {
|
if (status.isNotEmpty) {
|
||||||
updateLastMessageWith(
|
updateLastMessageWith(
|
||||||
(m) => m.copyWith(metadata: {...?m.metadata, 'status': status}),
|
(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) {
|
} else if (type == 'event:tool' && payload != null) {
|
||||||
// Accept files from both 'result' and 'files'
|
// Accept files from both 'result' and 'files'
|
||||||
final files = [
|
final files = [
|
||||||
@@ -672,7 +816,10 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
void channelEventsHandler(Map<String, dynamic> ev) {
|
void channelEventsHandler(
|
||||||
|
Map<String, dynamic> ev,
|
||||||
|
void Function(dynamic response)? ack,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
final data = ev['data'];
|
final data = ev['data'];
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
@@ -851,3 +998,173 @@ List<Map<String, dynamic>> _extractFilesFromResult(dynamic resp) {
|
|||||||
}
|
}
|
||||||
return results;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -422,6 +422,67 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String> followUps) {
|
||||||
|
updateMessageById(messageId, (current) {
|
||||||
|
return current.copyWith(followUps: List<String>.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) {
|
void appendToLastMessage(String content) {
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -1214,6 +1275,43 @@ Future<void> regenerateMessage(
|
|||||||
updateLastMessageWith: (updater) => ref
|
updateLastMessageWith: (updater) => ref
|
||||||
.read(chatMessagesProvider.notifier)
|
.read(chatMessagesProvider.notifier)
|
||||||
.updateLastMessageWithFunction(updater),
|
.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: () =>
|
finishStreaming: () =>
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming(),
|
ref.read(chatMessagesProvider.notifier).finishStreaming(),
|
||||||
getMessages: () => ref.read(chatMessagesProvider),
|
getMessages: () => ref.read(chatMessagesProvider),
|
||||||
@@ -1731,6 +1829,43 @@ Future<void> _sendMessageInternal(
|
|||||||
updateLastMessageWith: (updater) => ref
|
updateLastMessageWith: (updater) => ref
|
||||||
.read(chatMessagesProvider.notifier)
|
.read(chatMessagesProvider.notifier)
|
||||||
.updateLastMessageWithFunction(updater),
|
.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: () =>
|
finishStreaming: () =>
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming(),
|
ref.read(chatMessagesProvider.notifier).finishStreaming(),
|
||||||
getMessages: () => ref.read(chatMessagesProvider),
|
getMessages: () => ref.read(chatMessagesProvider),
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import '../../../shared/widgets/markdown/streaming_markdown_widget.dart';
|
|||||||
import '../../../core/utils/reasoning_parser.dart';
|
import '../../../core/utils/reasoning_parser.dart';
|
||||||
import '../../../core/utils/message_segments.dart';
|
import '../../../core/utils/message_segments.dart';
|
||||||
import '../../../core/utils/tool_calls_parser.dart';
|
import '../../../core/utils/tool_calls_parser.dart';
|
||||||
|
import '../../../core/models/chat_message.dart';
|
||||||
import '../providers/text_to_speech_provider.dart';
|
import '../providers/text_to_speech_provider.dart';
|
||||||
import 'enhanced_image_attachment.dart';
|
import 'enhanced_image_attachment.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'enhanced_attachment.dart';
|
import 'enhanced_attachment.dart';
|
||||||
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
||||||
import '../../../shared/widgets/model_avatar.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 {
|
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -58,6 +61,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
String _ttsPlainText = '';
|
String _ttsPlainText = '';
|
||||||
// press state handled by shared ChatActionButton
|
// press state handled by shared ChatActionButton
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -540,6 +556,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentationMessage() {
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
margin: const EdgeInsets.only(
|
margin: const EdgeInsets.only(
|
||||||
@@ -572,6 +597,11 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
if (hasStatusTimeline) ...[
|
||||||
|
StatusHistoryTimeline(updates: visibleStatusHistory),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
],
|
||||||
|
|
||||||
// Tool calls are rendered inline via segmented content
|
// Tool calls are rendered inline via segmented content
|
||||||
// Smoothly crossfade between typing indicator and content
|
// Smoothly crossfade between typing indicator and content
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
@@ -611,6 +641,27 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
child: _buildSegmentedContent(),
|
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<AssistantMessageWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StatusHistoryTimeline extends StatelessWidget {
|
||||||
|
const StatusHistoryTimeline({super.key, required this.updates});
|
||||||
|
|
||||||
|
final List<ChatStatusUpdate> 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<ChatCodeExecution> 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<void> _showCodeExecutionDetails(
|
||||||
|
BuildContext context,
|
||||||
|
ChatCodeExecution execution,
|
||||||
|
) async {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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<ChatSourceReference> 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<String> suggestions;
|
||||||
|
final ValueChanged<String> 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<void> _launchUri(String url) async {
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await launchUrlString(url, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('Unable to open url $url: $err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user