Merge pull request #98 from cogwheel0/feat/chat-archive-assistant-variants
feat(chat): regenerate variants and support
This commit is contained in:
@@ -30,12 +30,40 @@ sealed class ChatMessage with _$ChatMessage {
|
|||||||
@Default(<ChatSourceReference>[])
|
@Default(<ChatSourceReference>[])
|
||||||
List<ChatSourceReference> sources,
|
List<ChatSourceReference> sources,
|
||||||
Map<String, dynamic>? usage,
|
Map<String, dynamic>? usage,
|
||||||
|
// Previous generated versions of this assistant message (OpenWebUI-style)
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@Default(<ChatMessageVersion>[])
|
||||||
|
List<ChatMessageVersion> versions,
|
||||||
}) = _ChatMessage;
|
}) = _ChatMessage;
|
||||||
|
|
||||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ChatMessageFromJson(json);
|
_$ChatMessageFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ChatMessageVersion with _$ChatMessageVersion {
|
||||||
|
const factory ChatMessageVersion({
|
||||||
|
required String id,
|
||||||
|
required String content,
|
||||||
|
required DateTime timestamp,
|
||||||
|
String? model,
|
||||||
|
List<Map<String, dynamic>>? files,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'sources',
|
||||||
|
fromJson: _sourceRefsFromJson,
|
||||||
|
toJson: _sourceRefsToJson,
|
||||||
|
)
|
||||||
|
@Default(<ChatSourceReference>[])
|
||||||
|
List<ChatSourceReference> sources,
|
||||||
|
@Default(<String>[]) List<String> followUps,
|
||||||
|
@Default(<ChatCodeExecution>[]) List<ChatCodeExecution> codeExecutions,
|
||||||
|
Map<String, dynamic>? usage,
|
||||||
|
}) = _ChatMessageVersion;
|
||||||
|
|
||||||
|
factory ChatMessageVersion.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ChatMessageVersionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class ChatStatusUpdate with _$ChatStatusUpdate {
|
abstract class ChatStatusUpdate with _$ChatStatusUpdate {
|
||||||
const factory ChatStatusUpdate({
|
const factory ChatStatusUpdate({
|
||||||
|
|||||||
@@ -879,10 +879,59 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default path: parse message as-is
|
// Default path: parse message as-is
|
||||||
final message = _parseOpenWebUIMessage(
|
var message = _parseOpenWebUIMessage(msgData, historyMsg: historyMsg);
|
||||||
msgData,
|
|
||||||
historyMsg: historyMsg,
|
// Attach server-persisted variants (siblings) as versions for assistant
|
||||||
|
if (message.role == 'assistant' && historyMessagesMap != null) {
|
||||||
|
try {
|
||||||
|
final parentId = historyMsg?['parentId']?.toString();
|
||||||
|
if (parentId != null && parentId.isNotEmpty) {
|
||||||
|
final parent =
|
||||||
|
historyMessagesMap[parentId] as Map<String, dynamic>?;
|
||||||
|
final children = parent != null && parent['childrenIds'] is List
|
||||||
|
? (parent['childrenIds'] as List)
|
||||||
|
.map((e) => e.toString())
|
||||||
|
.toList()
|
||||||
|
: const <String>[];
|
||||||
|
final versions = <ChatMessageVersion>[];
|
||||||
|
|
||||||
|
for (final cid in children) {
|
||||||
|
if (cid == message.id) continue; // skip current assistant
|
||||||
|
final sibling = historyMessagesMap[cid];
|
||||||
|
if (sibling is Map<String, dynamic>) {
|
||||||
|
final role = (sibling['role'] ?? '').toString();
|
||||||
|
if (role != 'assistant') continue;
|
||||||
|
// Build a ChatMessage from sibling for consistent parsing
|
||||||
|
final siblingData = Map<String, dynamic>.from(sibling);
|
||||||
|
siblingData['id'] = cid;
|
||||||
|
final parsed = _parseOpenWebUIMessage(
|
||||||
|
siblingData,
|
||||||
|
historyMsg: sibling,
|
||||||
);
|
);
|
||||||
|
versions.add(
|
||||||
|
ChatMessageVersion(
|
||||||
|
id: parsed.id,
|
||||||
|
content: parsed.content,
|
||||||
|
timestamp: parsed.timestamp,
|
||||||
|
model: parsed.model,
|
||||||
|
files: parsed.files,
|
||||||
|
sources: parsed.sources,
|
||||||
|
followUps: parsed.followUps,
|
||||||
|
codeExecutions: parsed.codeExecutions,
|
||||||
|
usage: parsed.usage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versions.isNotEmpty) {
|
||||||
|
message = message.copyWith(versions: versions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort: ignore variants if parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
messages.add(message);
|
messages.add(message);
|
||||||
if (_traceFullChatParsing) {
|
if (_traceFullChatParsing) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
@@ -1412,14 +1461,19 @@ class ApiService {
|
|||||||
final List<Map<String, dynamic>> messagesArray = [];
|
final List<Map<String, dynamic>> messagesArray = [];
|
||||||
String? currentId;
|
String? currentId;
|
||||||
String? previousId;
|
String? previousId;
|
||||||
|
String? lastUserId;
|
||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
final messageId = msg.id;
|
final messageId = msg.id;
|
||||||
|
|
||||||
|
// Choose parent id (branch assistants from last user)
|
||||||
|
final parentId = msg.role == 'assistant'
|
||||||
|
? (lastUserId ?? previousId)
|
||||||
|
: previousId;
|
||||||
|
|
||||||
// Build message for history.messages map
|
// Build message for history.messages map
|
||||||
messagesMap[messageId] = {
|
messagesMap[messageId] = {
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': parentId,
|
||||||
'childrenIds': [],
|
'childrenIds': [],
|
||||||
'role': msg.role,
|
'role': msg.role,
|
||||||
'content': msg.content,
|
'content': msg.content,
|
||||||
@@ -1432,14 +1486,14 @@ class ApiService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Update parent's childrenIds if there's a previous message
|
// Update parent's childrenIds if there's a previous message
|
||||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
if (parentId != null && messagesMap.containsKey(parentId)) {
|
||||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build message for messages array
|
// Build message for messages array
|
||||||
messagesArray.add({
|
messagesArray.add({
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': parentId,
|
||||||
'childrenIds': [],
|
'childrenIds': [],
|
||||||
'role': msg.role,
|
'role': msg.role,
|
||||||
'content': msg.content,
|
'content': msg.content,
|
||||||
@@ -1453,6 +1507,9 @@ class ApiService {
|
|||||||
|
|
||||||
previousId = messageId;
|
previousId = messageId;
|
||||||
currentId = messageId;
|
currentId = messageId;
|
||||||
|
if (msg.role == 'user') {
|
||||||
|
lastUserId = messageId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the chat data structure matching OpenWebUI format exactly
|
// Create the chat data structure matching OpenWebUI format exactly
|
||||||
@@ -1509,6 +1566,7 @@ class ApiService {
|
|||||||
final List<Map<String, dynamic>> messagesArray = [];
|
final List<Map<String, dynamic>> messagesArray = [];
|
||||||
String? currentId;
|
String? currentId;
|
||||||
String? previousId;
|
String? previousId;
|
||||||
|
String? lastUserId;
|
||||||
|
|
||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
final messageId = msg.id;
|
final messageId = msg.id;
|
||||||
@@ -1517,9 +1575,19 @@ class ApiService {
|
|||||||
// The msg.files array already contains all attachments in the correct format
|
// The msg.files array already contains all attachments in the correct format
|
||||||
final sanitizedFiles = _sanitizeFilesForWebUI(msg.files);
|
final sanitizedFiles = _sanitizeFilesForWebUI(msg.files);
|
||||||
|
|
||||||
|
// Determine parent id: allow explicit parent override via metadata
|
||||||
|
final explicitParent = msg.metadata != null
|
||||||
|
? (msg.metadata!['parentId']?.toString())
|
||||||
|
: null;
|
||||||
|
// For assistant messages, branch from the last user (OpenWebUI-style)
|
||||||
|
final fallbackParent = msg.role == 'assistant'
|
||||||
|
? (lastUserId ?? previousId)
|
||||||
|
: previousId;
|
||||||
|
final parentId = explicitParent ?? fallbackParent;
|
||||||
|
|
||||||
messagesMap[messageId] = {
|
messagesMap[messageId] = {
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': parentId,
|
||||||
'childrenIds': <String>[],
|
'childrenIds': <String>[],
|
||||||
'role': msg.role,
|
'role': msg.role,
|
||||||
'content': msg.content,
|
'content': msg.content,
|
||||||
@@ -1536,8 +1604,8 @@ class ApiService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Update parent's childrenIds
|
// Update parent's childrenIds
|
||||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
if (parentId != null && messagesMap.containsKey(parentId)) {
|
||||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same properly formatted files array for messages array
|
// Use the same properly formatted files array for messages array
|
||||||
@@ -1545,7 +1613,7 @@ class ApiService {
|
|||||||
|
|
||||||
messagesArray.add({
|
messagesArray.add({
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': parentId,
|
||||||
'childrenIds': [],
|
'childrenIds': [],
|
||||||
'role': msg.role,
|
'role': msg.role,
|
||||||
'content': msg.content,
|
'content': msg.content,
|
||||||
@@ -1562,6 +1630,37 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
previousId = messageId;
|
previousId = messageId;
|
||||||
|
if (msg.role == 'user') {
|
||||||
|
lastUserId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side persistence of assistant versions (OpenWebUI-style)
|
||||||
|
if (msg.role == 'assistant' && (msg.versions.isNotEmpty)) {
|
||||||
|
final parentForVersions = explicitParent ?? lastUserId ?? previousId;
|
||||||
|
for (final ver in msg.versions) {
|
||||||
|
final vId = ver.id;
|
||||||
|
// Only add if not already present
|
||||||
|
if (!messagesMap.containsKey(vId)) {
|
||||||
|
messagesMap[vId] = {
|
||||||
|
'id': vId,
|
||||||
|
'parentId': parentForVersions,
|
||||||
|
'childrenIds': <String>[],
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': ver.content,
|
||||||
|
'timestamp': ver.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
if (ver.model != null) 'model': ver.model,
|
||||||
|
if (ver.model != null) 'modelName': ver.model,
|
||||||
|
'modelIdx': 0,
|
||||||
|
'done': true,
|
||||||
|
if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files),
|
||||||
|
};
|
||||||
|
// Link into parent (parentForVersions is always non-null here)
|
||||||
|
if (messagesMap.containsKey(parentForVersions)) {
|
||||||
|
(messagesMap[parentForVersions]['childrenIds'] as List).add(vId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
currentId = messageId;
|
currentId = messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,45 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
|||||||
state = next;
|
state = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archive the last assistant message's current content as a previous version
|
||||||
|
// and clear it to prepare for regeneration, keeping the same message id.
|
||||||
|
void archiveLastAssistantAsVersion() {
|
||||||
|
if (state.isEmpty) return;
|
||||||
|
final last = state.last;
|
||||||
|
if (last.role != 'assistant') return;
|
||||||
|
// Do not archive if it's already streaming (nothing final to archive)
|
||||||
|
if (last.isStreaming) return;
|
||||||
|
|
||||||
|
final snapshot = ChatMessageVersion(
|
||||||
|
id: last.id,
|
||||||
|
content: last.content,
|
||||||
|
timestamp: last.timestamp,
|
||||||
|
model: last.model,
|
||||||
|
files: last.files == null
|
||||||
|
? null
|
||||||
|
: List<Map<String, dynamic>>.from(last.files!),
|
||||||
|
sources: List<ChatSourceReference>.from(last.sources),
|
||||||
|
followUps: List<String>.from(last.followUps),
|
||||||
|
codeExecutions: List<ChatCodeExecution>.from(last.codeExecutions),
|
||||||
|
usage: last.usage == null ? null : Map<String, dynamic>.from(last.usage!),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = last.copyWith(
|
||||||
|
// Start a fresh stream for the new generation
|
||||||
|
isStreaming: true,
|
||||||
|
content: '',
|
||||||
|
files: null,
|
||||||
|
followUps: const [],
|
||||||
|
codeExecutions: const [],
|
||||||
|
sources: const [],
|
||||||
|
usage: null,
|
||||||
|
versions: [...last.versions, snapshot],
|
||||||
|
);
|
||||||
|
|
||||||
|
state = [...state.sublist(0, state.length - 1), updated];
|
||||||
|
_touchStreamingActivity();
|
||||||
|
}
|
||||||
|
|
||||||
void appendStatusUpdate(String messageId, ChatStatusUpdate update) {
|
void appendStatusUpdate(String messageId, ChatStatusUpdate update) {
|
||||||
final withTimestamp = update.occurredAt == null
|
final withTimestamp = update.occurredAt == null
|
||||||
? update.copyWith(occurredAt: DateTime.now())
|
? update.copyWith(occurredAt: DateTime.now())
|
||||||
@@ -644,10 +683,38 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
|||||||
final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content);
|
final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content);
|
||||||
final cleaned = _stripStreamingPlaceholders(finalized);
|
final cleaned = _stripStreamingPlaceholders(finalized);
|
||||||
|
|
||||||
state = [
|
var updatedLast = lastMessage.copyWith(
|
||||||
...state.sublist(0, state.length - 1),
|
isStreaming: false,
|
||||||
lastMessage.copyWith(isStreaming: false, content: cleaned),
|
content: cleaned,
|
||||||
];
|
);
|
||||||
|
|
||||||
|
// Fallback: if there is an immediately previous assistant message
|
||||||
|
// marked as an archived variant and we have no versions yet, attach it
|
||||||
|
// as a version so the UI shows a switcher.
|
||||||
|
if (state.length >= 2 && updatedLast.versions.isEmpty) {
|
||||||
|
final prev = state[state.length - 2];
|
||||||
|
final isArchivedAssistant =
|
||||||
|
prev.role == 'assistant' &&
|
||||||
|
(prev.metadata?['archivedVariant'] == true);
|
||||||
|
if (isArchivedAssistant) {
|
||||||
|
final snapshot = ChatMessageVersion(
|
||||||
|
id: prev.id,
|
||||||
|
content: prev.content,
|
||||||
|
timestamp: prev.timestamp,
|
||||||
|
model: prev.model,
|
||||||
|
files: prev.files,
|
||||||
|
sources: prev.sources,
|
||||||
|
followUps: prev.followUps,
|
||||||
|
codeExecutions: prev.codeExecutions,
|
||||||
|
usage: prev.usage,
|
||||||
|
);
|
||||||
|
updatedLast = updatedLast.copyWith(
|
||||||
|
versions: [...updatedLast.versions, snapshot],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = [...state.sublist(0, state.length - 1), updatedLast];
|
||||||
_messageStream = null;
|
_messageStream = null;
|
||||||
_stopRemoteTaskMonitor();
|
_stopRemoteTaskMonitor();
|
||||||
|
|
||||||
@@ -1014,8 +1081,9 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
Future<void> regenerateMessage(
|
Future<void> regenerateMessage(
|
||||||
dynamic ref,
|
dynamic ref,
|
||||||
String userMessageContent,
|
String userMessageContent,
|
||||||
List<String>? attachments,
|
List<String>? attachments, [
|
||||||
) async {
|
String? existingAssistantId,
|
||||||
|
]) async {
|
||||||
final reviewerMode = ref.read(reviewerModeProvider);
|
final reviewerMode = ref.read(reviewerModeProvider);
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final selectedModel = ref.read(selectedModelProvider);
|
final selectedModel = ref.read(selectedModelProvider);
|
||||||
@@ -1135,13 +1203,42 @@ Future<void> regenerateMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-seed assistant skeleton and persist chain
|
// Pre-seed assistant skeleton and persist chain; always use a new id so
|
||||||
|
// server history can branch like OpenWebUI.
|
||||||
final String assistantMessageId = await _preseedAssistantAndPersist(
|
final String assistantMessageId = await _preseedAssistantAndPersist(
|
||||||
ref,
|
ref,
|
||||||
|
existingAssistantId: null,
|
||||||
modelId: selectedModel.id,
|
modelId: selectedModel.id,
|
||||||
systemPrompt: effectiveSystemPrompt,
|
systemPrompt: effectiveSystemPrompt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Attach previous assistant as a version snapshot to the new assistant
|
||||||
|
try {
|
||||||
|
final msgs = ref.read(chatMessagesProvider);
|
||||||
|
if (msgs.length >= 2) {
|
||||||
|
final prev = msgs[msgs.length - 2];
|
||||||
|
final last = msgs.last;
|
||||||
|
if (prev.role == 'assistant' && last.id == assistantMessageId) {
|
||||||
|
final snapshot = ChatMessageVersion(
|
||||||
|
id: prev.id,
|
||||||
|
content: prev.content,
|
||||||
|
timestamp: prev.timestamp,
|
||||||
|
model: prev.model,
|
||||||
|
files: prev.files,
|
||||||
|
sources: prev.sources,
|
||||||
|
followUps: prev.followUps,
|
||||||
|
codeExecutions: prev.codeExecutions,
|
||||||
|
usage: prev.usage,
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(chatMessagesProvider.notifier)
|
||||||
|
.updateLastMessageWithFunction(
|
||||||
|
(m) => m.copyWith(versions: [...m.versions, snapshot]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Feature toggles
|
// Feature toggles
|
||||||
final webSearchEnabled =
|
final webSearchEnabled =
|
||||||
ref.read(webSearchEnabledProvider) &&
|
ref.read(webSearchEnabledProvider) &&
|
||||||
@@ -2223,8 +2320,16 @@ final regenerateLastMessageProvider = Provider<Future<void> Function()>((ref) {
|
|||||||
|
|
||||||
if (lastUserMessage == null) return;
|
if (lastUserMessage == null) return;
|
||||||
|
|
||||||
// Remove last assistant message
|
// Mark previous assistant as an archived variant so UI can hide it
|
||||||
ref.read(chatMessagesProvider.notifier).removeLastMessage();
|
final notifier = ref.read(chatMessagesProvider.notifier);
|
||||||
|
if (lastAssistantMessage != null) {
|
||||||
|
notifier.updateLastMessageWithFunction((m) {
|
||||||
|
final meta = Map<String, dynamic>.from(m.metadata ?? const {});
|
||||||
|
meta['archivedVariant'] = true;
|
||||||
|
// Keep content/files intact for server persistence
|
||||||
|
return m.copyWith(metadata: meta, isStreaming: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If previous assistant was image-only or had images, regenerate images instead of text
|
// If previous assistant was image-only or had images, regenerate images instead of text
|
||||||
if (lastAssistantHadImages) {
|
if (lastAssistantHadImages) {
|
||||||
|
|||||||
@@ -902,6 +902,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide archived assistant variants in the linear view
|
||||||
|
final isArchivedVariant =
|
||||||
|
!isUser && (message.metadata?['archivedVariant'] == true);
|
||||||
|
if (isArchivedVariant) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
final showFollowUps =
|
final showFollowUps =
|
||||||
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
|
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
|
||||||
|
|
||||||
@@ -990,8 +997,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the assistant message we want to regenerate
|
// Mark previous assistant as archived for UI; keep it for server history
|
||||||
ref.read(chatMessagesProvider.notifier).removeLastMessage();
|
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction((
|
||||||
|
m,
|
||||||
|
) {
|
||||||
|
final meta = Map<String, dynamic>.from(m.metadata ?? const {});
|
||||||
|
meta['archivedVariant'] = true;
|
||||||
|
return m.copyWith(metadata: meta, isStreaming: false);
|
||||||
|
});
|
||||||
|
|
||||||
// Regenerate response for the previous user message (without duplicating it)
|
// Regenerate response for the previous user message (without duplicating it)
|
||||||
final userMessage = messages[messageIndex - 1];
|
final userMessage = messages[messageIndex - 1];
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
bool _allowTypingIndicator = false;
|
bool _allowTypingIndicator = false;
|
||||||
Timer? _typingGateTimer;
|
Timer? _typingGateTimer;
|
||||||
String _ttsPlainText = '';
|
String _ttsPlainText = '';
|
||||||
|
// Active version index (-1 means current/live content)
|
||||||
|
int _activeVersionIndex = -1;
|
||||||
// press state handled by shared ChatActionButton
|
// press state handled by shared ChatActionButton
|
||||||
|
|
||||||
Future<void> _handleFollowUpTap(String suggestion) async {
|
Future<void> _handleFollowUpTap(String suggestion) async {
|
||||||
@@ -140,7 +142,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reparseSections() {
|
void _reparseSections() {
|
||||||
final raw0 = widget.message.content ?? '';
|
final raw0 = _activeVersionIndex >= 0
|
||||||
|
? (widget.message.versions[_activeVersionIndex].content as String?) ??
|
||||||
|
''
|
||||||
|
: widget.message.content ?? '';
|
||||||
// Strip any leftover placeholders from content before parsing
|
// Strip any leftover placeholders from content before parsing
|
||||||
const ti = '[TYPING_INDICATOR]';
|
const ti = '[TYPING_INDICATOR]';
|
||||||
const searchBanner = '🔍 Searching the web...';
|
const searchBanner = '🔍 Searching the web...';
|
||||||
@@ -633,6 +638,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
widget.showFollowUps &&
|
widget.showFollowUps &&
|
||||||
widget.message.followUps.isNotEmpty &&
|
widget.message.followUps.isNotEmpty &&
|
||||||
!widget.isStreaming;
|
!widget.isStreaming;
|
||||||
|
final bool showingVersion = _activeVersionIndex >= 0;
|
||||||
|
final activeFiles = showingVersion
|
||||||
|
? widget.message.versions[_activeVersionIndex].files
|
||||||
|
: widget.message.files;
|
||||||
final hasSources = widget.message.sources.isNotEmpty;
|
final hasSources = widget.message.sources.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -657,8 +666,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Display attachments - prioritize files array over attachmentIds to avoid duplication
|
// Display attachments - prioritize files array over attachmentIds to avoid duplication
|
||||||
if (widget.message.files != null &&
|
if (activeFiles != null && activeFiles.isNotEmpty) ...[
|
||||||
widget.message.files!.isNotEmpty) ...[
|
|
||||||
_buildFilesFromArray(),
|
_buildFilesFromArray(),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
] else if (widget.message.attachmentIds != null &&
|
] else if (widget.message.attachmentIds != null &&
|
||||||
@@ -729,6 +737,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
messageId: widget.message.id,
|
messageId: widget.message.id,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Version switcher moved inline with action buttons below
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -896,11 +906,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilesFromArray() {
|
Widget _buildFilesFromArray() {
|
||||||
if (widget.message.files == null || widget.message.files!.isEmpty) {
|
final filesArray = _activeVersionIndex >= 0
|
||||||
|
? widget.message.versions[_activeVersionIndex].files
|
||||||
|
: widget.message.files;
|
||||||
|
if (filesArray == null || filesArray.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final allFiles = widget.message.files!;
|
final allFiles = filesArray;
|
||||||
|
|
||||||
// Separate images and non-image files
|
// Separate images and non-image files
|
||||||
final imageFiles = allFiles
|
final imageFiles = allFiles
|
||||||
@@ -1077,6 +1090,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated: old in-content version switcher replaced by inline controls with action buttons.
|
||||||
|
|
||||||
Widget _buildActionButtons() {
|
Widget _buildActionButtons() {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final ttsState = ref.watch(textToSpeechControllerProvider);
|
final ttsState = ref.watch(textToSpeechControllerProvider);
|
||||||
@@ -1139,6 +1154,43 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
label: l10n.copy,
|
label: l10n.copy,
|
||||||
onTap: widget.onCopy,
|
onTap: widget.onCopy,
|
||||||
),
|
),
|
||||||
|
if (widget.message.versions.isNotEmpty && !widget.isStreaming) ...[
|
||||||
|
// Inline version toggle: Prev [1/n] Next
|
||||||
|
ChatActionButton(
|
||||||
|
icon: Icons.chevron_left,
|
||||||
|
label: 'Prev',
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
if (_activeVersionIndex < 0) {
|
||||||
|
_activeVersionIndex = widget.message.versions.length - 1;
|
||||||
|
} else if (_activeVersionIndex > 0) {
|
||||||
|
_activeVersionIndex -= 1;
|
||||||
|
}
|
||||||
|
_reparseSections();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ConduitChip(
|
||||||
|
label:
|
||||||
|
'${_activeVersionIndex < 0 ? (widget.message.versions.length + 1) : (_activeVersionIndex + 1)}/${widget.message.versions.length + 1}',
|
||||||
|
isCompact: true,
|
||||||
|
),
|
||||||
|
ChatActionButton(
|
||||||
|
icon: Icons.chevron_right,
|
||||||
|
label: 'Next',
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
if (_activeVersionIndex < 0) return; // already live
|
||||||
|
if (_activeVersionIndex < widget.message.versions.length - 1) {
|
||||||
|
_activeVersionIndex += 1;
|
||||||
|
} else {
|
||||||
|
_activeVersionIndex = -1; // move to live
|
||||||
|
}
|
||||||
|
_reparseSections();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
if (isErrorMessage) ...[
|
if (isErrorMessage) ...[
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
|
|||||||
Reference in New Issue
Block a user