diff --git a/lib/app_constants.dart b/lib/app_constants.dart index 202c3be7f..7b3705c2a 100644 --- a/lib/app_constants.dart +++ b/lib/app_constants.dart @@ -34,12 +34,14 @@ class RoutePaths { static const String NEIGHBORHOODS_VIEW = "parking/neighborhoods_view"; static const String NEIGHBORHOODS_LOTS_VIEW = "parking/neighborhoods_lot_view"; static const String AVAILABILITY_DETAILED_VIEW = "availability/detailed_view"; + static const String TGPT_CITATION_WEB = 'ai_assistant/citation_web'; } class RouteTitles { static const TITLE_MAP = { 'Maps': 'MAP', 'AI Assistant': 'AI ASSISTANT', + 'ai_assistant/citation_web': 'CITATION', 'MapSearch': 'MAP', 'MapLocationList': 'MAP', 'Notifications': 'NOTIFICATIONS', @@ -86,6 +88,16 @@ class DiningConstants { } class ErrorConstants { + /// TritonGPT chat / streaming failures (user-facing; no stack traces). + static const TRITONGPT_UNAVAILABLE = + 'Unable to reach TritonGPT right now. Please try again later.'; + static const TRITONGPT_NOT_FOUND = + 'TritonGPT could not be reached. Please try again later.'; + static const TRITONGPT_SERVER_ERROR = + 'TritonGPT is temporarily unavailable. Please try again later.'; + static const TRITONGPT_BAD_REQUEST = + 'Your message could not be processed. Please try again.'; + static const AUTHORIZED_POST_ERRORS = 'Failed to upload data: '; static const AUTHORIZED_PUT_ERRORS = 'Failed to update data: '; static const INVALID_BEARER_TOKEN = 'Invalid bearer token'; diff --git a/lib/app_router.dart b/lib/app_router.dart index 1a5914362..137062593 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -17,6 +17,7 @@ import 'package:campus_mobile_experimental/ui/map/map.dart' as prefix0; import 'package:campus_mobile_experimental/ui/map/map_search_view.dart'; import 'package:campus_mobile_experimental/ui/navigator/bottom.dart'; import 'package:campus_mobile_experimental/ui/navigator/top.dart'; +import 'package:campus_mobile_experimental/ui/ai_assistant/citation_web_view.dart'; import 'package:campus_mobile_experimental/ui/news/news_detail_view.dart'; import 'package:campus_mobile_experimental/ui/news/news_list.dart'; import 'package:campus_mobile_experimental/ui/notifications/notifications_list_view.dart'; @@ -76,6 +77,12 @@ class Router { Provider.of(_).changeTitle(settings.name); return NewsDetailView(data: newsItem); }); + case RoutePaths.TGPT_CITATION_WEB: + final String citationUrl = settings.arguments as String; + return MaterialPageRoute(builder: (_) { + Provider.of(_).changeTitle(settings.name); + return CitationWebView(initialUrl: citationUrl); + }); case RoutePaths.EVENT_DETAIL_VIEW: EventModel data = settings.arguments as EventModel; return MaterialPageRoute(builder: (_) { diff --git a/lib/core/models/tgpt_models/chat_message.dart b/lib/core/models/tgpt_models/chat_message.dart index 65af2a10e..f97af1ea7 100644 --- a/lib/core/models/tgpt_models/chat_message.dart +++ b/lib/core/models/tgpt_models/chat_message.dart @@ -9,8 +9,10 @@ class ChatCitationReference { required this.url, }); + /// Parses a citation object from legacy `citation_delta` arrays (`citation_num`) + /// or new `citation_info` packets (`citation_number`). factory ChatCitationReference.fromStreamJson(Map json) { - final Object? rawNumber = json['citation_num']; + final Object? rawNumber = json['citation_num'] ?? json['citation_number']; final int? number = rawNumber is int ? rawNumber : int.tryParse(rawNumber?.toString() ?? ''); return ChatCitationReference( @@ -21,11 +23,18 @@ class ChatCitationReference { } class AssistantMessageContent { + /// Line-based `[rq] question` (mobile / legacy TGPT lines). static final RegExp _relatedQuestionPattern = RegExp( r'^(?:[-*]\s*)?\[rq\]\s*(.*)$', caseSensitive: false, ); + /// Markdown links `[label](#rq)` from web-style TGPT output. + static final RegExp _rqMarkdownLink = RegExp(r'\[([^\]\n]+)\]\(\s*#rq\s*\)'); + + /// Bullet line that is only `- [label](#rq)`. + static final RegExp _bulletRqOnlyLine = RegExp(r'^\s*[-*+]\s*\[[^\]\n]+\]\(\s*#rq\s*\)\s*$'); + const AssistantMessageContent({ required this.markdown, this.relatedQuestions = const [], @@ -39,25 +48,31 @@ class AssistantMessageContent { return const AssistantMessageContent(markdown: ''); } - final List answerLines = []; + final String normalized = rawText.replaceAll('\r\n', '\n'); final List relatedQuestions = []; - for (final String line in rawText.replaceAll('\r\n', '\n').split('\n')) { - final String trimmedLine = line.trimLeft(); - final Match? match = _relatedQuestionPattern.firstMatch(trimmedLine); + for (final Match m in _rqMarkdownLink.allMatches(normalized)) { + _addUniqueRelatedQuestion(relatedQuestions, m.group(1) ?? ''); + } - if (match != null) { - final String question = match.group(1)?.trim() ?? ''; - if (question.isNotEmpty) { - relatedQuestions.add(question); - } + final List answerLines = []; + for (final String line in normalized.split('\n')) { + final String trimmedLeft = line.trimLeft(); + final Match? rqLine = _relatedQuestionPattern.firstMatch(trimmedLeft); + if (rqLine != null) { + _addUniqueRelatedQuestion(relatedQuestions, rqLine.group(1) ?? ''); + continue; + } + if (_bulletRqOnlyLine.hasMatch(line)) { + continue; + } + if (_isRelatedQuestionsHeadingLine(line)) { continue; } - answerLines.add(line); } - final List normalizedAnswerLines = _trimBlankLines(answerLines); + List normalizedAnswerLines = _trimBlankLines(answerLines); if (relatedQuestions.isNotEmpty && normalizedAnswerLines.isNotEmpty) { final String trailingLine = normalizedAnswerLines.last.trim().toLowerCase(); if (trailingLine == 'related questions' || trailingLine == 'related questions:') { @@ -65,12 +80,40 @@ class AssistantMessageContent { } } + String markdown = _trimBlankLines(normalizedAnswerLines).join('\n'); + markdown = markdown.replaceAllMapped(_rqMarkdownLink, (Match m) => m.group(1)!.trim()); + markdown = _collapseBlankLines(markdown).trim(); + return AssistantMessageContent( - markdown: _trimBlankLines(normalizedAnswerLines).join('\n'), + markdown: markdown, relatedQuestions: List.unmodifiable(relatedQuestions), ); } + static void _addUniqueRelatedQuestion(List list, String raw) { + final String q = raw.trim(); + if (q.isEmpty) return; + if (!list.contains(q)) { + list.add(q); + } + } + + static bool _isRelatedQuestionsHeadingLine(String line) { + final String t = line.trim(); + if (t.isEmpty) return false; + if (RegExp(r'^\*{0,2}\s*Related Questions\s*\*{0,2}\s*:?\s*$', caseSensitive: false).hasMatch(t)) { + return true; + } + if (RegExp(r'^#+\s*Related Questions\s*:?\s*$', caseSensitive: false).hasMatch(t)) { + return true; + } + return false; + } + + static String _collapseBlankLines(String text) { + return text.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + } + static List _trimBlankLines(List lines) { final List trimmedLines = List.from(lines); diff --git a/lib/core/models/tgpt_models/chat_response.dart b/lib/core/models/tgpt_models/chat_response.dart index fe41b1341..baebb6346 100644 --- a/lib/core/models/tgpt_models/chat_response.dart +++ b/lib/core/models/tgpt_models/chat_response.dart @@ -1,44 +1,21 @@ -/// Retrieval options for the chat message request. -class RetrievalOptions { - final String runSearch; - - RetrievalOptions({ - this.runSearch = 'always', - }); - - Map toJson() => { - 'run_search': runSearch, - }; -} - -/// Request model for sending chat messages via /chat/send-message. +/// Request model for sending chat messages via the TGPT chat API. /// -/// Simplified for mobile app usage. Fields like prompt_id and search_doc_ids -/// are always null for this app and included only for API completeness. +/// The server rejects unknown/extra fields (`extra_forbidden`); only send +/// keys the current schema allows (aligned with the web widget payload). class BasicCreateChatMessageRequest { final String message; final String chatSessionId; final int? parentMessageId; - final RetrievalOptions retrievalOptions; - final List fileDescriptors; BasicCreateChatMessageRequest({ required this.message, required this.chatSessionId, this.parentMessageId, - RetrievalOptions? retrievalOptions, - this.fileDescriptors = const [], - }) : retrievalOptions = retrievalOptions ?? RetrievalOptions(); + }); Map toJson() => { 'message': message, 'chat_session_id': chatSessionId, 'parent_message_id': parentMessageId, - 'retrieval_options': retrievalOptions.toJson(), - 'file_descriptors': fileDescriptors, - 'prompt_id': null, - 'search_doc_ids': null, }; } - - diff --git a/lib/core/providers/chat_provider.dart b/lib/core/providers/chat_provider.dart index d3fe1d7a0..027782bf5 100644 --- a/lib/core/providers/chat_provider.dart +++ b/lib/core/providers/chat_provider.dart @@ -6,6 +6,8 @@ import 'package:campus_mobile_experimental/core/providers/user.dart'; import 'package:campus_mobile_experimental/core/services/tgpt_services/chat_persistence.dart'; import 'package:campus_mobile_experimental/core/services/tgpt_services/chat_session_creation.dart'; import 'package:campus_mobile_experimental/core/services/tgpt_services/chat_stream.dart'; +import 'package:campus_mobile_experimental/app_constants.dart'; +import 'package:campus_mobile_experimental/core/services/tgpt_services/tgpt_error_message.dart'; import 'package:flutter/material.dart'; class ChatProvider extends ChangeNotifier { @@ -160,6 +162,8 @@ class ChatProvider extends ChangeNotifier { if (message.isEmpty || isStreaming) return; _errorMessage = null; + notifyListeners(); + final String? sessionId = await _ensureActiveSession(); if (sessionId == null) { notifyListeners(); @@ -230,13 +234,14 @@ class ChatProvider extends ChangeNotifier { _setSessionMessages(sessionId, sessionMessages); notifyListeners(); } - } catch (_) { - _errorMessage = 'Unable to reach TritonGPT right now.'; + } catch (e) { + final String userMessage = tgptErrorMessageFor(e); + _errorMessage = userMessage; final int placeholderIndex = sessionMessages.indexWhere((AssistantChatMessage item) => item.id == placeholderMessage.id); if (placeholderIndex != -1) { sessionMessages[placeholderIndex] = sessionMessages[placeholderIndex].copyWith( - text: 'Unable to reach TritonGPT right now.', + text: userMessage, isStreaming: false, ); } @@ -260,7 +265,7 @@ class ChatProvider extends ChangeNotifier { final session = await _chatSessionService.createChatSession(); if (session == null) { - _errorMessage = _chatSessionService.error ?? 'Unable to start a new chat.'; + _errorMessage = _chatSessionService.error ?? ErrorConstants.TRITONGPT_UNAVAILABLE; return null; } diff --git a/lib/core/services/tgpt_services/chat_send_message.dart b/lib/core/services/tgpt_services/chat_send_message.dart index 8a270279e..3b894fdde 100644 --- a/lib/core/services/tgpt_services/chat_send_message.dart +++ b/lib/core/services/tgpt_services/chat_send_message.dart @@ -10,9 +10,6 @@ class ChatMessageService { const ChatMessageService._(); /// Build the request body for sending a message. - /// - /// Shared logic that can be used anywhere a `/chat/send-message` payload is - /// needed. static String buildRequestBody({ required String message, required String chatSessionId, @@ -22,8 +19,6 @@ class ChatMessageService { message: message, chatSessionId: chatSessionId, parentMessageId: parentMessageId, - retrievalOptions: RetrievalOptions(runSearch: 'always'), - fileDescriptors: const [], ); return json.encode(req.toJson()); } diff --git a/lib/core/services/tgpt_services/chat_session_creation.dart b/lib/core/services/tgpt_services/chat_session_creation.dart index 74556b68a..c18b088fc 100644 --- a/lib/core/services/tgpt_services/chat_session_creation.dart +++ b/lib/core/services/tgpt_services/chat_session_creation.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:campus_mobile_experimental/app_networking.dart'; import 'package:campus_mobile_experimental/core/models/tgpt_models/chat_session.dart'; import 'package:campus_mobile_experimental/core/providers/user.dart'; +import 'package:campus_mobile_experimental/core/services/tgpt_services/tgpt_error_message.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; /// Service for creating chat sessions via the TGPT API. @@ -84,7 +85,7 @@ class ChatSessionService { } } - _error = e.toString(); + _error = tgptErrorMessageFor(e); _hasRetried = false; return null; } finally { diff --git a/lib/core/services/tgpt_services/chat_stream.dart b/lib/core/services/tgpt_services/chat_stream.dart index 5fedc135c..d6ef556a4 100644 --- a/lib/core/services/tgpt_services/chat_stream.dart +++ b/lib/core/services/tgpt_services/chat_stream.dart @@ -10,12 +10,14 @@ import 'dart:async'; import 'dart:convert'; +import 'package:campus_mobile_experimental/app_constants.dart'; import 'package:campus_mobile_experimental/app_networking.dart'; import 'package:campus_mobile_experimental/core/models/tgpt_models/chat_message.dart'; import 'package:dio/dio.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:campus_mobile_experimental/core/providers/user.dart'; import 'package:campus_mobile_experimental/core/services/tgpt_services/chat_send_message.dart'; +import 'package:campus_mobile_experimental/core/services/tgpt_services/tgpt_error_message.dart'; /// Streaming chunk data for real-time chat display. class StreamingChatChunk { @@ -32,6 +34,24 @@ class StreamingChatChunk { }); } +/// Merges search/retrieval documents into [documentIdToUrl] so [citation_info] can resolve URLs. +void _mergeDocumentsForUrls(Map documentIdToUrl, dynamic raw) { + if (raw is! List) return; + for (final Object? item in raw) { + if (item is! Map) continue; + final Object? id = item['document_id'] ?? item['id'] ?? item['semantic_identifier'] ?? item['url']; + if (id == null) continue; + final String idStr = id.toString(); + final Object? urlCandidate = item['url'] ?? item['source_url'] ?? item['document_id'] ?? id; + documentIdToUrl[idStr] = urlCandidate.toString(); + } +} + +List _sortedCitations(Map byNumber) { + final List keys = byNumber.keys.toList()..sort(); + return keys.map((int k) => byNumber[k]!).toList(); +} + /// Service for streaming chat messages from the TGPT API. /// /// Uses Dio to stream responses and yields [StreamingChatChunk] objects @@ -76,7 +96,7 @@ class ChatMessageStreamService { final endpoint = dotenv.env['CHAT_SEND_MESSAGE_ENDPOINT']; if (endpoint == null) { - yield const StreamingChatChunk(delta: 'Error: No endpoint configured', done: true); + yield const StreamingChatChunk(delta: ErrorConstants.TRITONGPT_UNAVAILABLE, done: true); return; } @@ -98,20 +118,21 @@ class ChatMessageStreamService { final response = await dio.post(endpoint, data: body); if (response.data == null) { - yield const StreamingChatChunk(delta: 'No response received.', done: true); + yield const StreamingChatChunk(delta: ErrorConstants.TRITONGPT_UNAVAILABLE, done: true); return; } String buffer = ''; - bool messageIdEmitted = false; + final Map citationByNumber = {}; + final Map documentIdToUrl = {}; await for (final chunk in response.data!.stream) { buffer += utf8.decode(chunk); // Process complete lines (newline-delimited JSON) while (buffer.contains('\n')) { - final newlineIndex = buffer.indexOf('\n'); - final line = buffer.substring(0, newlineIndex).trim(); + final int newlineIndex = buffer.indexOf('\n'); + final String line = buffer.substring(0, newlineIndex).trim(); buffer = buffer.substring(newlineIndex + 1); if (line.isEmpty) continue; @@ -124,49 +145,69 @@ class ChatMessageStreamService { } try { - final json = jsonDecode(jsonLine) as Map; - - // First line: extract message ID for threading - // {"user_message_id": 80067, "reserved_assistant_message_id": 80068} - if (!messageIdEmitted && json.containsKey('reserved_assistant_message_id')) { - messageIdEmitted = true; - // Handle both int and String types for reserved_assistant_message_id - final rawId = json['reserved_assistant_message_id']; - final int? messageId = rawId is int ? rawId : int.tryParse(rawId.toString()); - yield StreamingChatChunk( - delta: '', - messageId: messageId, - ); - continue; + final Map json = jsonDecode(jsonLine) as Map; + + final Map? obj = json['obj'] as Map?; + final Object? rawReserved = + json['reserved_assistant_message_id'] ?? obj?['reserved_assistant_message_id']; + if (rawReserved != null) { + final int? messageId = + rawReserved is int ? rawReserved : int.tryParse(rawReserved.toString()); + if (messageId != null) { + yield StreamingChatChunk(delta: '', messageId: messageId); + } + } + + // Optional legacy root field (some streams still emit it) + final Object? answerPiece = json['answer_piece']; + if (answerPiece is String && answerPiece.isNotEmpty) { + yield StreamingChatChunk(delta: answerPiece); } - // Message content: {"ind": 1, "obj": {"type": "message_delta", "content": "..."}} - final obj = json['obj'] as Map?; + _mergeDocumentsForUrls(documentIdToUrl, json['top_documents']); + if (obj != null) { - final type = obj['type'] as String?; + final String? type = obj['type'] as String?; - // Stop signal if (type == 'stop') { yield const StreamingChatChunk(delta: '', done: true); return; } - // Token delta - the actual streaming content + if (type == 'message_start') { + _mergeDocumentsForUrls(documentIdToUrl, obj['final_documents']); + } + if (type == 'message_delta') { - final content = obj['content'] as String?; - if (content != null && content.isNotEmpty) yield StreamingChatChunk(delta: content); + final String? content = obj['content'] as String?; + if (content != null && content.isNotEmpty) { + yield StreamingChatChunk(delta: content); + } + } + + // New schema: one citation per packet + if (type == 'citation_info') { + final String? docId = obj['document_id']?.toString(); + final Object? rawNum = obj['citation_number']; + final int? num = rawNum is int ? rawNum : int.tryParse(rawNum?.toString() ?? ''); + if (docId != null && docId.isNotEmpty && num != null && num > 0) { + final String url = documentIdToUrl[docId] ?? docId; + citationByNumber[num] = ChatCitationReference(number: num, url: url); + yield StreamingChatChunk(delta: '', citations: _sortedCitations(citationByNumber)); + } } - // Citation data - maps citation numbers to document IDs (URLs) + // Legacy: batch citation list if (type == 'citation_delta') { - final citationsList = obj['citations'] as List?; + final List? citationsList = obj['citations'] as List?; if (citationsList != null && citationsList.isNotEmpty) { - final citations = citationsList - .whereType>() - .map(ChatCitationReference.fromStreamJson) - .where((ChatCitationReference citation) => citation.url.isNotEmpty) - .toList(); - yield StreamingChatChunk(delta: '', citations: citations); + for (final Map row in citationsList.whereType>()) { + final ChatCitationReference ref = ChatCitationReference.fromStreamJson(row); + if (ref.url.isNotEmpty && ref.number > 0) { + citationByNumber[ref.number] = ref; + } + } + yield StreamingChatChunk(delta: '', citations: _sortedCitations(citationByNumber)); } } } @@ -196,10 +237,10 @@ class ChatMessageStreamService { } } _hasRetried = false; - yield StreamingChatChunk(delta: 'Error: $e', done: true); + yield StreamingChatChunk(delta: tgptErrorMessageFor(e), done: true); } catch (e) { _hasRetried = false; - yield StreamingChatChunk(delta: 'Error: $e', done: true); + yield StreamingChatChunk(delta: tgptErrorMessageFor(e), done: true); } finally { dio.close(); } diff --git a/lib/core/services/tgpt_services/tgpt_error_message.dart b/lib/core/services/tgpt_services/tgpt_error_message.dart new file mode 100644 index 000000000..b781dc9ba --- /dev/null +++ b/lib/core/services/tgpt_services/tgpt_error_message.dart @@ -0,0 +1,48 @@ +import 'package:campus_mobile_experimental/app_constants.dart'; +import 'package:dio/dio.dart'; + +/// Maps TGPT HTTP/stream failures to short, user-safe copy (no stack traces or Dio internals). +String tgptErrorMessageFor(Object error) { + if (error is DioException) { + final int? code = error.response?.statusCode; + if (code == 404) { + return ErrorConstants.TRITONGPT_NOT_FOUND; + } + if (code != null && code >= 500 && code < 600) { + return ErrorConstants.TRITONGPT_SERVER_ERROR; + } + if (code == 422 || code == 400) { + return ErrorConstants.TRITONGPT_BAD_REQUEST; + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return ErrorConstants.TRITONGPT_UNAVAILABLE; + default: + break; + } + if (code != null && code >= 400 && code < 500) { + return ErrorConstants.TRITONGPT_BAD_REQUEST; + } + return ErrorConstants.TRITONGPT_UNAVAILABLE; + } + + final String s = error.toString(); + if (s.contains('[404]') || s.contains('status code of 404')) { + return ErrorConstants.TRITONGPT_NOT_FOUND; + } + if (s.contains('[500]') || + s.contains('[502]') || + s.contains('[503]') || + s.contains('status code of 500') || + s.contains('status code of 502') || + s.contains('status code of 503')) { + return ErrorConstants.TRITONGPT_SERVER_ERROR; + } + if (s.contains('[422]') || s.contains('status code of 422')) { + return ErrorConstants.TRITONGPT_BAD_REQUEST; + } + return ErrorConstants.TRITONGPT_UNAVAILABLE; +} diff --git a/lib/ui/ai_assistant/chat_citation.dart b/lib/ui/ai_assistant/chat_citation.dart index 1bb8ca534..8c19a4d57 100644 --- a/lib/ui/ai_assistant/chat_citation.dart +++ b/lib/ui/ai_assistant/chat_citation.dart @@ -1,7 +1,7 @@ +import 'package:campus_mobile_experimental/app_constants.dart'; import 'package:campus_mobile_experimental/core/models/tgpt_models/chat_message.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; class ChatCitation extends StatelessWidget { const ChatCitation({ @@ -14,7 +14,7 @@ class ChatCitation extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: () => openCitation(citation.url), + onTap: () => openCitation(context, citation.url), borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), @@ -49,8 +49,34 @@ class ChatCitation extends StatelessWidget { ); } - static Future openCitation(String url) async { - final Uri uri = Uri.parse(url); - await launchUrl(uri, mode: LaunchMode.externalApplication); + /// Opens [url] in an in-app [CitationWebView] so the app bar title is **CITATION**. + /// + /// Ignores fragment-only placeholders (e.g. `[text](#rq)` from Related Questions) and + /// any string that is not a navigable `http`/`https` URL after resolution. + static Future openCitation(BuildContext context, String url) async { + final String trimmed = url.trim(); + if (trimmed.isEmpty) return; + // Related-question / UI placeholders from model markdown — not real URLs. + if (trimmed.startsWith('#')) return; + if (trimmed.toLowerCase().startsWith('javascript:')) return; + + String resolved = trimmed; + if (trimmed.startsWith('//')) { + resolved = 'https:$trimmed'; + } else { + final Uri parsed = Uri.tryParse(trimmed) ?? Uri(); + if (!parsed.hasScheme && trimmed.contains('.')) { + resolved = 'https://$trimmed'; + } + } + + final Uri? uri = Uri.tryParse(resolved); + if (uri == null || !uri.hasScheme) return; + if (uri.scheme != 'http' && uri.scheme != 'https') return; + + await Navigator.of(context).pushNamed( + RoutePaths.TGPT_CITATION_WEB, + arguments: resolved, + ); } } diff --git a/lib/ui/ai_assistant/chat_message_bubble.dart b/lib/ui/ai_assistant/chat_message_bubble.dart index bb439ed7b..0f32a27ba 100644 --- a/lib/ui/ai_assistant/chat_message_bubble.dart +++ b/lib/ui/ai_assistant/chat_message_bubble.dart @@ -1,8 +1,10 @@ import 'package:campus_mobile_experimental/app_styles.dart'; import 'package:campus_mobile_experimental/core/models/tgpt_models/chat_message.dart'; +import 'package:campus_mobile_experimental/core/providers/chat_provider.dart'; import 'package:campus_mobile_experimental/ui/ai_assistant/chat_citation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; class ChatMessageBubble extends StatelessWidget { const ChatMessageBubble({ @@ -73,7 +75,9 @@ class ChatMessageBubble extends StatelessWidget { shrinkWrap: true, onTapLink: (_, String? href, __) { if (href == null || href.isEmpty) return; - ChatCitation.openCitation(href); + // Skip Related Questions placeholders like #rq (handled in openCitation too). + if (href.trim().startsWith('#')) return; + ChatCitation.openCitation(context, href); }, styleSheet: MarkdownStyleSheet( p: const TextStyle( @@ -121,7 +125,10 @@ class ChatMessageBubble extends StatelessWidget { if (content.relatedQuestions.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 12), - child: _RelatedQuestionsSection(questions: content.relatedQuestions), + child: _RelatedQuestionsSection( + questions: content.relatedQuestions, + enabled: !message.isStreaming, + ), ), if (message.isStreaming && content.markdown.isEmpty && content.relatedQuestions.isEmpty) const _TypingIndicator(), @@ -136,9 +143,11 @@ class ChatMessageBubble extends StatelessWidget { class _RelatedQuestionsSection extends StatelessWidget { const _RelatedQuestionsSection({ required this.questions, + required this.enabled, }); final List questions; + final bool enabled; @override Widget build(BuildContext context) { @@ -186,14 +195,27 @@ class _RelatedQuestionsSection extends StatelessWidget { ), const SizedBox(width: 8), Expanded( - child: Text( - question, - style: const TextStyle( - fontFamily: 'Brix Sans', - fontSize: 16, - fontWeight: FontWeight.w400, - color: lightPrimaryColor, - height: 1.35, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled + ? () => context.read().sendMessage(question) + : null, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2), + child: Text( + question, + style: TextStyle( + fontFamily: 'Brix Sans', + fontSize: 16, + fontWeight: FontWeight.w400, + color: enabled ? linkTextColorLight : lightPrimaryColor, + decoration: enabled ? TextDecoration.underline : TextDecoration.none, + height: 1.35, + ), + ), + ), ), ), ), diff --git a/lib/ui/ai_assistant/citation_web_view.dart b/lib/ui/ai_assistant/citation_web_view.dart new file mode 100644 index 000000000..73681c2ad --- /dev/null +++ b/lib/ui/ai_assistant/citation_web_view.dart @@ -0,0 +1,54 @@ +import 'package:campus_mobile_experimental/ui/common/container_view.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Full-screen web view for TritonGPT citation / source links. +/// +/// Pushed from [ChatCitation.openCitation] so the app bar shows **CITATION** +/// (see [RouteTitles]) instead of reusing another feature title such as News. +class CitationWebView extends StatefulWidget { + const CitationWebView({ + super.key, + required this.initialUrl, + }); + + final String initialUrl; + + @override + State createState() => _CitationWebViewState(); +} + +class _CitationWebViewState extends State { + WebViewController? _controller; + + @override + void initState() { + super.initState(); + final Uri? uri = Uri.tryParse(widget.initialUrl); + final bool ok = + uri != null && uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https'); + if (!ok) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open link.')), + ); + Navigator.of(context).pop(); + }); + return; + } + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(uri); + } + + @override + Widget build(BuildContext context) { + final WebViewController? c = _controller; + return ContainerView( + child: c == null + ? const SizedBox.shrink() + : WebViewWidget(controller: c), + ); + } +}