diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 6687d5a..ef4f26c 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -26,6 +26,7 @@ import '../services/optimized_storage_service.dart'; import '../services/socket_service.dart'; import '../utils/debug_logger.dart'; import '../models/socket_event.dart'; +import '../services/worker_manager.dart'; import '../../shared/theme/tweakcn_themes.dart'; import '../../shared/theme/app_theme.dart'; import '../../features/tools/providers/tools_providers.dart'; @@ -259,6 +260,7 @@ final apiServiceProvider = Provider((ref) { return null; } final activeServer = ref.watch(activeServerProvider); + final workerManager = ref.watch(workerManagerProvider); return activeServer.maybeWhen( data: (server) { @@ -266,6 +268,7 @@ final apiServiceProvider = Provider((ref) { final apiService = ApiService( serverConfig: server, + workerManager: workerManager, authToken: null, // Will be set by auth state manager ); diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 00b6f48..34b0c83 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -20,11 +20,10 @@ import 'persistent_streaming_service.dart'; import 'connectivity_service.dart'; import 'sse_stream_parser.dart'; import '../utils/debug_logger.dart'; -import '../utils/openwebui_source_parser.dart'; +import 'conversation_parsing.dart'; +import 'worker_manager.dart'; const bool _traceApiLogs = false; -const bool _traceConversationParsing = false; -const bool _traceFullChatParsing = false; void _traceApi(String message) { if (!_traceApiLogs) { @@ -36,6 +35,7 @@ void _traceApi(String message) { class ApiService { final Dio _dio; final ServerConfig serverConfig; + final WorkerManager _workerManager; late final ApiAuthInterceptor _authInterceptor; // Removed legacy websocket/socket.io fields @@ -51,21 +51,25 @@ class ApiService { // New callback for the unified auth state manager Future Function()? onTokenInvalidated; - ApiService({required this.serverConfig, String? authToken}) - : _dio = Dio( - BaseOptions( - baseUrl: serverConfig.url, - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 30), - followRedirects: true, - maxRedirects: 5, - validateStatus: (status) => status != null && status < 400, - // Add custom headers from server config - headers: serverConfig.customHeaders.isNotEmpty - ? Map.from(serverConfig.customHeaders) - : null, - ), - ) { + ApiService({ + required this.serverConfig, + required WorkerManager workerManager, + String? authToken, + }) : _dio = Dio( + BaseOptions( + baseUrl: serverConfig.url, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + followRedirects: true, + maxRedirects: 5, + validateStatus: (status) => status != null && status < 400, + // Add custom headers from server config + headers: serverConfig.customHeaders.isNotEmpty + ? Map.from(serverConfig.customHeaders) + : null, + ), + ), + _workerManager = workerManager { _configureSelfSignedSupport(); // Use API key from server config if provided and no explicit auth token @@ -486,102 +490,28 @@ class ApiService { }, ); - // Convert OpenWebUI chat format to our Conversation format - final conversations = []; - final pinnedIds = {}; - final archivedIds = {}; - - // Process pinned conversations first - for (final chatData in pinnedChatList) { - try { - final conversation = _parseOpenWebUIChat(chatData); - // Create a new conversation instance with pinned=true - final pinnedConversation = conversation.copyWith(pinned: true); - conversations.add(pinnedConversation); - pinnedIds.add(conversation.id); - } catch (e) { - DebugLogger.error( - 'parse-pinned-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, + final parsedJson = await _workerManager + .schedule, List>>( + parseConversationSummariesWorker, + { + 'pinned': pinnedChatList, + 'archived': archivedChatList, + 'regular': regularChatList, + }, + debugLabel: 'parse_conversation_list', ); - } - } - // Process archived conversations - for (final chatData in archivedChatList) { - try { - final conversation = _parseOpenWebUIChat(chatData); - // Create a new conversation instance with archived=true - final archivedConversation = conversation.copyWith(archived: true); - conversations.add(archivedConversation); - archivedIds.add(conversation.id); - } catch (e) { - DebugLogger.error( - 'parse-archived-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, - ); - } - } - - // Process regular conversations (excluding pinned and archived ones) - var loggedSampleChat = false; - for (final chatData in regularChatList) { - try { - // Debug: Check if conversation has folder_id in raw data - if (chatData.containsKey('folder_id') && - chatData['folder_id'] != null) { - DebugLogger.log( - 'folder-ref', - scope: 'api/conversations', - data: { - 'conversationId': chatData['id'], - 'folderId': chatData['folder_id'], - }, - ); - } - - if (!loggedSampleChat && _traceConversationParsing) { - loggedSampleChat = true; - DebugLogger.log( - 'sample-keys', - scope: 'api/conversations', - data: {'keys': chatData.keys.take(6).toList()}, - ); - DebugLogger.log( - 'sample-data', - scope: 'api/conversations', - data: {'preview': chatData.toString()}, - ); - } - - final conversation = _parseOpenWebUIChat(chatData); - // Only add if not already added as pinned or archived - if (!pinnedIds.contains(conversation.id) && - !archivedIds.contains(conversation.id)) { - conversations.add(conversation); - } - } catch (e) { - DebugLogger.error( - 'parse-regular-failed', - scope: 'api/conversations', - error: e, - data: {'conversationId': chatData['id']}, - ); - // Continue with other chats even if one fails - } - } + final conversations = parsedJson + .map((json) => Conversation.fromJson(json)) + .toList(growable: false); DebugLogger.log( 'parse-complete', scope: 'api/conversations', data: { 'total': conversations.length, - 'pinned': pinnedIds.length, - 'archived': archivedIds.length, + 'pinned': conversations.where((c) => c.pinned).length, + 'archived': conversations.where((c) => c.archived).length, }, ); return conversations; @@ -619,845 +549,26 @@ class ApiService { return []; } - // Helper method to safely parse timestamps - DateTime _parseTimestamp(dynamic timestamp) { - if (timestamp == null) return DateTime.now(); - - if (timestamp is int) { - // OpenWebUI uses Unix timestamps in seconds - // Check if it's already in milliseconds (13 digits) or seconds (10 digits) - final timestampMs = timestamp > 1000000000000 - ? timestamp - : timestamp * 1000; - return DateTime.fromMillisecondsSinceEpoch(timestampMs); - } - - if (timestamp is String) { - final parsed = int.tryParse(timestamp); - if (parsed != null) { - final timestampMs = parsed > 1000000000000 ? parsed : parsed * 1000; - return DateTime.fromMillisecondsSinceEpoch(timestampMs); - } - } - - return DateTime.now(); // Fallback to current time - } - // Parse OpenWebUI chat format to our Conversation format - Conversation _parseOpenWebUIChat(Map chatData) { - // OpenWebUI ChatTitleIdResponse format: - // { - // "id": "string", - // "title": "string", - // "updated_at": integer (timestamp), - // "created_at": integer (timestamp), - // "pinned": boolean (optional), - // "archived": boolean (optional), - // "share_id": string (optional), - // "folder_id": string (optional) - // } - - final id = chatData['id'] as String; - final title = chatData['title'] as String; - - // Safely parse timestamps with validation - // Try both snake_case and camelCase field names - final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; - final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; - - final updatedAt = _parseTimestamp(updatedAtRaw); - final createdAt = _parseTimestamp(createdAtRaw); - - // Parse additional OpenWebUI fields - // The API response might not include these fields, so we need to handle them safely - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; - final shareId = chatData['share_id'] as String?; - final folderId = chatData['folder_id'] as String?; - - // Debug logging for folder assignment - if (_traceConversationParsing && folderId != null) { - final idPreview = id.length > 8 ? id.substring(0, 8) : id; - DebugLogger.log( - 'folder-ref', - scope: 'api/conversations', - data: {'conversationId': idPreview, 'folderId': folderId}, - ); - } - - if (_traceConversationParsing) { - DebugLogger.log( - 'parsed', - scope: 'api/conversations', - data: {'id': id, 'pinned': pinned, 'archived': archived}, - ); - } - - String? systemPrompt; - final chatObject = chatData['chat'] as Map?; - if (chatObject != null) { - final systemValue = chatObject['system']; - if (systemValue is String && systemValue.trim().isNotEmpty) { - systemPrompt = systemValue; - } - } else if (chatData['system'] is String) { - final systemValue = (chatData['system'] as String).trim(); - if (systemValue.isNotEmpty) systemPrompt = systemValue; - } - - // For the list endpoint, we don't get the full chat messages - // We'll need to fetch individual chats later if needed - return Conversation( - id: id, - title: title, - createdAt: createdAt, - updatedAt: updatedAt, - systemPrompt: systemPrompt, - pinned: pinned, - archived: archived, - shareId: shareId, - folderId: folderId, - messages: [], // Empty for now, will be loaded when chat is opened - ); - } - Future getConversation(String id) async { DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id}); final response = await _dio.get('/api/v1/chats/$id'); DebugLogger.log('fetch-ok', scope: 'api/chat'); - // Parse OpenWebUI ChatResponse format - final chatData = response.data as Map; - return _parseFullOpenWebUIChat(chatData); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // Parse full OpenWebUI chat with messages - Conversation _parseFullOpenWebUIChat(Map chatData) { - if (_traceFullChatParsing) { - DebugLogger.log( - 'parse-full', - scope: 'api/chat', - data: {'keys': chatData.keys.take(8).toList()}, - ); - } - - final id = chatData['id'] as String; - final title = chatData['title'] as String; - - if (_traceFullChatParsing) { - DebugLogger.log( - 'chat-meta', - scope: 'api/chat', - data: {'id': id, 'title': title}, - ); - } - - // Safely parse timestamps with validation - final updatedAt = _parseTimestamp(chatData['updated_at']); - final createdAt = _parseTimestamp(chatData['created_at']); - - // Parse additional OpenWebUI fields - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; - final shareId = chatData['share_id'] as String?; - final folderId = chatData['folder_id'] as String?; - - // Parse messages from the 'chat' object or top-level messages - final chatObject = chatData['chat'] as Map?; - String? systemPrompt; - if (chatObject != null) { - final systemValue = chatObject['system']; - if (systemValue is String && systemValue.trim().isNotEmpty) { - systemPrompt = systemValue; - } - } else if (chatData['system'] is String) { - final systemValue = (chatData['system'] as String).trim(); - if (systemValue.isNotEmpty) systemPrompt = systemValue; - } - final messages = []; - - // Extract model from chat.models array - String? model; - if (chatObject != null && chatObject['models'] != null) { - final models = chatObject['models'] as List?; - if (models != null && models.isNotEmpty) { - model = models.first as String; - if (_traceFullChatParsing) { - DebugLogger.log( - 'model', - scope: 'api/chat', - data: {'id': id, 'model': model}, - ); - } - } - } - - // Try multiple locations for messages - prefer history-based ordering like Open‑WebUI - List? messagesList; - Map? historyMessagesMap; - - if (chatObject != 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); - if (_traceFullChatParsing) { - DebugLogger.log( - 'history-chain', - scope: 'api/chat', - data: { - 'id': id, - 'count': messagesList.length, - 'currentId': currentId, - }, - ); - } - } - } - - // Fallback to chat.messages (list format) if history is missing or empty - if (((messagesList?.isEmpty ?? true)) && chatObject['messages'] != null) { - messagesList = chatObject['messages'] as List; - if (_traceFullChatParsing) { - DebugLogger.log( - 'messages-fallback', - scope: 'api/chat', - data: {'id': id, 'count': messagesList.length}, - ); - } - } - } else if (chatData['messages'] != null) { - messagesList = chatData['messages'] as List; - if (_traceFullChatParsing) { - DebugLogger.log( - 'messages-top-level', - scope: 'api/chat', - data: {'id': id, 'count': messagesList.length}, - ); - } - } - - // Parse messages from list format only (avoiding duplication) - if (messagesList != null) { - for (int idx = 0; idx < messagesList.length; idx++) { - final msgData = messagesList[idx] as Map; - try { - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-parse', - scope: 'api/chat', - data: { - 'chatId': id, - 'messageId': msgData['id'], - 'role': msgData['role'], - 'contentLen': msgData['content']?.toString().length ?? 0, - }, - ); - } - - // If this assistant message includes tool_calls, merge following tool results - final historyMsg = historyMessagesMap != null - ? (historyMessagesMap[msgData['id']] as Map?) - : null; - - final toolCalls = (msgData['tool_calls'] is List) - ? (msgData['tool_calls'] as List) - : (historyMsg != null && historyMsg['tool_calls'] is List) - ? (historyMsg['tool_calls'] as List) - : null; - - if ((msgData['role']?.toString() == 'assistant') && - toolCalls is List) { - // Collect subsequent tool results associated with this assistant turn - final List> results = []; - int j = idx + 1; - while (j < messagesList.length) { - final next = messagesList[j] as Map; - if ((next['role']?.toString() ?? '') != 'tool') break; - final toolCallId = next['tool_call_id']?.toString(); - final resContent = next['content']; - final resFiles = next['files']; - results.add({ - 'tool_call_id': toolCallId, - 'content': resContent, - if (resFiles != null) 'files': resFiles, - }); - j++; - } - - // Synthesize content from tool_calls and results - final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( - toolCalls, - results, - ); - - final mergedAssistant = Map.from(msgData); - mergedAssistant['content'] = synthesized; - - final message = _parseOpenWebUIMessage( - mergedAssistant, - historyMsg: historyMsg, - ); - messages.add(message); - - // Skip the tool messages we just merged - idx = j - 1; - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-tool-call', - scope: 'api/chat', - data: {'chatId': id, 'messageId': message.id}, - ); - } - continue; - } - - // Default path: parse message as-is - 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?; - final children = parent != null && parent['childrenIds'] is List - ? (parent['childrenIds'] as List) - .map((e) => e.toString()) - .toList() - : const []; - final versions = []; - - for (final cid in children) { - if (cid == message.id) continue; // skip current assistant - final sibling = historyMessagesMap[cid]; - if (sibling is Map) { - final role = (sibling['role'] ?? '').toString(); - if (role != 'assistant') continue; - // Build a ChatMessage from sibling for consistent parsing - final siblingData = Map.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( - 'message', - scope: 'api/chat', - data: { - 'chatId': id, - 'messageId': message.id, - 'role': message.role, - }, - ); - } - } catch (e) { - DebugLogger.error( - 'message-parse-failed', - scope: 'api/chat', - error: e, - data: {'chatId': id, 'messageId': msgData['id']}, - ); - } - } - } - - if (_traceFullChatParsing) { - DebugLogger.log( - 'message-count', - scope: 'api/chat', - data: {'chatId': id, 'count': messages.length}, - ); - } - - return Conversation( - id: id, - title: title, - createdAt: createdAt, - updatedAt: updatedAt, - model: model, - systemPrompt: systemPrompt, - pinned: pinned, - archived: archived, - shareId: shareId, - folderId: folderId, - messages: messages, - ); - } - // Parse OpenWebUI message format to our ChatMessage format - ChatMessage _parseOpenWebUIMessage( - Map msgData, { - Map? historyMsg, - }) { - // OpenWebUI message format may vary, but typically: - // { "role": "user|assistant", "content": "text", ... } - - // Create a single UUID instance to reuse - const uuid = Uuid(); - - // Prefer richer content from history entry if present - dynamic content = msgData['content']; - if ((content == null || (content is String && content.isEmpty)) && - historyMsg != null && - historyMsg['content'] != null) { - content = historyMsg['content']; - } - String contentString; - if (content is List) { - // 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); - if (synthesized.isNotEmpty) { - contentString = synthesized; - } - } - } else { - 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) - : (historyMsg != null && historyMsg['tool_calls'] is List) - ? (historyMsg['tool_calls'] as List) - : null; - if (contentString.trim().isEmpty && toolCallsList is List) { - final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList); - if (synthesized.isNotEmpty) { - contentString = synthesized; - } - } - - // Determine role based on available fields - String role; - if (msgData['role'] != null) { - role = msgData['role'] as String; - } else if (msgData['model'] != null) { - // Messages with model field are typically assistant messages - role = 'assistant'; - } else { - // Default to user if no role or model - role = 'user'; - } - - // Parse attachments and generated images from 'files' field - List? attachmentIds; - List>? files; - - final effectiveFiles = msgData['files'] ?? historyMsg?['files']; - if (effectiveFiles != null) { - final filesList = effectiveFiles as List; - - // Handle different file formats from OpenWebUI - final userAttachments = []; - final allFiles = >[]; - - for (final file in filesList) { - if (file is Map) { - if (file['file_id'] != null) { - // User uploaded file with file_id (legacy format) - userAttachments.add(file['file_id'] as String); - } else if (file['type'] != null && file['url'] != null) { - // File with type and url (OpenWebUI format) - final fileMap = { - 'type': file['type'], - 'url': file['url'], - }; - - // Add optional fields if present - if (file['name'] != null) fileMap['name'] = file['name']; - if (file['size'] != null) fileMap['size'] = file['size']; - - allFiles.add(fileMap); - - // If this is a user-uploaded file (URL contains file ID), also extract the ID - final url = file['url'] as String; - if (url.contains('/api/v1/files/') && url.contains('/content')) { - final fileIdMatch = RegExp( - r'/api/v1/files/([^/]+)/content', - ).firstMatch(url); - if (fileIdMatch != null) { - userAttachments.add(fileIdMatch.group(1)!); - } - } - } - } - } - - attachmentIds = userAttachments.isNotEmpty ? userAttachments : null; - files = allFiles.isNotEmpty ? allFiles : null; - } - - final dynamic statusRaw = - historyMsg != null && historyMsg.containsKey('statusHistory') - ? historyMsg['statusHistory'] - : msgData['statusHistory']; - final statusHistory = _parseStatusHistoryField(statusRaw); - - final dynamic followUpsRaw = - historyMsg != null && historyMsg.containsKey('followUps') - ? historyMsg['followUps'] - : msgData['followUps'] ?? msgData['follow_ups']; - final followUps = _parseFollowUpsField(followUpsRaw); - - final dynamic codeExecRaw = historyMsg != null - ? (historyMsg['code_executions'] ?? historyMsg['codeExecutions']) - : (msgData['code_executions'] ?? msgData['codeExecutions']); - final codeExecutions = _parseCodeExecutionsField(codeExecRaw); - - final dynamic sourcesRaw = - historyMsg != null && historyMsg.containsKey('sources') - ? historyMsg['sources'] - : msgData['sources']; - final sources = _parseSourcesField(sourcesRaw); - - return ChatMessage( - id: msgData['id']?.toString() ?? uuid.v4(), - role: role, - content: contentString, - timestamp: _parseTimestamp(msgData['timestamp']), - model: msgData['model'] as String?, - attachmentIds: attachmentIds, - files: files, - statusHistory: statusHistory, - followUps: followUps, - codeExecutions: codeExecutions, - sources: sources, - ); - } - // 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 - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll('<', '<') - .replaceAll('>', '>'); - } - - String _jsonStringify(dynamic v) { - try { - return jsonEncode(v); - } catch (_) { - return v?.toString() ?? ''; - } - } - - String _synthesizeToolDetailsFromToolCalls(List toolCalls) { - final buf = StringBuffer(); - for (final c in toolCalls) { - if (c is! Map) continue; - final func = c['function'] as Map?; - final name = - (func != null ? func['name'] : c['name'])?.toString() ?? 'tool'; - final id = - (c['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final done = (c['done']?.toString() ?? 'true'); - final argsRaw = func != null ? func['arguments'] : c['arguments']; - final resRaw = - c['result'] ?? c['output'] ?? (func != null ? func['result'] : null); - final argsStr = _jsonStringify(argsRaw); - final resStr = resRaw != null ? _jsonStringify(resRaw) : null; - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write(' done="${_escapeHtmlAttr(done)}"') - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null && resStr.isNotEmpty) { - attrs.write(' result="${_escapeHtmlAttr(resStr)}"'); - } - buf.writeln( - '
Tool Executed', - ); - buf.writeln('
'); - } - return buf.toString().trim(); - } - - String _synthesizeToolDetailsFromToolCallsWithResults( - List toolCalls, - List> results, - ) { - final buf = StringBuffer(); - Map> resultsMap = {}; - for (final r in results) { - final id = r['tool_call_id']?.toString(); - if (id != null) resultsMap[id] = r; - } - - for (final c in toolCalls) { - if (c is! Map) continue; - final func = c['function'] as Map?; - final name = - (func != null ? func['name'] : c['name'])?.toString() ?? 'tool'; - final id = - (c['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final argsRaw = func != null ? func['arguments'] : c['arguments']; - final argsStr = _jsonStringify(argsRaw); - final resultEntry = resultsMap[id]; - final resRaw = resultEntry != null ? resultEntry['content'] : null; - final filesRaw = resultEntry != null ? resultEntry['files'] : null; - final resStr = resRaw != null ? _jsonStringify(resRaw) : null; - final filesStr = filesRaw != null ? _jsonStringify(filesRaw) : null; - - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write( - ' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"', - ) - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null && resStr.isNotEmpty) { - attrs.write(' result="${_escapeHtmlAttr(resStr)}"'); - } - if (filesStr != null && filesStr.isNotEmpty) { - attrs.write(' files="${_escapeHtmlAttr(filesStr)}"'); - } - - buf.writeln( - '
${resultEntry != null ? 'Tool Executed' : 'Executing...'}', - ); - buf.writeln('
'); - } - return buf.toString().trim(); - } - - String _synthesizeToolDetailsFromContentArray(List content) { - final buf = StringBuffer(); - for (final item in content) { - if (item is! Map) continue; - final type = item['type']?.toString(); - if (type == null) continue; - // OpenWebUI content-blocks shape: { type: 'tool_calls', content: [...], results: [...] } - if (type == 'tool_calls') { - final calls = (item['content'] is List) - ? (item['content'] as List) - : []; - final results = >[]; - if (item['results'] is List) { - for (final r in (item['results'] as List)) { - if (r is Map) results.add(r); - } - } - final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( - calls, - results, - ); - if (synthesized.isNotEmpty) buf.writeln(synthesized); - continue; - } - - // Heuristics: handle other variants (single tool/function call entries) - if (type == 'tool_call' || type == 'function_call') { - final name = (item['name'] ?? item['tool'] ?? 'tool').toString(); - final id = - (item['id']?.toString() ?? - 'call_${DateTime.now().millisecondsSinceEpoch}'); - final argsStr = _jsonStringify(item['arguments'] ?? item['args']); - final resStr = item['result'] ?? item['output'] ?? item['response']; - final attrs = StringBuffer() - ..write('type="tool_calls"') - ..write( - ' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"', - ) - ..write(' id="${_escapeHtmlAttr(id)}"') - ..write(' name="${_escapeHtmlAttr(name)}"') - ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); - if (resStr != null) { - final r = _jsonStringify(resStr); - if (r.isNotEmpty) attrs.write(' result="${_escapeHtmlAttr(r)}"'); - } - buf.writeln( - '
${resStr != null ? 'Tool Executed' : 'Executing...'}', - ); - buf.writeln('
'); - } - } - return buf.toString().trim(); - } - - List _parseStatusHistoryField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((entry) { - try { - // Convert Map to Map safely - final Map statusMap = {}; - entry.forEach((key, value) { - statusMap[key.toString()] = value; - }); - final statusUpdate = ChatStatusUpdate.fromJson(statusMap); - - // Debug log to help diagnose template issues - if (statusUpdate.description?.contains('{{count}}') == true) { - DebugLogger.log( - 'template-placeholder-found', - scope: 'api/chat', - data: { - 'description': statusUpdate.description, - 'count': statusUpdate.count, - 'urls': statusUpdate.urls.length, - 'items': statusUpdate.items.length, - 'action': statusUpdate.action, - }, - ); - } - - return statusUpdate; - } catch (e) { - // Log the error and skip this entry - DebugLogger.log( - 'status-parse-error', - scope: 'api/chat', - data: {'error': e.toString(), 'entry': entry.toString()}, - ); - return null; - } - }) - .where((item) => item != null) - .cast() - .toList(growable: false); - } - return const []; - } - - List _parseFollowUpsField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((value) => value?.toString().trim() ?? '') - .where((value) => value.isNotEmpty) - .toList(growable: false); - } - if (raw is String && raw.trim().isNotEmpty) { - return [raw.trim()]; - } - return const []; - } - - List _parseCodeExecutionsField(dynamic raw) { - if (raw is List) { - return raw - .whereType() - .map((entry) { - try { - // Convert Map to Map safely - final Map execMap = {}; - entry.forEach((key, value) { - execMap[key.toString()] = value; - }); - return ChatCodeExecution.fromJson(execMap); - } catch (e) { - // Log the error and skip this entry - DebugLogger.log( - 'code-exec-parse-error', - scope: 'api/chat', - data: {'error': e.toString(), 'entry': entry.toString()}, - ); - return null; - } - }) - .where((item) => item != null) - .cast() - .toList(growable: false); - } - return const []; - } - List>? _sanitizeFilesForWebUI( List>? files, ) { @@ -1478,14 +589,6 @@ class ApiService { return sanitized.isNotEmpty ? sanitized : null; } - List _parseSourcesField(dynamic raw) { - try { - return parseOpenWebUISourceList(raw); - } catch (_) { - return const []; - } - } - // Create new conversation using OpenWebUI API Future createConversation({ required String title, @@ -1584,9 +687,14 @@ class ApiService { ); DebugLogger.log('create-ok', scope: 'api/conversation'); - // Parse the response - final responseData = response.data as Map; - return _parseFullOpenWebUIChat(responseData); + final responseData = response.data; + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': responseData}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // Sync conversation messages to ensure WebUI can load conversation history @@ -1770,7 +878,13 @@ class ApiService { Future cloneConversation(String id) async { _traceApi('Cloning conversation: $id'); final response = await _dio.post('/api/v1/chats/$id/clone'); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // User Settings @@ -1891,7 +1005,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/folder/$folderId'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -1935,7 +1052,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/tags/$tag'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -2998,7 +2118,10 @@ class ApiService { final response = await _dio.get('/api/v1/channels/$channelId/chats'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3792,7 +2915,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/pinned'); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3810,7 +2936,10 @@ class ApiService { ); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data.whereType().map((chatData) { + final map = Map.from(chatData); + return Conversation.fromJson(parseConversationSummary(map)); + }).toList(); } return []; } @@ -3858,7 +2987,7 @@ class ApiService { if (data is List) { return data .whereType>() - .map((e) => _parseOpenWebUIChat(e)) + .map((e) => Conversation.fromJson(parseConversationSummary(e))) .toList(); } if (data is Map) { @@ -3866,7 +2995,7 @@ class ApiService { if (list is List) { return list .whereType>() - .map((e) => _parseOpenWebUIChat(e)) + .map((e) => Conversation.fromJson(parseConversationSummary(e))) .toList(); } } @@ -3967,7 +3096,13 @@ class ApiService { '/api/v1/chats/$chatId/duplicate', data: {if (title != null) 'title': title}, ); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } /// Get recent chats with activity @@ -3982,7 +3117,13 @@ class ApiService { ); final data = response.data; if (data is List) { - return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList(); + return data + .whereType>() + .map( + (chatData) => + Conversation.fromJson(parseConversationSummary(chatData)), + ) + .toList(); } return []; } @@ -4094,7 +3235,13 @@ class ApiService { if (title != null) 'title': title, }, ); - return _parseFullOpenWebUIChat(response.data as Map); + final json = await _workerManager + .schedule, Map>( + parseFullConversationWorker, + {'conversation': response.data}, + debugLabel: 'parse_conversation_full', + ); + return Conversation.fromJson(json); } // ==================== END ADVANCED CHAT FEATURES ==================== diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart new file mode 100644 index 0000000..6cc8ee6 --- /dev/null +++ b/lib/core/services/conversation_parsing.dart @@ -0,0 +1,696 @@ +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +/// Utilities for converting OpenWebUI conversation payloads into JSON maps +/// that match the app's `Conversation` / `ChatMessage` schemas. All helpers +/// here are isolate-safe (they only work with primitive JSON types) so they +/// can be executed inside a background worker. + +const _uuid = Uuid(); + +Map parseConversationSummary(Map chatData) { + final id = (chatData['id'] ?? '').toString(); + final title = _stringOr(chatData['title'], 'Chat'); + + final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; + final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; + + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id']?.toString(); + final folderId = chatData['folder_id']?.toString(); + + String? systemPrompt; + final chatObject = chatData['chat']; + if (chatObject is Map) { + final value = chatObject['system']; + if (value is String && value.trim().isNotEmpty) { + systemPrompt = value; + } + } else if (chatData['system'] is String) { + final value = (chatData['system'] as String).trim(); + if (value.isNotEmpty) systemPrompt = value; + } + + return { + 'id': id, + 'title': title, + 'createdAt': _parseTimestamp(createdAtRaw).toIso8601String(), + 'updatedAt': _parseTimestamp(updatedAtRaw).toIso8601String(), + 'model': chatData['model']?.toString(), + 'systemPrompt': systemPrompt, + 'messages': const >[], + 'metadata': _coerceJsonMap(chatData['metadata']), + 'pinned': pinned, + 'archived': archived, + 'shareId': shareId, + 'folderId': folderId, + 'tags': _coerceStringList(chatData['tags']), + }; +} + +Map parseFullConversation(Map chatData) { + final id = (chatData['id'] ?? '').toString(); + final title = _stringOr(chatData['title'], 'Chat'); + + final updatedAt = _parseTimestamp( + chatData['updated_at'] ?? chatData['updatedAt'], + ); + final createdAt = _parseTimestamp( + chatData['created_at'] ?? chatData['createdAt'], + ); + final pinned = chatData['pinned'] as bool? ?? false; + final archived = chatData['archived'] as bool? ?? false; + final shareId = chatData['share_id']?.toString(); + final folderId = chatData['folder_id']?.toString(); + + String? systemPrompt; + final chatObject = chatData['chat']; + if (chatObject is Map) { + final value = chatObject['system']; + if (value is String && value.trim().isNotEmpty) { + systemPrompt = value; + } + } else if (chatData['system'] is String) { + final value = (chatData['system'] as String).trim(); + if (value.isNotEmpty) systemPrompt = value; + } + + String? model; + Map? historyMessagesMap; + List>? messagesList; + + if (chatObject is Map) { + final history = chatObject['history']; + if (history is Map) { + if (history['messages'] is Map) { + historyMessagesMap = history['messages'] as Map; + messagesList = _buildMessagesListFromHistory(history); + } + } + + if ((messagesList == null || messagesList.isEmpty) && + chatObject['messages'] is List) { + messagesList = (chatObject['messages'] as List) + .whereType>() + .toList(); + } + + final models = chatObject['models']; + if (models is List && models.isNotEmpty) { + model = models.first?.toString(); + } + } + + if ((messagesList == null || messagesList.isEmpty) && + chatData['messages'] is List) { + messagesList = (chatData['messages'] as List) + .whereType>() + .toList(); + } + + final messages = >[]; + if (messagesList != null) { + var index = 0; + while (index < messagesList.length) { + final msgData = Map.from(messagesList[index]); + final historyMsg = historyMessagesMap != null + ? (historyMessagesMap[msgData['id']] as Map?) + : null; + + final toolCalls = _extractToolCalls(msgData, historyMsg); + if ((msgData['role']?.toString() ?? '') == 'assistant' && + toolCalls != null) { + final results = >[]; + var j = index + 1; + while (j < messagesList.length) { + final nextRaw = messagesList[j]; + if ((nextRaw['role']?.toString() ?? '') != 'tool') break; + results.add({ + 'tool_call_id': nextRaw['tool_call_id']?.toString(), + 'content': nextRaw['content'], + if (nextRaw.containsKey('files')) 'files': nextRaw['files'], + }); + j++; + } + + final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( + toolCalls, + results, + ); + final merged = Map.from(msgData); + if (synthesized.isNotEmpty) { + merged['content'] = synthesized; + } + + messages.add( + _parseOpenWebUIMessageToJson(merged, historyMsg: historyMsg), + ); + index = j; + continue; + } + + messages.add( + _parseOpenWebUIMessageToJson(msgData, historyMsg: historyMsg), + ); + index++; + } + } + + return { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'model': model, + 'systemPrompt': systemPrompt, + 'messages': messages, + 'metadata': _coerceJsonMap(chatData['metadata']), + 'pinned': pinned, + 'archived': archived, + 'shareId': shareId, + 'folderId': folderId, + 'tags': _coerceStringList(chatData['tags']), + }; +} + +List>? _extractToolCalls( + Map msgData, + Map? historyMsg, +) { + final toolCallsRaw = + msgData['tool_calls'] ?? + historyMsg?['tool_calls'] ?? + historyMsg?['toolCalls']; + if (toolCallsRaw is List) { + return toolCallsRaw.whereType().map(_coerceJsonMap).toList(); + } + return null; +} + +Map _parseOpenWebUIMessageToJson( + Map msgData, { + Map? historyMsg, +}) { + dynamic content = msgData['content']; + if ((content == null || (content is String && content.isEmpty)) && + historyMsg != null && + historyMsg['content'] != null) { + content = historyMsg['content']; + } + + var contentString = ''; + if (content is List) { + final buffer = StringBuffer(); + for (final entry in content) { + if (entry is Map && entry['type'] == 'text') { + final text = entry['text']?.toString(); + if (text != null && text.isNotEmpty) { + buffer.write(text); + } + } + } + contentString = buffer.toString(); + if (contentString.trim().isEmpty) { + final synthesized = _synthesizeToolDetailsFromContentArray(content); + if (synthesized.isNotEmpty) { + contentString = synthesized; + } + } + } else { + contentString = content?.toString() ?? ''; + } + + 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 entry in histContent) { + if (entry is Map && entry['type'] == 'text') { + final text = entry['text']?.toString(); + if (text != null && text.isNotEmpty) { + buf.write(text); + } + } + } + final combined = buf.toString(); + if (combined.length > contentString.length) { + contentString = combined; + } + } + } + + final toolCallsList = _extractToolCalls(msgData, historyMsg); + if (contentString.trim().isEmpty && toolCallsList != null) { + final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList); + if (synthesized.isNotEmpty) { + contentString = synthesized; + } + } + + final role = _resolveRole(msgData); + + final effectiveFiles = msgData['files'] ?? historyMsg?['files']; + List? attachmentIds; + List>? files; + if (effectiveFiles is List) { + final attachments = []; + final allFiles = >[]; + for (final entry in effectiveFiles) { + if (entry is! Map) continue; + if (entry['file_id'] != null) { + attachments.add(entry['file_id'].toString()); + } else if (entry['type'] != null && entry['url'] != null) { + final fileMap = { + 'type': entry['type'], + 'url': entry['url'], + }; + if (entry['name'] != null) fileMap['name'] = entry['name']; + if (entry['size'] != null) fileMap['size'] = entry['size']; + allFiles.add(fileMap); + + final url = entry['url'].toString(); + final match = RegExp(r'/api/v1/files/([^/]+)/content').firstMatch(url); + if (match != null) { + attachments.add(match.group(1)!); + } + } + } + attachmentIds = attachments.isNotEmpty ? attachments : null; + files = allFiles.isNotEmpty ? allFiles : null; + } + + final statusHistoryRaw = + historyMsg != null && historyMsg.containsKey('statusHistory') + ? historyMsg['statusHistory'] + : msgData['statusHistory']; + final followUpsRaw = historyMsg != null && historyMsg.containsKey('followUps') + ? historyMsg['followUps'] + : msgData['followUps'] ?? msgData['follow_ups']; + final codeExecRaw = historyMsg != null + ? historyMsg['code_executions'] ?? historyMsg['codeExecutions'] + : msgData['code_executions'] ?? msgData['codeExecutions']; + final sourcesRaw = historyMsg != null && historyMsg.containsKey('sources') + ? historyMsg['sources'] + : msgData['sources']; + + return { + 'id': (msgData['id'] ?? _uuid.v4()).toString(), + 'role': role, + 'content': contentString, + 'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(), + 'model': msgData['model']?.toString(), + 'isStreaming': msgData['isStreaming'] as bool? ?? false, + if (attachmentIds != null) 'attachmentIds': attachmentIds, + if (files != null) 'files': files, + 'metadata': _coerceJsonMap(msgData['metadata']), + 'statusHistory': _parseStatusHistoryField(statusHistoryRaw), + 'followUps': _coerceStringList(followUpsRaw), + 'codeExecutions': _parseCodeExecutionsField(codeExecRaw), + 'sources': _parseSourcesField(sourcesRaw), + 'usage': _coerceJsonMap(msgData['usage']), + 'versions': const >[], + }; +} + +String _resolveRole(Map msgData) { + if (msgData['role'] != null) { + return msgData['role'].toString(); + } + if (msgData['model'] != null) { + return 'assistant'; + } + return 'user'; +} + +List> _buildMessagesListFromHistory( + Map history, +) { + final messagesMap = history['messages']; + final currentId = history['currentId']?.toString(); + if (messagesMap is! Map || currentId == null) { + return const []; + } + + List> buildChain(String? id) { + if (id == null) return const []; + final raw = messagesMap[id]; + if (raw is! Map) return const []; + final msg = _coerceJsonMap(raw); + msg['id'] = id; + final parentId = msg['parentId']?.toString(); + if (parentId != null && parentId.isNotEmpty) { + return [...buildChain(parentId), msg]; + } + return [msg]; + } + + return buildChain(currentId); +} + +DateTime _parseTimestamp(dynamic timestamp) { + if (timestamp == null) return DateTime.now(); + if (timestamp is int) { + final ts = timestamp > 1000000000000 ? timestamp : timestamp * 1000; + return DateTime.fromMillisecondsSinceEpoch(ts); + } + if (timestamp is String) { + final parsedInt = int.tryParse(timestamp); + if (parsedInt != null) { + final ts = parsedInt > 1000000000000 ? parsedInt : parsedInt * 1000; + return DateTime.fromMillisecondsSinceEpoch(ts); + } + return DateTime.tryParse(timestamp) ?? DateTime.now(); + } + if (timestamp is double) { + final ts = timestamp > 1000000000000 + ? timestamp.round() + : (timestamp * 1000).round(); + return DateTime.fromMillisecondsSinceEpoch(ts); + } + return DateTime.now(); +} + +List> _parseStatusHistoryField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((entry) => _coerceJsonMap(entry)) + .toList(growable: false); + } + return const >[]; +} + +List _coerceStringList(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((value) => value?.toString().trim() ?? '') + .where((value) => value.isNotEmpty) + .toList(growable: false); + } + if (raw is String && raw.trim().isNotEmpty) { + return [raw.trim()]; + } + return const []; +} + +List> _parseCodeExecutionsField(dynamic raw) { + if (raw is List) { + return raw + .whereType() + .map((entry) => _coerceJsonMap(entry)) + .toList(growable: false); + } + return const >[]; +} + +List> _parseSourcesField(dynamic raw) { + if (raw is List) { + return raw.whereType().map(_coerceJsonMap).toList(growable: false); + } + if (raw is Map) { + return [_coerceJsonMap(raw)]; + } + if (raw is String) { + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded.whereType().map(_coerceJsonMap).toList(); + } + } catch (_) {} + } + return const >[]; +} + +Map _coerceJsonMap(Object? value) { + if (value is Map) { + return value.map((key, v) => MapEntry(key.toString(), _coerceJsonValue(v))); + } + if (value is Map) { + final result = {}; + value.forEach((key, v) { + result[key.toString()] = _coerceJsonValue(v); + }); + return result; + } + return {}; +} + +dynamic _coerceJsonValue(dynamic value) { + if (value is Map) { + return _coerceJsonMap(value); + } + if (value is List) { + return value.map(_coerceJsonValue).toList(); + } + return value; +} + +String _stringOr(dynamic value, String fallback) { + if (value is String && value.isNotEmpty) { + return value; + } + return fallback; +} + +String _synthesizeToolDetailsFromToolCalls(List calls) { + final buffer = StringBuffer(); + for (final rawCall in calls) { + final call = Map.from(rawCall); + final function = call['function']; + final name = + (function is Map ? function['name'] : call['name'])?.toString() ?? + 'tool'; + final id = + (call['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final done = call['done']?.toString() ?? 'true'; + final argsRaw = function is Map ? function['arguments'] : call['arguments']; + final resRaw = + call['result'] ?? + call['output'] ?? + (function is Map ? function['result'] : null); + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write(' done="${_escapeHtmlAttr(done)}"') + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"'); + final resultStr = _jsonStringify(resRaw); + if (resultStr.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(resultStr)}"'); + } + buffer.writeln( + '
Tool Executed
', + ); + } + return buffer.toString().trim(); +} + +String _synthesizeToolDetailsFromToolCallsWithResults( + List calls, + List results, +) { + final buffer = StringBuffer(); + final resultsMap = >{}; + for (final rawResult in results) { + final result = Map.from(rawResult); + final id = result['tool_call_id']?.toString(); + if (id != null) { + resultsMap[id] = result; + } + } + + for (final rawCall in calls) { + final call = Map.from(rawCall); + final function = call['function']; + final name = + (function is Map ? function['name'] : call['name'])?.toString() ?? + 'tool'; + final id = + (call['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final argsRaw = function is Map ? function['arguments'] : call['arguments']; + final resultEntry = resultsMap[id]; + final resRaw = resultEntry != null ? resultEntry['content'] : null; + final filesRaw = resultEntry != null ? resultEntry['files'] : null; + + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write( + ' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"', + ) + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"'); + final resultStr = _jsonStringify(resRaw); + if (resultStr.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(resultStr)}"'); + } + final filesStr = _jsonStringify(filesRaw); + if (filesStr.isNotEmpty) { + attrs.write(' files="${_escapeHtmlAttr(filesStr)}"'); + } + buffer.writeln( + '
${resultEntry != null ? 'Tool Executed' : 'Executing...'}
', + ); + } + + return buffer.toString().trim(); +} + +String _synthesizeToolDetailsFromContentArray(List content) { + final buffer = StringBuffer(); + for (final item in content) { + if (item is! Map) continue; + final type = item['type']?.toString(); + if (type == null) continue; + if (type == 'tool_calls') { + final calls = >[]; + if (item['content'] is List) { + for (final entry in item['content'] as List) { + if (entry is Map) { + calls.add(Map.from(entry)); + } + } + } + + final results = >[]; + if (item['results'] is List) { + for (final entry in item['results'] as List) { + if (entry is Map) { + results.add(Map.from(entry)); + } + } + } + final synthesized = _synthesizeToolDetailsFromToolCallsWithResults( + calls, + results, + ); + if (synthesized.isNotEmpty) { + buffer.writeln(synthesized); + } + continue; + } + + if (type == 'tool_call' || type == 'function_call') { + final name = (item['name'] ?? item['tool'] ?? 'tool').toString(); + final id = + (item['id']?.toString() ?? + 'call_${DateTime.now().millisecondsSinceEpoch}'); + final argsStr = _jsonStringify(item['arguments'] ?? item['args']); + final resStr = item['result'] ?? item['output'] ?? item['response']; + final attrs = StringBuffer() + ..write('type="tool_calls"') + ..write(' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"') + ..write(' id="${_escapeHtmlAttr(id)}"') + ..write(' name="${_escapeHtmlAttr(name)}"') + ..write(' arguments="${_escapeHtmlAttr(argsStr)}"'); + final result = _jsonStringify(resStr); + if (result.isNotEmpty) { + attrs.write(' result="${_escapeHtmlAttr(result)}"'); + } + buffer.writeln( + '
${resStr != null ? 'Tool Executed' : 'Executing...'}
', + ); + } + } + return buffer.toString().trim(); +} + +String _jsonStringify(dynamic value) { + if (value == null) return ''; + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } +} + +String _escapeHtmlAttr(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +List> parseConversationSummariesWorker( + Map payload, +) { + final pinnedRaw = payload['pinned']; + final archivedRaw = payload['archived']; + final regularRaw = payload['regular']; + + final pinned = >[]; + if (pinnedRaw is List) { + for (final entry in pinnedRaw) { + if (entry is Map) { + pinned.add(Map.from(entry)); + } + } + } + + final archived = >[]; + if (archivedRaw is List) { + for (final entry in archivedRaw) { + if (entry is Map) { + archived.add(Map.from(entry)); + } + } + } + + final regular = >[]; + if (regularRaw is List) { + for (final entry in regularRaw) { + if (entry is Map) { + regular.add(Map.from(entry)); + } + } + } + + final summaries = >[]; + final pinnedIds = {}; + final archivedIds = {}; + + for (final entry in pinned) { + final summary = parseConversationSummary(entry); + summary['pinned'] = true; + summaries.add(summary); + pinnedIds.add(summary['id'] as String); + } + + for (final entry in archived) { + final summary = parseConversationSummary(entry); + summary['archived'] = true; + summaries.add(summary); + archivedIds.add(summary['id'] as String); + } + + for (final entry in regular) { + final summary = parseConversationSummary(entry); + final id = summary['id'] as String; + if (pinnedIds.contains(id) || archivedIds.contains(id)) { + continue; + } + summaries.add(summary); + } + + return summaries; +} + +Map parseFullConversationWorker(Map payload) { + final raw = payload['conversation']; + if (raw is Map) { + return parseFullConversation(raw); + } + if (raw is Map) { + return parseFullConversation(Map.from(raw)); + } + return parseFullConversation({}); +} diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 5d13db5..4dabc16 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -11,6 +11,7 @@ import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/api_service.dart'; +import '../../../core/services/worker_manager.dart'; import '../../../core/services/input_validation_service.dart'; import '../../../core/services/navigation_service.dart'; import '../../../core/widgets/error_boundary.dart'; @@ -81,7 +82,11 @@ class _ServerConnectionPageState extends ConsumerState { allowSelfSignedCertificates: _allowSelfSignedCertificates, ); - final api = ApiService(serverConfig: tempConfig); + final workerManager = ref.read(workerManagerProvider); + final api = ApiService( + serverConfig: tempConfig, + workerManager: workerManager, + ); final isHealthy = await api.checkHealth(); if (!isHealthy) { throw Exception('This does not appear to be an Open-WebUI server.');