diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 4ab7266..6a00242 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -33,6 +33,106 @@ void _traceApi(String message) { DebugLogger.log(message, scope: 'api/trace'); } +/// Converts ChatSourceReference list back to OpenWebUI's expected format. +/// OpenWebUI expects: { source: {...}, document: [...], metadata: [...] } +/// But ChatSourceReference stores: { id, title, url, snippet, type, metadata } +List> _convertSourcesToOpenWebUIFormat( + List sources, +) { + return sources.map((ref) { + final result = {}; + + // Build the source object + final sourceObj = {}; + if (ref.id != null) sourceObj['id'] = ref.id; + if (ref.title != null) sourceObj['name'] = ref.title; + if (ref.url != null) sourceObj['url'] = ref.url; + if (ref.type != null) sourceObj['type'] = ref.type; + + // Extract nested source from metadata if present + final metadataSource = ref.metadata?['source']; + if (metadataSource is Map) { + for (final entry in metadataSource.entries) { + sourceObj[entry.key.toString()] ??= entry.value; + } + } + + if (sourceObj.isNotEmpty) { + result['source'] = sourceObj; + } + + // Extract documents from metadata or use snippet + final documents = ref.metadata?['documents']; + if (documents is List && documents.isNotEmpty) { + result['document'] = documents; + } else if (ref.snippet != null && ref.snippet!.isNotEmpty) { + result['document'] = [ref.snippet]; + } + + // Extract metadata items + final metadataItems = ref.metadata?['items']; + if (metadataItems is List && metadataItems.isNotEmpty) { + result['metadata'] = metadataItems; + } else { + // Create a basic metadata entry + final basicMeta = {}; + if (ref.id != null) basicMeta['source'] = ref.id; + if (ref.title != null) basicMeta['name'] = ref.title; + if (result['document'] is List) { + result['metadata'] = List.generate( + (result['document'] as List).length, + (_) => Map.from(basicMeta), + ); + } + } + + // Extract distances if present + final distances = ref.metadata?['distances']; + if (distances is List && distances.isNotEmpty) { + result['distances'] = distances; + } + + return result; + }).toList(); +} + +/// Converts ChatCodeExecution list to OpenWebUI's expected format. +/// OpenWebUI expects `code_executions` (snake_case) with specific structure. +/// ChatCodeExecution stores: { id, name, language, code, result, metadata } +/// OpenWebUI expects: { id, name, code, language?, result?: { error?, output?, files? } } +List> _convertCodeExecutionsToOpenWebUIFormat( + List executions, +) { + return executions.map((exec) { + final result = { + 'id': exec.id, + if (exec.name != null) 'name': exec.name, + if (exec.code != null) 'code': exec.code, + if (exec.language != null) 'language': exec.language, + }; + + // Convert the result if present + if (exec.result != null) { + final execResult = {}; + if (exec.result!.output != null) execResult['output'] = exec.result!.output; + if (exec.result!.error != null) execResult['error'] = exec.result!.error; + if (exec.result!.files.isNotEmpty) { + execResult['files'] = exec.result!.files + .map((f) => { + if (f.name != null) 'name': f.name, + if (f.url != null) 'url': f.url, + }) + .toList(); + } + if (execResult.isNotEmpty) { + result['result'] = execResult; + } + } + + return result; + }).toList(); +} + class ApiService { final Dio _dio; final ServerConfig serverConfig; @@ -966,7 +1066,10 @@ class ApiService { if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, - if (msg.role == 'assistant') 'done': !msg.isStreaming, + // Always set done: true when persisting to server. + // If streaming is interrupted, the message should still be marked done + // to prevent the web client from treating it as an in-progress stream. + if (msg.role == 'assistant') 'done': true, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), @@ -977,9 +1080,11 @@ class ApiService { if (msg.followUps.isNotEmpty) 'followUps': List.from(msg.followUps), if (msg.codeExecutions.isNotEmpty) - 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), + 'code_executions': + _convertCodeExecutionsToOpenWebUIFormat(msg.codeExecutions), + // Convert sources back to OpenWebUI format (with document array) if (msg.sources.isNotEmpty) - 'sources': msg.sources.map((s) => s.toJson()).toList(), + 'sources': _convertSourcesToOpenWebUIFormat(msg.sources), // Include usage statistics for persistence (issue #274) if (msg.usage != null) 'usage': msg.usage, // Preserve error field for OpenWebUI compatibility @@ -1005,7 +1110,8 @@ class ApiService { if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, if (msg.role == 'assistant') 'modelIdx': 0, - if (msg.role == 'assistant') 'done': !msg.isStreaming, + // Always set done: true when persisting to server. + if (msg.role == 'assistant') 'done': true, if (msg.role == 'user' && model != null) 'models': [model], if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), @@ -1016,9 +1122,11 @@ class ApiService { if (msg.followUps.isNotEmpty) 'followUps': List.from(msg.followUps), if (msg.codeExecutions.isNotEmpty) - 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), + 'code_executions': + _convertCodeExecutionsToOpenWebUIFormat(msg.codeExecutions), + // Convert sources back to OpenWebUI format (with document array) if (msg.sources.isNotEmpty) - 'sources': msg.sources.map((s) => s.toJson()).toList(), + 'sources': _convertSourcesToOpenWebUIFormat(msg.sources), // Include usage statistics for persistence (issue #274) if (msg.usage != null) 'usage': msg.usage, // Preserve error field for OpenWebUI compatibility @@ -1053,11 +1161,11 @@ class ApiService { if (ver.followUps.isNotEmpty) 'followUps': List.from(ver.followUps), if (ver.codeExecutions.isNotEmpty) - 'codeExecutions': ver.codeExecutions - .map((e) => e.toJson()) - .toList(), + 'code_executions': + _convertCodeExecutionsToOpenWebUIFormat(ver.codeExecutions), + // Convert sources back to OpenWebUI format (with document array) if (ver.sources.isNotEmpty) - 'sources': ver.sources.map((s) => s.toJson()).toList(), + 'sources': _convertSourcesToOpenWebUIFormat(ver.sources), // Preserve error field for OpenWebUI compatibility if (ver.error != null) 'error': ver.error!.toJson(), }; diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 2c9d098..51d1462 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -883,7 +883,7 @@ class ChatMessagesNotifier extends Notifier> { } // Pre-seed an assistant skeleton message (with a given id or a new one), -// persist it to the server to keep the chain correct, and return the id. +// persist it to the server to establish the message structure, and return the id. Future _preseedAssistantAndPersist( dynamic ref, { String? existingAssistantId, @@ -926,7 +926,11 @@ Future _preseedAssistantAndPersist( } catch (_) {} } - // Sync conversation state to ensure WebUI can load conversation history + // Sync conversation state to establish the full message structure on the server. + // The server's upsert only sets parentId and model - we need to set role, + // timestamp, childrenIds, etc. for proper message rendering. + // Note: syncConversationMessages always sets done:true to prevent broken UI + // if streaming is interrupted (see api_service.dart). try { final api = ref.read(apiServiceProvider); final activeConv = ref.read(activeConversationProvider);