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:
@@ -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