Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/app_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions lib/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,6 +77,12 @@ class Router {
Provider.of<CustomAppBar>(_).changeTitle(settings.name);
return NewsDetailView(data: newsItem);
});
case RoutePaths.TGPT_CITATION_WEB:
final String citationUrl = settings.arguments as String;
return MaterialPageRoute(builder: (_) {
Provider.of<CustomAppBar>(_).changeTitle(settings.name);
return CitationWebView(initialUrl: citationUrl);
});
case RoutePaths.EVENT_DETAIL_VIEW:
EventModel data = settings.arguments as EventModel;
return MaterialPageRoute(builder: (_) {
Expand Down
69 changes: 56 additions & 13 deletions lib/core/models/tgpt_models/chat_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> 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(
Expand All @@ -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 <String>[],
Expand All @@ -39,38 +48,72 @@ class AssistantMessageContent {
return const AssistantMessageContent(markdown: '');
}

final List<String> answerLines = <String>[];
final String normalized = rawText.replaceAll('\r\n', '\n');
final List<String> relatedQuestions = <String>[];

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<String> answerLines = <String>[];
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<String> normalizedAnswerLines = _trimBlankLines(answerLines);
List<String> normalizedAnswerLines = _trimBlankLines(answerLines);
if (relatedQuestions.isNotEmpty && normalizedAnswerLines.isNotEmpty) {
final String trailingLine = normalizedAnswerLines.last.trim().toLowerCase();
if (trailingLine == 'related questions' || trailingLine == 'related questions:') {
normalizedAnswerLines.removeLast();
}
}

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<String>.unmodifiable(relatedQuestions),
);
}

static void _addUniqueRelatedQuestion(List<String> 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<String> _trimBlankLines(List<String> lines) {
final List<String> trimmedLines = List<String>.from(lines);

Expand Down
31 changes: 4 additions & 27 deletions lib/core/models/tgpt_models/chat_response.dart
Original file line number Diff line number Diff line change
@@ -1,44 +1,21 @@
/// Retrieval options for the chat message request.
class RetrievalOptions {
final String runSearch;

RetrievalOptions({
this.runSearch = 'always',
});

Map<String, dynamic> 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<dynamic> fileDescriptors;

BasicCreateChatMessageRequest({
required this.message,
required this.chatSessionId,
this.parentMessageId,
RetrievalOptions? retrievalOptions,
this.fileDescriptors = const [],
}) : retrievalOptions = retrievalOptions ?? RetrievalOptions();
});

Map<String, dynamic> 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,
};
}


13 changes: 9 additions & 4 deletions lib/core/providers/chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -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;
}

Expand Down
5 changes: 0 additions & 5 deletions lib/core/services/tgpt_services/chat_send_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,8 +19,6 @@ class ChatMessageService {
message: message,
chatSessionId: chatSessionId,
parentMessageId: parentMessageId,
retrievalOptions: RetrievalOptions(runSearch: 'always'),
fileDescriptors: const [],
);
return json.encode(req.toJson());
}
Expand Down
3 changes: 2 additions & 1 deletion lib/core/services/tgpt_services/chat_session_creation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -84,7 +85,7 @@ class ChatSessionService {
}
}

_error = e.toString();
_error = tgptErrorMessageFor(e);
_hasRetried = false;
return null;
} finally {
Expand Down
Loading
Loading