import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; import 'package:http_parser/http_parser.dart'; // Removed legacy websocket/socket.io imports import 'package:uuid/uuid.dart'; import '../models/backend_config.dart'; import '../models/server_config.dart'; import '../models/user.dart'; import '../models/model.dart'; import '../models/conversation.dart'; import '../models/chat_message.dart'; import '../models/file_info.dart'; import '../models/knowledge_base.dart'; import '../models/prompt.dart'; import '../auth/api_auth_interceptor.dart'; import '../error/api_error_interceptor.dart'; // Tool-call details are parsed in the UI layer to render collapsible blocks import 'connectivity_service.dart'; import '../utils/debug_logger.dart'; import 'conversation_parsing.dart'; import 'worker_manager.dart'; const bool _traceApiLogs = false; void _traceApi(String message) { if (!_traceApiLogs) { return; } DebugLogger.log(message, scope: 'api/trace'); } class ApiService { final Dio _dio; final ServerConfig serverConfig; final WorkerManager _workerManager; late final ApiAuthInterceptor _authInterceptor; // Removed legacy websocket/socket.io fields // Public getter for dio instance Dio get dio => _dio; // Public getter for base URL String get baseUrl => serverConfig.url; // Callback to notify when auth token becomes invalid void Function()? onAuthTokenInvalid; // New callback for the unified auth state manager Future Function()? onTokenInvalidated; 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 final effectiveAuthToken = authToken ?? serverConfig.apiKey; // Initialize the consistent auth interceptor _authInterceptor = ApiAuthInterceptor( authToken: effectiveAuthToken, onAuthTokenInvalid: onAuthTokenInvalid, onTokenInvalidated: onTokenInvalidated, customHeaders: serverConfig.customHeaders, ); // Add interceptors in order of priority: // 1. Auth interceptor (must be first to add auth headers) _dio.interceptors.add(_authInterceptor); // 2. Validation interceptor removed (no schema loading/logging) // 3. Error handling interceptor (transforms errors to standardized format) _dio.interceptors.add( ApiErrorInterceptor( logErrors: kDebugMode, throwApiErrors: true, // Transform DioExceptions to include ApiError ), ); // 4. Success pings to relax offline detection. // Any successful API response indicates recent connectivity; suppress // offline transitions briefly to avoid UI flicker. _dio.interceptors.add( InterceptorsWrapper( onResponse: (response, handler) { try { if ((response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 400) { ConnectivityService.suppressOfflineGlobally( const Duration(seconds: 4), ); } } catch (_) {} handler.next(response); }, ), ); // 5. Custom debug interceptor to log exactly what we're sending if (kDebugMode) { _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { handler.next(options); }, ), ); // LogInterceptor removed - was exposing sensitive data and creating verbose logs // We now use custom interceptors with secure logging via DebugLogger } // Validation interceptor fully removed } void updateAuthToken(String? token) { _authInterceptor.updateAuthToken(token); } String? get authToken => _authInterceptor.authToken; /// Ensure interceptor callbacks stay in sync if they are set after construction void setAuthCallbacks({ void Function()? onAuthTokenInvalid, Future Function()? onTokenInvalidated, }) { if (onAuthTokenInvalid != null) { this.onAuthTokenInvalid = onAuthTokenInvalid; _authInterceptor.onAuthTokenInvalid = onAuthTokenInvalid; } if (onTokenInvalidated != null) { this.onTokenInvalidated = onTokenInvalidated; _authInterceptor.onTokenInvalidated = onTokenInvalidated; } } /// Configures this Dio instance to accept self-signed certificates. /// /// When [ServerConfig.allowSelfSignedCertificates] is enabled, this method /// sets up a [badCertificateCallback] that trusts certificates from the /// configured server's host and port. /// /// Security considerations: /// - Only certificates from the exact host/port are trusted /// - If no port is specified, all ports on the host are trusted /// - Web platforms ignore this (browsers handle TLS validation) void _configureSelfSignedSupport() { if (kIsWeb || !serverConfig.allowSelfSignedCertificates) { return; } final baseUri = _parseBaseUri(serverConfig.url); if (baseUri == null) { return; } final adapter = _dio.httpClientAdapter; if (adapter is! IOHttpClientAdapter) { return; } adapter.createHttpClient = () { final client = HttpClient(); final host = baseUri.host.toLowerCase(); final port = baseUri.hasPort ? baseUri.port : null; client.badCertificateCallback = (X509Certificate cert, String requestHost, int requestPort) { // Only trust certificates from our configured server if (requestHost.toLowerCase() != host) { return false; } // If no specific port configured, trust any port on this host if (port == null) { return true; } // Otherwise, port must match exactly return requestPort == port; }; return client; }; } Uri? _parseBaseUri(String baseUrl) { final trimmed = baseUrl.trim(); if (trimmed.isEmpty) { return null; } Uri? parsed = Uri.tryParse(trimmed); if (parsed == null) { return null; } if (!parsed.hasScheme) { parsed = Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed'); } return parsed; } /// Basic health check - just verifies the server is reachable. Future checkHealth() async { try { final response = await _dio.get('/health'); return response.statusCode == 200; } catch (e) { return false; } } /// Verifies this is actually an OpenWebUI server by checking the /api/config /// endpoint for OpenWebUI-specific fields (version, status, features). /// /// Returns `true` if the server appears to be a valid OpenWebUI instance. Future verifyIsOpenWebUIServer() async { final config = await verifyAndGetConfig(); return config != null; } /// Verifies this is an OpenWebUI server and returns the backend config. /// /// Returns `BackendConfig` if the server is valid, `null` otherwise. /// This combines server verification and config fetching in a single call. Future verifyAndGetConfig() async { try { final response = await _dio.get('/api/config'); if (response.statusCode != 200) { return null; } final data = response.data; if (data is! Map) { return null; } // Check for OpenWebUI-specific fields // The /api/config endpoint always returns these fields on OpenWebUI final hasStatus = data['status'] == true; final hasVersion = data['version'] is String && (data['version'] as String).isNotEmpty; final hasFeatures = data['features'] is Map; if (!hasStatus || !hasVersion || !hasFeatures) { return null; } return BackendConfig.fromJson(data); } catch (e) { return null; } } // Enhanced health check with model availability Future> checkServerStatus() async { final result = { 'healthy': false, 'modelsAvailable': false, 'modelCount': 0, 'error': null, }; try { // Check basic health final healthResponse = await _dio.get('/health'); result['healthy'] = healthResponse.statusCode == 200; if (result['healthy']) { // Check model availability try { final modelsResponse = await _dio.get('/api/models'); final models = modelsResponse.data['data'] as List?; result['modelsAvailable'] = models != null && models.isNotEmpty; result['modelCount'] = models?.length ?? 0; } catch (e) { result['modelsAvailable'] = false; } } } catch (e) { result['error'] = e.toString(); } return result; } Future getBackendConfig() async { try { final response = await _dio.get('/api/config'); final data = response.data; Map? jsonMap; if (data is Map) { jsonMap = data; } else if (data is String && data.isNotEmpty) { final decoded = json.decode(data); if (decoded is Map) { jsonMap = decoded; } } if (jsonMap == null) { return null; } return BackendConfig.fromJson(jsonMap); } on DioException catch (e, stackTrace) { _traceApi('Backend config request failed: $e'); DebugLogger.error( 'backend-config-error', scope: 'api/config', error: e, stackTrace: stackTrace, ); rethrow; } catch (e, stackTrace) { _traceApi('Backend config decode error: $e'); DebugLogger.error( 'backend-config-decode', scope: 'api/config', error: e, stackTrace: stackTrace, ); rethrow; } } // Authentication Future> login(String username, String password) async { try { final response = await _dio.post( '/api/v1/auths/signin', data: {'email': username, 'password': password}, ); return response.data; } catch (e) { if (e is DioException) { // Handle specific redirect cases if (e.response?.statusCode == 307 || e.response?.statusCode == 308) { final location = e.response?.headers.value('location'); if (location != null) { throw Exception( 'Server redirect detected. Please check your server URL configuration. Redirect to: $location', ); } } } rethrow; } } Future logout() async { await _dio.get('/api/v1/auths/signout'); } /// LDAP authentication - uses username instead of email. /// /// Returns the same response format as regular login: /// `{"token": "...", "token_type": "Bearer", "id": "...", ...}` /// /// Throws an exception if LDAP is not enabled on the server (400 response). Future> ldapLogin( String username, String password, ) async { try { final response = await _dio.post( '/api/v1/auths/ldap', data: {'user': username, 'password': password}, ); return response.data; } catch (e) { if (e is DioException) { // Handle LDAP not enabled if (e.response?.statusCode == 400) { final data = e.response?.data; if (data is Map && data['detail'] != null) { throw Exception(data['detail']); } } // Handle specific redirect cases if (e.response?.statusCode == 307 || e.response?.statusCode == 308) { final location = e.response?.headers.value('location'); if (location != null) { throw Exception( 'Server redirect detected. Please check your server URL configuration. Redirect to: $location', ); } } } rethrow; } } // User info Future getCurrentUser() async { final response = await _dio.get('/api/v1/auths/'); DebugLogger.log('user-info', scope: 'api/user'); return User.fromJson(response.data); } // Models Future> getModels() async { final response = await _dio.get('/api/models'); // Normalize common response formats: // - {"data": [...]} (OpenAI) // - {"models": [...]} (some proxies) // - [...] (raw array) // - String payloads that need JSON decoding dynamic payload = response.data; if (payload is String) { try { payload = json.decode(payload); } catch (_) {} } List? rawModels; if (payload is Map && payload['data'] is List) { rawModels = payload['data'] as List; } else if (payload is Map && payload['models'] is List) { rawModels = payload['models'] as List; } else if (payload is List) { rawModels = payload; } if (rawModels == null) { DebugLogger.error( 'models-format', scope: 'api/models', data: {'type': payload.runtimeType}, ); return const []; } final models = []; for (final raw in rawModels) { try { if (raw is String) { models.add(Model(id: raw, name: raw, supportsStreaming: true)); continue; } if (raw is Map) { final normalized = raw.map( (key, value) => MapEntry(key.toString(), value), ); models.add(Model.fromJson(normalized)); continue; } DebugLogger.warning( 'models-entry-unknown', scope: 'api/models', data: {'type': raw.runtimeType}, ); } catch (error, stackTrace) { DebugLogger.error( 'model-parse-failed', scope: 'api/models', error: error, stackTrace: stackTrace, data: {'type': raw.runtimeType}, ); } } DebugLogger.log( 'models-count', scope: 'api/models', data: {'count': models.length}, ); return models; } // Get default model configuration from OpenWebUI user settings Future getDefaultModel() async { try { final response = await _dio.get('/api/v1/users/user/settings'); DebugLogger.log('settings-ok', scope: 'api/user-settings'); final data = response.data; if (data is! Map) { DebugLogger.warning( 'settings-format', scope: 'api/user-settings', data: {'type': data.runtimeType}, ); return null; } // Extract default model from ui.models array final ui = data['ui']; if (ui is Map) { final models = ui['models']; if (models is List && models.isNotEmpty) { // Return the first model in the user's preferred models list final defaultModel = models.first.toString(); DebugLogger.log( 'default-model', scope: 'api/user-settings', data: {'id': defaultModel}, ); return defaultModel; } } DebugLogger.warning('default-model-missing', scope: 'api/user-settings'); return null; } catch (e) { DebugLogger.error( 'default-model-error', scope: 'api/user-settings', error: e, ); // Do not call admin-only configs endpoint here; let the caller // handle fallback (e.g., first available model from /api/models). return null; } } // Conversations - Updated to use correct OpenWebUI API Future> getConversations({int? limit, int? skip}) async { final pinnedFuture = _fetchChatCollection( '/api/v1/chats/pinned', debugLabel: 'pinned chats', ); final archivedFuture = _fetchChatCollection( '/api/v1/chats/archived', debugLabel: 'archived chats', ); List allRegularChats = []; if (limit == null) { // Fetch all conversations using parallel pagination for better performance // Main chats endpoint uses 50 items per page allRegularChats = await _fetchAllPagedResults( endpoint: '/api/v1/chats/', baseParams: {'include_folders': true, 'include_pinned': true}, expectedPageSize: 50, debugLabel: 'conversations', ); } else { // Original single page fetch final pageQuery = { 'include_folders': true, 'include_pinned': true, }; if (limit > 0) { pageQuery['page'] = (((skip ?? 0) / limit).floor() + 1).clamp( 1, 1 << 30, ); } final regularResponse = await _dio.get( '/api/v1/chats/', // Convert skip/limit to 1-based page index expected by OpenWebUI. // Example: skip=0 => page=1, skip=limit => page=2, etc. queryParameters: pageQuery, ); if (regularResponse.data is! List) { throw Exception( 'Expected array of chats, got ${regularResponse.data.runtimeType}', ); } allRegularChats = regularResponse.data as List; } final pinnedAndArchived = await Future.wait>([ pinnedFuture, archivedFuture, ]); final pinnedChatList = pinnedAndArchived[0]; final archivedChatList = pinnedAndArchived[1]; final regularChatList = allRegularChats; DebugLogger.log( 'summary', scope: 'api/conversations', data: { 'regular': regularChatList.length, 'pinned': pinnedChatList.length, 'archived': archivedChatList.length, }, ); final parsedJson = await _workerManager .schedule, List>>( parseConversationSummariesWorker, { 'pinned': pinnedChatList, 'archived': archivedChatList, 'regular': regularChatList, }, debugLabel: 'parse_conversation_list', ); final conversations = parsedJson .map((json) => Conversation.fromJson(json)) .toList(growable: false); DebugLogger.log( 'parse-complete', scope: 'api/conversations', data: { 'total': conversations.length, 'pinned': conversations.where((c) => c.pinned).length, 'archived': conversations.where((c) => c.archived).length, }, ); return conversations; } Future> _fetchChatCollection( String path, { required String debugLabel, }) async { final scope = 'api/collection/${debugLabel.replaceAll(' ', '-')}'; try { final response = await _dio.get(path); DebugLogger.log( 'status', scope: scope, data: {'code': response.statusCode}, ); if (response.data is List) { return (response.data as List).cast(); } DebugLogger.warning( 'unexpected-type', scope: scope, data: {'type': response.data.runtimeType}, ); } on DioException catch (e) { DebugLogger.warning( 'network-skip', scope: scope, data: {'message': e.message}, ); } catch (e) { DebugLogger.warning('error-skip', scope: scope, data: {'error': e}); } return []; } /// Fetches all pages from a paginated endpoint using parallel batch requests. /// /// This method fetches pages in parallel batches for better performance, /// rather than fetching sequentially one page at a time. /// /// [endpoint] - The API endpoint to fetch from /// [baseParams] - Base query parameters to include with each request /// [expectedPageSize] - Expected items per page from the API (for early exit /// optimization). If the first page has fewer items, no more requests are /// made. Use 50 for main chats, 10 for folder chats. /// [batchSize] - Number of pages to fetch in parallel (default: 5) /// [maxPages] - Maximum number of pages to fetch (default: 100) /// [debugLabel] - Label for debug logging Future>> _fetchAllPagedResults({ required String endpoint, Map? baseParams, required int expectedPageSize, int batchSize = 5, int maxPages = 100, String? debugLabel, }) async { final results = >[]; final label = debugLabel ?? endpoint; // Fetch first page to check if there's data final firstResponse = await _dio.get( endpoint, queryParameters: {...?baseParams, 'page': 1}, ); final firstData = firstResponse.data; if (firstData is! List) { throw Exception('Expected array of $label, got ${firstData.runtimeType}'); } if (firstData.isEmpty) { _traceApi('$label: no results on first page'); return results; } results.addAll(firstData.whereType>()); // Use unfiltered length for pagination detection since the API returns // the same count regardless of filtering. If the first page has fewer // items than expected, we know there are no more pages. final firstPageCount = firstData.length; if (firstPageCount < expectedPageSize) { _traceApi('$label: fetched ${results.length} items (single page)'); return results; } // Fetch remaining pages in parallel batches int currentPage = 2; int totalPages = 1; while (currentPage <= maxPages) { final futures = >>[]; // Queue up a batch of parallel requests for (int i = 0; i < batchSize && currentPage <= maxPages; i++) { futures.add( _dio.get( endpoint, queryParameters: {...?baseParams, 'page': currentPage++}, ), ); } // Execute batch in parallel final responses = await Future.wait(futures); bool hasMore = false; for (final response in responses) { final data = response.data; // Validate response type - throw on non-list (e.g., error objects) // to preserve original error-surfacing behavior if (data is! List) { throw Exception('Expected array of $label, got ${data.runtimeType}'); } if (data.isNotEmpty) { results.addAll(data.whereType>()); totalPages++; // If this page is full (has expected number of items), there might // be more pages. Use unfiltered length for consistent detection. if (data.length >= expectedPageSize) { hasMore = true; } } } // Stop if no page in this batch was full if (!hasMore) break; } if (currentPage > maxPages) { _traceApi('WARNING: $label reached max page limit ($maxPages)'); } _traceApi( '$label: fetched ${results.length} items across $totalPages pages', ); return results; } // Parse OpenWebUI chat format to our Conversation format 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'); final json = await _workerManager .schedule, Map>( parseFullConversationWorker, {'conversation': response.data}, debugLabel: 'parse_conversation_full', ); return Conversation.fromJson(json); } // Parse full OpenWebUI chat with messages // Parse OpenWebUI message format to our ChatMessage format // Build ordered messages list from Open‑WebUI history using parent chain to currentId // ===== Helpers to synthesize tool-call details blocks for UI parsing ===== List>? _sanitizeFilesForWebUI( List>? files, ) { if (files == null || files.isEmpty) { return null; } final sanitized = >[]; for (final entry in files) { final safe = {}; for (final MapEntry(:key, :value) in entry.entries) { if (value == null) continue; safe[key.toString()] = value; } if (safe.isNotEmpty) { sanitized.add(safe); } } return sanitized.isNotEmpty ? sanitized : null; } // Create new conversation using OpenWebUI API Future createConversation({ required String title, required List messages, String? model, String? systemPrompt, }) async { _traceApi('Creating new conversation on OpenWebUI server'); _traceApi('Title: $title, Messages: ${messages.length}'); // Build messages with parent-child relationships final Map messagesMap = {}; final List> 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': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (_sanitizeFilesForWebUI(msg.files) != null) 'files': _sanitizeFilesForWebUI(msg.files), }; // Update parent's childrenIds if there's a previous message if (parentId != null && messagesMap.containsKey(parentId)) { (messagesMap[parentId]['childrenIds'] as List).add(messageId); } // Build message for messages array messagesArray.add({ 'id': messageId, 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (_sanitizeFilesForWebUI(msg.files) != null) 'files': _sanitizeFilesForWebUI(msg.files), }); previousId = messageId; currentId = messageId; if (msg.role == 'user') { lastUserId = messageId; } } // Create the chat data structure matching OpenWebUI format exactly final chatData = { 'chat': { 'id': '', 'title': title, 'models': model != null ? [model] : [], if (systemPrompt != null && systemPrompt.trim().isNotEmpty) 'system': systemPrompt, 'params': {}, 'history': { 'messages': messagesMap, if (currentId != null) 'currentId': currentId, }, 'messages': messagesArray, 'tags': [], 'timestamp': DateTime.now().millisecondsSinceEpoch, }, 'folder_id': null, }; _traceApi('Sending chat data with proper parent-child structure'); _traceApi('Request data: $chatData'); final response = await _dio.post('/api/v1/chats/new', data: chatData); DebugLogger.log( 'create-status', scope: 'api/conversation', data: {'code': response.statusCode}, ); DebugLogger.log('create-ok', scope: 'api/conversation'); 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 Future syncConversationMessages( String conversationId, List messages, { String? title, String? model, String? systemPrompt, }) async { _traceApi( 'Syncing conversation $conversationId with ${messages.length} messages', ); // Build messages map and array in OpenWebUI format final Map messagesMap = {}; final List> messagesArray = []; String? currentId; String? previousId; String? lastUserId; for (final msg in messages) { final messageId = msg.id; // Use the properly formatted files array for WebUI display // 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': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, if (msg.role == 'assistant' && msg.model != null) 'model': msg.model, if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, if (msg.role == 'assistant') 'done': !msg.isStreaming, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (sanitizedFiles != null) 'files': sanitizedFiles, // Mirror status updates, follow-ups, code executions, and sources if (msg.statusHistory.isNotEmpty) 'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(), if (msg.followUps.isNotEmpty) 'followUps': List.from(msg.followUps), if (msg.codeExecutions.isNotEmpty) 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), if (msg.sources.isNotEmpty) 'sources': msg.sources.map((s) => s.toJson()).toList(), }; // Update parent's childrenIds if (parentId != null && messagesMap.containsKey(parentId)) { (messagesMap[parentId]['childrenIds'] as List).add(messageId); } // Use the same properly formatted files array for messages array final sanitizedArrayFiles = _sanitizeFilesForWebUI(msg.files); messagesArray.add({ 'id': messageId, 'parentId': parentId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, if (msg.role == 'assistant' && msg.model != null) 'model': msg.model, if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, if (msg.role == 'assistant') 'done': !msg.isStreaming, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (sanitizedArrayFiles != null) 'files': sanitizedArrayFiles, // Mirror status updates, follow-ups, code executions, and sources if (msg.statusHistory.isNotEmpty) 'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(), if (msg.followUps.isNotEmpty) 'followUps': List.from(msg.followUps), if (msg.codeExecutions.isNotEmpty) 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), if (msg.sources.isNotEmpty) 'sources': msg.sources.map((s) => s.toJson()).toList(), }); 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': [], '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), // Mirror follow-ups, code executions, and sources for versions if (ver.followUps.isNotEmpty) 'followUps': List.from(ver.followUps), if (ver.codeExecutions.isNotEmpty) 'codeExecutions': ver.codeExecutions .map((e) => e.toJson()) .toList(), if (ver.sources.isNotEmpty) 'sources': ver.sources.map((s) => s.toJson()).toList(), }; // Link into parent (parentForVersions is always non-null here) if (messagesMap.containsKey(parentForVersions)) { (messagesMap[parentForVersions]['childrenIds'] as List).add(vId); } } } } currentId = messageId; } // Create the chat data structure matching OpenWebUI format exactly final chatData = { 'chat': { if (title != null) 'title': title, // Include the title if provided 'models': model != null ? [model] : [], if (systemPrompt != null && systemPrompt.trim().isNotEmpty) 'system': systemPrompt, 'messages': messagesArray, 'history': { 'messages': messagesMap, if (currentId != null) 'currentId': currentId, }, 'params': {}, 'files': [], }, }; _traceApi('Syncing chat with OpenWebUI format data using POST'); // OpenWebUI uses POST not PUT for updating chats await _dio.post('/api/v1/chats/$conversationId', data: chatData); DebugLogger.log('sync-ok', scope: 'api/conversation'); } Future updateConversation( String id, { String? title, String? systemPrompt, }) async { // OpenWebUI expects POST to /api/v1/chats/{id} with ChatForm { chat: {...} } final chatPayload = { if (title != null) 'title': title, if (systemPrompt != null) 'system': systemPrompt, }; await _dio.post('/api/v1/chats/$id', data: {'chat': chatPayload}); } Future deleteConversation(String id) async { await _dio.delete('/api/v1/chats/$id'); } // Pin/Unpin conversation Future pinConversation(String id, bool pinned) async { _traceApi('${pinned ? 'Pinning' : 'Unpinning'} conversation: $id'); await _dio.post('/api/v1/chats/$id/pin', data: {'pinned': pinned}); } // Archive/Unarchive conversation Future archiveConversation(String id, bool archived) async { _traceApi('${archived ? 'Archiving' : 'Unarchiving'} conversation: $id'); await _dio.post('/api/v1/chats/$id/archive', data: {'archived': archived}); } // Share conversation Future shareConversation(String id) async { _traceApi('Sharing conversation: $id'); final response = await _dio.post('/api/v1/chats/$id/share'); final data = response.data as Map; return data['share_id'] as String?; } // Clone conversation Future cloneConversation(String id) async { _traceApi('Cloning conversation: $id'); final response = await _dio.post('/api/v1/chats/$id/clone'); final json = await _workerManager .schedule, Map>( parseFullConversationWorker, {'conversation': response.data}, debugLabel: 'parse_conversation_full', ); return Conversation.fromJson(json); } // User Settings Future> getUserSettings() async { _traceApi('Fetching user settings'); final response = await _dio.get('/api/v1/users/user/settings'); return response.data as Map; } Future updateUserSettings(Map settings) async { _traceApi('Updating user settings'); // Align with web client update route await _dio.post('/api/v1/users/user/settings/update', data: settings); } // Suggestions Future> getSuggestions() async { _traceApi('Fetching conversation suggestions'); final response = await _dio.get('/api/v1/configs/suggestions'); final data = response.data; if (data is List) { return data.cast(); } return []; } Future> _parseConversationSummaryList( List regular, { required String debugLabel, }) async { final payload = { 'regular': List.from(regular), 'pinned': const [], 'archived': const [], }; final parsed = await _workerManager .schedule, List>>( parseConversationSummariesWorker, payload, debugLabel: debugLabel, ); return parsed .map((json) => Conversation.fromJson(json)) .toList(growable: false); } Future>> _normalizeList( List raw, { required String debugLabel, }) { return _workerManager .schedule, List>>( _normalizeMapListWorker, {'list': raw}, debugLabel: debugLabel, ); } // Tools - Check available tools on server Future>> getAvailableTools() async { _traceApi('Fetching available tools'); try { final response = await _dio.get('/api/v1/tools/'); final data = response.data; if (data is List) { return data.cast>(); } } catch (e) { _traceApi('Error fetching tools: $e'); } return []; } // Folders /// Returns a record with (folders data, feature enabled flag). /// When the folders feature is disabled server-side (403), returns ([], false). Future<(List>, bool)> getFolders() async { try { final response = await _dio.get('/api/v1/folders/'); DebugLogger.log( 'fetch-status', scope: 'api/folders', data: {'code': response.statusCode}, ); DebugLogger.log('fetch-ok', scope: 'api/folders'); final data = response.data; if (data is List) { _traceApi('Found ${data.length} folders'); return (data.cast>(), true); } else { DebugLogger.warning( 'unexpected-type', scope: 'api/folders', data: {'type': data.runtimeType}, ); return (const >[], true); } } on DioException catch (e) { // 403 indicates folders feature is disabled server-side if (e.response?.statusCode == 403) { DebugLogger.log( 'feature-disabled', scope: 'api/folders', data: {'status': 403}, ); return (const >[], false); } DebugLogger.error('fetch-failed', scope: 'api/folders', error: e); rethrow; } catch (e) { DebugLogger.error('fetch-failed', scope: 'api/folders', error: e); rethrow; } } Future> createFolder({ required String name, String? parentId, }) async { _traceApi('Creating folder: $name'); final response = await _dio.post( '/api/v1/folders/', data: {'name': name, if (parentId != null) 'parent_id': parentId}, ); return response.data as Map; } Future updateFolder(String id, {String? name, String? parentId}) async { _traceApi('Updating folder: $id'); // OpenWebUI folder update endpoints: // - POST /api/v1/folders/{id}/update -> rename (FolderForm) // - POST /api/v1/folders/{id}/update/parent -> move parent (FolderParentIdForm) if (name != null) { await _dio.post('/api/v1/folders/$id/update', data: {'name': name}); } if (parentId != null) { await _dio.post( '/api/v1/folders/$id/update/parent', data: {'parent_id': parentId}, ); } } Future deleteFolder(String id) async { _traceApi('Deleting folder: $id'); await _dio.delete('/api/v1/folders/$id'); } Future moveConversationToFolder( String conversationId, String? folderId, ) async { _traceApi('Moving conversation $conversationId to folder $folderId'); await _dio.post( '/api/v1/chats/$conversationId/folder', data: {'folder_id': folderId}, ); } Future> getFolderConversationSummaries( String folderId, ) async { // The backend endpoint has a hardcoded limit of 10 items per page, // so we use parallel pagination to fetch all conversations efficiently. final allChats = await _fetchAllPagedResults( endpoint: '/api/v1/chats/folder/$folderId/list', expectedPageSize: 10, debugLabel: 'folder-$folderId', ); // Parse in background isolate for better UI responsiveness final parsedJson = await _workerManager .schedule, List>>( parseFolderSummariesWorker, {'chats': allChats}, debugLabel: 'parse_folder_$folderId', ); return parsedJson.map(Conversation.fromJson).toList(growable: false); } // Tags Future> getConversationTags(String conversationId) async { _traceApi('Fetching tags for conversation: $conversationId'); final response = await _dio.get('/api/v1/chats/$conversationId/tags'); final data = response.data; if (data is List) { return data.cast(); } return []; } Future addTagToConversation(String conversationId, String tag) async { _traceApi('Adding tag "$tag" to conversation: $conversationId'); await _dio.post('/api/v1/chats/$conversationId/tags', data: {'tag': tag}); } Future removeTagFromConversation( String conversationId, String tag, ) async { _traceApi('Removing tag "$tag" from conversation: $conversationId'); await _dio.delete('/api/v1/chats/$conversationId/tags/$tag'); } Future> getAllTags() async { _traceApi('Fetching all available tags'); final response = await _dio.get('/api/v1/chats/tags'); final data = response.data; if (data is List) { return data.cast(); } return []; } Future> getConversationsByTag(String tag) async { _traceApi('Fetching conversations with tag: $tag'); final response = await _dio.get('/api/v1/chats/tags/$tag'); final data = response.data; if (data is List) { return _parseConversationSummaryList(data, debugLabel: 'parse_tag_$tag'); } return []; } // Files Future getFileContent(String fileId) async { _traceApi('Fetching file content: $fileId'); // The Open-WebUI endpoint returns the raw file bytes with appropriate // Content-Type headers, not JSON. We must read bytes and base64-encode // them for consistent handling across platforms/widgets. final response = await _dio.get( '/api/v1/files/$fileId/content', options: Options(responseType: ResponseType.bytes), ); // Try to determine the mime type from response headers; fallback to text/plain final contentType = response.headers.value(HttpHeaders.contentTypeHeader) ?? ''; String mimeType = 'text/plain'; if (contentType.isNotEmpty) { // Strip charset if present mimeType = contentType.split(';').first.trim(); } final bytes = response.data is List ? (response.data as List) : (response.data as Uint8List).toList(); final base64Data = base64Encode(bytes); // For images, return a data URL so UI can render directly; otherwise return raw base64 if (mimeType.startsWith('image/')) { return 'data:$mimeType;base64,$base64Data'; } return base64Data; } Future> getFileInfo(String fileId) async { _traceApi('Fetching file info: $fileId'); final response = await _dio.get('/api/v1/files/$fileId'); return response.data as Map; } Future> getUserFiles() async { _traceApi('Fetching user files'); final response = await _dio.get('/api/v1/files/'); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_file_list', ); return normalized.map(FileInfo.fromJson).toList(growable: false); } return const []; } // Enhanced File Operations Future> searchFiles({ String? query, String? contentType, int? limit, int? offset, }) async { _traceApi('Searching files with query: $query'); final queryParams = {}; if (query != null) queryParams['q'] = query; if (contentType != null) queryParams['content_type'] = contentType; if (limit != null) queryParams['limit'] = limit; if (offset != null) queryParams['offset'] = offset; final response = await _dio.get( '/api/v1/files/search', queryParameters: queryParams, ); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_file_search', ); return normalized.map(FileInfo.fromJson).toList(growable: false); } return const []; } Future> getAllFiles() async { _traceApi('Fetching all files (admin)'); final response = await _dio.get('/api/v1/files/all'); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_file_all', ); return normalized.map(FileInfo.fromJson).toList(growable: false); } return const []; } Future uploadFileWithProgress( String filePath, String fileName, { Function(int sent, int total)? onProgress, }) async { _traceApi('Uploading file with progress: $fileName'); final formData = FormData.fromMap({ 'file': await MultipartFile.fromFile(filePath, filename: fileName), }); final response = await _dio.post( '/api/v1/files/', data: formData, onSendProgress: onProgress, ); return response.data['id'] as String; } Future> updateFileContent( String fileId, String content, ) async { _traceApi('Updating file content: $fileId'); final response = await _dio.post( '/api/v1/files/$fileId/data/content/update', data: {'content': content}, ); return response.data as Map; } Future getFileHtmlContent(String fileId) async { _traceApi('Fetching file HTML content: $fileId'); final response = await _dio.get('/api/v1/files/$fileId/content/html'); return response.data as String; } Future deleteFile(String fileId) async { _traceApi('Deleting file: $fileId'); await _dio.delete('/api/v1/files/$fileId'); } Future> updateFileMetadata( String fileId, { String? filename, Map? metadata, }) async { _traceApi('Updating file metadata: $fileId'); final response = await _dio.put( '/api/v1/files/$fileId/metadata', data: { if (filename != null) 'filename': filename, if (metadata != null) 'metadata': metadata, }, ); return response.data as Map; } Future>> processFilesBatch( List fileIds, { String? operation, Map? options, }) async { _traceApi('Processing files batch: ${fileIds.length} files'); final response = await _dio.post( '/api/v1/retrieval/process/files/batch', data: { 'file_ids': fileIds, if (operation != null) 'operation': operation, if (options != null) 'options': options, }, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future>> getFilesByType(String contentType) async { _traceApi('Fetching files by type: $contentType'); final response = await _dio.get( '/api/v1/files/', queryParameters: {'content_type': contentType}, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> getFileStats() async { _traceApi('Fetching file statistics'); final response = await _dio.get('/api/v1/files/stats'); return response.data as Map; } // Knowledge Base Future> getKnowledgeBases() async { _traceApi('Fetching knowledge bases'); final response = await _dio.get('/api/v1/knowledge/'); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_knowledge_bases', ); return normalized.map(KnowledgeBase.fromJson).toList(growable: false); } return const []; } Future> createKnowledgeBase({ required String name, String? description, }) async { _traceApi('Creating knowledge base: $name'); final response = await _dio.post( '/api/v1/knowledge/', data: {'name': name, if (description != null) 'description': description}, ); return response.data as Map; } Future updateKnowledgeBase( String id, { String? name, String? description, }) async { _traceApi('Updating knowledge base: $id'); await _dio.put( '/api/v1/knowledge/$id', data: { if (name != null) 'name': name, if (description != null) 'description': description, }, ); } Future deleteKnowledgeBase(String id) async { _traceApi('Deleting knowledge base: $id'); await _dio.delete('/api/v1/knowledge/$id'); } Future> getKnowledgeBaseItems( String knowledgeBaseId, ) async { _traceApi('Fetching knowledge base items: $knowledgeBaseId'); final response = await _dio.get('/api/v1/knowledge/$knowledgeBaseId/items'); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_kb_items', ); return normalized.map(KnowledgeBaseItem.fromJson).toList(growable: false); } return const []; } Future> addKnowledgeBaseItem( String knowledgeBaseId, { required String content, String? title, Map? metadata, }) async { _traceApi('Adding item to knowledge base: $knowledgeBaseId'); final response = await _dio.post( '/api/v1/knowledge/$knowledgeBaseId/items', data: { 'content': content, if (title != null) 'title': title, if (metadata != null) 'metadata': metadata, }, ); return response.data as Map; } Future>> searchKnowledgeBase( String knowledgeBaseId, String query, ) async { _traceApi('Searching knowledge base: $knowledgeBaseId for: $query'); final response = await _dio.post( '/api/v1/knowledge/$knowledgeBaseId/search', data: {'query': query}, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future?> processWebpage({ required String url, String? collectionName, }) async { _traceApi('Processing webpage: $url'); try { final response = await _dio.post( '/api/v1/retrieval/process/web', data: { 'url': url, if (collectionName != null) 'collection_name': collectionName, }, ); if (response.data is Map) { return response.data as Map; } return null; } catch (e) { _traceApi('Process webpage failed: $e'); return null; } } Future?> processYoutube({ required String url, String? collectionName, }) async { _traceApi('Processing YouTube URL: $url'); try { final response = await _dio.post( '/api/v1/retrieval/process/youtube', data: { 'url': url, if (collectionName != null) 'collection_name': collectionName, }, ); if (response.data is Map) { return response.data as Map; } return null; } catch (e) { _traceApi('Process YouTube failed: $e'); return null; } } // Web Search Future> performWebSearch(List queries) async { _traceApi('Performing web search for queries: $queries'); try { final response = await _dio.post( '/api/v1/retrieval/process/web/search', data: {'queries': queries}, ); DebugLogger.log( 'status', scope: 'api/web-search', data: {'code': response.statusCode}, ); DebugLogger.log( 'response-type', scope: 'api/web-search', data: {'type': response.data.runtimeType}, ); DebugLogger.log('fetch-ok', scope: 'api/web-search'); return response.data as Map; } catch (e) { _traceApi('Web search API error: $e'); if (e is DioException) { DebugLogger.error('error-response', scope: 'api/web-search', error: e); _traceApi('Web search error status: ${e.response?.statusCode}'); } rethrow; } } // Get detailed model information Future?> getModelDetails(String modelId) async { try { final response = await _dio.get( '/api/v1/models/model', queryParameters: {'id': modelId}, ); if (response.statusCode == 200 && response.data != null) { final modelData = response.data as Map; DebugLogger.log('details', scope: 'api/models', data: {'id': modelId}); return modelData; } } catch (e) { _traceApi('Failed to get model details for $modelId: $e'); } return null; } // Send chat completed notification Future sendChatCompleted({ required String chatId, required String messageId, required List> messages, required String model, Map? modelItem, String? sessionId, }) async { _traceApi('Sending chat completed notification (optional endpoint)'); // This endpoint appears to be optional or deprecated in newer OpenWebUI versions // The main chat synchronization happens through /api/v1/chats/{id} updates // We'll still try to call it but won't fail if it doesn't work // Format messages to match OpenWebUI expected structure // Note: Removing 'id' field as it causes 400 error final formattedMessages = messages.map((msg) { final formatted = { // Don't include 'id' - it causes 400 error with detail: 'id' 'role': msg['role'], 'content': msg['content'], 'timestamp': msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, }; // Add model info for assistant messages if (msg['role'] == 'assistant') { formatted['model'] = model; if (msg.containsKey('usage')) { formatted['usage'] = msg['usage']; } } return formatted; }).toList(); // Include the message ID and session ID at the top level - server expects these final requestData = { 'id': messageId, // The server expects the assistant message ID here 'chat_id': chatId, 'model': model, 'messages': formattedMessages, 'session_id': sessionId ?? const Uuid().v4().substring(0, 20), // Add session_id // Don't include model_item as it might not be expected }; try { final response = await _dio.post( '/api/chat/completed', data: requestData, options: Options( sendTimeout: const Duration(seconds: 4), receiveTimeout: const Duration(seconds: 4), ), ); _traceApi('Chat completed response: ${response.statusCode}'); } catch (e) { // This is a non-critical endpoint - main sync happens via /api/v1/chats/{id} _traceApi( 'Chat completed endpoint not available or failed (non-critical): $e', ); } } // Query a collection for content Future> queryCollection( String collectionName, String query, ) async { _traceApi('Querying collection: $collectionName with query: $query'); try { final response = await _dio.post( '/api/v1/retrieval/query/collection', data: { 'collection_names': [collectionName], // API expects an array 'query': query, 'k': 5, // Limit to top 5 results }, ); _traceApi('Collection query response status: ${response.statusCode}'); _traceApi('Collection query response type: ${response.data.runtimeType}'); DebugLogger.log( 'query-ok', scope: 'api/collection', data: {'name': collectionName}, ); if (response.data is List) { return response.data as List; } else if (response.data is Map) { // If the response is a map, check for common result keys final data = response.data as Map; if (data.containsKey('results')) { return data['results'] as List? ?? []; } else if (data.containsKey('documents')) { return data['documents'] as List? ?? []; } else if (data.containsKey('data')) { return data['data'] as List? ?? []; } } return []; } catch (e) { _traceApi('Collection query API error: $e'); if (e is DioException) { _traceApi('Collection query error response: ${e.response?.data}'); _traceApi('Collection query error status: ${e.response?.statusCode}'); } rethrow; } } // Get retrieval configuration to check web search settings Future> getRetrievalConfig() async { _traceApi('Getting retrieval configuration'); try { final response = await _dio.get('/api/v1/retrieval/config'); _traceApi('Retrieval config response status: ${response.statusCode}'); DebugLogger.log('config-ok', scope: 'api/retrieval'); return response.data as Map; } catch (e) { _traceApi('Retrieval config API error: $e'); if (e is DioException) { _traceApi('Retrieval config error response: ${e.response?.data}'); _traceApi('Retrieval config error status: ${e.response?.statusCode}'); } rethrow; } } // Audio Future getDefaultServerVoice() async { _traceApi('Fetching default server TTS voice'); final response = await _dio.get('/api/v1/audio/config'); final data = response.data; if (data is Map) { final ttsConfig = data['tts']; if (ttsConfig is Map) { final voice = ttsConfig['VOICE'] ?? ttsConfig['voice']; if (voice is String && voice.trim().isNotEmpty) { return voice.trim(); } } } return null; } Future>> getAvailableServerVoices() async { _traceApi('Fetching server TTS voices'); final response = await _dio.get('/api/v1/audio/voices'); final data = response.data; if (data is Map) { final voices = data['voices']; if (voices is List) { return _normalizeList(voices, debugLabel: 'parse_voice_list'); } } if (data is List) { // Fallback: plain list of ids return data .map((e) => {'id': e.toString(), 'name': e.toString()}) .toList(); } return []; } Future> transcribeSpeech({ required Uint8List audioBytes, String? fileName, String? mimeType, String? language, }) async { if (audioBytes.isEmpty) { throw ArgumentError('audioBytes cannot be empty for transcription'); } final sanitizedFileName = (fileName != null && fileName.trim().isNotEmpty ? fileName.trim() : 'audio.m4a'); final resolvedMimeType = (mimeType != null && mimeType.trim().isNotEmpty) ? mimeType.trim() : _inferMimeTypeFromName(sanitizedFileName); _traceApi( 'Uploading $sanitizedFileName (${audioBytes.length} bytes) for transcription', ); final formData = FormData.fromMap({ 'file': MultipartFile.fromBytes( audioBytes, filename: sanitizedFileName, contentType: _parseMediaType(resolvedMimeType), ), if (language != null && language.trim().isNotEmpty) 'language': language.trim(), }); final response = await _dio.post( '/api/v1/audio/transcriptions', data: formData, options: Options(headers: const {'accept': 'application/json'}), ); final data = response.data; if (data is Map) { return data; } if (data is String) { return {'text': data}; } throw StateError( 'Unexpected transcription response type: ${data.runtimeType}', ); } Future<({Uint8List bytes, String mimeType})> generateSpeech({ required String text, String? voice, double? speed, }) async { final textPreview = text.length > 50 ? text.substring(0, 50) : text; _traceApi('Generating speech for text: $textPreview...'); final response = await _dio.post( '/api/v1/audio/speech', data: { 'input': text, if (voice != null) 'voice': voice, if (speed != null) 'speed': speed, }, options: Options(responseType: ResponseType.bytes), ); final rawMimeType = response.headers.value('content-type'); final audioBytes = _coerceAudioBytes(response.data); final resolvedMimeType = _resolveAudioMimeType(rawMimeType, audioBytes); return (bytes: audioBytes, mimeType: resolvedMimeType); } Uint8List _coerceAudioBytes(Object? data) { if (data is Uint8List && data.isNotEmpty) { return Uint8List.fromList(data); } if (data is List) { return Uint8List.fromList(data); } if (data is List) { return Uint8List.fromList(data.cast()); } return Uint8List(0); } String _resolveAudioMimeType(String? rawMimeType, Uint8List bytes) { final sanitized = rawMimeType?.split(';').first.trim(); if (sanitized != null && sanitized.isNotEmpty) { return sanitized; } if (_matchesPrefix(bytes, const [0x52, 0x49, 0x46, 0x46]) && _matchesPrefix(bytes, const [0x57, 0x41, 0x56, 0x45], offset: 8)) { return 'audio/wav'; } if (_matchesPrefix(bytes, const [0x4F, 0x67, 0x67, 0x53])) { return 'audio/ogg'; } if (_matchesPrefix(bytes, const [0x66, 0x4C, 0x61, 0x43])) { return 'audio/flac'; } if (_looksLikeMp4(bytes)) { return 'audio/mp4'; } if (_looksLikeMpeg(bytes)) { return 'audio/mpeg'; } return 'audio/mpeg'; } bool _matchesPrefix(Uint8List bytes, List signature, {int offset = 0}) { if (bytes.length < offset + signature.length) { return false; } for (var i = 0; i < signature.length; i++) { if (bytes[offset + i] != signature[i]) { return false; } } return true; } bool _looksLikeMp4(Uint8List bytes) { return bytes.length >= 8 && _matchesPrefix(bytes, const [0x66, 0x74, 0x79, 0x70], offset: 4); } bool _looksLikeMpeg(Uint8List bytes) { if (bytes.length >= 3 && bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) { return true; } return bytes.length >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0; } String _inferMimeTypeFromName(String name) { final dotIndex = name.lastIndexOf('.'); if (dotIndex == -1 || dotIndex == name.length - 1) { return 'audio/mpeg'; } final ext = name.substring(dotIndex + 1).toLowerCase(); switch (ext) { case 'wav': return 'audio/wav'; case 'ogg': return 'audio/ogg'; case 'm4a': case 'mp4': return 'audio/mp4'; case 'aac': return 'audio/aac'; case 'webm': return 'audio/webm'; case 'flac': return 'audio/flac'; case 'mp3': return 'audio/mpeg'; default: return 'audio/mpeg'; } } MediaType? _parseMediaType(String? value) { if (value == null || value.isEmpty) { return null; } try { return MediaType.parse(value); } catch (_) { return null; } } // Image Generation Future>> getImageModels() async { _traceApi('Fetching image generation models'); final response = await _dio.get('/api/v1/images/models'); final data = response.data; if (data is List) { return _normalizeList(data, debugLabel: 'parse_image_models'); } return []; } Future generateImage({ required String prompt, String? model, int? width, int? height, int? steps, double? guidance, }) async { final promptPreview = prompt.length > 50 ? prompt.substring(0, 50) : prompt; _traceApi('Generating image with prompt: $promptPreview...'); try { final response = await _dio.post( '/api/v1/images/generations', data: { 'prompt': prompt, if (model != null) 'model': model, if (width != null) 'width': width, if (height != null) 'height': height, if (steps != null) 'steps': steps, if (guidance != null) 'guidance': guidance, }, ); return response.data; } on DioException catch (e) { _traceApi('images/generations failed: ${e.response?.statusCode}'); DebugLogger.error( 'images-generate-failed', scope: 'api/images', error: e, data: {'status': e.response?.statusCode}, ); // Do not attempt singular fallback here - surface the original error rethrow; } } // Prompts Future> getPrompts() async { _traceApi('Fetching prompts'); final response = await _dio.get('/api/v1/prompts/'); final data = response.data; if (data is List) { final normalized = await _normalizeList( data, debugLabel: 'parse_prompts', ); return normalized .map(Prompt.fromJson) .where((prompt) => prompt.command.isNotEmpty) .toList(growable: false); } return const []; } // Permissions & Features Future> getUserPermissions() async { _traceApi('Fetching user permissions'); try { final response = await _dio.get('/api/v1/users/permissions'); return response.data as Map; } catch (e) { _traceApi('Error fetching user permissions: $e'); if (e is DioException) { _traceApi('Permissions error response: ${e.response?.data}'); _traceApi('Permissions error status: ${e.response?.statusCode}'); } rethrow; } } Future> createPrompt({ required String title, required String content, String? description, List? tags, }) async { _traceApi('Creating prompt: $title'); final response = await _dio.post( '/api/v1/prompts/', data: { 'title': title, 'content': content, if (description != null) 'description': description, if (tags != null) 'tags': tags, }, ); return response.data as Map; } Future updatePrompt( String id, { String? title, String? content, String? description, List? tags, }) async { _traceApi('Updating prompt: $id'); await _dio.put( '/api/v1/prompts/$id', data: { if (title != null) 'title': title, if (content != null) 'content': content, if (description != null) 'description': description, if (tags != null) 'tags': tags, }, ); } Future deletePrompt(String id) async { _traceApi('Deleting prompt: $id'); await _dio.delete('/api/v1/prompts/$id'); } // Tools & Functions Future>> getTools() async { _traceApi('Fetching tools'); final response = await _dio.get('/api/v1/tools/'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future>> getFunctions() async { _traceApi('Fetching functions'); final response = await _dio.get('/api/v1/functions/'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> createTool({ required String name, required Map spec, }) async { _traceApi('Creating tool: $name'); final response = await _dio.post( '/api/v1/tools/', data: {'name': name, 'spec': spec}, ); return response.data as Map; } Future> createFunction({ required String name, required String code, String? description, }) async { _traceApi('Creating function: $name'); final response = await _dio.post( '/api/v1/functions/', data: { 'name': name, 'code': code, if (description != null) 'description': description, }, ); return response.data as Map; } // Enhanced Tools Management Operations Future> getTool(String toolId) async { _traceApi('Fetching tool details: $toolId'); final response = await _dio.get('/api/v1/tools/id/$toolId'); return response.data as Map; } Future> updateTool( String toolId, { String? name, Map? spec, String? description, }) async { _traceApi('Updating tool: $toolId'); final response = await _dio.post( '/api/v1/tools/id/$toolId/update', data: { if (name != null) 'name': name, if (spec != null) 'spec': spec, if (description != null) 'description': description, }, ); return response.data as Map; } Future deleteTool(String toolId) async { _traceApi('Deleting tool: $toolId'); await _dio.delete('/api/v1/tools/id/$toolId/delete'); } Future> getToolValves(String toolId) async { _traceApi('Fetching tool valves: $toolId'); final response = await _dio.get('/api/v1/tools/id/$toolId/valves'); return response.data as Map; } Future> updateToolValves( String toolId, Map valves, ) async { _traceApi('Updating tool valves: $toolId'); final response = await _dio.post( '/api/v1/tools/id/$toolId/valves/update', data: valves, ); return response.data as Map; } Future> getUserToolValves(String toolId) async { _traceApi('Fetching user tool valves: $toolId'); final response = await _dio.get('/api/v1/tools/id/$toolId/valves/user'); return response.data as Map; } Future> updateUserToolValves( String toolId, Map valves, ) async { _traceApi('Updating user tool valves: $toolId'); final response = await _dio.post( '/api/v1/tools/id/$toolId/valves/user/update', data: valves, ); return response.data as Map; } Future>> exportTools() async { _traceApi('Exporting tools configuration'); final response = await _dio.get('/api/v1/tools/export'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> loadToolFromUrl(String url) async { _traceApi('Loading tool from URL: $url'); final response = await _dio.post( '/api/v1/tools/load/url', data: {'url': url}, ); return response.data as Map; } // Enhanced Functions Management Operations Future> getFunction(String functionId) async { _traceApi('Fetching function details: $functionId'); final response = await _dio.get('/api/v1/functions/id/$functionId'); return response.data as Map; } Future> updateFunction( String functionId, { String? name, String? code, String? description, }) async { _traceApi('Updating function: $functionId'); final response = await _dio.post( '/api/v1/functions/id/$functionId/update', data: { if (name != null) 'name': name, if (code != null) 'code': code, if (description != null) 'description': description, }, ); return response.data as Map; } Future deleteFunction(String functionId) async { _traceApi('Deleting function: $functionId'); await _dio.delete('/api/v1/functions/id/$functionId/delete'); } Future> toggleFunction(String functionId) async { _traceApi('Toggling function: $functionId'); final response = await _dio.post('/api/v1/functions/id/$functionId/toggle'); return response.data as Map; } Future> toggleGlobalFunction(String functionId) async { _traceApi('Toggling global function: $functionId'); final response = await _dio.post( '/api/v1/functions/id/$functionId/toggle/global', ); return response.data as Map; } Future> getFunctionValves(String functionId) async { _traceApi('Fetching function valves: $functionId'); final response = await _dio.get('/api/v1/functions/id/$functionId/valves'); return response.data as Map; } Future> updateFunctionValves( String functionId, Map valves, ) async { _traceApi('Updating function valves: $functionId'); final response = await _dio.post( '/api/v1/functions/id/$functionId/valves/update', data: valves, ); return response.data as Map; } Future> getUserFunctionValves(String functionId) async { _traceApi('Fetching user function valves: $functionId'); final response = await _dio.get( '/api/v1/functions/id/$functionId/valves/user', ); return response.data as Map; } Future> updateUserFunctionValves( String functionId, Map valves, ) async { _traceApi('Updating user function valves: $functionId'); final response = await _dio.post( '/api/v1/functions/id/$functionId/valves/user/update', data: valves, ); return response.data as Map; } Future> syncFunctions() async { _traceApi('Syncing functions'); final response = await _dio.post('/api/v1/functions/sync'); return response.data as Map; } Future>> exportFunctions() async { _traceApi('Exporting functions configuration'); final response = await _dio.get('/api/v1/functions/export'); final data = response.data; if (data is List) { return data.cast>(); } return []; } // Memory & Notes Future>> getMemories() async { _traceApi('Fetching memories'); final response = await _dio.get('/api/v1/memories/'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> createMemory({ required String content, String? title, }) async { _traceApi('Creating memory'); final response = await _dio.post( '/api/v1/memories/', data: {'content': content, if (title != null) 'title': title}, ); return response.data as Map; } // Team Collaboration Future>> getChannels() async { _traceApi('Fetching channels'); final response = await _dio.get('/api/v1/channels/'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> createChannel({ required String name, String? description, bool isPrivate = false, }) async { _traceApi('Creating channel: $name'); final response = await _dio.post( '/api/v1/channels/', data: { 'name': name, if (description != null) 'description': description, 'is_private': isPrivate, }, ); return response.data as Map; } Future joinChannel(String channelId) async { _traceApi('Joining channel: $channelId'); await _dio.post('/api/v1/channels/$channelId/join'); } Future leaveChannel(String channelId) async { _traceApi('Leaving channel: $channelId'); await _dio.post('/api/v1/channels/$channelId/leave'); } Future>> getChannelMembers(String channelId) async { _traceApi('Fetching channel members: $channelId'); final response = await _dio.get('/api/v1/channels/$channelId/members'); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> getChannelConversations(String channelId) async { _traceApi('Fetching channel conversations: $channelId'); final response = await _dio.get('/api/v1/channels/$channelId/chats'); final data = response.data; if (data is List) { return data.whereType().map((chatData) { final map = Map.from(chatData); return Conversation.fromJson(parseConversationSummary(map)); }).toList(); } return []; } // Enhanced Channel Management Operations Future> getChannel(String channelId) async { _traceApi('Fetching channel details: $channelId'); final response = await _dio.get('/api/v1/channels/$channelId'); return response.data as Map; } Future> updateChannel( String channelId, { String? name, String? description, bool? isPrivate, }) async { _traceApi('Updating channel: $channelId'); final response = await _dio.post( '/api/v1/channels/$channelId/update', data: { if (name != null) 'name': name, if (description != null) 'description': description, if (isPrivate != null) 'is_private': isPrivate, }, ); return response.data as Map; } Future deleteChannel(String channelId) async { _traceApi('Deleting channel: $channelId'); await _dio.delete('/api/v1/channels/$channelId/delete'); } Future>> getChannelMessages( String channelId, { int? limit, int? offset, DateTime? before, DateTime? after, }) async { _traceApi('Fetching channel messages: $channelId'); final queryParams = {}; if (limit != null) queryParams['limit'] = limit; if (offset != null) queryParams['offset'] = offset; if (before != null) queryParams['before'] = before.toIso8601String(); if (after != null) queryParams['after'] = after.toIso8601String(); final response = await _dio.get( '/api/v1/channels/$channelId/messages', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> postChannelMessage( String channelId, { required String content, String? messageType, Map? metadata, }) async { _traceApi('Posting message to channel: $channelId'); final response = await _dio.post( '/api/v1/channels/$channelId/messages/post', data: { 'content': content, if (messageType != null) 'message_type': messageType, if (metadata != null) 'metadata': metadata, }, ); return response.data as Map; } Future> updateChannelMessage( String channelId, String messageId, { String? content, Map? metadata, }) async { _traceApi('Updating channel message: $channelId/$messageId'); final response = await _dio.post( '/api/v1/channels/$channelId/messages/$messageId/update', data: { if (content != null) 'content': content, if (metadata != null) 'metadata': metadata, }, ); return response.data as Map; } Future deleteChannelMessage(String channelId, String messageId) async { _traceApi('Deleting channel message: $channelId/$messageId'); await _dio.delete('/api/v1/channels/$channelId/messages/$messageId'); } Future> addMessageReaction( String channelId, String messageId, String emoji, ) async { _traceApi('Adding reaction to message: $channelId/$messageId'); final response = await _dio.post( '/api/v1/channels/$channelId/messages/$messageId/reactions', data: {'emoji': emoji}, ); return response.data as Map; } Future removeMessageReaction( String channelId, String messageId, String emoji, ) async { _traceApi('Removing reaction from message: $channelId/$messageId'); await _dio.delete( '/api/v1/channels/$channelId/messages/$messageId/reactions/$emoji', ); } Future>> getMessageReactions( String channelId, String messageId, ) async { _traceApi('Fetching message reactions: $channelId/$messageId'); final response = await _dio.get( '/api/v1/channels/$channelId/messages/$messageId/reactions', ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future>> getMessageThread( String channelId, String messageId, ) async { _traceApi('Fetching message thread: $channelId/$messageId'); final response = await _dio.get( '/api/v1/channels/$channelId/messages/$messageId/thread', ); final data = response.data; if (data is List) { return data.cast>(); } return []; } Future> replyToMessage( String channelId, String messageId, { required String content, Map? metadata, }) async { _traceApi('Replying to message: $channelId/$messageId'); final response = await _dio.post( '/api/v1/channels/$channelId/messages/$messageId/reply', data: {'content': content, if (metadata != null) 'metadata': metadata}, ); return response.data as Map; } Future markChannelRead(String channelId, {String? messageId}) async { _traceApi('Marking channel as read: $channelId'); await _dio.post( '/api/v1/channels/$channelId/read', data: {if (messageId != null) 'last_read_message_id': messageId}, ); } Future> getChannelUnreadCount(String channelId) async { _traceApi('Fetching unread count for channel: $channelId'); final response = await _dio.get('/api/v1/channels/$channelId/unread'); return response.data as Map; } // Chat streaming with conversation context // Track cancellable streaming requests by messageId for stop parity final Map _streamCancelTokens = {}; // Send message using WebSocket-only streaming. // Matches OpenWebUI web client behavior when session_id + chat_id + message_id are provided: // - HTTP POST returns JSON with task_id (no SSE streaming) // - All content and metadata delivered via WebSocket events // - Events: chat:completion, chat:message:delta, status, source, follow_ups, etc. // Returns a record with (stream, messageId, sessionId, socketSessionId, isBackgroundFlow) ({ Stream stream, String messageId, String sessionId, String? socketSessionId, bool isBackgroundFlow, }) sendMessage({ required List> messages, required String model, String? conversationId, List? toolIds, List? filterIds, bool enableWebSearch = false, bool enableImageGeneration = false, Map? modelItem, String? sessionIdOverride, String? socketSessionId, List>? toolServers, Map? backgroundTasks, String? responseMessageId, Map? userSettings, String? parentMessageId, }) { final streamController = StreamController(); // Generate unique IDs final messageId = (responseMessageId != null && responseMessageId.isNotEmpty) ? responseMessageId : const Uuid().v4(); final sessionId = (sessionIdOverride != null && sessionIdOverride.isNotEmpty) ? sessionIdOverride : const Uuid().v4().substring(0, 20); // NOTE: Previously used to branch for Gemini-specific handling; not needed now. // Process messages to match OpenWebUI format final processedMessages = messages.map((message) { final role = message['role'] as String; final content = message['content']; // Safely cast files list - may be List from spread operations final rawFiles = message['files']; final files = rawFiles is List ? rawFiles.whereType>().toList() : >[]; final isContentArray = content is List; final hasImages = files.isNotEmpty && files.any((file) => file['type'] == 'image'); if (isContentArray) { return {'role': role, 'content': content}; } else if (hasImages && role == 'user') { final imageFiles = files .where((file) => file['type'] == 'image') .toList(); final contentText = content is String ? content : ''; final contentArray = >[ {'type': 'text', 'text': contentText}, ]; for (final file in imageFiles) { contentArray.add({ 'type': 'image_url', 'image_url': {'url': file['url']}, }); } return {'role': role, 'content': contentArray}; } else { final contentText = content is String ? content : ''; return {'role': role, 'content': contentText}; } }).toList(); // Separate files from messages final allFiles = >[]; for (final message in messages) { // Safely cast files list - may be List from spread operations final rawFiles = message['files']; if (rawFiles is List) { final files = rawFiles.whereType>().toList(); final nonImageFiles = files .where((file) => file['type'] != 'image') .toList(); allFiles.addAll(nonImageFiles); } } final bool hasBackgroundTasksPayload = backgroundTasks != null && backgroundTasks.isNotEmpty; // Build request data. Always request streamed responses so the backend can // forward deltas over WebSocket when running in background task mode. final data = { 'stream': true, 'model': model, 'messages': processedMessages, }; // Add only essential parameters if (conversationId != null) { data['chat_id'] = conversationId; } // Add feature flags via 'features' object only (not as top-level params). // Top-level 'web_search'/'image_generation' params are not recognized by // OpenAI and cause errors when forwarded. Open WebUI expects these in the // 'features' object which is properly handled by the middleware. // See: https://github.com/cogwheel0/conduit/issues/271 // Check if memory is enabled in user's OpenWebUI settings // This syncs with the user's preference from the web interface final bool memoryEnabled = userSettings?['memory'] == true; if (enableWebSearch || enableImageGeneration || memoryEnabled) { data['features'] = { 'web_search': enableWebSearch, 'image_generation': enableImageGeneration, 'code_interpreter': false, 'memory': memoryEnabled, }; if (enableWebSearch) { _traceApi('Web search enabled in streaming request'); } if (enableImageGeneration) { _traceApi('Image generation enabled in streaming request'); } if (memoryEnabled) { _traceApi('Memory enabled in streaming request (from user settings)'); } } data['id'] = messageId; // No default reasoning parameters included; providers handle thinking UIs natively. // Add filter_ids if provided (Open-WebUI toggle filters) if (filterIds != null && filterIds.isNotEmpty) { data['filter_ids'] = filterIds; _traceApi('Including filter_ids in streaming request: $filterIds'); } // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings) if (toolIds != null && toolIds.isNotEmpty) { data['tool_ids'] = toolIds; _traceApi('Including tool_ids in streaming request: $toolIds'); // Respect user's function_calling preference from backend settings // If not set, backend will default to 'default' mode (safer, more compatible) try { final userParams = userSettings?['params'] as Map?; final functionCallingMode = userParams?['function_calling'] as String?; if (functionCallingMode != null) { final params = (data['params'] as Map?) ?? {}; params['function_calling'] = functionCallingMode; data['params'] = params; _traceApi( 'Set params.function_calling = $functionCallingMode (from user settings)', ); } else { _traceApi( 'No function_calling preference in user settings, backend will use default mode', ); } } catch (_) { // Non-fatal; continue without setting function_calling mode } } // Include tool_servers if provided (for native function calling with OpenAPI servers) if (toolServers != null && toolServers.isNotEmpty) { data['tool_servers'] = toolServers; _traceApi('Including tool_servers in request (${toolServers.length})'); } // Include non-image files at the top level as expected by Open WebUI if (allFiles.isNotEmpty) { data['files'] = allFiles; _traceApi('Including non-image files in request: ${allFiles.length}'); } _traceApi('Preparing WebSocket-only chat request'); _traceApi('Model: $model'); _traceApi('Message count: ${processedMessages.length}'); // Debug the data being sent _traceApi('Request data keys (pre-dispatch): ${data.keys.toList()}'); _traceApi('Has background_tasks: ${data.containsKey('background_tasks')}'); _traceApi('Has session_id: ${data.containsKey('session_id')}'); _traceApi('background_tasks value: ${data['background_tasks']}'); _traceApi('session_id value: ${data['session_id']}'); _traceApi('id value: ${data['id']}'); _traceApi( 'Request features: hasBackgroundTasks=$hasBackgroundTasksPayload, ' 'tools=${toolIds?.isNotEmpty == true}, ' 'webSearch=$enableWebSearch, imageGen=$enableImageGeneration, ' 'toolServers=${toolServers?.isNotEmpty == true}', ); // Attach identifiers to trigger background task processing on the server data['session_id'] = sessionId; data['id'] = messageId; if (conversationId != null) { data['chat_id'] = conversationId; } // Include parent_id for proper message linking (required since OpenWebUI 0.6.41) // This links the assistant response to the user message it's responding to if (parentMessageId != null) { data['parent_id'] = parentMessageId; } // Attach background_tasks if provided if (backgroundTasks != null && backgroundTasks.isNotEmpty) { data['background_tasks'] = backgroundTasks; } // Extra diagnostics to confirm dynamic-channel payload _traceApi('Background flow payload keys: ${data.keys.toList()}'); _traceApi('Using session_id: $sessionId'); _traceApi('Using message id: $messageId'); _traceApi( 'Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}', ); _traceApi('Has background_tasks: ${data.containsKey('background_tasks')}'); _traceApi('Initiating WebSocket-only chat request'); _traceApi('Posting to /api/chat/completions'); // Create a cancel token for this request final cancelToken = CancelToken(); _streamCancelTokens[messageId] = cancelToken; // Send HTTP request to initiate chat task // With session_id + chat_id + message_id, the server returns a task_id // and all streaming happens via WebSocket events (not SSE) () async { try { final resp = await _dio.post( '/api/chat/completions', data: data, options: Options( responseType: ResponseType.json, receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 30), ), cancelToken: cancelToken, ); final respData = resp.data; if (respData is Map) { if (respData['task_id'] != null) { final taskId = respData['task_id'].toString(); _traceApi('Background task created: $taskId'); } else if (respData['status'] == true) { _traceApi('Chat task initiated successfully'); } else if (respData['error'] != null) { _traceApi('Server error: ${respData['error']}'); if (!streamController.isClosed) { streamController.addError( Exception(respData['error'].toString()), ); } } } // Close HTTP stream controller - WebSocket handles all content delivery if (!streamController.isClosed) { streamController.close(); } } on DioException catch (e) { if (CancelToken.isCancel(e)) { _traceApi('Request cancelled for message: $messageId'); } else { _traceApi('Request error: $e'); if (!streamController.isClosed) { streamController.addError(e); streamController.close(); } } } catch (e) { _traceApi('Unexpected error: $e'); if (!streamController.isClosed) { streamController.addError(e); streamController.close(); } } // Note: Don't remove cancel token here - it should remain until WebSocket // streaming finishes so Stop button can cancel the active generation. // Token is removed by clearStreamCancelToken() when streaming completes. }(); // Determine if this is actually a background flow based on the request payload final bool isBackgroundFlow = hasBackgroundTasksPayload || (toolIds != null && toolIds.isNotEmpty) || (toolServers != null && toolServers.isNotEmpty) || enableWebSearch || enableImageGeneration; return ( stream: streamController.stream, messageId: messageId, sessionId: sessionId, socketSessionId: socketSessionId, isBackgroundFlow: isBackgroundFlow, ); } // === Tasks control (parity with Web client) === Future stopTask(String taskId) async { try { await _dio.post('/api/tasks/stop/$taskId'); } catch (e) { rethrow; } } Future> getTaskIdsByChat(String chatId) async { try { final resp = await _dio.get('/api/tasks/chat/$chatId'); final data = resp.data; if (data is Map && data['task_ids'] is List) { return (data['task_ids'] as List).map((e) => e.toString()).toList(); } return const []; } catch (e) { rethrow; } } // Cancel an active streaming message by its messageId (client-side abort) void cancelStreamingMessage(String messageId) { try { final token = _streamCancelTokens.remove(messageId); if (token != null && !token.isCancelled) { token.cancel('User cancelled'); } } catch (_) {} } /// Clears the cancel token for a message when streaming completes normally. /// Called by streaming_helper when finishStreaming is invoked. void clearStreamCancelToken(String messageId) { _streamCancelTokens.remove(messageId); } // File upload for RAG Future uploadFile(String filePath, String fileName) async { _traceApi('Starting file upload: $fileName from $filePath'); try { // Check if file exists final file = File(filePath); if (!await file.exists()) { throw Exception('File does not exist: $filePath'); } final formData = FormData.fromMap({ 'file': await MultipartFile.fromFile(filePath, filename: fileName), }); _traceApi('Uploading to /api/v1/files/'); final response = await _dio.post('/api/v1/files/', data: formData); DebugLogger.log( 'upload-status', scope: 'api/files', data: {'code': response.statusCode}, ); DebugLogger.log('upload-ok', scope: 'api/files'); if (response.data is Map && response.data['id'] != null) { final fileId = response.data['id'] as String; _traceApi('File uploaded successfully with ID: $fileId'); return fileId; } else { throw Exception('Invalid response format: missing file ID'); } } catch (e) { DebugLogger.error('upload-failed', scope: 'api/files', error: e); rethrow; } } // Search conversations Future> searchConversations(String query) async { final response = await _dio.get( '/api/v1/chats/search', queryParameters: {'q': query}, ); final results = response.data; if (results is List) { return _parseConversationSummaryList(results, debugLabel: 'parse_search'); } return []; } // Debug method to test API endpoints Future debugApiEndpoints() async { _traceApi('=== DEBUG API ENDPOINTS ==='); _traceApi('Server URL: ${serverConfig.url}'); _traceApi('Auth token present: ${authToken != null}'); // Test different possible endpoints final endpoints = [ '/api/v1/chats', '/api/chats', '/api/v1/conversations', '/api/conversations', ]; for (final endpoint in endpoints) { try { _traceApi('Testing endpoint: $endpoint'); final response = await _dio.get(endpoint); _traceApi('✅ $endpoint - Status: ${response.statusCode}'); DebugLogger.log( 'response-type', scope: 'api/diagnostics', data: {'endpoint': endpoint, 'type': response.data.runtimeType}, ); if (response.data is List) { DebugLogger.log( 'array-length', scope: 'api/diagnostics', data: { 'endpoint': endpoint, 'count': (response.data as List).length, }, ); } else if (response.data is Map) { DebugLogger.log( 'object-keys', scope: 'api/diagnostics', data: { 'endpoint': endpoint, 'keys': (response.data as Map).keys.take(5).toList(), }, ); } DebugLogger.log( 'sample', scope: 'api/diagnostics', data: {'endpoint': endpoint, 'preview': response.data.toString()}, ); } catch (e) { _traceApi('❌ $endpoint - Error: $e'); } _traceApi('---'); } _traceApi('=== END DEBUG ==='); } // Check if server has API documentation Future checkApiDocumentation() async { _traceApi('=== CHECKING API DOCUMENTATION ==='); final docEndpoints = ['/docs', '/api/docs', '/swagger', '/api/swagger']; for (final endpoint in docEndpoints) { try { final response = await _dio.get(endpoint); if (response.statusCode == 200) { _traceApi('✅ API docs available at: ${serverConfig.url}$endpoint'); if (response.data is String && response.data.toString().contains('swagger')) { _traceApi(' This appears to be Swagger documentation'); } } } catch (e) { _traceApi('❌ No docs at $endpoint'); } } _traceApi('=== END API DOCS CHECK ==='); } // dispose() removed – no legacy websocket resources to clean up // Helper method to get current weekday name // ==================== ADVANCED CHAT FEATURES ==================== // Chat import/export, bulk operations, and advanced search /// Import chat data from external sources Future>> importChats({ required List> chatsData, String? folderId, bool overwriteExisting = false, }) async { _traceApi('Importing ${chatsData.length} chats'); final response = await _dio.post( '/api/v1/chats/import', data: { 'chats': chatsData, if (folderId != null) 'folder_id': folderId, 'overwrite_existing': overwriteExisting, }, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } /// Export chat data for backup or migration Future>> exportChats({ List? chatIds, String? folderId, bool includeMessages = true, String? format, }) async { _traceApi( 'Exporting chats${chatIds != null ? ' (${chatIds.length} chats)' : ''}', ); final queryParams = {}; if (chatIds != null) queryParams['chat_ids'] = chatIds.join(','); if (folderId != null) queryParams['folder_id'] = folderId; if (!includeMessages) queryParams['include_messages'] = false; if (format != null) queryParams['format'] = format; final response = await _dio.get( '/api/v1/chats/export', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } /// Archive all chats in bulk Future> archiveAllChats({ List? excludeIds, String? beforeDate, }) async { _traceApi('Archiving all chats in bulk'); final response = await _dio.post( '/api/v1/chats/archive/all', data: { if (excludeIds != null) 'exclude_ids': excludeIds, if (beforeDate != null) 'before_date': beforeDate, }, ); return response.data as Map; } /// Delete all chats in bulk Future> deleteAllChats({ List? excludeIds, String? beforeDate, bool archived = false, }) async { _traceApi('Deleting all chats in bulk (archived: $archived)'); final response = await _dio.post( '/api/v1/chats/delete/all', data: { if (excludeIds != null) 'exclude_ids': excludeIds, if (beforeDate != null) 'before_date': beforeDate, 'archived_only': archived, }, ); return response.data as Map; } /// Get pinned chats Future> getPinnedChats() async { _traceApi('Fetching pinned chats'); final response = await _dio.get('/api/v1/chats/pinned'); final data = response.data; if (data is List) { return data.whereType().map((chatData) { final map = Map.from(chatData); return Conversation.fromJson(parseConversationSummary(map)); }).toList(); } return []; } /// Get archived chats Future> getArchivedChats({int? limit, int? offset}) async { _traceApi('Fetching archived chats'); final queryParams = {}; if (limit != null) queryParams['limit'] = limit; if (offset != null) queryParams['offset'] = offset; final response = await _dio.get( '/api/v1/chats/archived', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data.whereType().map((chatData) { final map = Map.from(chatData); return Conversation.fromJson(parseConversationSummary(map)); }).toList(); } return []; } /// Advanced search for chats and messages Future> searchChats({ String? query, String? userId, String? model, String? tag, String? folderId, DateTime? fromDate, DateTime? toDate, bool? pinned, bool? archived, int? limit, int? offset, String? sortBy, String? sortOrder, }) async { _traceApi('Searching chats with query: $query'); final queryParams = {}; // OpenAPI expects 'text' for this endpoint; keep extras if server tolerates them if (query != null) queryParams['text'] = query; if (userId != null) queryParams['user_id'] = userId; if (model != null) queryParams['model'] = model; if (tag != null) queryParams['tag'] = tag; if (folderId != null) queryParams['folder_id'] = folderId; if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String(); if (toDate != null) queryParams['to_date'] = toDate.toIso8601String(); if (pinned != null) queryParams['pinned'] = pinned; if (archived != null) queryParams['archived'] = archived; if (limit != null) queryParams['limit'] = limit; if (offset != null) queryParams['offset'] = offset; if (sortBy != null) queryParams['sort_by'] = sortBy; if (sortOrder != null) queryParams['sort_order'] = sortOrder; final response = await _dio.get( '/api/v1/chats/search', queryParameters: queryParams, ); final data = response.data; // The endpoint can return a List[ChatTitleIdResponse] or a map. // Normalize to a List using our isolate parser. if (data is List) { return _parseConversationSummaryList( data, debugLabel: 'parse_search_direct', ); } if (data is Map) { final list = (data['conversations'] ?? data['items'] ?? data['results']); if (list is List) { return _parseConversationSummaryList( list, debugLabel: 'parse_search_wrapped', ); } } return const []; } /// Search within messages content (capability-safe) /// /// Many OpenWebUI versions do not expose a dedicated messages search endpoint. /// We attempt a GET to `/api/v1/chats/messages/search` and gracefully return /// an empty list when the endpoint is missing or method is not allowed /// (404/405), avoiding noisy errors. Future>> searchMessages({ required String query, String? chatId, String? userId, String? role, // 'user' or 'assistant' DateTime? fromDate, DateTime? toDate, int? limit, int? offset, }) async { _traceApi('Searching messages with query: $query'); // Build query parameters; include both 'text' and 'query' for compatibility final qp = { 'text': query, 'query': query, if (chatId != null) 'chat_id': chatId, if (userId != null) 'user_id': userId, if (role != null) 'role': role, if (fromDate != null) 'from_date': fromDate.toIso8601String(), if (toDate != null) 'to_date': toDate.toIso8601String(), if (limit != null) 'limit': limit, if (offset != null) 'offset': offset, }; try { final response = await _dio.get( '/api/v1/chats/messages/search', queryParameters: qp, // Accept 404/405 to avoid throwing when endpoint is unsupported options: Options( validateStatus: (code) => code != null && (code < 400 || code == 404 || code == 405), ), ); // If not supported, quietly return empty results if (response.statusCode == 404 || response.statusCode == 405) { _traceApi( 'messages search endpoint not supported (status: ${response.statusCode})', ); return []; } final data = response.data; if (data is List) { return _normalizeList(data, debugLabel: 'parse_message_search'); } if (data is Map) { final list = (data['items'] ?? data['results'] ?? data['messages']); if (list is List) { return _normalizeList( list, debugLabel: 'parse_message_search_wrapped', ); } } return const []; } on DioException catch (e) { // On any transport or other error, degrade gracefully without surfacing _traceApi('messages search request failed gracefully: ${e.type}'); return const []; } } /// Get chat statistics and analytics Future> getChatStats({ String? userId, DateTime? fromDate, DateTime? toDate, }) async { _traceApi('Fetching chat statistics'); final queryParams = {}; if (userId != null) queryParams['user_id'] = userId; if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String(); if (toDate != null) queryParams['to_date'] = toDate.toIso8601String(); final response = await _dio.get( '/api/v1/chats/stats', queryParameters: queryParams, ); return response.data as Map; } /// Duplicate/copy a chat Future duplicateChat(String chatId, {String? title}) async { _traceApi('Duplicating chat: $chatId'); final response = await _dio.post( '/api/v1/chats/$chatId/duplicate', data: {if (title != null) 'title': title}, ); final json = await _workerManager .schedule, Map>( parseFullConversationWorker, {'conversation': response.data}, debugLabel: 'parse_conversation_full', ); return Conversation.fromJson(json); } /// Get recent chats with activity Future> getRecentChats({int limit = 10, int? days}) async { _traceApi('Fetching recent chats (limit: $limit)'); final queryParams = {'limit': limit}; if (days != null) queryParams['days'] = days; final response = await _dio.get( '/api/v1/chats/recent', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data .whereType>() .map( (chatData) => Conversation.fromJson(parseConversationSummary(chatData)), ) .toList(); } return []; } /// Get chat history with pagination and filters Future> getChatHistory({ int? limit, int? offset, String? cursor, String? model, String? tag, bool? pinned, bool? archived, String? sortBy, String? sortOrder, }) async { _traceApi('Fetching chat history with filters'); final queryParams = {}; if (limit != null) queryParams['limit'] = limit; if (offset != null) queryParams['offset'] = offset; if (cursor != null) queryParams['cursor'] = cursor; if (model != null) queryParams['model'] = model; if (tag != null) queryParams['tag'] = tag; if (pinned != null) queryParams['pinned'] = pinned; if (archived != null) queryParams['archived'] = archived; if (sortBy != null) queryParams['sort_by'] = sortBy; if (sortOrder != null) queryParams['sort_order'] = sortOrder; final response = await _dio.get( '/api/v1/chats/history', queryParameters: queryParams, ); return response.data as Map; } /// Batch operations on multiple chats Future> batchChatOperation({ required List chatIds, required String operation, // 'archive', 'delete', 'pin', 'unpin', 'move_to_folder' Map? params, }) async { _traceApi( 'Performing batch operation "$operation" on ${chatIds.length} chats', ); final response = await _dio.post( '/api/v1/chats/batch', data: { 'chat_ids': chatIds, 'operation': operation, if (params != null) 'params': params, }, ); return response.data as Map; } /// Get suggested prompts based on chat history Future> getChatSuggestions({ String? context, int limit = 5, }) async { _traceApi('Fetching chat suggestions'); final queryParams = {'limit': limit}; if (context != null) queryParams['context'] = context; final response = await _dio.get( '/api/v1/chats/suggestions', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data.cast(); } return []; } /// Get chat templates for quick starts Future>> getChatTemplates({ String? category, String? tag, }) async { _traceApi('Fetching chat templates'); final queryParams = {}; if (category != null) queryParams['category'] = category; if (tag != null) queryParams['tag'] = tag; final response = await _dio.get( '/api/v1/chats/templates', queryParameters: queryParams, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } /// Create a chat from template Future createChatFromTemplate( String templateId, { Map? variables, String? title, }) async { _traceApi('Creating chat from template: $templateId'); final response = await _dio.post( '/api/v1/chats/templates/$templateId/create', data: { if (variables != null) 'variables': variables, if (title != null) 'title': title, }, ); final json = await _workerManager .schedule, Map>( parseFullConversationWorker, {'conversation': response.data}, debugLabel: 'parse_conversation_full', ); return Conversation.fromJson(json); } // ==================== END ADVANCED CHAT FEATURES ==================== // ==================== NOTES ==================== /// Get all notes with user information. /// Returns a record with (notes data, feature enabled flag). /// When the notes feature is disabled server-side (403), returns ([], false). Future<(List>, bool)> getNotes() async { try { _traceApi('Fetching notes'); final response = await _dio.get('/api/v1/notes/'); DebugLogger.log( 'fetch-status', scope: 'api/notes', data: {'code': response.statusCode}, ); DebugLogger.log('fetch-ok', scope: 'api/notes'); final data = response.data; if (data is List) { _traceApi('Found ${data.length} notes'); return (data.cast>(), true); } else { DebugLogger.warning( 'unexpected-type', scope: 'api/notes', data: {'type': data.runtimeType}, ); return (const >[], true); } } on DioException catch (e) { // 403 indicates notes feature is disabled server-side if (e.response?.statusCode == 403) { DebugLogger.log( 'feature-disabled', scope: 'api/notes', data: {'status': 403}, ); return (const >[], false); } DebugLogger.error('fetch-failed', scope: 'api/notes', error: e); rethrow; } catch (e) { DebugLogger.error('fetch-failed', scope: 'api/notes', error: e); rethrow; } } /// Get paginated note list (title, id, timestamps only) Future>> getNoteList({int? page}) async { _traceApi('Fetching note list, page: $page'); final queryParams = {}; if (page != null) queryParams['page'] = page; final response = await _dio.get( '/api/v1/notes/list', queryParameters: queryParams.isNotEmpty ? queryParams : null, ); final data = response.data; if (data is List) { return data.cast>(); } return []; } /// Get a single note by ID Future> getNoteById(String id) async { _traceApi('Fetching note: $id'); final response = await _dio.get('/api/v1/notes/$id'); return response.data as Map; } /// Create a new note Future> createNote({ required String title, Map? data, Map? meta, Map? accessControl, }) async { _traceApi('Creating note: $title'); final response = await _dio.post( '/api/v1/notes/create', data: { 'title': title, if (data != null) 'data': data, if (meta != null) 'meta': meta, if (accessControl != null) 'access_control': accessControl, }, ); return response.data as Map; } /// Update an existing note Future> updateNote( String id, { String? title, Map? data, Map? meta, Map? accessControl, }) async { _traceApi('Updating note: $id'); final response = await _dio.post( '/api/v1/notes/$id/update', data: { if (title != null) 'title': title, if (data != null) 'data': data, if (meta != null) 'meta': meta, if (accessControl != null) 'access_control': accessControl, }, ); return response.data as Map; } /// Delete a note by ID Future deleteNote(String id) async { _traceApi('Deleting note: $id'); final response = await _dio.delete('/api/v1/notes/$id/delete'); return response.data == true; } /// Generate a title for note content using AI Future generateNoteTitle( String content, { required String modelId, }) async { _traceApi('Generating title for note content with model: $modelId'); final prompt = '''### Task: Generate a concise, 3-5 word title with an emoji summarizing the content in the content's primary language. ### Guidelines: - The title should clearly represent the main theme or subject of the content. - Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. - Write the title in the content's primary language. - Prioritize accuracy over excessive creativity; keep it clear and simple. - Your entire response must consist solely of the JSON object, without any introductory or concluding text. - The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text. - Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure. ### Output: JSON format: { "title": "your concise title here" } ### Examples: - { "title": "📉 Stock Market Trends" }, - { "title": "🍪 Perfect Chocolate Chip Recipe" }, - { "title": "Evolution of Music Streaming" }, - { "title": "Remote Work Productivity Tips" }, - { "title": "Artificial Intelligence in Healthcare" }, - { "title": "🎮 Video Game Development Insights" } ### Content: $content '''; try { final response = await _dio.post( '/api/chat/completions', data: { 'model': modelId, 'stream': false, 'messages': [ {'role': 'user', 'content': prompt}, ], }, ); final responseText = response.data?['choices']?[0]?['message']?['content'] as String? ?? ''; _traceApi('Title generation response: $responseText'); // Parse JSON from response final jsonStart = responseText.indexOf('{'); final jsonEnd = responseText.lastIndexOf('}'); if (jsonStart != -1 && jsonEnd != -1) { final jsonStr = responseText.substring(jsonStart, jsonEnd + 1); final parsed = jsonDecode(jsonStr) as Map; return (parsed['title'] as String?)?.trim(); } } catch (e) { _traceApi('Failed to generate note title: $e'); rethrow; } return null; } /// Enhance note content using AI Future enhanceNoteContent( String content, { required String modelId, }) async { _traceApi('Enhancing note content with AI, model: $modelId'); const systemPrompt = '''Enhance existing notes using the content's primary language. Your task is to make the notes more useful and comprehensive. # Output Format Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, and emphasis to improve clarity and presentation. Ensure that all integrated content is accurately reflected. Return only the markdown formatted note.'''; try { final response = await _dio.post( '/api/chat/completions', data: { 'model': modelId, 'stream': false, 'messages': [ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': '$content'}, ], }, ); return response.data?['choices']?[0]?['message']?['content'] as String?; } catch (e) { _traceApi('Failed to enhance note content: $e'); rethrow; } } // ==================== END NOTES ==================== // Legacy streaming wrapper methods removed } List> _normalizeMapListWorker( Map payload, ) { final raw = payload['list']; if (raw is! List) { return const >[]; } final normalized = >[]; for (final entry in raw) { if (entry is Map) { normalized.add(Map.from(entry)); } } return normalized; }