feat(chat): regenerate variants and support

Hide archived assistant variants in the linear chat view and track
previous assistant as versions so regenerated responses do not
duplicate or lose history. When regenerating, mark the previous assistant
message with an archivedVariant flag for the UI and keep it in server
history. Add a ChatMessageVersion model and a versions field to
ChatMessage to store prior generated variants. Implement
archiveLastAssistantAsVersion in chat providers to snapshot the last
assistant message into versions and reset the message for a fresh
streamed generation. Finalize flow updates to attach an adjacent archived
assistant as a version when needed so the UI can present a switcher
between current and past variants. These changes prevent duplicate
messages, preserve previous responses, and enable variant switching.
This commit is contained in:
cogwheel0
2025-10-23 22:29:28 +05:30
parent 1a38cf02e5
commit 1cb8926e21
5 changed files with 326 additions and 29 deletions

View File

@@ -494,6 +494,45 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
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) {
final withTimestamp = update.occurredAt == null
? update.copyWith(occurredAt: DateTime.now())
@@ -644,10 +683,38 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
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<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
Future<void> regenerateMessage(
dynamic ref,
String userMessageContent,
List<String>? attachments,
) async {
List<String>? attachments, [
String? existingAssistantId,
]) async {
final reviewerMode = ref.read(reviewerModeProvider);
final api = ref.read(apiServiceProvider);
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(
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<Future<void> 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<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 (lastAssistantHadImages) {

View File

@@ -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 =
!isUser && !hasUserBubbleBelow && !hasAssistantBubbleBelow;
@@ -990,8 +997,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<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)
final userMessage = messages[messageIndex - 1];

View File

@@ -70,6 +70,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
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<void> _handleFollowUpTap(String suggestion) async {
@@ -140,7 +142,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}
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<AssistantMessageWidget>
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<AssistantMessageWidget>
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<AssistantMessageWidget>
messageId: widget.message.id,
),
],
// Version switcher moved inline with action buttons below
],
),
),
@@ -896,11 +906,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}
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<AssistantMessageWidget>
);
}
// 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<AssistantMessageWidget>
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