diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index a1560cf..258f964 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1130,7 +1130,7 @@ class ApiService { if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, - if (msg.role == 'assistant') 'done': true, + if (msg.role == 'assistant') 'done': !msg.isStreaming, if (msg.role == 'user' && model != null) 'models': [model], if (combinedFilesMap.isNotEmpty) 'files': combinedFilesMap, }; @@ -1166,7 +1166,7 @@ class ApiService { if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, - if (msg.role == 'assistant') 'done': true, + if (msg.role == 'assistant') 'done': !msg.isStreaming, if (msg.role == 'user' && model != null) 'models': [model], if (combinedFilesArray.isNotEmpty) 'files': combinedFilesArray, }); @@ -2652,11 +2652,14 @@ class ApiService { String? sessionIdOverride, List>? toolServers, Map? backgroundTasks, + String? responseMessageId, }) { final streamController = StreamController(); // Generate unique IDs - final messageId = const Uuid().v4(); + final messageId = (responseMessageId != null && responseMessageId.isNotEmpty) + ? responseMessageId + : const Uuid().v4(); final sessionId = (sessionIdOverride != null && sessionIdOverride.isNotEmpty) ? sessionIdOverride @@ -2809,6 +2812,8 @@ class ApiService { // Always use task-based background flow for unified pipeline. // When a dynamic channel (session_id) is not provided, this method falls // back to polling and streams deltas to the UI. + // Always use background task flow (matches web client) to ensure + // server maintains correct history with pre-seeded assistant id. final bool useBackgroundTasks = true; // Use background flow only when required; otherwise prefer SSE even with chat_id. diff --git a/lib/core/utils/tool_calls_parser.dart b/lib/core/utils/tool_calls_parser.dart index d6e7bee..1d80012 100644 --- a/lib/core/utils/tool_calls_parser.dart +++ b/lib/core/utils/tool_calls_parser.dart @@ -236,6 +236,71 @@ class ToolCallsParser { return raw.length > max ? '${raw.substring(0, max)}…' : raw; } } + + /// Sanitize assistant/user content before sending to the API, mirroring + /// the web client's `processDetails` behavior: + /// - Remove
and
blocks + /// - Replace
...
blocks with the + /// JSON-serialized `result` attribute (as a quoted string) when available; + /// otherwise replace with an empty string. + static String sanitizeForApi(String content) { + if (content.isEmpty) return content; + + // Remove blocks we never want to include in conversation context + final removeTypes = ['reasoning', 'code_interpreter']; + for (final t in removeTypes) { + content = content.replaceAll( + RegExp( + ']*>[\\s\\S]*?<\\/details>', + multiLine: true, + dotAll: true, + ), + '', + ); + } + + if (!content.contains(']*>[\s\S]*?<\/details>', + multiLine: true, + dotAll: true, + ), + '', + ); + if (t.isNotEmpty) buf.write(t); + } + } + + return buf.toString().trim(); + } } /// Ordered piece of content: either plain text or a tool-call entry diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index d78f1d8..16a721d 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -450,37 +450,31 @@ Future regenerateMessage( for (final msg in messages) { if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { + // Clean up tool/details markup to match web client behavior + final cleaned = ToolCallsParser.sanitizeForApi(msg.content); + // Handle messages with attachments if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) { final List> contentArray = []; // Add text content first - if (msg.content.isNotEmpty) { - contentArray.add({'type': 'text', 'text': msg.content}); + if (cleaned.isNotEmpty) { + contentArray.add({'type': 'text', 'text': cleaned}); } conversationMessages.add({ 'role': msg.role, - 'content': contentArray.isNotEmpty ? contentArray : msg.content, + 'content': contentArray.isNotEmpty ? contentArray : cleaned, }); } else { // Regular text message - conversationMessages.add({'role': msg.role, 'content': msg.content}); + conversationMessages.add({'role': msg.role, 'content': cleaned}); } } } - // Stream response using SSE - final response = api!.sendMessage( - messages: conversationMessages, - model: selectedModel.id, - conversationId: activeConversation.id, - ); - - final stream = response.stream; - final assistantMessageId = response.messageId; - - // Add assistant message placeholder + // Pre-seed assistant skeleton + final String assistantMessageId = const Uuid().v4(); final assistantMessage = ChatMessage( id: assistantMessageId, role: 'assistant', @@ -490,6 +484,24 @@ Future regenerateMessage( isStreaming: true, ); ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); + try { + final msgsForSeed = ref.read(chatMessagesProvider); + await api!.updateConversationWithMessages( + activeConversation.id, + msgsForSeed, + model: selectedModel.id, + ); + } catch (_) {} + + // Stream response via background task (socket/dynamic channel or polling) + final response = api!.sendMessage( + messages: conversationMessages, + model: selectedModel.id, + conversationId: activeConversation.id, + responseMessageId: assistantMessageId, + ); + + final stream = response.stream; // Handle streaming response (basic chunking for this path) final chunkedStream = StreamChunker.chunkStream( @@ -705,6 +717,9 @@ Future _sendMessageInternal( // Skip only empty assistant message placeholders that are currently streaming // Include completed messages (both user and assistant) for conversation history if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) { + // Prepare cleaned text content (strip tool details etc.) + final cleaned = ToolCallsParser.sanitizeForApi(msg.content); + // Check if message has attachments (images and non-images) if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) { // All models use the same content array format (OpenWebUI standard) @@ -715,8 +730,8 @@ Future _sendMessageInternal( final List> nonImageFiles = []; // Add text content first - if (msg.content.isNotEmpty) { - contentArray.add({'type': 'text', 'text': msg.content}); + if (cleaned.isNotEmpty) { + contentArray.add({'type': 'text', 'text': cleaned}); } // Add image attachments with proper MIME type handling; collect non-image attachments @@ -772,7 +787,7 @@ Future _sendMessageInternal( conversationMessages.add(messageMap); } else { // Regular text-only message - conversationMessages.add({'role': msg.role, 'content': msg.content}); + conversationMessages.add({'role': msg.role, 'content': cleaned}); } } } @@ -789,6 +804,33 @@ Future _sendMessageInternal( : null; try { + // Pre-seed assistant skeleton on server to ensure correct chain + // Generate assistant message id now (must be consistent across client/server) + final String assistantMessageId = const Uuid().v4(); + + // Add assistant placeholder locally before sending + final assistantPlaceholder = ChatMessage( + id: assistantMessageId, + role: 'assistant', + content: '', + timestamp: DateTime.now(), + model: selectedModel.id, + isStreaming: true, + ); + ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder); + + // Persist skeleton chain to server so web can load correct history + try { + final activeConvForSeed = ref.read(activeConversationProvider); + if (activeConvForSeed != null) { + final msgsForSeed = ref.read(chatMessagesProvider); + await api.updateConversationWithMessages( + activeConvForSeed.id, + msgsForSeed, + model: selectedModel.id, + ); + } + } catch (_) {} // Use the model's actual supported parameters if available final supportedParams = selectedModel.supportedParameters ?? @@ -1028,11 +1070,12 @@ Future _sendMessageInternal( (conv.title == 'New Chat' && msgs.length <= 1); } catch (_) {} + // Match web client: request background follow-ups always; title/tags on first turn final bgTasks = { if (shouldGenerateTitle) 'title_generation': true, if (shouldGenerateTitle) 'tags_generation': true, 'follow_up_generation': true, - if (webSearchEnabled) 'web_search': true, // enable bg workflow for web search + if (webSearchEnabled) 'web_search': true, // enable bg web search if (imageGenerationEnabled) 'image_generation': true, // enable bg image flow }; @@ -1056,23 +1099,12 @@ Future _sendMessageInternal( sessionIdOverride: wantSessionBinding ? socketSessionId : null, toolServers: toolServers, backgroundTasks: bgTasks, + responseMessageId: assistantMessageId, ); final stream = response.stream; - final assistantMessageId = response.messageId; final sessionId = response.sessionId; - // Add assistant message placeholder with the generated ID and immediate typing indicator - final assistantMessage = ChatMessage( - id: assistantMessageId, - role: 'assistant', - content: '', - timestamp: DateTime.now(), - model: selectedModel.id, - isStreaming: true, - ); - ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage); - // If socket is available, start listening for chat-events immediately // Background-tools flow OR any session-bound flow relies on socket/dynamic channel for // streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress @@ -1718,10 +1750,10 @@ Future _sendMessageInternal( } } - // Save conversation to OpenWebUI server only after streaming is complete - // Add a small delay to ensure the last message content is fully updated - await Future.delayed(const Duration(milliseconds: 100)); - await _saveConversationToServer(ref); + // Do not persist conversation to server here. Server manages chat state. + // Keep local save only for quick resume. + await Future.delayed(const Duration(milliseconds: 50)); + await _saveConversationLocally(ref); // Removed post-assistant image generation; images are handled immediately after user message }, @@ -1956,17 +1988,7 @@ Future _checkForTitleInBackground( } // Save current conversation to OpenWebUI server -Future _saveConversationToServer(dynamic ref) async { - // Enqueue save task; local fallback remains if queue fails - try { - final activeConversation = ref.read(activeConversationProvider); - await ref - .read(taskQueueProvider.notifier) - .enqueueSaveConversation(conversationId: activeConversation?.id); - } catch (_) { - await _saveConversationLocally(ref); - } -} +// Removed server persistence; only local caching is used in mobile app. // Fallback: Save current conversation to local storage Future _saveConversationLocally(dynamic ref) async { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index c66d564..6edb8e6 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1030,7 +1030,9 @@ class _ChatPageState extends ConsumerState { ref.read(chatMessagesProvider.notifier).finishStreaming(); } - await _saveConversationBeforeLeaving(ref); + // Do not push conversation state back to server on exit. + // Server already maintains chat state from message sends. + // Keep any local persistence only. if (context.mounted) { final canPopNavigator = Navigator.of(context).canPop(); diff --git a/lib/shared/services/tasks/outbound_task.dart b/lib/shared/services/tasks/outbound_task.dart index 0bab8c8..cf77ffe 100644 --- a/lib/shared/services/tasks/outbound_task.dart +++ b/lib/shared/services/tasks/outbound_task.dart @@ -74,17 +74,6 @@ abstract class OutboundTask with _$OutboundTask { String? error, }) = GenerateImageTask; - const factory OutboundTask.saveConversation({ - required String id, - String? conversationId, - @Default(TaskStatus.queued) TaskStatus status, - @Default(0) int attempt, - String? idempotencyKey, - DateTime? enqueuedAt, - DateTime? startedAt, - DateTime? completedAt, - String? error, - }) = SaveConversationTask; const factory OutboundTask.generateTitle({ required String id, @@ -121,7 +110,6 @@ abstract class OutboundTask with _$OutboundTask { uploadMedia: (t) => t.conversationId, executeToolCall: (t) => t.conversationId, generateImage: (t) => t.conversationId, - saveConversation: (t) => t.conversationId, generateTitle: (t) => t.conversationId, imageToDataUrl: (t) => t.conversationId, ); diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index f1595f9..dda42fd 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -273,22 +273,7 @@ class TaskQueueNotifier extends StateNotifier> { return id; } - Future enqueueSaveConversation({ - required String? conversationId, - String? idempotencyKey, - }) async { - final id = _uuid.v4(); - final task = OutboundTask.saveConversation( - id: id, - conversationId: conversationId, - idempotencyKey: idempotencyKey, - enqueuedAt: DateTime.now(), - ); - state = [...state, task]; - await _save(); - _process(); - return id; - } + // Removed: enqueueSaveConversation — mobile app no longer persists chats to server. Future enqueueGenerateTitle({ required String conversationId, diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index f6087bd..866dd82 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -22,7 +22,7 @@ class TaskWorker { executeToolCall: _performExecuteToolCall, generateImage: _performGenerateImage, imageToDataUrl: _performImageToDataUrl, - saveConversation: _performSaveConversation, + // saveConversation removed — we no longer push chat state to server generateTitle: _performGenerateTitle, ); } @@ -328,36 +328,7 @@ class TaskWorker { } } - Future _performSaveConversation(SaveConversationTask task) async { - final api = _ref.read(apiServiceProvider); - final messages = _ref.read(chat.chatMessagesProvider); - final activeConv = _ref.read(activeConversationProvider); - final selectedModel = _ref.read(selectedModelProvider); - if (api == null || messages.isEmpty || activeConv == null) return; - - // Skip if last assistant is empty placeholder - final last = messages.last; - if (last.role == 'assistant' && - last.content.trim().isEmpty && - (last.files?.isEmpty ?? true) && - (last.attachmentIds?.isEmpty ?? true)) { - return; - } - - try { - await api.updateConversationWithMessages( - activeConv.id, - messages, - model: selectedModel?.id, - ); - final updated = activeConv.copyWith( - messages: messages, - updatedAt: DateTime.now(), - ); - _ref.read(activeConversationProvider.notifier).state = updated; - _ref.invalidate(conversationsProvider); - } catch (_) {} - } + // _performSaveConversation removed Future _performGenerateTitle(GenerateTitleTask task) async { final api = _ref.read(apiServiceProvider); @@ -387,15 +358,8 @@ class TaskWorker { updatedAt: DateTime.now(), ); _ref.read(activeConversationProvider.notifier).state = updated; - try { - final cur = _ref.read(chat.chatMessagesProvider); - await api.updateConversationWithMessages( - updated.id, - cur, - title: updated.title, - model: selectedModel.id, - ); - } catch (_) {} + // Do not push full messages to server; skip remote update. + // Optionally refresh list to reflect server-side title when it’s generated there. _ref.invalidate(conversationsProvider); } }