Merge pull request #98 from cogwheel0/feat/chat-archive-assistant-variants

feat(chat): regenerate variants and support
This commit is contained in:
cogwheel
2025-10-23 22:30:17 +05:30
committed by GitHub
5 changed files with 326 additions and 29 deletions

View File

@@ -30,12 +30,40 @@ sealed class ChatMessage with _$ChatMessage {
@Default(<ChatSourceReference>[])
List<ChatSourceReference> sources,
Map<String, dynamic>? usage,
// Previous generated versions of this assistant message (OpenWebUI-style)
@JsonKey(includeFromJson: false, includeToJson: false)
@Default(<ChatMessageVersion>[])
List<ChatMessageVersion> versions,
}) = _ChatMessage;
factory ChatMessage.fromJson(Map<String, dynamic> 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
abstract class ChatStatusUpdate with _$ChatStatusUpdate {
const factory ChatStatusUpdate({

View File

@@ -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<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);
if (_traceFullChatParsing) {
DebugLogger.log(
@@ -1412,14 +1461,19 @@ class ApiService {
final List<Map<String, dynamic>> 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<Map<String, dynamic>> 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': <String>[],
'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': <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;
}

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