diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index 2d36041..c84b028 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -30,12 +30,40 @@ sealed class ChatMessage with _$ChatMessage { @Default([]) List sources, Map? usage, + // Previous generated versions of this assistant message (OpenWebUI-style) + @JsonKey(includeFromJson: false, includeToJson: false) + @Default([]) + List versions, }) = _ChatMessage; factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); } +@freezed +abstract class ChatMessageVersion with _$ChatMessageVersion { + const factory ChatMessageVersion({ + required String id, + required String content, + required DateTime timestamp, + String? model, + List>? files, + @JsonKey( + name: 'sources', + fromJson: _sourceRefsFromJson, + toJson: _sourceRefsToJson, + ) + @Default([]) + List sources, + @Default([]) List followUps, + @Default([]) List codeExecutions, + Map? usage, + }) = _ChatMessageVersion; + + factory ChatMessageVersion.fromJson(Map json) => + _$ChatMessageVersionFromJson(json); +} + @freezed abstract class ChatStatusUpdate with _$ChatStatusUpdate { const factory ChatStatusUpdate({ diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 46044aa..eb5f590 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -879,10 +879,59 @@ class ApiService { } // Default path: parse message as-is - final message = _parseOpenWebUIMessage( - msgData, - historyMsg: historyMsg, - ); + var message = _parseOpenWebUIMessage(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?; + final children = parent != null && parent['childrenIds'] is List + ? (parent['childrenIds'] as List) + .map((e) => e.toString()) + .toList() + : const []; + final versions = []; + + for (final cid in children) { + if (cid == message.id) continue; // skip current assistant + final sibling = historyMessagesMap[cid]; + if (sibling is Map) { + final role = (sibling['role'] ?? '').toString(); + if (role != 'assistant') continue; + // Build a ChatMessage from sibling for consistent parsing + final siblingData = Map.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); if (_traceFullChatParsing) { DebugLogger.log( @@ -1412,14 +1461,19 @@ class ApiService { final List> messagesArray = []; String? currentId; String? previousId; - + String? lastUserId; for (final msg in messages) { 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 messagesMap[messageId] = { 'id': messageId, - 'parentId': previousId, + 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, @@ -1432,14 +1486,14 @@ class ApiService { }; // Update parent's childrenIds if there's a previous message - if (previousId != null && messagesMap.containsKey(previousId)) { - (messagesMap[previousId]['childrenIds'] as List).add(messageId); + if (parentId != null && messagesMap.containsKey(parentId)) { + (messagesMap[parentId]['childrenIds'] as List).add(messageId); } // Build message for messages array messagesArray.add({ 'id': messageId, - 'parentId': previousId, + 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, @@ -1453,6 +1507,9 @@ class ApiService { previousId = messageId; currentId = messageId; + if (msg.role == 'user') { + lastUserId = messageId; + } } // Create the chat data structure matching OpenWebUI format exactly @@ -1509,6 +1566,7 @@ class ApiService { final List> messagesArray = []; String? currentId; String? previousId; + String? lastUserId; for (final msg in messages) { final messageId = msg.id; @@ -1517,9 +1575,19 @@ class ApiService { // The msg.files array already contains all attachments in the correct format 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] = { 'id': messageId, - 'parentId': previousId, + 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, @@ -1536,8 +1604,8 @@ class ApiService { }; // Update parent's childrenIds - if (previousId != null && messagesMap.containsKey(previousId)) { - (messagesMap[previousId]['childrenIds'] as List).add(messageId); + if (parentId != null && messagesMap.containsKey(parentId)) { + (messagesMap[parentId]['childrenIds'] as List).add(messageId); } // Use the same properly formatted files array for messages array @@ -1545,7 +1613,7 @@ class ApiService { messagesArray.add({ 'id': messageId, - 'parentId': previousId, + 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, @@ -1562,6 +1630,37 @@ class ApiService { }); 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': [], + '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; } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index d578f45..31a691f 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -494,6 +494,45 @@ class ChatMessagesNotifier extends Notifier> { 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>.from(last.files!), + sources: List.from(last.sources), + followUps: List.from(last.followUps), + codeExecutions: List.from(last.codeExecutions), + usage: last.usage == null ? null : Map.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) { final withTimestamp = update.occurredAt == null ? update.copyWith(occurredAt: DateTime.now()) @@ -644,10 +683,38 @@ class ChatMessagesNotifier extends Notifier> { final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content); final cleaned = _stripStreamingPlaceholders(finalized); - state = [ - ...state.sublist(0, state.length - 1), - lastMessage.copyWith(isStreaming: false, content: cleaned), - ]; + var updatedLast = lastMessage.copyWith( + isStreaming: false, + 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; _stopRemoteTaskMonitor(); @@ -1014,8 +1081,9 @@ Future> _buildMessagePayloadWithAttachments({ Future regenerateMessage( dynamic ref, String userMessageContent, - List? attachments, -) async { + List? attachments, [ + String? existingAssistantId, +]) async { final reviewerMode = ref.read(reviewerModeProvider); final api = ref.read(apiServiceProvider); final selectedModel = ref.read(selectedModelProvider); @@ -1135,13 +1203,42 @@ Future 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( ref, + existingAssistantId: null, modelId: selectedModel.id, 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 final webSearchEnabled = ref.read(webSearchEnabledProvider) && @@ -2223,8 +2320,16 @@ final regenerateLastMessageProvider = Provider Function()>((ref) { if (lastUserMessage == null) return; - // Remove last assistant message - ref.read(chatMessagesProvider.notifier).removeLastMessage(); + // Mark previous assistant as an archived variant so UI can hide it + final notifier = ref.read(chatMessagesProvider.notifier); + if (lastAssistantMessage != null) { + notifier.updateLastMessageWithFunction((m) { + final meta = Map.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 (lastAssistantHadImages) { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index e51f922..a157b4f 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -902,6 +902,13 @@ class _ChatPageState extends ConsumerState { } } + // Hide archived assistant variants in the linear view + final isArchivedVariant = + !isUser && (message.metadata?['archivedVariant'] == true); + if (isArchivedVariant) { + return const SizedBox.shrink(); + } + final showFollowUps = !isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow; @@ -990,8 +997,14 @@ class _ChatPageState extends ConsumerState { return; } - // Remove the assistant message we want to regenerate - ref.read(chatMessagesProvider.notifier).removeLastMessage(); + // Mark previous assistant as archived for UI; keep it for server history + ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(( + m, + ) { + final meta = Map.from(m.metadata ?? const {}); + meta['archivedVariant'] = true; + return m.copyWith(metadata: meta, isStreaming: false); + }); // Regenerate response for the previous user message (without duplicating it) final userMessage = messages[messageIndex - 1]; diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 5d6a42b..6bd4ea9 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -70,6 +70,8 @@ class _AssistantMessageWidgetState extends ConsumerState bool _allowTypingIndicator = false; Timer? _typingGateTimer; String _ttsPlainText = ''; + // Active version index (-1 means current/live content) + int _activeVersionIndex = -1; // press state handled by shared ChatActionButton Future _handleFollowUpTap(String suggestion) async { @@ -140,7 +142,10 @@ class _AssistantMessageWidgetState extends ConsumerState } 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 const ti = '[TYPING_INDICATOR]'; const searchBanner = '🔍 Searching the web...'; @@ -633,6 +638,10 @@ class _AssistantMessageWidgetState extends ConsumerState widget.showFollowUps && widget.message.followUps.isNotEmpty && !widget.isStreaming; + final bool showingVersion = _activeVersionIndex >= 0; + final activeFiles = showingVersion + ? widget.message.versions[_activeVersionIndex].files + : widget.message.files; final hasSources = widget.message.sources.isNotEmpty; return Container( @@ -657,8 +666,7 @@ class _AssistantMessageWidgetState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ // Display attachments - prioritize files array over attachmentIds to avoid duplication - if (widget.message.files != null && - widget.message.files!.isNotEmpty) ...[ + if (activeFiles != null && activeFiles.isNotEmpty) ...[ _buildFilesFromArray(), const SizedBox(height: Spacing.md), ] else if (widget.message.attachmentIds != null && @@ -729,6 +737,8 @@ class _AssistantMessageWidgetState extends ConsumerState messageId: widget.message.id, ), ], + + // Version switcher moved inline with action buttons below ], ), ), @@ -896,11 +906,14 @@ class _AssistantMessageWidgetState extends ConsumerState } 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(); } - final allFiles = widget.message.files!; + final allFiles = filesArray; // Separate images and non-image files final imageFiles = allFiles @@ -1077,6 +1090,8 @@ class _AssistantMessageWidgetState extends ConsumerState ); } + // Deprecated: old in-content version switcher replaced by inline controls with action buttons. + Widget _buildActionButtons() { final l10n = AppLocalizations.of(context)!; final ttsState = ref.watch(textToSpeechControllerProvider); @@ -1139,6 +1154,43 @@ class _AssistantMessageWidgetState extends ConsumerState label: l10n.copy, 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) ...[ _buildActionButton( icon: Platform.isIOS