From b8856679da2d3718b2b80ddb34d89acaaabe89e9 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:59:29 +0530 Subject: [PATCH] feat: show sources --- lib/core/services/api_service.dart | 38 +- lib/core/services/streaming_helper.dart | 30 +- .../widgets/assistant_message_widget.dart | 12 +- .../widgets/sources/openwebui_sources.dart | 358 ++++++++++++++++++ 4 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 lib/features/chat/widgets/sources/openwebui_sources.dart diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 5acfa10..ef1a21b 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1290,11 +1290,43 @@ class ApiService { .map((entry) { try { // Convert Map to Map safely - final Map sourceMap = {}; + final Map entryMap = {}; entry.forEach((key, value) { - sourceMap[key.toString()] = value; + entryMap[key.toString()] = value; }); - return ChatSourceReference.fromJson(sourceMap); + + // Handle nested source structure from OpenWebUI + // Sources can have structure like: { "source": { "name": "...", "id": "..." }, "document": [...], "metadata": [...] } + final sourceData = entryMap['source']; + if (sourceData is Map) { + // Extract the actual source information from nested structure + final Map sourceMap = {}; + sourceData.forEach((key, value) { + sourceMap[key.toString()] = value; + }); + + // Add additional metadata from the outer structure if available + if (entryMap.containsKey('document') && + entryMap['document'] is List) { + final documents = entryMap['document'] as List; + if (documents.isNotEmpty) { + sourceMap['snippet'] = documents.first?.toString(); + } + } + + if (entryMap.containsKey('metadata') && + entryMap['metadata'] is List) { + final metadata = entryMap['metadata'] as List; + if (metadata.isNotEmpty && metadata.first is Map) { + sourceMap['metadata'] = metadata.first; + } + } + + return ChatSourceReference.fromJson(sourceMap); + } else { + // Fallback: treat the entire entry as a source (for backward compatibility) + return ChatSourceReference.fromJson(entryMap); + } } catch (e) { // Log the error and skip this entry DebugLogger.log( diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index eb2b542..c1cc1fc 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -705,7 +705,35 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ } catch (_) {} } else { try { - final source = ChatSourceReference.fromJson(map); + // Handle nested source structure from OpenWebUI + final sourceData = map['source']; + final ChatSourceReference source; + + if (sourceData is Map) { + // Extract the actual source information from nested structure + final sourceMap = Map.from(sourceData); + + // Add additional metadata from the outer structure if available + if (map.containsKey('document') && map['document'] is List) { + final documents = map['document'] as List; + if (documents.isNotEmpty) { + sourceMap['snippet'] = documents.first?.toString(); + } + } + + if (map.containsKey('metadata') && map['metadata'] is List) { + final metadata = map['metadata'] as List; + if (metadata.isNotEmpty && metadata.first is Map) { + sourceMap['metadata'] = metadata.first; + } + } + + source = ChatSourceReference.fromJson(sourceMap); + } else { + // Fallback: treat the entire map as a source (for backward compatibility) + source = ChatSourceReference.fromJson(map); + } + final targetId = _resolveTargetMessageId(messageId, getMessages); if (targetId != null) { appendSourceReference(targetId, source); diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 9cf9b2f..20bdc25 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -20,6 +20,7 @@ import '../../../shared/widgets/model_avatar.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../providers/chat_providers.dart' show sendMessage; import '../../../core/utils/debug_logger.dart'; +import 'sources/openwebui_sources.dart'; class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -654,8 +655,11 @@ class _AssistantMessageWidgetState extends ConsumerState ], if (hasSources) ...[ - const SizedBox(height: Spacing.md), - CitationListView(sources: widget.message.sources), + const SizedBox(height: Spacing.xs), + OpenWebUISourcesWidget( + sources: widget.message.sources, + messageId: widget.message.id, + ), ], ], ), @@ -1848,6 +1852,9 @@ class CodeExecutionListView extends StatelessWidget { } } +// Legacy CitationListView - replaced with OpenWebUISourcesWidget +// Keeping for reference, can be removed after testing +/* class CitationListView extends StatelessWidget { const CitationListView({super.key, required this.sources}); @@ -1899,6 +1906,7 @@ class CitationListView extends StatelessWidget { ); } } +*/ class FollowUpSuggestionBar extends StatelessWidget { const FollowUpSuggestionBar({ diff --git a/lib/features/chat/widgets/sources/openwebui_sources.dart b/lib/features/chat/widgets/sources/openwebui_sources.dart new file mode 100644 index 0000000..585097e --- /dev/null +++ b/lib/features/chat/widgets/sources/openwebui_sources.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../core/models/chat_message.dart'; +import '../../../../shared/theme/theme_extensions.dart'; + +/// OpenWebUI-style sources component with compact button and expandable list +class OpenWebUISourcesWidget extends StatefulWidget { + const OpenWebUISourcesWidget({ + super.key, + required this.sources, + this.messageId, + }); + + final List sources; + final String? messageId; + + @override + State createState() => _OpenWebUISourcesWidgetState(); +} + +class _OpenWebUISourcesWidgetState extends State { + bool _showSources = false; + + @override + Widget build(BuildContext context) { + if (widget.sources.isEmpty) { + return const SizedBox.shrink(); + } + + // Debug logging can be enabled here if needed for future debugging + // debugPrint('OpenWebUI Sources: ${widget.sources.length} sources'); + + final theme = context.conduitTheme; + final urlSources = widget.sources.where((s) { + // Check multiple possible URL fields + String? url = s.url; + if (url == null || url.isEmpty) { + if (s.id != null && s.id!.startsWith('http')) { + url = s.id; + } else if (s.title != null && s.title!.startsWith('http')) { + url = s.title; + } else if (s.metadata != null) { + url = + s.metadata!['url']?.toString() ?? + s.metadata!['source']?.toString(); + } + } + return url != null && url.isNotEmpty && url.startsWith('http'); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Compact sources toggle button + Padding( + padding: const EdgeInsets.only(top: 0, bottom: 4), + child: Row( + children: [ + InkWell( + onTap: () { + setState(() { + _showSources = !_showSources; + }); + }, + borderRadius: BorderRadius.circular(20), + hoverColor: theme.surfaceContainer.withValues(alpha: 0.1), + splashColor: theme.surfaceContainer.withValues(alpha: 0.2), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.5), + width: 1, + ), + color: theme.surfaceContainer.withValues(alpha: 0.3), + boxShadow: [ + BoxShadow( + color: theme.cardShadow.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Favicon previews for URL sources + if (urlSources.isNotEmpty) ...[ + SizedBox( + width: urlSources.length > 3 + ? 52 + : urlSources.length * 18.0, + height: 16, + child: Stack( + children: [ + for ( + int i = 0; + i < + (urlSources.length > 3 + ? 3 + : urlSources.length); + i++ + ) + Positioned( + left: i * 12.0, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.surfaceBackground, + width: 1, + ), + color: theme.surfaceBackground, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + 'https://www.google.com/s2/favicons?sz=32&domain=${_extractDomain(_getSourceUrl(urlSources[i])!)}', + width: 14, + height: 14, + errorBuilder: + (context, error, stackTrace) { + return Container( + width: 14, + height: 14, + color: theme.textSecondary + .withValues(alpha: 0.1), + child: Icon( + Icons.language, + size: 8, + color: theme.textSecondary + .withValues(alpha: 0.6), + ), + ); + }, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ], + Text( + widget.sources.length == 1 + ? '1 Source' + : '${widget.sources.length} Sources', + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w600, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Expandable sources list + if (_showSources) ...[ + const SizedBox(height: 6), + Column( + children: [ + for (int i = 0; i < widget.sources.length; i++) ...[ + _buildSourceItem(context, widget.sources[i], i + 1), + if (i < widget.sources.length - 1) const SizedBox(height: 2), + ], + ], + ), + ], + ], + ); + } + + Widget _buildSourceItem( + BuildContext context, + ChatSourceReference source, + int index, + ) { + final theme = context.conduitTheme; + + // Get URL using helper method + final url = _getSourceUrl(source); + final isUrl = url != null && url.isNotEmpty && url.startsWith('http'); + + // Debug: debugPrint('Building source item $index: $displayText'); + + // Determine display text + String displayText; + String? title = source.title; + + // If no direct title, check metadata + if ((title == null || title.isEmpty) && source.metadata != null) { + title = source.metadata!['title']?.toString(); + } + + if (title != null && title.isNotEmpty) { + displayText = title; + } else if (isUrl) { + displayText = _extractDomain(url); + } else if (source.id != null && source.id!.isNotEmpty) { + displayText = source.id!; + } else { + displayText = 'Source $index'; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: InkWell( + onTap: isUrl ? () => _launchUrl(url) : null, + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + // Source number badge + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: theme.surfaceContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + index.toString(), + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w600, + color: theme.textPrimary, + ), + ), + ), + ), + const SizedBox(width: 12), + + // Favicon for URL sources + if (isUrl) ...[ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.surfaceBackground, width: 1), + color: theme.surfaceBackground, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + 'https://www.google.com/s2/favicons?sz=32&domain=${_extractDomain(url)}', + width: 14, + height: 14, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 14, + height: 14, + color: theme.textSecondary.withValues(alpha: 0.1), + child: Icon( + Icons.language, + size: 8, + color: theme.textSecondary.withValues(alpha: 0.6), + ), + ); + }, + ), + ), + ), + const SizedBox(width: 8), + ] else ...[ + // Show a generic icon for non-URL sources + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.surfaceContainer, + ), + child: Icon( + Icons.description, + size: 10, + color: theme.textSecondary, + ), + ), + const SizedBox(width: 8), + ], + + // Source URL/title + Expanded( + child: Text( + displayText, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + void _launchUrl(String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + // Handle error silently + } + } + + String? _getSourceUrl(ChatSourceReference source) { + String? url = source.url; + if (url == null || url.isEmpty) { + if (source.id != null && source.id!.startsWith('http')) { + url = source.id; + } else if (source.title != null && source.title!.startsWith('http')) { + url = source.title; + } else if (source.metadata != null) { + // Check multiple possible metadata keys for URL + url = + source.metadata!['source']?.toString() ?? + source.metadata!['url']?.toString() ?? + source.metadata!['link']?.toString(); + } + } + debugPrint( + '_getSourceUrl: source.url=${source.url}, metadata_source=${source.metadata?['source']}, final_url=$url', + ); + return url; + } + + String _extractDomain(String url) { + try { + final uri = Uri.parse(url); + String domain = uri.host; + if (domain.startsWith('www.')) { + domain = domain.substring(4); + } + return domain; + } catch (e) { + return url; + } + } +}