diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 441571b..df6c596 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -568,42 +568,33 @@ class ApiService { } } - // Try multiple locations for messages - prefer list format to avoid duplication + // Try multiple locations for messages - prefer history-based ordering like Open‑WebUI List? messagesList; Map? historyMessagesMap; if (chatObject != null) { - // Check for messages in chat.messages (list format) - PREFERRED - if (chatObject['messages'] != null) { + // Prefer history.messages with currentId to reconstruct the selected branch + final history = chatObject['history'] as Map?; + if (history != null && history['messages'] is Map) { + historyMessagesMap = history['messages'] as Map; + + // Reconstruct ordered list using parent chain up to currentId + final currentId = history['currentId']?.toString(); + if (currentId != null && currentId.isNotEmpty) { + messagesList = _buildMessagesListFromHistory(history); + debugPrint( + 'DEBUG: Built ${messagesList.length} messages from history chain to currentId=$currentId', + ); + } + } + + // Fallback to chat.messages (list format) if history is missing or empty + if ((messagesList == null || (messagesList is List && messagesList.isEmpty)) && + chatObject['messages'] != null) { messagesList = chatObject['messages'] as List; debugPrint( - 'DEBUG: Found ${messagesList.length} messages in chat.messages', + 'DEBUG: Found ${messagesList.length} messages in chat.messages (fallback)', ); - // Also capture history map for richer assistant entries (tool_calls, files) - final history = chatObject['history'] as Map?; - if (history != null && history['messages'] is Map) { - historyMessagesMap = history['messages'] as Map; - } - } else { - // Fallback: Check for messages in chat.history.messages (map format) - final history = chatObject['history'] as Map?; - if (history != null && history['messages'] != null) { - final messagesMap = history['messages'] as Map; - historyMessagesMap = messagesMap; - debugPrint( - 'DEBUG: Found ${messagesMap.length} messages in chat.history.messages (converting to list)', - ); - - // Convert map to list format to use common parsing logic - messagesList = []; - for (final entry in messagesMap.entries) { - final msgData = Map.from( - entry.value as Map, - ); - msgData['id'] = entry.key; // Use the key as the message ID - messagesList.add(msgData); - } - } } } else if (chatData['messages'] != null) { messagesList = chatData['messages'] as List; @@ -725,12 +716,15 @@ class ApiService { } String contentString; if (content is List) { - // Extract text content from array; if none, build from tool-like items later - final textContent = content.firstWhere( - (item) => item is Map && item['type'] == 'text', - orElse: () => {'text': ''}, - ); - contentString = (textContent['text'] as String?) ?? ''; + // Concatenate all text fragments in order (Open‑WebUI may split long text) + final buffer = StringBuffer(); + for (final item in content) { + if (item is Map && item['type'] == 'text') { + final t = item['text']?.toString(); + if (t != null && t.isNotEmpty) buffer.write(t); + } + } + contentString = buffer.toString(); if (contentString.trim().isEmpty) { // Fallback: look for tool-related entries in the array and synthesize details blocks final synthesized = _synthesizeToolDetailsFromContentArray(content); @@ -742,6 +736,26 @@ class ApiService { contentString = (content as String?) ?? ''; } + // Prefer longer content from history if available (guards against truncated previews) + if (historyMsg != null) { + final histContent = historyMsg['content']; + if (histContent is String && histContent.length > contentString.length) { + contentString = histContent; + } else if (histContent is List) { + final buf = StringBuffer(); + for (final item in histContent) { + if (item is Map && item['type'] == 'text') { + final t = item['text']?.toString(); + if (t != null && t.isNotEmpty) buf.write(t); + } + } + final combined = buf.toString(); + if (combined.length > contentString.length) { + contentString = combined; + } + } + } + // Final fallback: some servers store tool calls under tool_calls instead of content final toolCallsList = (msgData['tool_calls'] is List) ? (msgData['tool_calls'] as List) @@ -806,6 +820,31 @@ class ApiService { ); } + // Build ordered messages list from Open‑WebUI history using parent chain to currentId + List> _buildMessagesListFromHistory( + Map history, + ) { + final messagesMap = history['messages'] as Map?; + final currentId = history['currentId']?.toString(); + + if (messagesMap == null || currentId == null) return []; + + List> buildChain(String? id) { + if (id == null) return []; + final raw = messagesMap[id]; + if (raw == null) return []; + final msg = Map.from(raw as Map); + msg['id'] = id; // ensure id present + final parentId = msg['parentId']?.toString(); + if (parentId != null && parentId.isNotEmpty) { + return [...buildChain(parentId), msg]; + } + return [msg]; + } + + return buildChain(currentId); + } + // ===== Helpers to synthesize tool-call details blocks for UI parsing ===== String _escapeHtmlAttr(String s) { return s diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 7a6273a..7c702bd 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1046,12 +1046,14 @@ Future _sendMessageInternal( 'title_generation': true, 'tags_generation': true, 'follow_up_generation': true, + if (webSearchEnabled) 'web_search': true, // enable bg workflow for web search }; - // Determine if we need background task flow (tools/tool servers) + // Determine if we need background task flow (tools/tool servers or web search) final bool isBackgroundToolsFlowPre = (toolIdsForApi != null && toolIdsForApi.isNotEmpty) || (toolServers != null && toolServers.isNotEmpty); + final bool isBackgroundWebSearchPre = webSearchEnabled; final response = await api.sendMessage( messages: conversationMessages, @@ -1089,7 +1091,8 @@ Future _sendMessageInternal( // 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 // socket TEXT to avoid duplicates (still surface tool_call status). - final bool isBackgroundFlow = isBackgroundToolsFlowPre || wantSessionBinding; + final bool isBackgroundFlow = + isBackgroundToolsFlowPre || isBackgroundWebSearchPre || wantSessionBinding; bool suppressSocketContent = !isBackgroundFlow; // allow socket text when session-bound or tools bool usingDynamicChannel = false; // set true when server provides a channel if (socketService != null) { diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index cd305e4..f1595f9 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -149,6 +149,27 @@ class TaskQueueNotifier extends StateNotifier> { return id; } + Future enqueueExecuteToolCall({ + required String? conversationId, + required String toolName, + Map arguments = const {}, + String? idempotencyKey, + }) async { + final id = _uuid.v4(); + final task = OutboundTask.executeToolCall( + id: id, + conversationId: conversationId, + toolName: toolName, + arguments: arguments, + idempotencyKey: idempotencyKey, + enqueuedAt: DateTime.now(), + ); + state = [...state, task]; + await _save(); + _process(); + return id; + } + Future _process() async { if (_processing) return; _processing = true; diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index 3991812..dafcb2b 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'dart:io'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -151,9 +150,67 @@ class TaskWorker { } Future _performExecuteToolCall(ExecuteToolCallTask task) async { - // Placeholder: In this client, native tool execution is orchestrated server-side. - // We keep this task type for future local tools or MCP bridges. - debugPrint('ExecuteToolCallTask stub: ${task.toolName}'); + // Resolve API + selected model + final api = _ref.read(apiServiceProvider); + final selectedModel = _ref.read(selectedModelProvider); + if (api == null || selectedModel == null) { + throw Exception('API or model not available'); + } + + // Optionally bring the target conversation to foreground + try { + final active = _ref.read(activeConversationProvider); + if (task.conversationId != null && + task.conversationId!.isNotEmpty && + (active == null || active.id != task.conversationId)) { + try { + final conv = await api.getConversation(task.conversationId!); + _ref.read(activeConversationProvider.notifier).state = conv; + } catch (_) {} + } + } catch (_) {} + + // Lookup tool by name (or id fallback) + String? resolvedToolId; + try { + final tools = await api.getAvailableTools(); + for (final t in tools) { + final id = (t['id'] ?? '').toString(); + final name = (t['name'] ?? '').toString(); + if (name.toLowerCase() == task.toolName.toLowerCase() || + id.toLowerCase() == task.toolName.toLowerCase()) { + resolvedToolId = id; + break; + } + } + } catch (_) {} + + // Build an explicit user instruction to run the tool with arguments. + // Passing the specific tool id hints the server/provider to execute it via native function calling. + final args = task.arguments; + String argsSnippet; + try { + argsSnippet = const JsonEncoder.withIndent(' ').convert(args); + } catch (_) { + argsSnippet = args.toString(); + } + final instruction = + 'Run the tool "${task.toolName}" with the following JSON arguments and return the result succinctly.\n' + 'If the tool is not available, respond with a brief error.\n\n' + 'Arguments:\n' + '```json\n$argsSnippet\n```'; + + // Send as a normal message but constrain tools to the resolved tool (if found) + final toolIds = (resolvedToolId != null && resolvedToolId.isNotEmpty) + ? [resolvedToolId] + : null; + + await chat.sendMessageFromService( + _ref, + instruction, + null, + toolIds, + ); } Future _performGenerateImage(GenerateImageTask task) async {