From ae9b12345d09277e0273ad544b8942b088c91fd6 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Tue, 21 Jul 2026 23:45:00 +0200 Subject: [PATCH 1/9] Reintroduce orphaned mail feature --- flutter_app/lib/src/app_router.dart | 33 +++++++++++++++++-- flutter_app/lib/src/app_shell_controller.dart | 3 ++ flutter_app/lib/src/views/home_view.dart | 7 ++++ flutter_app/test/navigation_test.dart | 15 ++++++++- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/flutter_app/lib/src/app_router.dart b/flutter_app/lib/src/app_router.dart index 66edafc..58a807f 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -8,6 +8,7 @@ import 'onboarding_flow.dart'; import 'studyos_theme.dart'; import 'views/chat_route.dart'; import 'views/home_view.dart'; +import 'views/mail_view.dart'; import 'views/maps_view.dart'; import 'views/memories_view.dart'; import 'views/official_documents_view.dart'; @@ -168,6 +169,13 @@ GoRouter buildAppRouter({ child: _DocumentsRoute(controller: shellController()), ), ), + GoRoute( + path: '/mail', + builder: (context, state) => _ScopedAppRoute( + controller: shellController(), + child: _MailRoute(controller: shellController()), + ), + ), GoRoute( path: '/talks', builder: (context, state) => _ScopedAppRoute( @@ -222,8 +230,7 @@ class _HomeRoute extends StatelessWidget { onOpenAssistant: () => context.push('/settings'), onOpenNotes: () => context.push('/memories'), onOpenTalks: () => context.push('/talks'), - onOpenMail: () => - context.push('/chat?prompt=Show%20my%20university%20mail'), + onOpenMail: () => context.push('/mail'), onOpenMaps: () => context.push('/maps'), onOpenCampus: () => context.push( '/chat?prompt=What%20is%20good%20at%20the%20Mensa%20today%3F', @@ -279,6 +286,28 @@ class _TalksRoute extends StatelessWidget { } } +class _MailRoute extends StatelessWidget { + const _MailRoute({required this.controller}); + + final AppShellController? controller; + + @override + Widget build(BuildContext context) { + final controller = this.controller ?? AppShellScope.of(context); + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _RouteScaffold( + title: 'Mail', + showTitle: false, + child: MailView( + profile: controller.profile, + repository: controller.mailRepository, + ), + ), + ); + } +} + class _MapsRoute extends StatelessWidget { const _MapsRoute({required this.controller}); diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index 877b95f..bbfbfc2 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -83,6 +83,9 @@ class AppShellController extends ChangeNotifier { final SessionStore _sessionStore = SessionStore(); final AgentConfigStore _configStore = AgentConfigStore(); final MailRepository _mailRepository = MailRepository(); + + /// Shared mail repository so the mail view reuses the cached IMAP session. + MailRepository get mailRepository => _mailRepository; final MemoryStore _memoryStore = MemoryStore(); final TimetableRepository _timetableRepository = TimetableRepository(); final AcademicRepository _academicRepository = AcademicRepository(); diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 0f88fb2..aff44b5 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -92,6 +92,13 @@ class HomeView extends StatelessWidget { detail: 'Upcoming public talks and guest lectures', onTap: onOpenTalks, ), + _ToolRow( + itemKey: const ValueKey('home-status-mail'), + icon: Icons.mail_outline_rounded, + title: 'University Mail', + detail: 'Read your Tübingen mailbox', + onTap: onOpenMail, + ), ], ), ], diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 1579d22..e2e59de 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -172,6 +172,7 @@ void main() { testWidgets('home presents a daily focus before StudyOS controls', ( WidgetTester tester, ) async { + var openedMail = false; await tester.pumpWidget( MaterialApp( theme: buildStudyOsTheme(), @@ -191,7 +192,7 @@ void main() { onOpenAssistant: () {}, onOpenNotes: () {}, onOpenTalks: () {}, - onOpenMail: () {}, + onOpenMail: () => openedMail = true, onOpenMaps: () {}, onOpenCampus: () {}, onOpenSchedule: () {}, @@ -212,8 +213,20 @@ void main() { ); expect(find.text('StudyOS'), findsOneWidget); expect(find.text('Tübingen Talks'), findsOneWidget); + expect(find.text('University Mail'), findsOneWidget); expect(find.text('Generated component preview'), findsNothing); expect(find.byType(RefreshIndicator), findsOneWidget); + + final mailRow = find.byKey(const ValueKey('home-status-mail')); + await tester.scrollUntilVisible( + mailRow, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(mailRow); + await tester.pumpAndSettle(); + await tester.tap(mailRow); + expect(openedMail, isTrue); }); test( From dd1e9554beb83ee69be4c10f1a7c50abbb1adf39 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 22 Jul 2026 00:12:04 +0200 Subject: [PATCH 2/9] Restore caching, add search --- flutter_app/lib/src/mail_repository.dart | 264 ++++++++++--- flutter_app/lib/src/views/mail_view.dart | 360 ++++++++++++------ .../lib/src/views/mail_view_components.dart | 125 ++++-- flutter_app/test/cloud_agent_client_test.dart | 7 +- flutter_app/test/mail_repository_test.dart | 63 +++ flutter_app/test/mail_tools_test.dart | 7 +- flutter_app/test/mail_view_test.dart | 149 ++++++++ 7 files changed, 768 insertions(+), 207 deletions(-) create mode 100644 flutter_app/test/mail_view_test.dart diff --git a/flutter_app/lib/src/mail_repository.dart b/flutter_app/lib/src/mail_repository.dart index 90a9bbc..afdbb78 100644 --- a/flutter_app/lib/src/mail_repository.dart +++ b/flutter_app/lib/src/mail_repository.dart @@ -6,27 +6,91 @@ class MailRepository { factory MailRepository({ ProfileStore? profileStore, MailClient Function()? clientFactory, + Duration cacheTtl = const Duration(minutes: 2), }) { - return MailRepository._(profileStore, clientFactory ?? MailClient.new); + return MailRepository._( + profileStore, + clientFactory ?? MailClient.new, + cacheTtl, + ); } MailRepository.test({ ProfileStore? profileStore, MailClient Function()? clientFactory, - }) : this._(profileStore, clientFactory ?? MailClient.new); + Duration cacheTtl = const Duration(minutes: 2), + }) : this._(profileStore, clientFactory ?? MailClient.new, cacheTtl); - MailRepository._(this._profileStore, this._clientFactory); + MailRepository._(this._profileStore, this._clientFactory, this.cacheTtl); final ProfileStore? _profileStore; final MailClient Function() _clientFactory; + final Duration cacheTtl; + final Map> _cache = + >{}; - Future> listMailboxes(OnboardingProfile? profile) async { - final client = await _authenticatedClient(profile); - try { - return await client.listMailboxes(); - } finally { - client.close(); - } + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + return _cached>( + 'mailboxes|$account', + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.listMailboxes(); + } finally { + client.close(); + } + }, + ); + } + + Future> _fetchMailboxesWithoutCache( + MailClient client, + String account, + ) async { + final mailboxes = await client.listMailboxes(); + _cache['mailboxes|$account'] = _MailCacheEntry(mailboxes); + return mailboxes; + } + + Future _fetchSummaryWithoutCache( + MailClient client, + String account, { + required String mailbox, + required int limit, + required bool unreadOnly, + required String query, + required String sender, + required String since, + required int scanLimit, + }) async { + final summary = await client.fetchMailboxSummary( + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + _cache[_summaryKey( + account: account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + )] = _MailCacheEntry( + summary, + ); + return summary; } Future fetchMailboxSummary( @@ -38,21 +102,40 @@ class MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - return await client.fetchMailboxSummary( - mailbox: mailbox, - limit: limit, - unreadOnly: unreadOnly, - query: query, - sender: sender, - since: since, - scanLimit: scanLimit, - ); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + final key = _summaryKey( + account: account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + return _cached( + key, + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.fetchMailboxSummary( + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + } finally { + client.close(); + } + }, + ); } Future fetchMailboxSnapshot( @@ -60,35 +143,77 @@ class MailRepository { String mailbox = 'INBOX', int limit = 12, bool unreadOnly = false, + String query = '', + String sender = '', + String since = '', + int scanLimit = 200, + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - final mailboxes = await client.listMailboxes(); - final inbox = await client.fetchMailboxSummary( - mailbox: mailbox, - limit: limit, - unreadOnly: unreadOnly, - ); - return MailMailboxSnapshot(mailboxes: mailboxes, inbox: inbox); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + final key = + 'snapshot|${_summaryKey(account: account, mailbox: mailbox, limit: limit, unreadOnly: unreadOnly, query: query, sender: sender, since: since, scanLimit: scanLimit)}'; + return _cached( + key, + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + final mailboxes = await _fetchMailboxesWithoutCache(client, account); + final inbox = await _fetchSummaryWithoutCache( + client, + account, + mailbox: mailbox, + limit: limit, + unreadOnly: unreadOnly, + query: query, + sender: sender, + since: since, + scanLimit: scanLimit, + ); + return MailMailboxSnapshot(mailboxes: mailboxes, inbox: inbox); + } finally { + client.close(); + } + }, + ); } Future fetchMessageDetail( OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { - final client = await _authenticatedClient(profile); - try { - return await client.fetchMessageDetail(uid, mailbox: mailbox); - } finally { - client.close(); - } + await _ensureMailAccessAllowed(profile); + final account = _accountKey(profile); + return _cached( + 'detail|$account|$mailbox|$uid', + forceRefresh: forceRefresh, + loader: () async { + final client = await _authenticatedClient(profile); + try { + return await client.fetchMessageDetail(uid, mailbox: mailbox); + } finally { + client.close(); + } + }, + ); + } + + void clearCache() { + _cache.clear(); } Future _authenticatedClient(OnboardingProfile? profile) async { + await _ensureMailAccessAllowed(profile); + final password = await (_profileStore ?? ProfileStore()).readPassword(); + final client = _clientFactory(); + await client.login(profile!.username, password!); + return client; + } + + Future _ensureMailAccessAllowed(OnboardingProfile? profile) async { if (profile == null) { throw const MailException('Sign in again to access university mail.'); } @@ -96,8 +221,57 @@ class MailRepository { if (password == null || password.isEmpty) { throw const MailException('Sign in again to access university mail.'); } - final client = _clientFactory(); - await client.login(profile.username, password); - return client; } + + String _accountKey(OnboardingProfile? profile) { + return profile?.username.trim().toLowerCase() ?? 'anonymous'; + } + + String _summaryKey({ + required String account, + required String mailbox, + required int limit, + required bool unreadOnly, + required String query, + required String sender, + required String since, + required int scanLimit, + }) { + return [ + 'summary', + account, + mailbox, + limit, + unreadOnly, + query.trim().toLowerCase(), + sender.trim().toLowerCase(), + since.trim(), + scanLimit, + ].join('|'); + } + + Future _cached( + String key, { + required bool forceRefresh, + required Future Function() loader, + }) async { + if (!forceRefresh) { + final cached = _cache[key]; + if (cached != null && !cached.isExpired(cacheTtl)) { + return cached.value as T; + } + } + final value = await loader(); + _cache[key] = _MailCacheEntry(value); + return value; + } +} + +class _MailCacheEntry { + _MailCacheEntry(this.value) : createdAt = DateTime.now(); + + final T value; + final DateTime createdAt; + + bool isExpired(Duration ttl) => DateTime.now().difference(createdAt) > ttl; } diff --git a/flutter_app/lib/src/views/mail_view.dart b/flutter_app/lib/src/views/mail_view.dart index 5d8d9f7..430d7c9 100644 --- a/flutter_app/lib/src/views/mail_view.dart +++ b/flutter_app/lib/src/views/mail_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../mail_parsing.dart'; @@ -19,38 +21,124 @@ class MailView extends StatefulWidget { class _MailViewState extends State { late final MailRepository _repository = widget.repository ?? MailRepository(); + final TextEditingController _queryController = TextEditingController(); + Timer? _searchDebounce; late Future _state; MailMessageDetail? _selectedMessage; String? _openingMessageUid; String? _messageError; String _mailbox = 'INBOX'; bool _unreadOnly = false; + String _query = ''; + List _mailboxes = const []; @override void initState() { super.initState(); + // Reuse the shared repository's cache on entry so revisiting the tab is + // instant; the refresh button forces a live reload. _state = _load(); } - void _refresh() { + @override + void dispose() { + _searchDebounce?.cancel(); + _queryController.dispose(); + super.dispose(); + } + + /// Reloads the mailbox listing. [force] bypasses the repository cache, e.g. + /// when the user taps refresh. Switching mailbox, filter, or search keeps the + /// cache (each combination is a separate cache key). + void _reload({bool force = false}) { setState(() { _selectedMessage = null; _openingMessageUid = null; _messageError = null; - _state = _load(); + _state = _load(force: force); }); } - Future _load() { - return _repository.fetchMailboxSnapshot( + /// Pull-to-refresh handler: always forces a live reload and keeps the + /// spinner up until the fresh snapshot resolves. + Future _handleRefresh() async { + final future = _load(force: true); + setState(() { + _selectedMessage = null; + _openingMessageUid = null; + _messageError = null; + _state = future; + }); + try { + await future; + } on Object { + // The FutureBuilder renders the error state; nothing to do here. + } + } + + Future _load({bool force = false}) { + final future = _repository.fetchMailboxSnapshot( widget.profile, mailbox: _mailbox, limit: 20, unreadOnly: _unreadOnly, + query: _query, + forceRefresh: force, ); + // Keep the folder list around so the controls stay put while a search or + // filter reload is in flight (the future rebuilds the message list only). + unawaited( + future + .then((snapshot) { + if (!mounted || _sameMailboxes(snapshot.mailboxes)) return; + setState(() => _mailboxes = snapshot.mailboxes); + }) + .catchError((Object _) {}), + ); + return future; + } + + bool _sameMailboxes(List next) { + if (_mailboxes.length != next.length) return false; + for (var i = 0; i < next.length; i++) { + if (_mailboxes[i].name != next[i].name || + _mailboxes[i].unreadCount != next[i].unreadCount) { + return false; + } + } + return true; + } + + void _onQueryChanged(String value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 400), () { + _applyQuery(value); + }); + } + + void _applyQuery(String value) { + _searchDebounce?.cancel(); + final trimmed = value.trim(); + if (!mounted || trimmed == _query) return; + setState(() => _query = trimmed); + _reload(); + } + + void _closeMessage() { + setState(() { + _selectedMessage = null; + _openingMessageUid = null; + _messageError = null; + }); } Future _openMessage(MailMessageSummary message) async { + // Tapping the message that is already open collapses it again. + if (_selectedMessage?.uid == message.uid || + _openingMessageUid == message.uid) { + _closeMessage(); + return; + } setState(() { _selectedMessage = null; _openingMessageUid = message.uid; @@ -74,133 +162,157 @@ class _MailViewState extends State { @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.only( - top: StudyOsSpacing.xl, - bottom: StudyOsSpacing.xxl, - ), - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Inbox', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: StudyOsSpacing.xs), - Text( - 'Your university mail, in one place.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - IconButton( - tooltip: 'Refresh mail', - onPressed: _refresh, - icon: const Icon(Icons.refresh_rounded), - ), - ], + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only( + top: StudyOsSpacing.xl, + bottom: StudyOsSpacing.xxl, ), - const SizedBox(height: StudyOsSpacing.lg), - FutureBuilder( - future: _state, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return _MailMessageCard( - icon: Icons.mark_email_unread_outlined, - title: 'Could not load mail', - body: snapshot.error.toString(), - ); - } - final state = snapshot.data!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MailControls( - mailboxes: state.mailboxes, - mailbox: _mailbox, - unreadOnly: _unreadOnly, - onMailboxChanged: (value) { - setState(() => _mailbox = value); - _refresh(); - }, - onUnreadOnlyChanged: (value) { - setState(() => _unreadOnly = value); - _refresh(); - }, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Inbox', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + 'Your university mail, in one place.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), - const SizedBox(height: StudyOsSpacing.md), - if (_selectedMessage != null) ...[ - _MailDetailCard(message: _selectedMessage!), - const SizedBox(height: StudyOsSpacing.md), - ], - if (_openingMessageUid != null) ...[ - const _MailMessageCard( - icon: Icons.hourglass_top_rounded, - title: 'Opening message', - body: 'Loading the selected message...', - ), - const SizedBox(height: StudyOsSpacing.md), - ], - if (_messageError != null) ...[ - _MailMessageCard( - icon: Icons.error_outline_rounded, - title: 'Could not open message', - body: _messageError!, - ), - const SizedBox(height: StudyOsSpacing.md), - ], - if (state.inbox.messages.isEmpty) - const _MailMessageCard( - icon: Icons.inbox_outlined, - title: 'No messages found', - body: 'Try another folder or turn off the unread filter.', - ) - else - Material( - color: StudyOsColors.surface, - borderRadius: BorderRadius.circular(StudyOsRadii.md), - child: ClipRRect( + ), + IconButton( + tooltip: 'Refresh mail', + onPressed: () => _reload(force: true), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.lg), + _MailControls( + mailboxes: _mailboxes, + mailbox: _mailbox, + unreadOnly: _unreadOnly, + queryController: _queryController, + onQueryChanged: _onQueryChanged, + onQuerySubmitted: _applyQuery, + onClearQuery: () { + _queryController.clear(); + _applyQuery(''); + }, + onMailboxChanged: (value) { + setState(() => _mailbox = value); + _reload(); + }, + onUnreadOnlyChanged: (value) { + setState(() => _unreadOnly = value); + _reload(); + }, + ), + const SizedBox(height: StudyOsSpacing.md), + FutureBuilder( + future: _state, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: StudyOsSpacing.xxl), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return _MailMessageCard( + icon: Icons.mark_email_unread_outlined, + title: 'Could not load mail', + body: snapshot.error.toString(), + ); + } + final state = snapshot.data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_selectedMessage != null) ...[ + _MailDetailCard( + message: _selectedMessage!, + onClose: _closeMessage, + ), + const SizedBox(height: StudyOsSpacing.md), + ], + if (_openingMessageUid != null) ...[ + const _MailMessageCard( + icon: Icons.hourglass_top_rounded, + title: 'Opening message', + body: 'Loading the selected message...', + ), + const SizedBox(height: StudyOsSpacing.md), + ], + if (_messageError != null) ...[ + _MailMessageCard( + icon: Icons.error_outline_rounded, + title: 'Could not open message', + body: _messageError!, + ), + const SizedBox(height: StudyOsSpacing.md), + ], + if (state.inbox.messages.isEmpty) + _MailMessageCard( + icon: _query.isEmpty + ? Icons.inbox_outlined + : Icons.search_off_rounded, + title: _query.isEmpty + ? 'No messages found' + : 'No messages match your search', + body: _query.isEmpty + ? 'Try another folder or turn off the unread filter.' + : 'No mail in this folder matches "$_query". ' + 'Try a different term or clear the search.', + ) + else + Material( + color: StudyOsColors.surface, borderRadius: BorderRadius.circular(StudyOsRadii.md), - child: Column( - children: [ - for ( - var index = 0; - index < state.inbox.messages.length; - index++ - ) ...[ - _MailSummaryCard( - message: state.inbox.messages[index], - selected: - _selectedMessage?.uid == - state.inbox.messages[index].uid || - _openingMessageUid == - state.inbox.messages[index].uid, - onTap: () => - _openMessage(state.inbox.messages[index]), - ), - if (index < state.inbox.messages.length - 1) - const Padding( - padding: EdgeInsets.only(left: 62), - child: Divider(), + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: Column( + children: [ + for ( + var index = 0; + index < state.inbox.messages.length; + index++ + ) ...[ + _MailSummaryCard( + message: state.inbox.messages[index], + selected: + _selectedMessage?.uid == + state.inbox.messages[index].uid || + _openingMessageUid == + state.inbox.messages[index].uid, + onTap: () => + _openMessage(state.inbox.messages[index]), ), + if (index < state.inbox.messages.length - 1) + const Padding( + padding: EdgeInsets.only(left: 62), + child: Divider(), + ), + ], ], - ], + ), ), ), - ), - ], - ); - }, - ), - ], + ], + ); + }, + ), + ], + ), ); } } diff --git a/flutter_app/lib/src/views/mail_view_components.dart b/flutter_app/lib/src/views/mail_view_components.dart index 08cac9b..841d7a6 100644 --- a/flutter_app/lib/src/views/mail_view_components.dart +++ b/flutter_app/lib/src/views/mail_view_components.dart @@ -5,6 +5,10 @@ class _MailControls extends StatelessWidget { required this.mailboxes, required this.mailbox, required this.unreadOnly, + required this.queryController, + required this.onQueryChanged, + required this.onQuerySubmitted, + required this.onClearQuery, required this.onMailboxChanged, required this.onUnreadOnlyChanged, }); @@ -12,6 +16,10 @@ class _MailControls extends StatelessWidget { final List mailboxes; final String mailbox; final bool unreadOnly; + final TextEditingController queryController; + final ValueChanged onQueryChanged; + final ValueChanged onQuerySubmitted; + final VoidCallback onClearQuery; final ValueChanged onMailboxChanged; final ValueChanged onUnreadOnlyChanged; @@ -29,46 +37,74 @@ class _MailControls extends StatelessWidget { ] : mailboxes; return Container( - padding: const EdgeInsets.symmetric(horizontal: StudyOsSpacing.md), + padding: const EdgeInsets.all(StudyOsSpacing.md), decoration: BoxDecoration( color: StudyOsColors.surface, borderRadius: BorderRadius.circular(StudyOsRadii.md), ), - child: Row( + child: Column( children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: mailbox, - decoration: const InputDecoration(labelText: 'Mailbox'), - items: options - .map( - (item) => DropdownMenuItem( - value: item.name, - child: Text( - item.unreadCount == null || item.unreadCount == 0 - ? item.label - : '${item.label} (${item.unreadCount})', - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) onMailboxChanged(value); - }, - ), + ValueListenableBuilder( + valueListenable: queryController, + builder: (context, value, _) { + return TextField( + controller: queryController, + textInputAction: TextInputAction.search, + onChanged: onQueryChanged, + onSubmitted: onQuerySubmitted, + decoration: InputDecoration( + labelText: 'Search mail', + hintText: 'Subject, sender, or keyword', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: value.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: onClearQuery, + icon: const Icon(Icons.close_rounded), + ), + ), + ); + }, ), - const SizedBox(width: StudyOsSpacing.sm), - IconButton( - tooltip: unreadOnly ? 'Show all messages' : 'Show unread only', - onPressed: () => onUnreadOnlyChanged(!unreadOnly), - icon: Icon( - unreadOnly - ? Icons.mark_email_unread_rounded - : Icons.mark_email_read_outlined, - color: unreadOnly - ? StudyOsColors.accent - : StudyOsColors.textMuted, - ), + const SizedBox(height: StudyOsSpacing.sm), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: mailbox, + decoration: const InputDecoration(labelText: 'Mailbox'), + items: options + .map( + (item) => DropdownMenuItem( + value: item.name, + child: Text( + item.unreadCount == null || item.unreadCount == 0 + ? item.label + : '${item.label} (${item.unreadCount})', + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) onMailboxChanged(value); + }, + ), + ), + const SizedBox(width: StudyOsSpacing.sm), + IconButton( + tooltip: unreadOnly ? 'Show all messages' : 'Show unread only', + onPressed: () => onUnreadOnlyChanged(!unreadOnly), + icon: Icon( + unreadOnly + ? Icons.mark_email_unread_rounded + : Icons.mark_email_read_outlined, + color: unreadOnly + ? StudyOsColors.accent + : StudyOsColors.textMuted, + ), + ), + ], ), ], ), @@ -165,9 +201,10 @@ class _MailSummaryCard extends StatelessWidget { } class _MailDetailCard extends StatelessWidget { - const _MailDetailCard({required this.message}); + const _MailDetailCard({required this.message, required this.onClose}); final MailMessageDetail message; + final VoidCallback onClose; @override Widget build(BuildContext context) { @@ -179,7 +216,23 @@ class _MailDetailCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(message.subject, style: Theme.of(context).textTheme.titleMedium), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + message.subject, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Close message', + onPressed: onClose, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close_rounded), + ), + ], + ), const SizedBox(height: StudyOsSpacing.xs), Text( '${message.senderLabel} · ${message.receivedAt ?? 'Unknown date'}', diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index af58876..d9fb300 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -669,7 +669,10 @@ class _FakeMailRepository extends MailRepository { _FakeMailRepository() : super.test(); @override - Future> listMailboxes(OnboardingProfile? profile) async { + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { return const [ MailboxSummary( name: 'INBOX', @@ -691,6 +694,7 @@ class _FakeMailRepository extends MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { return const MailInboxSummary( account: 'ada42', @@ -715,6 +719,7 @@ class _FakeMailRepository extends MailRepository { OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { return const MailMessageDetail( uid: '7', diff --git a/flutter_app/test/mail_repository_test.dart b/flutter_app/test/mail_repository_test.dart index b80d772..7d6caa7 100644 --- a/flutter_app/test/mail_repository_test.dart +++ b/flutter_app/test/mail_repository_test.dart @@ -35,6 +35,69 @@ void main() { expect(client.summaryCount, 1); expect(client.closeCount, 1); }); + + test('repeated snapshot within TTL is served from cache', () async { + final client = _CountingMailClient(); + final repository = MailRepository.test( + profileStore: _FakeProfileStore(), + clientFactory: () => client, + ); + + await repository.fetchMailboxSnapshot( + _profile, + mailbox: 'INBOX', + limit: 20, + unreadOnly: true, + ); + await repository.fetchMailboxSnapshot( + _profile, + mailbox: 'INBOX', + limit: 20, + unreadOnly: true, + ); + + // Second call reuses the cached snapshot: no extra login/fetch. + expect(client.loginCount, 1); + expect(client.listCount, 1); + expect(client.summaryCount, 1); + + // forceRefresh bypasses the cache and hits the server again. + await repository.fetchMailboxSnapshot( + _profile, + mailbox: 'INBOX', + limit: 20, + unreadOnly: true, + forceRefresh: true, + ); + expect(client.loginCount, 2); + expect(client.summaryCount, 2); + }); + + test('expired cache entries trigger a fresh fetch', () async { + final client = _CountingMailClient(); + final repository = MailRepository.test( + profileStore: _FakeProfileStore(), + clientFactory: () => client, + cacheTtl: const Duration(milliseconds: 10), + ); + + await repository.fetchMailboxSnapshot( + _profile, + mailbox: 'INBOX', + limit: 20, + unreadOnly: true, + ); + await Future.delayed(const Duration(milliseconds: 25)); + await repository.fetchMailboxSnapshot( + _profile, + mailbox: 'INBOX', + limit: 20, + unreadOnly: true, + ); + + expect(client.loginCount, 2); + expect(client.summaryCount, 2); + }); } const _profile = OnboardingProfile( diff --git a/flutter_app/test/mail_tools_test.dart b/flutter_app/test/mail_tools_test.dart index 88cacb8..3039dc9 100644 --- a/flutter_app/test/mail_tools_test.dart +++ b/flutter_app/test/mail_tools_test.dart @@ -63,6 +63,7 @@ class _ToolMailRepository extends MailRepository { String sender = '', String since = '', int scanLimit = 200, + bool forceRefresh = false, }) async { expect(limit, lessThanOrEqualTo(10)); return const MailInboxSummary( @@ -84,7 +85,10 @@ class _ToolMailRepository extends MailRepository { } @override - Future> listMailboxes(OnboardingProfile? profile) async { + Future> listMailboxes( + OnboardingProfile? profile, { + bool forceRefresh = false, + }) async { return const []; } @@ -93,6 +97,7 @@ class _ToolMailRepository extends MailRepository { OnboardingProfile? profile, { required String uid, String mailbox = 'INBOX', + bool forceRefresh = false, }) async { return const MailMessageDetail( uid: '42', diff --git a/flutter_app/test/mail_view_test.dart b/flutter_app/test/mail_view_test.dart new file mode 100644 index 0000000..6e1bdf4 --- /dev/null +++ b/flutter_app/test/mail_view_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/mail_repository.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/views/mail_view.dart'; + +void main() { + Future pumpMailView( + WidgetTester tester, + _RecordingMailRepository repository, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MailView(profile: _profile, repository: repository), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('search field forwards the debounced query to the repository', ( + tester, + ) async { + final repository = _RecordingMailRepository(); + await pumpMailView(tester, repository); + + expect(repository.lastQuery, ''); + + await tester.enterText(find.byType(TextField), 'exam'); + // Wait past the 400ms debounce window. + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + expect(repository.lastQuery, 'exam'); + expect(find.text('Exam registration'), findsOneWidget); + expect(find.text('Cafeteria menu'), findsNothing); + }); + + testWidgets('clearing the search resets the query', (tester) async { + final repository = _RecordingMailRepository(); + await pumpMailView(tester, repository); + + await tester.enterText(find.byType(TextField), 'exam'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(repository.lastQuery, 'exam'); + + await tester.tap(find.byTooltip('Clear search')); + await tester.pumpAndSettle(); + + expect(repository.lastQuery, ''); + expect(find.text('Cafeteria menu'), findsOneWidget); + }); + + testWidgets('pull to refresh forces a live reload', (tester) async { + final repository = _RecordingMailRepository(); + await pumpMailView(tester, repository); + + expect(repository.forceRefreshCount, 0); + + await tester.fling( + find.byType(ListView), + const Offset(0, 400), + 1000, + ); + await tester.pumpAndSettle(); + + expect(repository.forceRefreshCount, 1); + }); +} + +const _profile = OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: 'ada@example.edu', + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, +); + +const _mailboxes = [ + MailboxSummary( + name: 'INBOX', + label: 'Inbox', + specialUse: 'inbox', + messageCount: 2, + unreadCount: 1, + ), +]; + +const _allMessages = [ + MailMessageSummary( + uid: '1', + subject: 'Exam registration', + fromName: 'Prof X', + fromAddress: 'prof@example.edu', + receivedAt: 'Tue, 16 Jun 2026 10:00:00 +0200', + preview: 'Please register for the exam.', + isUnread: true, + ), + MailMessageSummary( + uid: '2', + subject: 'Cafeteria menu', + fromName: 'Studentenwerk', + fromAddress: 'mensa@example.edu', + receivedAt: 'Mon, 15 Jun 2026 09:00:00 +0200', + preview: 'Today at the Mensa.', + isUnread: false, + ), +]; + +class _RecordingMailRepository extends MailRepository { + _RecordingMailRepository() : super.test(); + + String lastQuery = ''; + int forceRefreshCount = 0; + + @override + Future fetchMailboxSnapshot( + OnboardingProfile? profile, { + String mailbox = 'INBOX', + int limit = 12, + bool unreadOnly = false, + String query = '', + String sender = '', + String since = '', + int scanLimit = 200, + bool forceRefresh = false, + }) async { + lastQuery = query; + if (forceRefresh) forceRefreshCount += 1; + final needle = query.trim().toLowerCase(); + final messages = needle.isEmpty + ? _allMessages + : _allMessages + .where((m) => m.subject.toLowerCase().contains(needle)) + .toList(); + return MailMailboxSnapshot( + mailboxes: _mailboxes, + inbox: MailInboxSummary( + account: 'ada42', + mailbox: mailbox, + unreadCount: 1, + messages: messages, + ), + ); + } +} From 5e111f6f0ba25b7ab0a15e58f1f2498ac8d87772 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 22 Jul 2026 13:03:38 +0200 Subject: [PATCH 3/9] 1. Model optimisations: Ensure proper model memory management, remove duplicate tool loop, add message timout 2. Enable GenUI component workflow for Mail, Deadlines --- .../offline/LiteRtLocalPromptClient.java | 225 ++++++------------ .../AndroidLiteRtToolExecutor.kt | 108 --------- .../studyos_agent/AndroidLocalPromptClient.kt | 62 +++-- .../com/studyos/studyos_agent/MainActivity.kt | 180 ++++---------- flutter_app/lib/src/agent_llm_provider.dart | 34 ++- flutter_app/lib/src/app_shell_controller.dart | 73 ++++++ flutter_app/lib/src/cloud_agent_client.dart | 3 + .../lib/src/generative_ui_registry.dart | 223 ++++++++++++++++- flutter_app/lib/src/models.dart | 12 + flutter_app/lib/src/native_bridge.dart | 13 +- flutter_app/lib/src/prompt_context.dart | 43 +++- flutter_app/lib/src/tool_trace.dart | 12 + flutter_app/lib/src/views/chat_route.dart | 1 + flutter_app/lib/src/views/chat_view.dart | 6 + .../widgets/generated_ui_preview_section.dart | 14 +- flutter_app/lib/src/widgets/message_list.dart | 77 +++++- flutter_app/test/agent_llm_provider_test.dart | 127 +++++++++- .../test/generative_ui_registry_test.dart | 150 ++++++++++++ 18 files changed, 899 insertions(+), 464 deletions(-) delete mode 100644 flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLiteRtToolExecutor.kt diff --git a/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java b/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java index 84a6d22..ae14db5 100644 --- a/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java +++ b/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java @@ -13,23 +13,45 @@ import com.google.ai.edge.litertlm.SamplerConfig; import java.io.File; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.concurrent.CountDownLatch; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; +/** + * Thin wrapper over the LiteRT-LM engine for on-device generation. + * + *

This client is a pure generator: it streams tokens for one turn + * and keeps no tool-calling logic. All StudyOS tool routing lives in the Dart + * layer ({@code LocalNativeLlmProvider}), which owns the single {@code [TOOL:…]} + * loop; the native side only produces text. + * + *

The system prompt is installed once as the conversation's system + * instruction and reused across turns; the conversation (and its KV cache) is + * only rebuilt when the model path, backend preference, or system instruction + * actually changes (see {@link #ensureConversation}). + */ public class LiteRtLocalPromptClient implements AutoCloseable { private static final String TAG = "LiteRtLocalPrompt"; - private static final Pattern TOOL_CALL_PATTERN = Pattern.compile("\\[TOOL:([^:\\]]+):?([^\\]]*)\\]"); - private static final int MAX_TOOL_ROUNDS = 3; - private static final int TOOL_SAMPLER_TOP_K = 10; - private static final double TOOL_SAMPLER_TOP_P = 0.95; - private static final double TOOL_SAMPLER_TEMPERATURE = 0.2; - private static final int TOOL_SAMPLER_RANDOM_SEED = 0; + + // Deterministic on-device sampling profile. LiteRT-LM 0.13.1 binds the + // sampler once at conversation creation — there is no per-message override + // (LiteRT-LM issue #2249), and rebuilding a conversation to change it would + // drop the KV cache. So a single low-temperature profile is used for all + // local generation, favouring reliable tool-directive/JSON formatting. + private static final int LOCAL_SAMPLER_TOP_K = 10; + private static final double LOCAL_SAMPLER_TOP_P = 0.95; + private static final double LOCAL_SAMPLER_TEMPERATURE = 0.2; + private static final int LOCAL_SAMPLER_RANDOM_SEED = 0; + + /** Upper bound on a single generation before it is cancelled and surfaced as an error. */ + private static final long GENERATION_TIMEOUT_SECONDS = 120; + + private static final String DEFAULT_SYSTEM_INSTRUCTION = + "You are StudyOS Agent. Answer from the provided context."; /** Prefer the GPU backend, falling back to CPU when GPU init fails. */ public static final String BACKEND_GPU = "gpu"; @@ -39,10 +61,16 @@ public class LiteRtLocalPromptClient implements AutoCloseable { private Engine engine; private Conversation conversation; private String activeModelPath; + private String activeSystemInstruction; private String activeBackend; private String activeBackendPreference; private volatile String backendPreference = BACKEND_GPU; + /** Receives streamed tokens as they are generated. */ + public interface StreamListener { + void onToken(String token); + } + /** The accelerator the live engine initialized on ("GPU" or "CPU"), or null. */ public String getActiveBackend() { return activeBackend; @@ -58,114 +86,20 @@ public void setBackendPreference(String preference) { backendPreference = BACKEND_CPU.equals(preference) ? BACKEND_CPU : BACKEND_GPU; } - public interface ToolExecutor { - boolean canExecute(String toolName); - - String execute(String toolName, String argument); - } - - /** Receives streamed tokens and a reset signal between tool rounds. */ - public interface StreamListener { - void onToken(String token); - - void onReset(); - } - - public synchronized String generate(String modelPath, String prompt, String cacheDir) throws Exception { - ensureConversation(modelPath, cacheDir); - return extractText(conversation.sendMessage(prompt, Collections.emptyMap())); - } - - public synchronized String generateWithTools( - String modelPath, - String prompt, - String cacheDir, - ToolExecutor toolExecutor - ) throws Exception { - ensureConversation(modelPath, cacheDir); - String responseText = extractText(conversation.sendMessage(prompt, Collections.emptyMap())); - - for (int round = 0; round < MAX_TOOL_ROUNDS; round++) { - List toolCalls = parseToolCalls(responseText); - if (toolCalls.isEmpty()) { - return responseText; - } - if (!allCallsCanExecute(toolCalls, toolExecutor)) { - return responseText; - } - - StringBuilder feedback = new StringBuilder(); - for (ToolCall call : toolCalls) { - String output = toolExecutor.execute(call.name, call.argument); - feedback - .append("- ") - .append(call.name) - .append(": ") - .append(output == null ? "" : output.trim()) - .append("\n"); - } - - String instruction = "System feedback from executed Android local tools:\n" - + feedback.toString().trim() - + "\n\nIf another tool is still needed, respond only with " - + "[TOOL:TOOL_NAME:ARGUMENT]. Otherwise answer the user naturally " - + "using the tool results and provided StudyOS context."; - responseText = extractText(conversation.sendMessage(instruction, Collections.emptyMap())); - } - - return responseText; - } - /** - * Like {@link #generateWithTools}, but streams each round's tokens to - * {@code streamListener}. When a round resolves into a tool directive, the - * listener is reset so the bracketed call does not linger in the live UI - * before the follow-up answer streams. + * Generates a reply for {@code prompt}, streaming tokens to {@code listener}. + * {@code systemInstruction} is installed as the conversation's system prompt + * and only triggers a conversation rebuild when it changes. */ - public synchronized String generateWithToolsStreaming( + public synchronized String generateStreaming( String modelPath, String prompt, String cacheDir, - ToolExecutor toolExecutor, + String systemInstruction, StreamListener streamListener ) throws Exception { - ensureConversation(modelPath, cacheDir); - String responseText = streamSendMessage(prompt, streamListener); - - for (int round = 0; round < MAX_TOOL_ROUNDS; round++) { - List toolCalls = parseToolCalls(responseText); - if (toolCalls.isEmpty()) { - return responseText; - } - if (!allCallsCanExecute(toolCalls, toolExecutor)) { - return responseText; - } - - // This round was a tool directive, not a user-facing answer. - if (streamListener != null) { - streamListener.onReset(); - } - - StringBuilder feedback = new StringBuilder(); - for (ToolCall call : toolCalls) { - String output = toolExecutor.execute(call.name, call.argument); - feedback - .append("- ") - .append(call.name) - .append(": ") - .append(output == null ? "" : output.trim()) - .append("\n"); - } - - String instruction = "System feedback from executed Android local tools:\n" - + feedback.toString().trim() - + "\n\nIf another tool is still needed, respond only with " - + "[TOOL:TOOL_NAME:ARGUMENT]. Otherwise answer the user naturally " - + "using the tool results and provided StudyOS context."; - responseText = streamSendMessage(instruction, streamListener); - } - - return responseText; + ensureConversation(modelPath, cacheDir, systemInstruction); + return streamSendMessage(prompt, streamListener); } private String streamSendMessage(String prompt, StreamListener streamListener) throws Exception { @@ -197,7 +131,14 @@ public void onError(Throwable throwable) { } }, Collections.emptyMap()); - latch.await(); + boolean completed = latch.await(GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!completed) { + // The callback never settled. Cancel the in-flight decode and surface + // a timeout instead of blocking the executor thread forever. + cancel(); + throw new TimeoutException( + "Local generation timed out after " + GENERATION_TIMEOUT_SECONDS + "s."); + } if (failure[0] != null) { if (failure[0] instanceof Exception) { throw (Exception) failure[0]; @@ -207,19 +148,12 @@ public void onError(Throwable throwable) { return full.toString().trim(); } - private boolean allCallsCanExecute(List toolCalls, ToolExecutor toolExecutor) { - for (ToolCall call : toolCalls) { - if (!toolExecutor.canExecute(call.name)) { - return false; - } - } - return true; - } - - private void ensureConversation(String modelPath, String cacheDir) throws Exception { + private void ensureConversation(String modelPath, String cacheDir, String systemInstruction) + throws Exception { if (conversation != null && modelPath.equals(activeModelPath) - && backendPreference.equals(activeBackendPreference)) { + && backendPreference.equals(activeBackendPreference) + && Objects.equals(systemInstruction, activeSystemInstruction)) { return; } close(); @@ -230,19 +164,23 @@ private void ensureConversation(String modelPath, String cacheDir) throws Except } engine = initializeEngine(modelFile, cacheDir); + String instruction = (systemInstruction == null || systemInstruction.isBlank()) + ? DEFAULT_SYSTEM_INSTRUCTION + : systemInstruction; ConversationConfig config = new ConversationConfig( - Contents.Companion.of("You are StudyOS Agent. Answer from the provided context."), - List.of(Message.Companion.user("System ready.")), + Contents.Companion.of(instruction), + List.of(), List.of(), new SamplerConfig( - TOOL_SAMPLER_TOP_K, - TOOL_SAMPLER_TOP_P, - TOOL_SAMPLER_TEMPERATURE, - TOOL_SAMPLER_RANDOM_SEED + LOCAL_SAMPLER_TOP_K, + LOCAL_SAMPLER_TOP_P, + LOCAL_SAMPLER_TEMPERATURE, + LOCAL_SAMPLER_RANDOM_SEED ) ); conversation = engine.createConversation(config); activeModelPath = modelFile.getAbsolutePath(); + activeSystemInstruction = systemInstruction; } /** @@ -306,10 +244,6 @@ private static void closeQuietly(Engine engine) { } } - private String extractText(Message message) { - return extractChunk(message).trim(); - } - /** Joins a message's contents without trimming, preserving token spacing. */ private String extractChunk(Message message) { if (message == null || message.getContents() == null || message.getContents().getContents() == null) { @@ -321,32 +255,6 @@ private String extractChunk(Message message) { .collect(Collectors.joining()); } - private List parseToolCalls(String text) { - if (text == null || text.isBlank()) { - return Collections.emptyList(); - } - Matcher matcher = TOOL_CALL_PATTERN.matcher(text); - List toolCalls = new ArrayList<>(); - while (matcher.find()) { - String name = matcher.group(1) == null ? "" : matcher.group(1).trim(); - String argument = matcher.group(2) == null ? "" : matcher.group(2).trim(); - if (!name.isEmpty()) { - toolCalls.add(new ToolCall(name, argument)); - } - } - return toolCalls; - } - - private static final class ToolCall { - private final String name; - private final String argument; - - private ToolCall(String name, String argument) { - this.name = name; - this.argument = argument; - } - } - /** * Best-effort cancel of an in-flight generation. Safe to call from another * thread than the one blocked in {@link #streamSendMessage}; the pending @@ -375,6 +283,7 @@ public synchronized void close() { conversation = null; engine = null; activeModelPath = null; + activeSystemInstruction = null; activeBackend = null; activeBackendPreference = null; } diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLiteRtToolExecutor.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLiteRtToolExecutor.kt deleted file mode 100644 index 0ad1592..0000000 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLiteRtToolExecutor.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.studyos.studyos_agent - -import android.content.Context -import com.example.studyOS.offline.Tools -import java.util.Locale -import java.util.UUID - -class AndroidLiteRtToolExecutor( - context: Context, - private val emitToolTrace: ( - toolName: String, - status: String, - summary: String, - callId: String, - ) -> Unit, -) { - private val appContext = context.applicationContext - private val tools: Tools by lazy { Tools(appContext) } - - fun canExecute(toolName: String): Boolean { - return normalizeToolName(toolName) in androidToolNames - } - - fun execute( - toolName: String, - argument: String, - systemPrompt: String, - memory: String, - ): String { - val normalized = normalizeToolName(toolName) - val callId = "android-litert-${normalized.lowercase(Locale.US)}-${UUID.randomUUID()}" - emitToolTrace( - normalized, - "running", - "Running Android LiteRT local tool.", - callId, - ) - - return try { - val output = when (normalized) { - "GET_STUDY_CONTEXT" -> systemPrompt.ifBlank { - "No StudyOS context was provided." - } - "READ_MEMORIES" -> memory.trim().ifBlank { - "No saved StudyOS memories were provided." - } - "GET_SCHEDULE" -> scheduleContext(systemPrompt) - "GET_STATUS" -> tools.getDeviceStatus() - "LIGHT_CONTROL" -> tools.toggleFlashlight( - argument.uppercase(Locale.US).contains("ON") || - argument.uppercase(Locale.US).contains("AN") || - argument.equals("true", ignoreCase = true), - ) - "OPEN_APP" -> if (argument.isBlank()) { - "App name was not provided." - } else { - tools.openApp(argument) - } - "SEARCH_YOUTUBE" -> { - val query = argument.ifBlank { "StudyOS" } - tools.searchYoutube(query) - "Opened YouTube search for '$query'." - } - else -> "Tool is not available: $toolName" - } - emitToolTrace(normalized, "done", "Returned ${output.length} chars.", callId) - output - } catch (error: Throwable) { - val message = "Android LiteRT tool failed: ${error.message}" - emitToolTrace(normalized, "failed", message, callId) - message - } - } - - private fun normalizeToolName(toolName: String): String { - return when (toolName.trim().lowercase(Locale.US)) { - "get_study_context" -> "GET_STUDY_CONTEXT" - "read_memories" -> "READ_MEMORIES" - "get_schedule" -> "GET_SCHEDULE" - "get_status" -> "GET_STATUS" - "light_control" -> "LIGHT_CONTROL" - "open_app" -> "OPEN_APP" - "search_youtube" -> "SEARCH_YOUTUBE" - else -> toolName.trim().uppercase(Locale.US) - } - } - - private fun scheduleContext(systemPrompt: String): String { - val marker = "Cached timetable summary:" - val index = systemPrompt.indexOf(marker) - if (index < 0) { - return "No cached timetable summary was provided." - } - return systemPrompt.substring(index).trim() - } - - private companion object { - val androidToolNames = setOf( - "GET_STUDY_CONTEXT", - "READ_MEMORIES", - "GET_SCHEDULE", - "GET_STATUS", - "LIGHT_CONTROL", - "OPEN_APP", - "SEARCH_YOUTUBE", - ) - } -} diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt index 999671a..d61d810 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt @@ -22,47 +22,48 @@ class AndroidLocalPromptClient(context: Context) { liteRtClient.cancel() } + /** + * Releases the LiteRT engine and its KV cache. Posted to the same single + * worker so it serializes behind any in-flight generation rather than + * blocking the caller (e.g. the main thread during onTrimMemory). The next + * generate() call transparently rebuilds the engine. + */ + fun close() { + executor.execute { liteRtClient.close() } + } + + /** + * Generates a reply for [prompt]. Tool routing is owned entirely by the Dart + * layer; this only produces text and streams tokens through [onDelta]. + * + * [systemInstruction] is the stable system prompt: on the LiteRT path it is + * installed once as the conversation's system instruction (reused across + * turns); the stateless Gemini Nano path folds it into the prompt. + */ fun generate( prompt: String, + systemInstruction: String, modelId: String, modelPath: String, backend: String, - canExecuteTool: (String) -> Boolean, - onToolRequest: (String, String) -> String, onDelta: (String) -> Unit, - onReset: () -> Unit, onSuccess: (String) -> Unit, onError: (String) -> Unit, ) { - // Locals so the anonymous StreamListener can call the lambdas without - // shadowing its own onReset() override. val deltaSink = onDelta - val resetSink = onReset executor.execute { try { if (modelPath.isNotBlank()) { liteRtClient.setBackendPreference(backend) - val response = liteRtClient.generateWithToolsStreaming( + val response = liteRtClient.generateStreaming( modelPath, prompt, appContext.cacheDir.absolutePath, - object : LiteRtLocalPromptClient.ToolExecutor { - override fun canExecute(toolName: String): Boolean { - return canExecuteTool(toolName) - } - - override fun execute(toolName: String, argument: String): String { - return onToolRequest(toolName, argument) - } - }, + systemInstruction, object : LiteRtLocalPromptClient.StreamListener { override fun onToken(token: String) { deltaSink(token) } - - override fun onReset() { - resetSink() - } }, ) if (response.isBlank()) { @@ -73,16 +74,23 @@ class AndroidLocalPromptClient(context: Context) { return@execute } + // Gemini Nano through ML Kit is stateless per call, so fold the + // system instruction into the one-shot prompt. + val nanoPrompt = if (systemInstruction.isBlank()) { + prompt + } else { + "$systemInstruction\n\n$prompt" + } val model = AndroidAiCoreModelCatalog.clientFor(modelId) when (val status = model.checkStatus().get(2, TimeUnit.SECONDS)) { FeatureStatus.AVAILABLE -> { val future = model.generateContent( - prompt, + nanoPrompt, StreamingCallback { text -> deltaSink(text) }, ) activeNanoFuture = future val response = try { - future.get() + future.get(NANO_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) } finally { activeNanoFuture = null } @@ -128,10 +136,14 @@ class AndroidLocalPromptClient(context: Context) { "AICore does not expose a general installed-model list. " + "Apps initialize a desired Gemini Nano configuration and check its status.", "androidLocalToolCalling" to - "ML Kit Prompt API does not expose native function calling. " + - "Downloaded LiteRT-LM models can use StudyOS bracketed " + - "[TOOL:NAME:ARG] calls executed by the Android bridge.", + "Tool routing is handled by the StudyOS Dart layer via bracketed " + + "[TOOL:NAME:ARG] directives parsed from the model's output; the " + + "native model only generates text.", "androidLocalModelContext" to appContext.packageName, ) } + + private companion object { + const val NANO_GENERATION_TIMEOUT_SECONDS = 120L + } } diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt index 637118d..69d9540 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt @@ -1,6 +1,7 @@ package com.studyos.studyos_agent import android.Manifest +import android.content.ComponentCallbacks2 import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle @@ -29,12 +30,17 @@ class MainActivity : FlutterActivity() { private var nativeInitialized = false private var localPromptClient: AndroidLocalPromptClient? = null private var localModelStore: AndroidLocalModelStore? = null - private var liteRtToolExecutor: AndroidLiteRtToolExecutor? = null private var nativeToolExecutor: AndroidNativeToolExecutor? = null private var pdfPreview: AndroidPdfPreview? = null private var pendingCalendarOperation: (() -> Unit)? = null private lateinit var intentBridge: AndroidIntentBridge + // Idle-unload timer: releases the on-device model after a stretch of no + // activity so it does not hold RAM indefinitely on mid-range devices. + private val idleUnloadHandler = Handler(Looper.getMainLooper()) + private val idleUnloadRunnable = Runnable { localPromptClient?.close() } + private val idleUnloadDelayMs = 5 * 60 * 1000L + override fun onCreate(savedInstanceState: Bundle?) { intentBridge = AndroidIntentBridge(applicationContext) intentBridge.captureIntent(intent) @@ -47,11 +53,37 @@ class MainActivity : FlutterActivity() { intentBridge.captureIntent(intent) } + override fun onStop() { + // Backgrounded: release the on-device model so the OS is less likely to + // reclaim the app under memory pressure. The next message rebuilds it. + cancelIdleUnload() + localPromptClient?.close() + super.onStop() + } + + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE) { + localPromptClient?.close() + } + } + override fun onDestroy() { + cancelIdleUnload() + localPromptClient?.close() aiCoreModelExecutor.shutdownNow() super.onDestroy() } + private fun scheduleIdleUnload() { + idleUnloadHandler.removeCallbacks(idleUnloadRunnable) + idleUnloadHandler.postDelayed(idleUnloadRunnable, idleUnloadDelayMs) + } + + private fun cancelIdleUnload() { + idleUnloadHandler.removeCallbacks(idleUnloadRunnable) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -108,8 +140,7 @@ class MainActivity : FlutterActivity() { } sendMessageToNativeLayer( text = text, - systemPrompt = call.argument("systemPrompt").orEmpty(), - memory = call.argument("memory").orEmpty(), + systemInstruction = call.argument("systemInstruction").orEmpty(), localModelId = call.argument("localModelId").orEmpty(), localModelPath = call.argument("localModelPath").orEmpty(), localBackend = call.argument("localBackend").orEmpty(), @@ -160,8 +191,7 @@ class MainActivity : FlutterActivity() { private fun sendMessageToNativeLayer( text: String, - systemPrompt: String, - memory: String, + systemInstruction: String, localModelId: String, localModelPath: String, localBackend: String, @@ -170,34 +200,17 @@ class MainActivity : FlutterActivity() { if (!nativeInitialized) { initializeNativeLayer() } + scheduleIdleUnload() try { - val prompt = localPrompt( - systemPrompt = systemPrompt, - userText = text, - supportsLiteRtTools = localModelPath.isNotBlank(), - ) localPromptClient().generate( - prompt = prompt, + prompt = text, + systemInstruction = systemInstruction, modelId = localModelId, modelPath = localModelPath, backend = localBackend, - canExecuteTool = { toolName -> - liteRtToolExecutor().canExecute(toolName) - }, - onToolRequest = { toolName, argument -> - liteRtToolExecutor().execute( - toolName = toolName, - argument = argument, - systemPrompt = systemPrompt, - memory = memory, - ) - }, onDelta = { token -> - emitAssistantDelta(token, reset = false) - }, - onReset = { - emitAssistantDelta("", reset = true) + emitAssistantDelta(token) }, onSuccess = { response -> emitStatus( @@ -210,6 +223,7 @@ class MainActivity : FlutterActivity() { Handler(Looper.getMainLooper()).post { result.success(response) } + scheduleIdleUnload() }, onError = { message -> emitStatus(message) @@ -220,6 +234,7 @@ class MainActivity : FlutterActivity() { null, ) } + scheduleIdleUnload() }, ) } catch (error: Throwable) { @@ -229,81 +244,6 @@ class MainActivity : FlutterActivity() { } } - private fun localPrompt( - systemPrompt: String, - userText: String, - supportsLiteRtTools: Boolean, - ): String { - return buildString { - appendLine(systemPrompt.ifBlank { "You are StudyOS Agent." }) - appendLine() - if (supportsLiteRtTools) { - appendLine("Android LiteRT local tool protocol:") - appendLine( - "Use tools only when they are helpful. For normal questions, " + - "answer directly from the provided context.", - ) - appendLine( - "To call tools, respond only with one or more directives " + - "in this exact form: [TOOL:TOOL_NAME:ARGUMENT].", - ) - appendLine( - "After the app returns tool results, answer naturally. " + - "Do not show raw tool directives to the user in the final answer.", - ) - appendLine("Only call tools from this list; do not invent tool names.") - appendLine() - appendLine("Available Android LiteRT tools:") - appendLine( - "- GET_STUDY_CONTEXT, no argument: read the current StudyOS " + - "profile, timetable summary, memory, and device context.", - ) - appendLine( - " Example: [TOOL:GET_STUDY_CONTEXT:]", - ) - appendLine( - "- READ_MEMORIES, no argument: read the provided local " + - "StudyOS long-term memories.", - ) - appendLine(" Example: [TOOL:READ_MEMORIES:]") - appendLine( - "- GET_SCHEDULE, no argument: read cached timetable context " + - "when it is present in the StudyOS prompt.", - ) - appendLine(" Example: [TOOL:GET_SCHEDULE:]") - appendLine( - "- GET_STATUS, no argument: read Android device status such " + - "as volume, Wi-Fi, location, and airplane mode.", - ) - appendLine(" Example: [TOOL:GET_STATUS:]") - appendLine( - "- LIGHT_CONTROL, argument ON or OFF: turn the flashlight on " + - "or off.", - ) - appendLine(" Example: [TOOL:LIGHT_CONTROL:ON]") - appendLine( - "- OPEN_APP, argument app name: open an installed Android app " + - "by its display name.", - ) - appendLine(" Example: [TOOL:OPEN_APP:Camera]") - appendLine( - "- SEARCH_YOUTUBE, argument search query: open YouTube search " + - "results for the query.", - ) - appendLine(" Example: [TOOL:SEARCH_YOUTUBE:study techniques]") - } else { - appendLine( - "Runtime note: Android Gemini Nano through ML Kit Prompt API " + - "does not expose tool calling in this app. Answer from " + - "provided context and say what is missing.", - ) - } - appendLine() - appendLine("User request:") - appendLine(userText) - }.trim() - } - private fun localPromptClient(): AndroidLocalPromptClient { val existing = localPromptClient if (existing != null) return existing @@ -380,14 +320,6 @@ class MainActivity : FlutterActivity() { } } - private fun liteRtToolExecutor(): AndroidLiteRtToolExecutor { - val existing = liteRtToolExecutor - if (existing != null) return existing - return AndroidLiteRtToolExecutor(applicationContext, ::emitToolTrace).also { - liteRtToolExecutor = it - } - } - private fun nativeToolExecutor(): AndroidNativeToolExecutor { val existing = nativeToolExecutor if (existing != null) return existing @@ -691,37 +623,11 @@ class MainActivity : FlutterActivity() { } } - private fun emitToolTrace( - toolName: String, - status: String, - summary: String, - callId: String, - ) { - val payload = mapOf( - "type" to "toolTrace", - "message" to summary, - "trace" to mapOf( - "toolName" to toolName, - "status" to status, - "summary" to summary, - "callId" to callId, - ), - "timestamp" to SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ss", - Locale.US - ).format(Date()), - ) - - Handler(Looper.getMainLooper()).post { - eventSink?.success(payload) - } - } - - private fun emitAssistantDelta(text: String, reset: Boolean) { + private fun emitAssistantDelta(text: String) { val payload = mapOf( "type" to "assistantDelta", "message" to text, - "reset" to reset, + "reset" to false, "timestamp" to SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss", Locale.US diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index c8a8ad6..d6f53de 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -135,14 +135,16 @@ class LocalNativeLlmProvider implements AgentLlmProvider { Future send(AgentLlmRequest request) async { final nativeTools = NativeToolRouter(_bridge); final supportedNativeToolNames = await nativeTools.supportedToolNames(); - final systemPrompt = _localSystemPrompt( - request.context.systemPrompt(), + // The stable system prompt + tool protocol is installed once as the native + // conversation's system instruction; only the volatile per-turn context and + // the user text travel on the message itself. + final systemInstruction = _localSystemPrompt( + request.context.stableSystemPrompt(), supportedNativeToolNames, ); var response = await _bridge.sendMessage( - request.userText, - systemPrompt: systemPrompt, - memory: request.memoryText, + _composeFirstTurn(request.context.ephemeralContext(), request.userText), + systemInstruction: systemInstruction, localModelId: request.config.localModelId, localModelPath: request.config.localModelPath, localBackend: request.config.localBackend.name, @@ -164,6 +166,12 @@ class LocalNativeLlmProvider implements AgentLlmProvider { final calls = _toolCalls(response); if (calls.isEmpty) return response; + // This streamed turn resolved into tool directives, not a user-facing + // answer. Clear the live buffer so the bracketed calls don't linger on + // screen before the follow-up answer streams. Mirrors CloudLlmProvider; + // this replaces the tool-round reset the native loop used to emit. + request.onDelta?.call(const AgentStreamDelta(reset: true)); + final feedback = []; for (final call in calls) { final callId = @@ -193,8 +201,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { response = await _bridge.sendMessage( _localToolFeedbackPrompt(feedback), - systemPrompt: systemPrompt, - memory: request.memoryText, + systemInstruction: systemInstruction, localModelId: request.config.localModelId, localModelPath: request.config.localModelPath, localBackend: request.config.localBackend.name, @@ -242,6 +249,16 @@ class LocalNativeLlmProvider implements AgentLlmProvider { return buffer.toString().trim(); } + /// Prepends the volatile per-turn context (wall-clock time, world state) to + /// the user's message for the first turn. The stable system prompt already + /// lives in the conversation's system instruction, so only this ephemeral + /// slice needs to ride the message. + String _composeFirstTurn(String ephemeralContext, String userText) { + final ephemeral = ephemeralContext.trim(); + if (ephemeral.isEmpty) return userText; + return '$ephemeral\n\n$userText'; + } + String _localToolFeedbackPrompt(List feedback) { return [ 'System feedback from executed StudyOS tools:', @@ -287,6 +304,9 @@ class LocalNativeLlmProvider implements AgentLlmProvider { status: status, summary: '$summary$outputSuffix', callId: callId, + component: output == null + ? null + : componentPayloadForTool(call.name, output), ); } } diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index bbfbfc2..db53bb2 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -15,6 +15,7 @@ import 'mail_tools.dart'; import 'memory_store.dart'; import 'models.dart'; import 'native_bridge.dart'; +import 'native_tool_router.dart'; import 'official_document_models.dart'; import 'official_documents_repository.dart'; import 'profile_context.dart'; @@ -47,6 +48,18 @@ class ChatRouteRequest { } } +/// Chooses when a deadline reminder should fire: one day before the due time, +/// stepping closer (one hour before, then a short delay) as the deadline nears +/// so the reminder never lands in the past. Pure so it can be unit-tested. +DateTime reminderTimeForDeadline(DateTime dueAt, {DateTime? now}) { + final reference = now ?? DateTime.now(); + final dayBefore = dueAt.subtract(const Duration(days: 1)); + if (dayBefore.isAfter(reference)) return dayBefore; + final hourBefore = dueAt.subtract(const Duration(hours: 1)); + if (hourBefore.isAfter(reference)) return hourBefore; + return reference.add(const Duration(minutes: 10)); +} + class AppShellController extends ChangeNotifier { AppShellController({ required OnboardingProfile? initialProfile, @@ -57,12 +70,14 @@ class AppShellController extends ChangeNotifier { NativeBridge? nativeBridge, TalksRepository? talksRepository, CalendarOverviewSource? calendarOverviewSource, + NativeToolRunner? nativeToolRunner, }) : bridge = nativeBridge ?? NativeBridge(), talksRepository = talksRepository ?? TalksRepository(), _ownsTalksRepository = talksRepository == null, _profile = initialProfile, _onLogout = initialOnLogout, _onSaveProfile = initialOnSaveProfile { + _nativeToolRunner = nativeToolRunner ?? NativeToolRouter(bridge); _privateStudyTools = CombinedPrivateStudyToolRunner( portal: LivePrivateStudyToolRunner( PrivateStudyCapability(profileProvider: () => _profile), @@ -77,6 +92,7 @@ class AppShellController extends ChangeNotifier { } final NativeBridge bridge; + late final NativeToolRunner _nativeToolRunner; final TalksRepository talksRepository; final bool _ownsTalksRepository; late final CalendarOverviewSource calendarOverviewSource; @@ -137,6 +153,11 @@ class AppShellController extends ChangeNotifier { Timer? _streamNotifyTimer; AgentCancelToken? _cancelToken; + /// Generative-UI component produced by a tool during the in-flight turn, held + /// until the assistant's final message is committed so it can render beneath + /// the reply text (e.g. a mail-triage card) instead of in the trace stream. + Map? _pendingTurnComponent; + OnboardingProfile? get profile => _profile; VoidCallback? get onLogout => _onLogout; List get sessions => _sessions; @@ -435,6 +456,48 @@ class AppShellController extends ChangeNotifier { onOpenChatRequest?.call(ChatRouteRequest(prompt: text)); } + /// Dispatches an action emitted by an interactive generative-UI component. + /// Prompt actions go back through the agent; reminder actions create a native + /// device reminder directly (the tap is the user's authorization). + void handleComponentAction(GeneratedComponentAction action) { + switch (action) { + case PromptComponentAction(:final prompt): + unawaited(runComponentPrompt(prompt)); + case ReminderComponentAction(:final title, :final dueAt): + unawaited(addDeadlineReminder(title: title, dueAt: dueAt)); + } + } + + /// Runs a prompt requested by a component (e.g. mail Summarize): prefills the + /// composer and sends it, reusing the autosent chat-route path so a turn is + /// created immediately. + Future runComponentPrompt(String text) { + return applyChatRoute(prompt: text, autosend: true); + } + + /// Creates a native device reminder ahead of [dueAt] via the capability-gated + /// native tool runner, then reports the outcome as an assistant message. On + /// platforms without reminder support the runner returns a friendly message, + /// which is surfaced as-is. + Future addDeadlineReminder({ + required String title, + required DateTime dueAt, + }) async { + final when = reminderTimeForDeadline(dueAt); + final result = await _nativeToolRunner.execute( + nativeCreateReminderToolName, + jsonEncode({ + 'title': title, + 'time': when.toIso8601String(), + }), + ); + if (_disposed) return; + final detail = result.trim(); + addAssistantMessage( + detail.isEmpty ? 'Reminder requested for "$title".' : detail, + ); + } + Future applyChatRoute({ String? prompt, bool autosend = false, @@ -466,6 +529,7 @@ class AppShellController extends ChangeNotifier { if (text.isEmpty || _isSending) return; _isSending = true; + _pendingTurnComponent = null; inputController.clear(); _notify(); appendMessage(ChatMessage(author: 'You', text: text, isUser: true)); @@ -607,12 +671,15 @@ class AppShellController extends ChangeNotifier { void addAssistantMessage(String text, {String? reasoning}) { if (_disposed) return; + final component = _pendingTurnComponent; + _pendingTurnComponent = null; appendMessage( ChatMessage( author: 'StudyOS Agent', text: text, isUser: false, reasoning: reasoning, + component: component, ), ); _status = text; @@ -661,6 +728,12 @@ class AppShellController extends ChangeNotifier { } void addToolTrace(ToolTrace trace) { + // A tool that emitted a generative-UI component: hold it for the assistant + // message rather than rendering it in the trace stream. Last producer in + // the turn wins (mirrors how the reply summarises the latest fetch). + if (trace.component != null) { + _pendingTurnComponent = trace.component; + } _applySessionMutation( upsertToolTraceInSessions( sessions: _sessions, diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index de83f74..8f8a5c1 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -448,6 +448,9 @@ class CloudAgentClient { status: status, summary: '$summary$outputSuffix', callId: call.id, + component: output == null + ? null + : componentPayloadForTool(call.name, output), ); } } diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart index b0fd520..10c0869 100644 --- a/flutter_app/lib/src/generative_ui_registry.dart +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -1,9 +1,13 @@ +import 'dart:convert'; + enum GeneratedComponentKind { nextAction('next_action'), scheduleSummary('schedule_summary'), routeHint('route_hint'), deadlineCard('deadline_card'), - quickReply('quick_reply'); + quickReply('quick_reply'), + mailList('mail_list'), + deadlineList('deadline_list'); const GeneratedComponentKind(this.wireName); @@ -117,10 +121,141 @@ abstract final class GenerativeUiRegistry { GeneratedComponentKind.quickReply => _requireStrings(arguments, [ 'reply', ]), + GeneratedComponentKind.mailList => _validateItemList( + arguments, + 'messages', + ), + GeneratedComponentKind.deadlineList => _validateItemList( + arguments, + 'deadlines', + ), }; } } +/// Single entry point the provider tool loops use to turn a completed tool's +/// JSON output into a generative-UI component payload, or `null` when the tool +/// has no card. Each component kind registers its builder here, so adding a +/// component never touches the provider code again — the registry is the one +/// place that maps tools to cards. +Map? componentPayloadForTool(String toolName, String output) { + return mailTriageComponentPayload(toolName, output) ?? + deadlineListComponentPayload(toolName, output); +} + +/// Builds a `mail_list` GenUI payload from the JSON a mail-summary tool +/// (`get_recent_mail` / `search_mail`) returns, or `null` when [toolName] is not +/// a mail-list producer or [output] cannot be parsed into a non-empty list. +/// +/// Kept provider-agnostic (pure, no Flutter imports) so both the local and the +/// cloud tool loops can attach the result to the tool's [ToolTrace]. It only +/// forwards the summary fields the card renders — no message bodies. +Map? mailTriageComponentPayload(String toolName, String output) { + const producers = {'get_recent_mail', 'search_mail'}; + if (!producers.contains(toolName)) return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawMessages = decoded['messages']; + if (rawMessages is! List) return null; + + final messages = >[]; + for (final raw in rawMessages) { + if (raw is! Map) continue; + final uid = _string(raw['uid']); + final subject = _string(raw['subject']); + if (uid == null || subject == null) continue; + messages.add({ + 'uid': uid, + 'subject': subject, + 'sender': + _string(raw['from_name']) ?? + _string(raw['from_address']) ?? + 'Unknown sender', + 'received_at': _string(raw['received_at']), + 'preview': _string(raw['preview']), + 'is_unread': raw['is_unread'] == true, + 'is_approved_broadcast': raw['is_approved_broadcast'] == true, + }); + } + if (messages.isEmpty) return null; + + final mailbox = _string(decoded['mailbox']) ?? 'INBOX'; + final rawUnread = decoded['unread_count']; + final unread = rawUnread is int + ? rawUnread + : messages.where((message) => message['is_unread'] == true).length; + final count = messages.length; + return { + 'type': 'mail_list', + 'title': unread > 0 ? '$mailbox · $unread unread' : mailbox, + 'body': count == 1 ? '1 message' : '$count messages', + 'arguments': { + 'mailbox': mailbox, + 'unread_count': unread, + 'messages': messages, + }, + }; +} + +List _validateItemList(Map arguments, String listKey) { + final items = arguments[listKey]; + if (items is! List || items.isEmpty) { + return ['Missing non-empty list argument: $listKey']; + } + return const []; +} + +/// Builds a `deadline_list` payload from the JSON `get_deadlines` returns (a +/// [CapabilityResult] whose `data` is the deadline list), or `null` for other +/// tools / empty results. Forwards only the fields the card renders. +Map? deadlineListComponentPayload( + String toolName, + String output, +) { + if (toolName != 'get_deadlines') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawData = decoded['data']; + if (rawData is! List) return null; + + final deadlines = >[]; + for (final raw in rawData) { + if (raw is! Map) continue; + final title = _string(raw['title']); + final dueAt = _string(raw['dueAt']); + if (title == null || dueAt == null) continue; + deadlines.add({ + 'id': _string(raw['id']), + 'title': title, + 'course': _string(raw['courseTitle']), + 'due_at': dueAt, + 'requirement': _string(raw['requirement']), + 'status': _string(raw['status']), + }); + } + if (deadlines.isEmpty) return null; + + final count = deadlines.length; + return { + 'type': 'deadline_list', + 'title': count == 1 ? 'Upcoming deadline' : '$count upcoming deadlines', + 'body': count == 1 ? '1 deadline' : '$count deadlines', + 'arguments': {'deadlines': deadlines}, + }; +} + const List> generativeUiFixturePayloads = >[ { @@ -168,8 +303,94 @@ generativeUiFixturePayloads = >[ 'reply': 'Plan a 45 minute review block around my next lecture.', }, }, + { + 'type': 'mail_list', + 'title': 'INBOX · 2 unread', + 'body': '3 messages', + 'arguments': { + 'mailbox': 'INBOX', + 'unread_count': 2, + 'messages': >[ + { + 'uid': '4821', + 'subject': 'ML exercise sheet 7 — submission Friday', + 'sender': 'Prof. Dr. Weber', + 'received_at': '2026-07-08T09:12:00', + 'preview': 'Please upload your solutions to Ilias before 18:00 on…', + 'is_unread': true, + 'is_approved_broadcast': true, + }, + { + 'uid': '4820', + 'subject': 'Room change for Thursday tutorial', + 'sender': 'Studierendensekretariat', + 'received_at': '2026-07-07T16:40:00', + 'preview': 'The tutorial moves to room A301 starting this week.', + 'is_unread': true, + 'is_approved_broadcast': false, + }, + { + 'uid': '4818', + 'subject': 'Re: Study group notes', + 'sender': 'Lena', + 'received_at': '2026-07-07T11:05:00', + 'preview': 'Thanks! I added the missing derivations to the shared…', + 'is_unread': false, + 'is_approved_broadcast': false, + }, + ], + }, + }, + { + 'type': 'deadline_list', + 'title': '2 upcoming deadlines', + 'body': '2 deadlines', + 'arguments': { + 'deadlines': >[ + { + 'id': 'ilias:9921', + 'title': 'ML exercise sheet 7', + 'course': 'Machine Learning', + 'due_at': '2026-12-11T18:00:00.000Z', + 'requirement': 'Graded submission', + 'status': 'open', + }, + { + 'id': 'moodle:5540', + 'title': 'Databases project milestone', + 'course': 'Databases', + 'due_at': '2026-12-15T23:59:00.000Z', + 'requirement': null, + 'status': 'open', + }, + ], + }, + }, ]; +/// An interaction requested by a generative-UI component. Cards emit these +/// through a single callback so the widget layer stays uniform as new component +/// kinds are added; the app shell dispatches on the concrete type. +sealed class GeneratedComponentAction { + const GeneratedComponentAction(); +} + +/// Submit [prompt] into the chat composer and send it (e.g. mail Summarize). +class PromptComponentAction extends GeneratedComponentAction { + const PromptComponentAction(this.prompt); + + final String prompt; +} + +/// Create a native device reminder for a deadline. Side-effecting, but always +/// user-initiated (a tap), so the tap itself is the authorization. +class ReminderComponentAction extends GeneratedComponentAction { + const ReminderComponentAction({required this.title, required this.dueAt}); + + final String title; + final DateTime dueAt; +} + List _requireStrings( Map arguments, List keys, diff --git a/flutter_app/lib/src/models.dart b/flutter_app/lib/src/models.dart index 91b80f2..f703119 100644 --- a/flutter_app/lib/src/models.dart +++ b/flutter_app/lib/src/models.dart @@ -79,6 +79,7 @@ class ChatMessage { required this.isUser, this.trace, this.reasoning, + this.component, }); ChatMessage.toolTrace({ @@ -90,6 +91,7 @@ class ChatMessage { text = summary, isUser = false, reasoning = null, + component = null, trace = ToolTrace( toolName: toolName, status: status, @@ -105,6 +107,11 @@ class ChatMessage { /// Optional model "thinking"/reasoning trace, shown in a collapsed panel. final String? reasoning; + /// Optional generative-UI component (validated by [GenerativeUiRegistry]) + /// rendered beneath this message's text. Set on the assistant turn whose tool + /// call produced it — e.g. a mail-triage card under "Here are your emails:". + final Map? component; + bool get isTrace => trace != null; Map toJson() { @@ -114,6 +121,7 @@ class ChatMessage { 'isUser': isUser, if (trace != null) 'trace': trace!.toJson(), if (reasoning != null && reasoning!.isNotEmpty) 'reasoning': reasoning, + if (component != null) 'component': component, }; } @@ -123,12 +131,16 @@ class ChatMessage { ? ToolTrace.fromJson(Map.from(rawTrace)) : null; final reasoning = json['reasoning']?.toString(); + final rawComponent = json['component']; return ChatMessage( author: json['author']?.toString() ?? 'StudyOS Agent', text: json['text']?.toString() ?? '', isUser: json['isUser'] == true, trace: trace, reasoning: reasoning == null || reasoning.isEmpty ? null : reasoning, + component: rawComponent is Map + ? Map.from(rawComponent) + : null, ); } } diff --git a/flutter_app/lib/src/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index 701b7d9..db7196d 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -192,10 +192,16 @@ class NativeBridge { }); } + /// Sends one turn to the local (native) model. + /// + /// [systemInstruction] is the stable system prompt; the native LiteRT path + /// installs it once as the conversation's system instruction and only rebuilds + /// the conversation when it changes, so callers should pass the same stable + /// value across a turn's tool rounds. [text] carries only the per-turn content + /// (the user message with ephemeral context, or tool feedback). Future sendMessage( String text, { - String? systemPrompt, - String? memory, + String? systemInstruction, String? localModelId, String? localModelPath, String? localBackend, @@ -203,8 +209,7 @@ class NativeBridge { final result = await _methods .invokeMethod('sendMessage', { 'text': text, - 'systemPrompt': systemPrompt, - 'memory': memory, + 'systemInstruction': systemInstruction, 'localModelId': localModelId, 'localModelPath': localModelPath, 'localBackend': localBackend, diff --git a/flutter_app/lib/src/prompt_context.dart b/flutter_app/lib/src/prompt_context.dart index a4344bd..36b9987 100644 --- a/flutter_app/lib/src/prompt_context.dart +++ b/flutter_app/lib/src/prompt_context.dart @@ -13,11 +13,26 @@ class PromptContext { final Map worldState; final TimetableSnapshot? timetable; + /// The full system prompt: the stable instruction plus the current ephemeral + /// context. The cloud path re-sends the whole prompt every request, so it uses + /// this. The local path installs [stableSystemPrompt] once as the + /// conversation's system instruction and carries [ephemeralContext] on the + /// turn instead (see `LocalNativeLlmProvider`). String systemPrompt() { - final now = DateTime.now().toLocal(); + final stable = stableSystemPrompt(); + final ephemeral = ephemeralContext(); + if (ephemeral.isEmpty) return stable; + return '$stable\n\n$ephemeral'; + } + + /// The stable portion of the system prompt: identity, behaviour rules, + /// student profile, long-term memory, and the cached timetable. This only + /// changes when the profile/memory/timetable change, so the local model can + /// keep it as its system instruction across turns without re-encoding it (and + /// without invalidating the KV cache) every message. + String stableSystemPrompt() { final buffer = StringBuffer() ..writeln('You are StudyOS Agent, a tool-grounded study agent.') - ..writeln('Current local timestamp: ${now.toIso8601String()}.') ..writeln('Use Markdown when formatting helps readability.') ..writeln( 'Use StudyOS tools when current data, actions, or durable memory updates are needed; answer directly when provided context is enough.', @@ -31,6 +46,18 @@ class PromptContext { ..writeln( 'If required data is unavailable, say what is missing instead of guessing.', ) + ..writeln( + 'The get_recent_mail and search_mail tools render their results as an ' + 'interactive mail card in the app. After calling them, reply with a ' + 'short lead-in only (for example "Here are your recent emails:") and do ' + 'not list, tabulate, or restate the individual messages — the card ' + 'already shows sender, subject, and preview.', + ) + ..writeln( + 'The get_deadlines tool likewise renders an interactive deadline card ' + 'with due dates and per-item actions. After calling it, give a short ' + 'lead-in only and do not re-list the individual deadlines.', + ) ..writeln('Do not expose secrets or credentials.'); final profileBlock = _profileBlock(); if (profileBlock.isNotEmpty) { @@ -52,9 +79,19 @@ class PromptContext { ..writeln('Cached timetable summary:') ..writeln(timetableBlock.trim()); } + return buffer.toString().trim(); + } + + /// The per-turn volatile context: wall-clock time and the device world state. + /// Deliberately excluded from [stableSystemPrompt] because these change on + /// every call and would otherwise force the local model to re-encode its whole + /// system instruction each turn. + String ephemeralContext() { + final now = DateTime.now().toLocal(); + final buffer = StringBuffer() + ..writeln('Current local timestamp: ${now.toIso8601String()}.'); if (worldState.isNotEmpty) { buffer - ..writeln() ..writeln('Current local context:') ..writeln(worldState.toString()); } diff --git a/flutter_app/lib/src/tool_trace.dart b/flutter_app/lib/src/tool_trace.dart index 8d5a399..3539d60 100644 --- a/flutter_app/lib/src/tool_trace.dart +++ b/flutter_app/lib/src/tool_trace.dart @@ -4,6 +4,7 @@ class ToolTrace { required this.status, required this.summary, this.callId, + this.component, }); final String toolName; @@ -11,21 +12,32 @@ class ToolTrace { final String summary; final String? callId; + /// Optional generative-UI component payload emitted by the tool, validated at + /// render time by [GenerativeUiRegistry]. Rides the trace so the chat surface + /// can render a rich card (e.g. mail triage) in place of the plain trace row, + /// and survives session persistence via [toJson]/[fromJson]. + final Map? component; + Map toJson() { return { 'toolName': toolName, 'status': status, 'summary': summary, if (callId != null) 'callId': callId, + if (component != null) 'component': component, }; } static ToolTrace fromJson(Map json) { + final rawComponent = json['component']; return ToolTrace( toolName: json['toolName']?.toString() ?? 'tool', status: json['status']?.toString() ?? 'done', summary: json['summary']?.toString() ?? '', callId: json['callId']?.toString(), + component: rawComponent is Map + ? Map.from(rawComponent) + : null, ); } } diff --git a/flutter_app/lib/src/views/chat_route.dart b/flutter_app/lib/src/views/chat_route.dart index e0ce471..c88300d 100644 --- a/flutter_app/lib/src/views/chat_route.dart +++ b/flutter_app/lib/src/views/chat_route.dart @@ -93,6 +93,7 @@ class _ChatRouteState extends State { onStop: controller.cancelMessage, streaming: controller.streaming, voice: controller.voice, + onComponentAction: controller.handleComponentAction, ), ), ], diff --git a/flutter_app/lib/src/views/chat_view.dart b/flutter_app/lib/src/views/chat_view.dart index ad6c6e8..820269e 100644 --- a/flutter_app/lib/src/views/chat_view.dart +++ b/flutter_app/lib/src/views/chat_view.dart @@ -19,6 +19,7 @@ class ChatView extends StatelessWidget { this.onStop, this.streaming, this.voice, + this.onComponentAction, super.key, }); @@ -33,6 +34,10 @@ class ChatView extends StatelessWidget { final StreamingAssistantMessage? streaming; final VoiceController? voice; + /// Dispatches an action requested by an interactive component in the message + /// list (e.g. a mail card's Summarize or a deadline card's Add reminder). + final ValueChanged? onComponentAction; + @override Widget build(BuildContext context) { return Column( @@ -43,6 +48,7 @@ class ChatView extends StatelessWidget { compact: compactMessages, controller: messageScrollController, streaming: streaming, + onComponentAction: onComponentAction, ), ), if (messages.isEmpty) SuggestionStrip(onSelected: onSuggestionSelected), diff --git a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart index 7e292b5..c92424d 100644 --- a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart +++ b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import 'deadline_card.dart'; +import 'mail_triage_card.dart'; class GeneratedUiPreviewSection extends StatefulWidget { const GeneratedUiPreviewSection({super.key}); @@ -45,7 +47,15 @@ class _GeneratedUiPreviewSectionState extends State { ), const SizedBox(height: StudyOsSpacing.sm), if (validation.component case final component?) - _GeneratedComponentCard(component: component) + switch (component.kind) { + GeneratedComponentKind.mailList => MailTriageCard( + component: component, + ), + GeneratedComponentKind.deadlineList => DeadlineCard( + component: component, + ), + _ => _GeneratedComponentCard(component: component), + } else _InvalidComponentCard(errors: validation.errors), ], @@ -113,6 +123,8 @@ class _GeneratedComponentCard extends StatelessWidget { GeneratedComponentKind.routeHint => Icons.map_outlined, GeneratedComponentKind.deadlineCard => Icons.assignment_late_outlined, GeneratedComponentKind.quickReply => Icons.quickreply_outlined, + GeneratedComponentKind.mailList => Icons.mail_outline_rounded, + GeneratedComponentKind.deadlineList => Icons.assignment_late_outlined, }; } } diff --git a/flutter_app/lib/src/widgets/message_list.dart b/flutter_app/lib/src/widgets/message_list.dart index eb12c63..c9dfe5a 100644 --- a/flutter_app/lib/src/widgets/message_list.dart +++ b/flutter_app/lib/src/widgets/message_list.dart @@ -4,6 +4,8 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../message_trace_compaction.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import 'deadline_card.dart'; +import 'mail_triage_card.dart'; import 'thinking_trace.dart'; class MessageList extends StatelessWidget { @@ -12,6 +14,7 @@ class MessageList extends StatelessWidget { required this.compact, required this.controller, this.streaming, + this.onComponentAction, super.key, }); @@ -23,6 +26,10 @@ class MessageList extends StatelessWidget { /// committed messages. Null when no reply is in flight. final StreamingAssistantMessage? streaming; + /// Dispatches an action requested by an interactive generative-UI component + /// (e.g. a mail or deadline card). Null disables component actions. + final ValueChanged? onComponentAction; + @override Widget build(BuildContext context) { final visibleMessages = compactTraceMessages(messages); @@ -40,12 +47,43 @@ class MessageList extends StatelessWidget { if (message.isTrace) { return _ToolTraceRow(message: message, compact: compact); } - return _MessageBubble(message: message, compact: compact); + return _MessageBubble( + message: message, + compact: compact, + onComponentAction: onComponentAction, + ); }, ); } } +/// Returns a rich generative-UI card for a message's component payload, or null +/// to render nothing extra. Only the mail-list kind has a bespoke renderer +/// today; unknown or invalid payloads are ignored so the reply degrades to +/// plain text. +Widget? generatedComponentCard( + Map? payload, { + ValueChanged? onAction, + bool compact = false, +}) { + if (payload == null) return null; + final component = GenerativeUiRegistry.validate(payload).component; + if (component == null) return null; + return switch (component.kind) { + GeneratedComponentKind.mailList => MailTriageCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.deadlineList => DeadlineCard( + component: component, + onAction: onAction, + compact: compact, + ), + _ => null, + }; +} + class _ToolTraceRow extends StatelessWidget { const _ToolTraceRow({required this.message, required this.compact}); @@ -150,15 +188,24 @@ class _TraceStatusStyle { } class _MessageBubble extends StatelessWidget { - const _MessageBubble({required this.message, required this.compact}); + const _MessageBubble({ + required this.message, + required this.compact, + this.onComponentAction, + }); final ChatMessage message; final bool compact; + final ValueChanged? onComponentAction; @override Widget build(BuildContext context) { if (!message.isUser) { - return _AssistantText(message: message, compact: compact); + return _AssistantText( + message: message, + compact: compact, + onComponentAction: onComponentAction, + ); } return Align( @@ -190,14 +237,24 @@ class _MessageBubble extends StatelessWidget { } class _AssistantText extends StatelessWidget { - const _AssistantText({required this.message, required this.compact}); + const _AssistantText({ + required this.message, + required this.compact, + this.onComponentAction, + }); final ChatMessage message; final bool compact; + final ValueChanged? onComponentAction; @override Widget build(BuildContext context) { final reasoning = message.reasoning?.trim() ?? ''; + final card = generatedComponentCard( + message.component, + onAction: onComponentAction, + compact: compact, + ); return Align( alignment: Alignment.centerLeft, child: Container( @@ -207,11 +264,13 @@ class _AssistantText extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (reasoning.isNotEmpty) ThinkingTrace(reasoning: reasoning), - MarkdownBody( - data: message.text, - selectable: true, - styleSheet: assistantMarkdownStyle(context), - ), + if (message.text.trim().isNotEmpty) + MarkdownBody( + data: message.text, + selectable: true, + styleSheet: assistantMarkdownStyle(context), + ), + ?card, ], ), ), diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index f6861a6..8e97120 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -107,12 +107,12 @@ void main() { ); expect(response, 'Plain local response.'); - expect(bridge.lastSystemPrompt, contains('search_talks')); - expect(bridge.lastSystemPrompt, contains('get_recent_mail')); - expect(bridge.lastSystemPrompt, contains('search_mail')); - expect(bridge.lastSystemPrompt, contains('find_mail_deadlines')); + expect(bridge.lastSystemInstruction, contains('search_talks')); + expect(bridge.lastSystemInstruction, contains('get_recent_mail')); + expect(bridge.lastSystemInstruction, contains('search_mail')); + expect(bridge.lastSystemInstruction, contains('find_mail_deadlines')); expect( - bridge.lastSystemPrompt, + bridge.lastSystemInstruction, isNot(contains(nativeSetFlashlightToolName)), ); }, @@ -164,9 +164,9 @@ void main() { ), ); - expect(bridge.lastSystemPrompt, contains(nativeDeviceStatusToolName)); + expect(bridge.lastSystemInstruction, contains(nativeDeviceStatusToolName)); expect( - bridge.lastSystemPrompt, + bridge.lastSystemInstruction, isNot(contains(nativeSetFlashlightToolName)), ); }); @@ -212,6 +212,110 @@ void main() { expect(response, 'I used fresh memory.'); expect(prompts.last, contains('Fresh memory from disk')); expect(prompts.last, isNot(contains('Stale memory snapshot'))); + // The system instruction is installed once and reused verbatim across the + // tool round rather than rebuilt per message. + expect(bridge.systemInstructions.toSet(), hasLength(1)); + }); + + test( + 'local provider keeps stable context in the system instruction and ' + 'ephemeral context on the turn', + () async { + final prompts = []; + final bridge = _FakeNativeBridge.sequence([ + 'Plain local response.', + ], prompts: prompts); + final provider = LocalNativeLlmProvider(bridge); + + await provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'How is my day?', + context: const PromptContext( + profile: null, + memory: 'Prefers morning study blocks.', + worldState: {'platform': 'test-device'}, + ), + memoryText: 'Prefers morning study blocks.', + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + ), + ); + + // Stable content is the system instruction; volatile context is not. + expect( + bridge.lastSystemInstruction, + contains('Prefers morning study blocks.'), + ); + expect( + bridge.lastSystemInstruction, + isNot(contains('Current local timestamp')), + ); + + // The volatile per-turn context rides the message with the user text. + expect(prompts.single, contains('Current local timestamp')); + expect(prompts.single, contains('test-device')); + expect(prompts.single, contains('How is my day?')); + }, + ); + + test('local provider resets the live stream before a tool follow-up', () async { + final bridge = _FakeNativeBridge.sequence([ + '[TOOL:read_memories:{}]', + 'Answer from tool results.', + ]); + final provider = LocalNativeLlmProvider(bridge); + final deltas = []; + + final response = await provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'What should I remember?', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + memoryText: '', + appendMemory: (_) async {}, + readMemory: () async => 'Fresh memory from disk', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + onDelta: deltas.add, + ), + ); + + expect(response, 'Answer from tool results.'); + // The bracketed tool directive turn is cleared before the answer streams. + expect(deltas.where((delta) => delta.reset), hasLength(1)); }); test('local provider throws when tool rounds are exhausted', () async { @@ -297,7 +401,8 @@ class _FakeNativeBridge extends NativeBridge { final List? responses; final List? prompts; final List> nativeTools; - String? lastSystemPrompt; + String? lastSystemInstruction; + final List systemInstructions = []; int _responseIndex = 0; @override @@ -308,13 +413,13 @@ class _FakeNativeBridge extends NativeBridge { @override Future sendMessage( String text, { - String? systemPrompt, - String? memory, + String? systemInstruction, String? localModelId, String? localModelPath, String? localBackend, }) async { - lastSystemPrompt = systemPrompt; + lastSystemInstruction = systemInstruction; + systemInstructions.add(systemInstruction); prompts?.add(text); final queued = responses; if (queued == null) return response; diff --git a/flutter_app/test/generative_ui_registry_test.dart b/flutter_app/test/generative_ui_registry_test.dart index 7e2ae8b..faba1e5 100644 --- a/flutter_app/test/generative_ui_registry_test.dart +++ b/flutter_app/test/generative_ui_registry_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:studyos_agent/src/models.dart'; @@ -48,4 +50,152 @@ void main() { contains('Field arguments must be an object when present'), ); }); + + group('mailTriageComponentPayload', () { + String inboxJson({bool withMessages = true}) { + return jsonEncode({ + 'account': 'linus@uni-tuebingen.de', + 'mailbox': 'INBOX', + 'unread_count': 1, + 'messages': withMessages + ? >[ + { + 'uid': '4821', + 'subject': 'Exercise sheet 7', + 'from_name': 'Prof. Weber', + 'from_address': 'weber@uni-tuebingen.de', + 'received_at': '2026-07-08T09:12:00', + 'preview': 'Please upload before Friday…', + 'is_unread': true, + 'is_approved_broadcast': true, + }, + { + 'uid': '4818', + 'subject': 'Study group notes', + 'from_name': null, + 'from_address': 'lena@example.com', + 'received_at': '2026-07-07T11:05:00', + 'preview': 'Thanks!', + 'is_unread': false, + 'is_approved_broadcast': false, + }, + ] + : >[], + }); + } + + test('builds a valid mail_list component from a mail summary tool', () { + final payload = mailTriageComponentPayload( + 'get_recent_mail', + inboxJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.mailList); + expect(component.title, 'INBOX · 1 unread'); + + final messages = component.arguments['messages'] as List; + expect(messages, hasLength(2)); + final first = messages.first as Map; + expect(first['uid'], '4821'); + expect(first['sender'], 'Prof. Weber'); + expect(first['is_approved_broadcast'], isTrue); + // Falls back to the address when no display name is present. + final second = messages[1] as Map; + expect(second['sender'], 'lena@example.com'); + }); + + test('search_mail is also treated as a producer', () { + expect( + mailTriageComponentPayload('search_mail', inboxJson()), + isNotNull, + ); + }); + + test('returns null for non-producer tools', () { + expect( + mailTriageComponentPayload('get_schedule', inboxJson()), + isNull, + ); + }); + + test('returns null for empty inboxes and unparseable output', () { + expect( + mailTriageComponentPayload('get_recent_mail', inboxJson(withMessages: false)), + isNull, + ); + expect( + mailTriageComponentPayload('get_recent_mail', 'Mail is not available.'), + isNull, + ); + }); + }); + + group('deadlineListComponentPayload', () { + String deadlinesJson({bool withData = true}) { + return jsonEncode({ + 'state': withData ? 'fresh' : 'empty', + 'fetched_at': '2026-07-08T09:00:00.000Z', + 'data': withData + ? >[ + { + 'id': 'ilias:9921', + 'source': 'ilias', + 'title': 'ML exercise sheet 7', + 'courseTitle': 'Machine Learning', + 'dueAt': '2026-07-10T18:00:00.000Z', + 'requirement': 'Graded submission', + 'status': 'open', + 'target': 'https://ilias.uni-tuebingen.de/goto_9921', + }, + ] + : >[], + }); + } + + test('builds a valid deadline_list from get_deadlines output', () { + final payload = deadlineListComponentPayload( + 'get_deadlines', + deadlinesJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.deadlineList); + + final deadlines = component.arguments['deadlines'] as List; + expect(deadlines, hasLength(1)); + final first = deadlines.first as Map; + expect(first['title'], 'ML exercise sheet 7'); + expect(first['course'], 'Machine Learning'); + expect(first['due_at'], '2026-07-10T18:00:00.000Z'); + }); + + test('the shared dispatcher routes each tool to its builder', () { + expect( + componentPayloadForTool('get_deadlines', deadlinesJson()), + isNotNull, + ); + expect( + componentPayloadForTool('get_schedule', deadlinesJson()), + isNull, + ); + }); + + test('returns null for empty results and non-deadline tools', () { + expect( + deadlineListComponentPayload('get_deadlines', deadlinesJson(withData: false)), + isNull, + ); + expect( + deadlineListComponentPayload('get_tasks', deadlinesJson()), + isNull, + ); + }); + }); } From 976ac48a95e4c61f2cb358e8f735256a40bbbec4 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 22 Jul 2026 20:52:20 +0200 Subject: [PATCH 4/9] Further GenUI Widgets --- flutter_app/lib/src/agent_llm_provider.dart | 1 + flutter_app/lib/src/app_shell_controller.dart | 122 ++++- .../lib/src/generative_ui_registry.dart | 510 +++++++++++++++++- flutter_app/lib/src/prompt_context.dart | 20 +- .../widgets/generated_ui_preview_section.dart | 26 + flutter_app/lib/src/widgets/message_list.dart | 57 +- .../test/generative_ui_registry_test.dart | 314 +++++++++++ 7 files changed, 1024 insertions(+), 26 deletions(-) diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index d6f53de..87a3d20 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -363,6 +363,7 @@ class CloudLlmProvider implements AgentLlmProvider { appendMemory: _appendMemory, readMemory: _memoryStore.read, readSchedule: request.readSchedule, + readAcademicStatus: request.readAcademicStatus, searchTalks: request.searchTalks, mailTools: request.mailTools, publicStudyTools: request.publicStudyTools, diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index db53bb2..8b6db98 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'agent_config_store.dart'; import 'agent_message_sender.dart'; @@ -60,6 +61,20 @@ DateTime reminderTimeForDeadline(DateTime dueAt, {DateTime? now}) { return reference.add(const Duration(minutes: 10)); } +/// Builds the Google Maps search URL for coordinates. Mirrors the deep link the +/// in-app map view uses for its "Open in maps" control, so both surfaces behave +/// identically. Pure so it can be unit-tested. +Uri campusMapsUri(double latitude, double longitude) { + return Uri.https('www.google.com', '/maps/search/', { + 'api': '1', + 'query': '$latitude,$longitude', + }); +} + +Future _launchExternal(Uri uri) { + return launchUrl(uri, mode: LaunchMode.externalApplication); +} + class AppShellController extends ChangeNotifier { AppShellController({ required OnboardingProfile? initialProfile, @@ -71,9 +86,15 @@ class AppShellController extends ChangeNotifier { TalksRepository? talksRepository, CalendarOverviewSource? calendarOverviewSource, NativeToolRunner? nativeToolRunner, + AcademicRepository? academicRepository, + TimetableRepository? timetableRepository, + Future Function(Uri uri)? urlLauncher, }) : bridge = nativeBridge ?? NativeBridge(), talksRepository = talksRepository ?? TalksRepository(), _ownsTalksRepository = talksRepository == null, + _academicRepository = academicRepository ?? AcademicRepository(), + _timetableRepository = timetableRepository ?? TimetableRepository(), + _urlLauncher = urlLauncher ?? _launchExternal, _profile = initialProfile, _onLogout = initialOnLogout, _onSaveProfile = initialOnSaveProfile { @@ -93,6 +114,7 @@ class AppShellController extends ChangeNotifier { final NativeBridge bridge; late final NativeToolRunner _nativeToolRunner; + final Future Function(Uri uri) _urlLauncher; final TalksRepository talksRepository; final bool _ownsTalksRepository; late final CalendarOverviewSource calendarOverviewSource; @@ -103,8 +125,8 @@ class AppShellController extends ChangeNotifier { /// Shared mail repository so the mail view reuses the cached IMAP session. MailRepository get mailRepository => _mailRepository; final MemoryStore _memoryStore = MemoryStore(); - final TimetableRepository _timetableRepository = TimetableRepository(); - final AcademicRepository _academicRepository = AcademicRepository(); + final TimetableRepository _timetableRepository; + final AcademicRepository _academicRepository; final OfficialDocumentsRepository _documentsRepository = OfficialDocumentsRepository(); final PublicStudyToolRunner _publicStudyTools = LivePublicStudyToolRunner(); @@ -131,8 +153,10 @@ class AppShellController extends ChangeNotifier { AgentConfig _agentConfig = const AgentConfig.defaults(); String _memoryText = ''; TimetableSnapshot? _timetable; + Future? _timetableRefresh; AcademicStatusSnapshot? _academicStatus; String? _academicStatusError; + Future? _academicStatusRefresh; String? _academicReportError; List _officialDocuments = []; String? _officialDocumentsError; @@ -337,9 +361,21 @@ class AppShellController extends ChangeNotifier { unawaited(refreshAcademicStatus()); } - Future refreshAcademicStatus() async { + Future refreshAcademicStatus() { final profile = _profile; - if (profile == null || _isRefreshingAcademicStatus) return; + if (profile == null) return Future.value(); + // Coalesce concurrent refreshes so callers await the in-flight fetch + // instead of racing past a still-running one. Previously the guard made a + // second caller (e.g. the get_academic_status tool, fired while the + // background refresh started in initialize() was still running) return + // immediately and read a null snapshot — surfacing "Academic status is not + // available." and masking the real error. Callers now share one future. + return _academicStatusRefresh ??= _runAcademicStatusRefresh( + profile, + ).whenComplete(() => _academicStatusRefresh = null); + } + + Future _runAcademicStatusRefresh(OnboardingProfile profile) async { _isRefreshingAcademicStatus = true; _academicStatusError = null; _notify(); @@ -424,13 +460,18 @@ class AppShellController extends ChangeNotifier { } Future readAcademicStatusForAgent() async { - final status = _academicStatus; - if (status == null) { + if (_profile == null) { + return 'Academic status is unavailable: no student profile is signed in.'; + } + if (_academicStatus == null) { await refreshAcademicStatus(); } final resolved = _academicStatus; if (resolved == null) { - return _academicStatusError ?? 'Academic status is not available.'; + // The refresh finished without a snapshot; surface the real reason + // (e.g. an authentication prompt) instead of a generic string. + return _academicStatusError ?? + 'Academic status could not be loaded right now. Please try again in a moment.'; } return jsonEncode({ 'term': resolved.term, @@ -465,6 +506,14 @@ class AppShellController extends ChangeNotifier { unawaited(runComponentPrompt(prompt)); case ReminderComponentAction(:final title, :final dueAt): unawaited(addDeadlineReminder(title: title, dueAt: dueAt)); + case MapComponentAction(:final name, :final latitude, :final longitude): + unawaited( + openLocationInMaps( + name: name, + latitude: latitude, + longitude: longitude, + ), + ); } } @@ -498,6 +547,25 @@ class AppShellController extends ChangeNotifier { ); } + /// Opens a geocoded place in the device's external maps app. Reports a message + /// only on failure (success hands off to the maps app). + Future openLocationInMaps({ + required String name, + required double latitude, + required double longitude, + }) async { + bool opened; + try { + opened = await _urlLauncher(campusMapsUri(latitude, longitude)); + } on Object { + opened = false; + } + if (_disposed) return; + if (!opened) { + addAssistantMessage('Could not open $name in maps.'); + } + } + Future applyChatRoute({ String? prompt, bool autosend = false, @@ -786,14 +854,22 @@ class AppShellController extends ChangeNotifier { } } - Future refreshTimetable() async { - if (_isRefreshingTimetable) return; + Future refreshTimetable() { final profile = _profile; if (profile == null) { _timetableError = 'Sign in again to refresh your timetable.'; _notify(); - return; + return Future.value(); } + // Coalesce concurrent refreshes so a caller (e.g. the get_schedule tool) + // awaits the in-flight fetch instead of racing past it — same fix as + // academic status. + return _timetableRefresh ??= _runTimetableRefresh( + profile, + ).whenComplete(() => _timetableRefresh = null); + } + + Future _runTimetableRefresh(OnboardingProfile profile) async { _isRefreshingTimetable = true; _timetableError = null; _notify(); @@ -874,8 +950,30 @@ class AppShellController extends ChangeNotifier { await refreshTimetable(); snapshot = _timetable; } - return snapshot?.compactSummary(limit: 12) ?? - 'No timetable has been synced yet.'; + if (snapshot == null || snapshot.events.isEmpty) { + return _timetableError ?? 'No timetable has been synced yet.'; + } + final upcoming = snapshot.upcoming.take(12).toList(growable: false); + if (upcoming.isEmpty) { + return 'No upcoming lectures in the synced timetable.'; + } + // Structured output so the client can render an interactive schedule card + // (see schedule_agenda in GenerativeUiRegistry). The model gets the same + // data as JSON instead of a prose summary. + return jsonEncode({ + 'source_term': snapshot.sourceTerm, + 'refreshed_at': snapshot.refreshedAt.toIso8601String(), + 'events': upcoming + .map( + (event) => { + 'title': event.title, + 'start': event.start.toIso8601String(), + 'end': event.end?.toIso8601String(), + 'location': event.location, + }, + ) + .toList(growable: false), + }); } Future searchTalksForAgent(String query, int limit) async { diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart index 10c0869..9dabff3 100644 --- a/flutter_app/lib/src/generative_ui_registry.dart +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -7,7 +7,13 @@ enum GeneratedComponentKind { deadlineCard('deadline_card'), quickReply('quick_reply'), mailList('mail_list'), - deadlineList('deadline_list'); + deadlineList('deadline_list'), + talkList('talk_list'), + academicStatus('academic_status'), + studyProgress('study_progress'), + mensaMenu('mensa_menu'), + campusLocations('campus_locations'), + scheduleAgenda('schedule_agenda'); const GeneratedComponentKind(this.wireName); @@ -129,6 +135,27 @@ abstract final class GenerativeUiRegistry { arguments, 'deadlines', ), + GeneratedComponentKind.talkList => _validateItemList(arguments, 'talks'), + GeneratedComponentKind.academicStatus => _validateItemList( + arguments, + 'entries', + ), + GeneratedComponentKind.studyProgress => _validateItemList( + arguments, + 'modules', + ), + GeneratedComponentKind.mensaMenu => _validateItemList( + arguments, + 'options', + ), + GeneratedComponentKind.campusLocations => _validateItemList( + arguments, + 'locations', + ), + GeneratedComponentKind.scheduleAgenda => _validateItemList( + arguments, + 'events', + ), }; } } @@ -140,7 +167,13 @@ abstract final class GenerativeUiRegistry { /// place that maps tools to cards. Map? componentPayloadForTool(String toolName, String output) { return mailTriageComponentPayload(toolName, output) ?? - deadlineListComponentPayload(toolName, output); + deadlineListComponentPayload(toolName, output) ?? + talkListComponentPayload(toolName, output) ?? + academicStatusComponentPayload(toolName, output) ?? + studyProgressComponentPayload(toolName, output) ?? + mensaMenuComponentPayload(toolName, output) ?? + campusLocationsComponentPayload(toolName, output) ?? + scheduleAgendaComponentPayload(toolName, output); } /// Builds a `mail_list` GenUI payload from the JSON a mail-summary tool @@ -256,6 +289,290 @@ Map? deadlineListComponentPayload( }; } +/// Builds a `talk_list` payload from `search_talks` output (a `{items: [...]}` +/// envelope of Tübingen talks), or `null` otherwise. Forwards only the fields +/// the card renders plus the ISO timestamp its "Remind me" action needs. +Map? talkListComponentPayload(String toolName, String output) { + if (toolName != 'search_talks') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawItems = decoded['items']; + if (rawItems is! List) return null; + + final talks = >[]; + for (final raw in rawItems) { + if (raw is! Map) continue; + final title = _string(raw['title']); + if (title == null) continue; + talks.add({ + 'title': title, + 'timestamp': _string(raw['timestamp']), + 'speaker': _string(raw['speaker_name']), + 'location': _string(raw['location']), + }); + } + if (talks.isEmpty) return null; + + final count = talks.length; + return { + 'type': 'talk_list', + 'title': count == 1 ? 'Upcoming talk' : '$count upcoming talks', + 'body': count == 1 ? '1 talk' : '$count talks', + 'arguments': {'talks': talks}, + }; +} + +/// Builds an `academic_status` payload from `get_academic_status` output (a +/// `{term, entries: [...]}` snapshot of exam/course statuses), or `null` +/// otherwise. Read-only card — no per-item actions. +Map? academicStatusComponentPayload( + String toolName, + String output, +) { + if (toolName != 'get_academic_status') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawEntries = decoded['entries']; + if (rawEntries is! List) return null; + + final entries = >[]; + for (final raw in rawEntries) { + if (raw is! Map) continue; + final title = _string(raw['title']); + if (title == null) continue; + entries.add({ + 'category': _string(raw['category']) ?? 'Other', + 'title': title, + 'status': _string(raw['status']), + 'semester': _string(raw['semester']), + }); + } + if (entries.isEmpty) return null; + + final term = _string(decoded['term']); + final count = entries.length; + return { + 'type': 'academic_status', + 'title': term == null ? 'Academic status' : 'Academic status · $term', + 'body': count == 1 ? '1 entry' : '$count entries', + 'arguments': { + 'term': ?term, + 'entries': entries, + }, + }; +} + +/// Builds a `study_progress` payload from `get_study_planner` output (a +/// [CapabilityResult] whose `data` is an ALMA planner page with modules that +/// carry earned/required ECTS), or `null` otherwise. Also computes the overall +/// earned-vs-required total across modules that report both. +Map? studyProgressComponentPayload( + String toolName, + String output, +) { + if (toolName != 'get_study_planner') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final data = decoded['data']; + if (data is! Map) return null; + final rawModules = data['modules']; + if (rawModules is! List) return null; + + final modules = >[]; + var totalEarned = 0.0; + var totalRequired = 0.0; + for (final raw in rawModules) { + if (raw is! Map) continue; + final title = _string(raw['title']); + if (title == null) continue; + final earned = _double(raw['creditsEarned']); + final required = _double(raw['creditsRequired']); + if (earned != null && required != null && required > 0) { + totalEarned += earned; + totalRequired += required; + } + modules.add({ + 'title': title, + 'number': _string(raw['number']), + 'earned': earned, + 'required': required, + 'summary': _string(raw['creditsSummary']), + }); + } + if (modules.isEmpty) return null; + + final pageTitle = _string(data['title']) ?? 'Study progress'; + final body = totalRequired > 0 + ? '${_trimNumber(totalEarned)} / ${_trimNumber(totalRequired)} ECTS' + : '${modules.length} modules'; + return { + 'type': 'study_progress', + 'title': pageTitle, + 'body': body, + 'arguments': { + 'total_earned': totalRequired > 0 ? totalEarned : null, + 'total_required': totalRequired > 0 ? totalRequired : null, + 'modules': modules, + }, + }; +} + +/// Builds a `mensa_menu` payload from `get_mensa_options` output (a +/// [CapabilityResult] whose `data` is a list of canteen menu lines), or `null` +/// otherwise. Read-only card. +Map? mensaMenuComponentPayload( + String toolName, + String output, +) { + if (toolName != 'get_mensa_options') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawData = decoded['data']; + if (rawData is! List) return null; + + final options = >[]; + for (final raw in rawData) { + if (raw is! Map) continue; + final line = _string(raw['line']); + final items = _stringList(raw['items']); + if (line == null && items.isEmpty) continue; + options.add({ + 'canteen': _string(raw['canteen']), + 'line': line ?? 'Menu', + 'items': items, + 'markers': _stringList(raw['dietary_markers']), + 'price': _string(raw['student_price']), + }); + } + if (options.isEmpty) return null; + + final canteens = options + .map((option) => _string(option['canteen'])) + .whereType() + .toSet(); + final count = options.length; + return { + 'type': 'mensa_menu', + 'title': canteens.length == 1 ? canteens.first : 'Mensa menu', + 'body': count == 1 ? '1 option' : '$count options', + 'arguments': {'options': options}, + }; +} + +/// Builds a `campus_locations` payload from `search_campus_locations` output (a +/// [CapabilityResult] whose `data` is a list of geocoded places), or `null` +/// otherwise. Each location keeps its coordinates so the card's "Open in Maps" +/// action can launch them. +Map? campusLocationsComponentPayload( + String toolName, + String output, +) { + if (toolName != 'search_campus_locations') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawData = decoded['data']; + if (rawData is! List) return null; + + final locations = >[]; + for (final raw in rawData) { + if (raw is! Map) continue; + final name = _string(raw['name']); + final latitude = _double(raw['latitude']); + final longitude = _double(raw['longitude']); + if (name == null || latitude == null || longitude == null) continue; + locations.add({ + 'name': name, + 'address': _string(raw['address']), + 'category': _string(raw['category']), + 'latitude': latitude, + 'longitude': longitude, + }); + } + if (locations.isEmpty) return null; + + final count = locations.length; + return { + 'type': 'campus_locations', + 'title': count == 1 ? locations.first['name'] : '$count places', + 'body': count == 1 ? '1 place' : '$count places', + 'arguments': {'locations': locations}, + }; +} + +/// Builds a `schedule_agenda` payload from `get_schedule` output (a +/// `{source_term, events: [...]}` snapshot of upcoming lectures), or `null` +/// otherwise. Read-only card; the widget groups events by day. +Map? scheduleAgendaComponentPayload( + String toolName, + String output, +) { + if (toolName != 'get_schedule') return null; + + final Object? decoded; + try { + decoded = jsonDecode(output); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawEvents = decoded['events']; + if (rawEvents is! List) return null; + + final events = >[]; + for (final raw in rawEvents) { + if (raw is! Map) continue; + final title = _string(raw['title']); + final start = _string(raw['start']); + if (title == null || start == null) continue; + events.add({ + 'title': title, + 'start': start, + 'end': _string(raw['end']), + 'location': _string(raw['location']), + }); + } + if (events.isEmpty) return null; + + final term = _string(decoded['source_term']); + final count = events.length; + return { + 'type': 'schedule_agenda', + 'title': term == null ? 'Upcoming schedule' : 'Schedule · $term', + 'body': count == 1 ? '1 lecture' : '$count lectures', + 'arguments': {'events': events}, + }; +} + const List> generativeUiFixturePayloads = >[ { @@ -366,6 +683,160 @@ generativeUiFixturePayloads = >[ ], }, }, + { + 'type': 'talk_list', + 'title': '2 upcoming talks', + 'body': '2 talks', + 'arguments': { + 'talks': >[ + { + 'title': 'Foundation models for scientific discovery', + 'timestamp': '2026-12-09T16:15:00.000Z', + 'speaker': 'Dr. Amelie Roth', + 'location': 'Hörsaal 21, Kupferbau', + }, + { + 'title': 'Reinforcement learning in robotics', + 'timestamp': '2026-12-11T14:00:00.000Z', + 'speaker': 'Prof. Chen', + 'location': 'MPI-IS, Lecture Hall N0.002', + }, + ], + }, + }, + { + 'type': 'academic_status', + 'title': 'Academic status · WS 2026/27', + 'body': '3 entries', + 'arguments': { + 'term': 'WS 2026/27', + 'entries': >[ + { + 'category': 'Exams', + 'title': 'Machine Learning — written exam', + 'status': 'Registered', + 'semester': 'WS 2026/27', + }, + { + 'category': 'Exams', + 'title': 'Databases — oral exam', + 'status': 'Passed (1.7)', + 'semester': 'WS 2026/27', + }, + { + 'category': 'Courses', + 'title': 'Statistics III', + 'status': 'Enrolled', + 'semester': 'WS 2026/27', + }, + ], + }, + }, + { + 'type': 'study_progress', + 'title': 'M.Sc. Machine Learning', + 'body': '78 / 120 ECTS', + 'arguments': { + 'total_earned': 78, + 'total_required': 120, + 'modules': >[ + { + 'title': 'Core Machine Learning', + 'number': 'ML-4100', + 'earned': 27, + 'required': 30, + 'summary': '27 / 30 ECTS', + }, + { + 'title': 'Theoretical Foundations', + 'number': 'ML-4200', + 'earned': 18, + 'required': 30, + 'summary': '18 / 30 ECTS', + }, + { + 'title': "Master's Thesis", + 'number': 'ML-4900', + 'earned': 0, + 'required': 30, + 'summary': '0 / 30 ECTS', + }, + ], + }, + }, + { + 'type': 'mensa_menu', + 'title': 'Mensa Wilhelmstraße', + 'body': '2 options', + 'arguments': { + 'options': >[ + { + 'canteen': 'Mensa Wilhelmstraße', + 'line': 'Line 1', + 'items': ['Gemüse-Lasagne', 'Blattsalat'], + 'markers': ['Vegetarisch'], + 'price': '3,20 €', + }, + { + 'canteen': 'Mensa Wilhelmstraße', + 'line': 'Line 2', + 'items': ['Rindergulasch', 'Semmelknödel'], + 'markers': [], + 'price': '4,10 €', + }, + ], + }, + }, + { + 'type': 'campus_locations', + 'title': '2 places', + 'body': '2 places', + 'arguments': { + 'locations': >[ + { + 'name': 'Universitätsbibliothek Tübingen', + 'address': 'Wilhelmstraße 32, 72074 Tübingen', + 'category': 'library', + 'latitude': 48.5296, + 'longitude': 9.0596, + }, + { + 'name': 'Mensa Wilhelmstraße', + 'address': 'Wilhelmstraße 13, 72074 Tübingen', + 'category': 'canteen', + 'latitude': 48.5309, + 'longitude': 9.0625, + }, + ], + }, + }, + { + 'type': 'schedule_agenda', + 'title': 'Schedule · WS 2026/27', + 'body': '3 lectures', + 'arguments': { + 'events': >[ + { + 'title': 'Machine Learning', + 'start': '2026-12-09T10:15:00', + 'end': '2026-12-09T11:45:00', + 'location': 'Hörsaal 21', + }, + { + 'title': 'Databases Tutorial', + 'start': '2026-12-09T14:00:00', + 'end': '2026-12-09T15:30:00', + 'location': 'A301', + }, + { + 'title': 'Statistics III', + 'start': '2026-12-10T08:15:00', + 'end': '2026-12-10T09:45:00', + 'location': null, + }, + ], + }, + }, ]; /// An interaction requested by a generative-UI component. Cards emit these @@ -391,6 +862,20 @@ class ReminderComponentAction extends GeneratedComponentAction { final DateTime dueAt; } +/// Open a geocoded place in the device's maps app (external launch). Benign and +/// user-initiated, so the tap is the authorization. +class MapComponentAction extends GeneratedComponentAction { + const MapComponentAction({ + required this.name, + required this.latitude, + required this.longitude, + }); + + final String name; + final double latitude; + final double longitude; +} + List _requireStrings( Map arguments, List keys, @@ -408,3 +893,24 @@ String? _string(Object? value) { final text = value?.toString().trim(); return text == null || text.isEmpty ? null : text; } + +double? _double(Object? value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString().replaceAll(',', '.') ?? ''); +} + +List _stringList(Object? value) { + if (value is! List) return const []; + return value + .map((item) => item?.toString().trim() ?? '') + .where((item) => item.isNotEmpty) + .toList(growable: false); +} + +/// Formats an ECTS number without a trailing `.0` (e.g. `30` not `30.0`, but +/// `7.5` stays `7.5`). +String _trimNumber(double value) { + return value == value.roundToDouble() + ? value.toInt().toString() + : value.toString(); +} diff --git a/flutter_app/lib/src/prompt_context.dart b/flutter_app/lib/src/prompt_context.dart index 36b9987..f64deaa 100644 --- a/flutter_app/lib/src/prompt_context.dart +++ b/flutter_app/lib/src/prompt_context.dart @@ -47,16 +47,16 @@ class PromptContext { 'If required data is unavailable, say what is missing instead of guessing.', ) ..writeln( - 'The get_recent_mail and search_mail tools render their results as an ' - 'interactive mail card in the app. After calling them, reply with a ' - 'short lead-in only (for example "Here are your recent emails:") and do ' - 'not list, tabulate, or restate the individual messages — the card ' - 'already shows sender, subject, and preview.', - ) - ..writeln( - 'The get_deadlines tool likewise renders an interactive deadline card ' - 'with due dates and per-item actions. After calling it, give a short ' - 'lead-in only and do not re-list the individual deadlines.', + 'These tools display their own results visually in the app: ' + 'get_recent_mail, search_mail, get_deadlines, search_talks, ' + 'get_schedule, get_academic_status, get_study_planner, ' + 'get_mensa_options, search_campus_locations. After ' + 'calling one, reply with a single short, natural lead-in sentence (for ' + 'example "Here are your recent emails:") and nothing more. Do not list, ' + 'tabulate, or restate the returned items, and never mention, describe, ' + 'or promise a card, widget, or that something "will appear" — just the ' + 'lead-in. If the tool returned no items, say briefly and plainly what ' + 'was empty or missing instead.', ) ..writeln('Do not expose secrets or credentials.'); final profileBlock = _profileBlock(); diff --git a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart index c92424d..f227c3a 100644 --- a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart +++ b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart @@ -2,8 +2,14 @@ import 'package:flutter/material.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import 'academic_status_card.dart'; +import 'campus_location_card.dart'; import 'deadline_card.dart'; import 'mail_triage_card.dart'; +import 'mensa_card.dart'; +import 'schedule_card.dart'; +import 'study_progress_card.dart'; +import 'talk_card.dart'; class GeneratedUiPreviewSection extends StatefulWidget { const GeneratedUiPreviewSection({super.key}); @@ -54,6 +60,20 @@ class _GeneratedUiPreviewSectionState extends State { GeneratedComponentKind.deadlineList => DeadlineCard( component: component, ), + GeneratedComponentKind.talkList => TalkCard(component: component), + GeneratedComponentKind.academicStatus => AcademicStatusCard( + component: component, + ), + GeneratedComponentKind.studyProgress => StudyProgressCard( + component: component, + ), + GeneratedComponentKind.mensaMenu => MensaCard(component: component), + GeneratedComponentKind.campusLocations => CampusLocationCard( + component: component, + ), + GeneratedComponentKind.scheduleAgenda => ScheduleCard( + component: component, + ), _ => _GeneratedComponentCard(component: component), } else @@ -125,6 +145,12 @@ class _GeneratedComponentCard extends StatelessWidget { GeneratedComponentKind.quickReply => Icons.quickreply_outlined, GeneratedComponentKind.mailList => Icons.mail_outline_rounded, GeneratedComponentKind.deadlineList => Icons.assignment_late_outlined, + GeneratedComponentKind.talkList => Icons.forum_outlined, + GeneratedComponentKind.academicStatus => Icons.school_outlined, + GeneratedComponentKind.studyProgress => Icons.donut_large_outlined, + GeneratedComponentKind.mensaMenu => Icons.restaurant_outlined, + GeneratedComponentKind.campusLocations => Icons.place_outlined, + GeneratedComponentKind.scheduleAgenda => Icons.calendar_month_outlined, }; } } diff --git a/flutter_app/lib/src/widgets/message_list.dart b/flutter_app/lib/src/widgets/message_list.dart index c9dfe5a..efeb9a1 100644 --- a/flutter_app/lib/src/widgets/message_list.dart +++ b/flutter_app/lib/src/widgets/message_list.dart @@ -4,8 +4,14 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../message_trace_compaction.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import 'academic_status_card.dart'; +import 'campus_location_card.dart'; import 'deadline_card.dart'; import 'mail_triage_card.dart'; +import 'mensa_card.dart'; +import 'schedule_card.dart'; +import 'study_progress_card.dart'; +import 'talk_card.dart'; import 'thinking_trace.dart'; class MessageList extends StatelessWidget { @@ -80,6 +86,32 @@ Widget? generatedComponentCard( onAction: onAction, compact: compact, ), + GeneratedComponentKind.talkList => TalkCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.academicStatus => AcademicStatusCard( + component: component, + compact: compact, + ), + GeneratedComponentKind.studyProgress => StudyProgressCard( + component: component, + compact: compact, + ), + GeneratedComponentKind.mensaMenu => MensaCard( + component: component, + compact: compact, + ), + GeneratedComponentKind.campusLocations => CampusLocationCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.scheduleAgenda => ScheduleCard( + component: component, + compact: compact, + ), _ => null, }; } @@ -255,6 +287,13 @@ class _AssistantText extends StatelessWidget { onAction: onComponentAction, compact: compact, ); + // When a card is attached it *is* the answer, so drop everything after the + // model's lead-in line. The models don't reliably honour the "don't restate + // the data" prompt rule, and a restated table/list beneath the card reads as + // duplication. Keeping just the first line preserves "Here are your …:". + final text = card == null + ? message.text + : _leadInLine(message.text); return Align( alignment: Alignment.centerLeft, child: Container( @@ -264,9 +303,9 @@ class _AssistantText extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (reasoning.isNotEmpty) ThinkingTrace(reasoning: reasoning), - if (message.text.trim().isNotEmpty) + if (text.trim().isNotEmpty) MarkdownBody( - data: message.text, + data: text, selectable: true, styleSheet: assistantMarkdownStyle(context), ), @@ -278,6 +317,20 @@ class _AssistantText extends StatelessWidget { } } +/// Returns the first non-empty line of [text], used as the lead-in above a +/// generative-UI card. Anything after it (a restated list or table the card +/// already shows) is dropped. A leading Markdown list/heading/quote marker is +/// treated as "no lead-in" so a card-only restatement collapses to nothing. +String _leadInLine(String text) { + for (final line in text.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + if (RegExp(r'^([-*+>#]|\d+[.)]|\|)').hasMatch(trimmed)) return ''; + return trimmed; + } + return ''; +} + /// Live bubble for the reply that is still streaming in. Shows accumulated /// reasoning (collapsed) and the partial answer, or animated dots before the /// first token arrives. diff --git a/flutter_app/test/generative_ui_registry_test.dart b/flutter_app/test/generative_ui_registry_test.dart index faba1e5..554d97a 100644 --- a/flutter_app/test/generative_ui_registry_test.dart +++ b/flutter_app/test/generative_ui_registry_test.dart @@ -198,4 +198,318 @@ void main() { ); }); }); + + group('talkListComponentPayload', () { + String talksJson({bool withItems = true}) { + return jsonEncode({ + 'scope': 'upcoming', + 'query': 'ml', + 'items': withItems + ? >[ + { + 'id': 42, + 'title': 'Foundation models for science', + 'timestamp': '2026-12-09T16:15:00.000Z', + 'speaker_name': 'Dr. Amelie Roth', + 'location': 'Kupferbau', + 'tags': [], + }, + ] + : >[], + }); + } + + test('builds a valid talk_list from search_talks output', () { + final payload = talkListComponentPayload('search_talks', talksJson()); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + expect(validation.component!.kind, GeneratedComponentKind.talkList); + + final talks = validation.component!.arguments['talks'] as List; + final first = talks.first as Map; + expect(first['title'], 'Foundation models for science'); + expect(first['speaker'], 'Dr. Amelie Roth'); + expect(first['timestamp'], '2026-12-09T16:15:00.000Z'); + }); + + test('the dispatcher routes search_talks; empty items yield no card', () { + expect(componentPayloadForTool('search_talks', talksJson()), isNotNull); + expect( + talkListComponentPayload('search_talks', talksJson(withItems: false)), + isNull, + ); + }); + }); + + group('academicStatusComponentPayload', () { + String statusJson({bool withEntries = true}) { + return jsonEncode({ + 'term': 'WS 2026/27', + 'entries': withEntries + ? >[ + { + 'category': 'Exams', + 'title': 'ML written exam', + 'status': 'Registered', + 'semester': 'WS 2026/27', + }, + ] + : >[], + }); + } + + test('builds a valid academic_status from get_academic_status output', () { + final payload = academicStatusComponentPayload( + 'get_academic_status', + statusJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.academicStatus); + expect(component.title, 'Academic status · WS 2026/27'); + + final entries = component.arguments['entries'] as List; + final first = entries.first as Map; + expect(first['category'], 'Exams'); + expect(first['status'], 'Registered'); + }); + + test('empty entries and non-status tools yield no card', () { + expect( + academicStatusComponentPayload( + 'get_academic_status', + statusJson(withEntries: false), + ), + isNull, + ); + expect( + componentPayloadForTool('get_recent_mail', statusJson()), + isNull, + ); + }); + }); + + group('studyProgressComponentPayload', () { + String plannerJson({bool withModules = true}) { + return jsonEncode({ + 'state': 'fresh', + 'data': { + 'title': 'M.Sc. Machine Learning', + 'pageUrl': 'https://alma.uni-tuebingen.de/planner', + 'modules': withModules + ? >[ + { + 'rowIndex': 0, + 'columnStart': 0, + 'columnSpan': 1, + 'title': 'Core ML', + 'number': 'ML-4100', + 'creditsEarned': 27, + 'creditsRequired': 30, + 'creditsSummary': '27 / 30 ECTS', + }, + { + 'rowIndex': 1, + 'columnStart': 0, + 'columnSpan': 1, + 'title': 'Theory', + 'creditsEarned': 18, + 'creditsRequired': 30, + }, + ] + : >[], + 'viewState': { + 'showRecommendedPlan': true, + 'showMyModules': true, + 'showAlternativeSemesters': false, + }, + }, + }); + } + + test('builds a study_progress card and totals the ECTS', () { + final payload = studyProgressComponentPayload( + 'get_study_planner', + plannerJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.studyProgress); + expect(component.arguments['total_earned'], 45.0); + expect(component.arguments['total_required'], 60.0); + expect(component.body, '45 / 60 ECTS'); + }); + + test('empty modules and non-planner tools yield no card', () { + expect( + studyProgressComponentPayload( + 'get_study_planner', + plannerJson(withModules: false), + ), + isNull, + ); + expect( + componentPayloadForTool('get_deadlines', plannerJson()), + isNull, + ); + }); + }); + + group('mensaMenuComponentPayload', () { + String mensaJson({bool withData = true}) { + return jsonEncode({ + 'state': 'fresh', + 'data': withData + ? >[ + { + 'id': 'wilhelm:1', + 'canteen': 'Mensa Wilhelmstraße', + 'date': '2026-07-22', + 'line': 'Line 1', + 'items': ['Gemüse-Lasagne', 'Salat'], + 'dietary_markers': ['Vegetarisch'], + 'student_price': '3,20 €', + }, + ] + : >[], + }); + } + + test('builds a mensa_menu card from get_mensa_options output', () { + final payload = mensaMenuComponentPayload('get_mensa_options', mensaJson()); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.mensaMenu); + expect(component.title, 'Mensa Wilhelmstraße'); + + final options = component.arguments['options'] as List; + final first = options.first as Map; + expect(first['line'], 'Line 1'); + expect(first['items'], ['Gemüse-Lasagne', 'Salat']); + expect(first['markers'], ['Vegetarisch']); + expect(first['price'], '3,20 €'); + }); + + test('empty data and non-mensa tools yield no card', () { + expect( + mensaMenuComponentPayload('get_mensa_options', mensaJson(withData: false)), + isNull, + ); + expect(componentPayloadForTool('search_talks', mensaJson()), isNull); + }); + }); + + group('campusLocationsComponentPayload', () { + String locationsJson({bool withData = true}) { + return jsonEncode({ + 'state': 'fresh', + 'data': withData + ? >[ + { + 'id': 'nominatim:48.529600,9.059600', + 'name': 'Universitätsbibliothek Tübingen', + 'address': 'Wilhelmstraße 32, 72074 Tübingen', + 'category': 'library', + 'latitude': 48.5296, + 'longitude': 9.0596, + }, + ] + : >[], + }); + } + + test('builds a campus_locations card keeping coordinates', () { + final payload = campusLocationsComponentPayload( + 'search_campus_locations', + locationsJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.campusLocations); + + final locations = component.arguments['locations'] as List; + final first = locations.first as Map; + expect(first['name'], 'Universitätsbibliothek Tübingen'); + expect(first['latitude'], 48.5296); + expect(first['longitude'], 9.0596); + }); + + test('empty data and non-location tools yield no card', () { + expect( + campusLocationsComponentPayload( + 'search_campus_locations', + locationsJson(withData: false), + ), + isNull, + ); + expect(componentPayloadForTool('get_mensa_options', locationsJson()), isNull); + }); + }); + + group('scheduleAgendaComponentPayload', () { + String scheduleJson({bool withEvents = true}) { + return jsonEncode({ + 'source_term': 'WS 2026/27', + 'refreshed_at': '2026-12-08T09:00:00.000Z', + 'events': withEvents + ? >[ + { + 'title': 'Machine Learning', + 'start': '2026-12-09T10:15:00', + 'end': '2026-12-09T11:45:00', + 'location': 'Hörsaal 21', + }, + ] + : >[], + }); + } + + test('builds a schedule_agenda card from get_schedule output', () { + final payload = scheduleAgendaComponentPayload( + 'get_schedule', + scheduleJson(), + ); + expect(payload, isNotNull); + + final validation = GenerativeUiRegistry.validate(payload!); + expect(validation.errors, isEmpty); + final component = validation.component!; + expect(component.kind, GeneratedComponentKind.scheduleAgenda); + expect(component.title, 'Schedule · WS 2026/27'); + + final events = component.arguments['events'] as List; + final first = events.first as Map; + expect(first['title'], 'Machine Learning'); + expect(first['location'], 'Hörsaal 21'); + }); + + test('the prose "not synced" fallback does not produce a card', () { + expect( + scheduleAgendaComponentPayload( + 'get_schedule', + 'No timetable has been synced yet.', + ), + isNull, + ); + expect( + scheduleAgendaComponentPayload('get_schedule', scheduleJson(withEvents: false)), + isNull, + ); + expect(componentPayloadForTool('get_deadlines', scheduleJson()), isNull); + }); + }); } From 4d22015cc3a4f305b51b2765754654dad5cc237d Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 22 Jul 2026 23:14:58 +0200 Subject: [PATCH 5/9] Add inline math support, add flexibility for generative ui displays --- flutter_app/lib/src/app_shell_controller.dart | 49 +++++--- .../lib/src/generative_ui_registry.dart | 114 +++++++++++++++++- flutter_app/lib/src/prompt_context.dart | 72 +++++++++-- flutter_app/lib/src/views/settings_view.dart | 8 ++ .../widgets/generated_ui_preview_section.dart | 17 +++ flutter_app/lib/src/widgets/message_list.dart | 68 ++++++++++- flutter_app/pubspec.lock | 90 +++++++++++++- flutter_app/pubspec.yaml | 2 + 8 files changed, 387 insertions(+), 33 deletions(-) diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index 8b6db98..686473b 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -11,6 +11,7 @@ import 'academic_repository.dart'; import 'calendar_overview_repository.dart'; import 'chat_scroll.dart'; import 'chat_session_mutation.dart'; +import 'generated_ui_message.dart'; import 'mail_repository.dart'; import 'mail_tools.dart'; import 'memory_store.dart'; @@ -177,10 +178,14 @@ class AppShellController extends ChangeNotifier { Timer? _streamNotifyTimer; AgentCancelToken? _cancelToken; - /// Generative-UI component produced by a tool during the in-flight turn, held - /// until the assistant's final message is committed so it can render beneath - /// the reply text (e.g. a mail-triage card) instead of in the trace stream. - Map? _pendingTurnComponent; + /// Generative-UI card payloads produced by tools during the in-flight turn, + /// keyed by tool name (last call of each tool wins). Nothing is shown just + /// because a tool ran: a card surfaces only if the assistant's final reply + /// references it with a `tool_card` block, which is resolved against this map + /// when the message is committed (see [addAssistantMessage]). Cleared at the + /// start of every turn. + final Map> _turnToolComponents = + >{}; OnboardingProfile? get profile => _profile; VoidCallback? get onLogout => _onLogout; @@ -597,7 +602,7 @@ class AppShellController extends ChangeNotifier { if (text.isEmpty || _isSending) return; _isSending = true; - _pendingTurnComponent = null; + _turnToolComponents.clear(); inputController.clear(); _notify(); appendMessage(ChatMessage(author: 'You', text: text, isUser: true)); @@ -710,7 +715,8 @@ class AppShellController extends ChangeNotifier { // interfere with the live streaming text. _scheduleStreamNotify(); if (hasContent && voice.isVoicingReply) { - voice.pushReplyText(streaming.text); + // Never speak the trailing `ui` component block. + voice.pushReplyText(streamingVisibleText(streaming.text)); } } @@ -739,21 +745,31 @@ class AppShellController extends ChangeNotifier { void addAssistantMessage(String text, {String? reasoning}) { if (_disposed) return; - final component = _pendingTurnComponent; - _pendingTurnComponent = null; + // Split off any model-emitted `ui` block (always stripped from the visible + // text so raw JSON is never shown), then decide the card: an explicit + // reference or composed component if present, else the most recent tool + // card when the reply reads as a short lead-in. A long pivot answer that + // merely ran a tool gets no card. + final parts = splitAssistantComponent(text); + final component = resolveMessageComponent( + emitted: parts.component, + capturedToolComponents: _turnToolComponents, + replyText: parts.text, + ); + _turnToolComponents.clear(); appendMessage( ChatMessage( author: 'StudyOS Agent', - text: text, + text: parts.text, isUser: false, reasoning: reasoning, component: component, ), ); - _status = text; + _status = parts.text; _notify(); unawaited(HapticFeedback.lightImpact()); - voice.endSpokenReply(text); + voice.endSpokenReply(parts.text); } Future loadSessions() async { @@ -796,11 +812,12 @@ class AppShellController extends ChangeNotifier { } void addToolTrace(ToolTrace trace) { - // A tool that emitted a generative-UI component: hold it for the assistant - // message rather than rendering it in the trace stream. Last producer in - // the turn wins (mirrors how the reply summarises the latest fetch). - if (trace.component != null) { - _pendingTurnComponent = trace.component; + // Capture a tool's card payload for the turn, keyed by tool name. It is only + // shown if the assistant's final reply opts it in with a `tool_card` + // reference — running the tool alone never surfaces a card. + final component = trace.component; + if (component != null) { + _turnToolComponents[trace.toolName] = component; } _applySessionMutation( upsertToolTraceInSessions( diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart index 9dabff3..dbc7e57 100644 --- a/flutter_app/lib/src/generative_ui_registry.dart +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -1,5 +1,17 @@ import 'dart:convert'; +/// Bounds on a `custom_view` node tree, enforced during validation so a +/// malformed or oversized payload from a small model can't blow up layout or +/// recursion. The renderer stays tolerant of individual bad leaf nodes (it +/// skips them); these caps only guard the overall shape. +const int customViewMaxNodes = 48; +const int customViewMaxDepth = 4; +const int customViewMaxChildrenPerContainer = 24; + +/// The one recursive container node in the `custom_view` vocabulary. Its +/// children live under the same `blocks` key the root uses. +const String customViewContainerNode = 'group'; + enum GeneratedComponentKind { nextAction('next_action'), scheduleSummary('schedule_summary'), @@ -13,7 +25,8 @@ enum GeneratedComponentKind { studyProgress('study_progress'), mensaMenu('mensa_menu'), campusLocations('campus_locations'), - scheduleAgenda('schedule_agenda'); + scheduleAgenda('schedule_agenda'), + customView('custom_view'); const GeneratedComponentKind(this.wireName); @@ -156,8 +169,53 @@ abstract final class GenerativeUiRegistry { arguments, 'events', ), + GeneratedComponentKind.customView => _validateCustomView(arguments), }; } + + /// Validates only the *structure* of a `custom_view` tree: a non-empty + /// `blocks` list within the node-count, depth, and per-container caps. Leaf + /// nodes are intentionally not field-checked here — the renderer skips any it + /// can't draw — so a mostly-good tree from a weak model still renders instead + /// of collapsing to plain text. + static List _validateCustomView(Map arguments) { + final blocks = arguments['blocks']; + if (blocks is! List || blocks.isEmpty) { + return ['Missing non-empty list argument: blocks']; + } + final errors = []; + var nodeCount = 0; + + void walk(List nodes, int depth) { + if (errors.isNotEmpty) return; + if (depth > customViewMaxDepth) { + errors.add('Custom view nesting exceeds depth $customViewMaxDepth'); + return; + } + if (nodes.length > customViewMaxChildrenPerContainer) { + errors.add( + 'Custom view container exceeds ' + '$customViewMaxChildrenPerContainer children', + ); + return; + } + for (final node in nodes) { + nodeCount++; + if (nodeCount > customViewMaxNodes) { + errors.add('Custom view exceeds $customViewMaxNodes nodes'); + return; + } + if (node is Map && node['node'] == customViewContainerNode) { + final children = node['blocks']; + if (children is List) walk(children, depth + 1); + if (errors.isNotEmpty) return; + } + } + } + + walk(blocks, 1); + return errors; + } } /// Single entry point the provider tool loops use to turn a completed tool's @@ -837,6 +895,60 @@ generativeUiFixturePayloads = >[ ], }, }, + { + 'type': 'custom_view', + 'title': 'Supervised vs. unsupervised', + 'body': 'A quick comparison for your exam prep.', + 'arguments': { + 'blocks': >[ + { + 'node': 'badges', + 'items': >[ + {'text': 'Exam topic', 'tone': 'positive'}, + {'text': 'ML core', 'tone': 'neutral'}, + ], + }, + { + 'node': 'table', + 'columns': ['Aspect', 'Supervised', 'Unsupervised'], + 'rows': >[ + ['Labels', 'Required', 'None'], + ['Goal', 'Predict targets', 'Find structure'], + ['Example', 'Classification', 'Clustering'], + ], + }, + { + 'node': 'stats', + 'items': >[ + {'value': '2', 'label': 'Lectures left'}, + {'value': '5 days', 'label': 'Until exam'}, + ], + }, + { + 'node': 'group', + 'blocks': >[ + {'node': 'heading', 'text': 'Revise next'}, + { + 'node': 'bullets', + 'items': [ + 'k-means and its assumptions', + 'Bias–variance trade-off', + ], + }, + ], + }, + {'node': 'divider'}, + { + 'node': 'button', + 'label': 'Plan a review block', + 'action': { + 'type': 'prompt', + 'prompt': 'Plan a 45 minute review block on unsupervised learning.', + }, + }, + ], + }, + }, ]; /// An interaction requested by a generative-UI component. Cards emit these diff --git a/flutter_app/lib/src/prompt_context.dart b/flutter_app/lib/src/prompt_context.dart index f64deaa..1b97029 100644 --- a/flutter_app/lib/src/prompt_context.dart +++ b/flutter_app/lib/src/prompt_context.dart @@ -47,18 +47,72 @@ class PromptContext { 'If required data is unavailable, say what is missing instead of guessing.', ) ..writeln( - 'These tools display their own results visually in the app: ' + 'These tools can display their result visually in the app: ' 'get_recent_mail, search_mail, get_deadlines, search_talks, ' 'get_schedule, get_academic_status, get_study_planner, ' - 'get_mensa_options, search_campus_locations. After ' - 'calling one, reply with a single short, natural lead-in sentence (for ' - 'example "Here are your recent emails:") and nothing more. Do not list, ' - 'tabulate, or restate the returned items, and never mention, describe, ' - 'or promise a card, widget, or that something "will appear" — just the ' - 'lead-in. If the tool returned no items, say briefly and plainly what ' - 'was empty or missing instead.', + 'get_mensa_options, search_campus_locations. When your answer is ' + 'presenting one of these results, reply with ONLY a single short, ' + 'natural lead-in sentence (for example "Here are your recent emails:") ' + 'and nothing else — the app then shows the result as a card, so do not ' + 'list, tabulate, or restate the returned items, and never describe or ' + 'promise the card. If you called such a tool but your answer is about ' + 'something else (you were checking, or the question turned out to be ' + 'about another topic), write your normal full answer with no lead-in — ' + 'no card appears. If the tool returned no items, say briefly and plainly ' + 'what was empty or missing. When you fetched from several of these tools ' + 'in one turn and need a specific one shown, you may end the message with ' + 'a reference naming it:\n' + '```ui\n' + '{"type":"tool_card","tool":"get_recent_mail"}\n' + '```', ) - ..writeln('Do not expose secrets or credentials.'); + ..writeln('Do not expose secrets or credentials.') + ..writeln( + 'When a reply that used none of those tools would be helped by a small ' + 'interactive card, you MAY end the message with exactly one fenced ' + '```ui block holding a single JSON object. Use it sparingly and only ' + 'when it clearly helps; most replies need no card. Put the block last, ' + 'write nothing after it, and do not mention or describe the card in ' + 'your prose. Every block needs "type", "title", "body", and ' + '"arguments". Supported cards:\n' + '- quick_reply: suggest one tappable follow-up. ' + 'arguments: {"reply": ""}.\n' + '- next_action: offer one next step as a button. ' + 'arguments: {"action_id": "", "cta": ""}.\n' + '- deadline_card: highlight one deadline. ' + 'arguments: {"course": "", "due": ""}.\n' + 'Example:\n' + '```ui\n' + '{"type":"quick_reply","title":"Suggestion","body":"Want a study ' + 'plan?","arguments":{"reply":"Plan a 45 minute review block before my ' + 'next lecture."}}\n' + '```', + ) + ..writeln( + 'When a reply needs a richer layout than those (a comparison, ' + 'checklist, steps, or key figures), you MAY instead emit a custom_view ' + 'card. Its "arguments" is {"blocks": [ ... ]}, an ordered list of ' + 'nodes; each node is an object with a "node" field. Node types: ' + 'heading {text}; paragraph {text}; bullets {items:[string]}; ' + 'key_values {rows:[{label,value}]}; table {columns:[string], ' + 'rows:[[string]]}; stats {items:[{value,label}]}; badges ' + '{items:[{text,tone}]} with tone neutral|positive|warning; divider {}; ' + 'group {blocks:[...]} to nest one level; button {label, action}. A ' + 'button "action" is one of {"type":"prompt","prompt":"..."}, ' + '{"type":"reminder","title":"...","due":""}, or ' + '{"type":"map","name":"...","latitude":,"longitude":}. Keep it ' + 'small: a few blocks and shallow nesting. Same rules — put the block ' + 'last, write nothing after it, keep your prose to a short lead-in. ' + 'Example:\n' + '```ui\n' + '{"type":"custom_view","title":"Two options","body":"Quick ' + 'compare.","arguments":{"blocks":[{"node":"table","columns":["Aspect",' + '"A","B"],"rows":[["Cost","Low","High"]]},{"node":"button","label":' + '"Explain more","action":{"type":"prompt","prompt":"Explain option A in ' + 'detail."}}]}}\n' + '```', + ); final profileBlock = _profileBlock(); if (profileBlock.isNotEmpty) { buffer diff --git a/flutter_app/lib/src/views/settings_view.dart b/flutter_app/lib/src/views/settings_view.dart index aa45ee6..9f1fec4 100644 --- a/flutter_app/lib/src/views/settings_view.dart +++ b/flutter_app/lib/src/views/settings_view.dart @@ -6,6 +6,7 @@ import '../native_bridge.dart'; import '../studyos_theme.dart'; import '../widgets/feedback_settings_card.dart'; import '../widgets/cloud_assistant_settings.dart'; +import '../widgets/generated_ui_preview_section.dart'; import '../widgets/local_model_settings_card.dart'; import '../widgets/profile_row.dart'; import '../widgets/settings_card.dart'; @@ -240,6 +241,13 @@ class _SettingsViewState extends State { children: [FeedbackSettingsCard(status: widget.status)], ), ), + const SizedBox(height: StudyOsSpacing.xl), + const _SettingsSection( + title: 'Developer', + child: SettingsCard( + children: [GeneratedUiPreviewSection()], + ), + ), ], ); } diff --git a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart index f227c3a..bc0f26f 100644 --- a/flutter_app/lib/src/widgets/generated_ui_preview_section.dart +++ b/flutter_app/lib/src/widgets/generated_ui_preview_section.dart @@ -4,9 +4,13 @@ import '../models.dart'; import '../studyos_theme.dart'; import 'academic_status_card.dart'; import 'campus_location_card.dart'; +import 'custom_view_card.dart'; import 'deadline_card.dart'; +import 'deadline_highlight_card.dart'; import 'mail_triage_card.dart'; import 'mensa_card.dart'; +import 'next_action_card.dart'; +import 'quick_reply_card.dart'; import 'schedule_card.dart'; import 'study_progress_card.dart'; import 'talk_card.dart'; @@ -74,6 +78,18 @@ class _GeneratedUiPreviewSectionState extends State { GeneratedComponentKind.scheduleAgenda => ScheduleCard( component: component, ), + GeneratedComponentKind.quickReply => QuickReplyCard( + component: component, + ), + GeneratedComponentKind.nextAction => NextActionCard( + component: component, + ), + GeneratedComponentKind.deadlineCard => DeadlineHighlightCard( + component: component, + ), + GeneratedComponentKind.customView => CustomViewCard( + component: component, + ), _ => _GeneratedComponentCard(component: component), } else @@ -151,6 +167,7 @@ class _GeneratedComponentCard extends StatelessWidget { GeneratedComponentKind.mensaMenu => Icons.restaurant_outlined, GeneratedComponentKind.campusLocations => Icons.place_outlined, GeneratedComponentKind.scheduleAgenda => Icons.calendar_month_outlined, + GeneratedComponentKind.customView => Icons.dashboard_customize_outlined, }; } } diff --git a/flutter_app/lib/src/widgets/message_list.dart b/flutter_app/lib/src/widgets/message_list.dart index efeb9a1..2468143 100644 --- a/flutter_app/lib/src/widgets/message_list.dart +++ b/flutter_app/lib/src/widgets/message_list.dart @@ -1,14 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import '../generated_ui_message.dart'; import '../message_trace_compaction.dart'; import '../models.dart'; import '../studyos_theme.dart'; import 'academic_status_card.dart'; import 'campus_location_card.dart'; +import 'custom_view_card.dart'; import 'deadline_card.dart'; +import 'deadline_highlight_card.dart'; import 'mail_triage_card.dart'; +import 'markdown_math.dart'; import 'mensa_card.dart'; +import 'next_action_card.dart'; +import 'quick_reply_card.dart'; import 'schedule_card.dart'; import 'study_progress_card.dart'; import 'talk_card.dart'; @@ -112,10 +118,51 @@ Widget? generatedComponentCard( component: component, compact: compact, ), + GeneratedComponentKind.quickReply => QuickReplyCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.nextAction => NextActionCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.deadlineCard => DeadlineHighlightCard( + component: component, + onAction: onAction, + compact: compact, + ), + GeneratedComponentKind.customView => CustomViewCard( + component: component, + onAction: onAction, + compact: compact, + ), _ => null, }; } +/// Component kinds produced by a tool from fetched data (mail, deadlines, …). +/// Their reply text is a one-line lead-in over a list the card already shows, so +/// [_AssistantText] trims it to the lead-in. Model-emitted kinds (quick_reply, +/// next_action, deadline_card) instead sit under a full prose answer, which is +/// kept intact. +const Set _dataRestatingComponentTypes = { + 'mail_list', + 'deadline_list', + 'talk_list', + 'academic_status', + 'study_progress', + 'mensa_menu', + 'campus_locations', + 'schedule_agenda', +}; + +bool _restatesData(Map? payload) { + return payload != null && + _dataRestatingComponentTypes.contains(payload['type']); +} + class _ToolTraceRow extends StatelessWidget { const _ToolTraceRow({required this.message, required this.compact}); @@ -287,13 +334,15 @@ class _AssistantText extends StatelessWidget { onAction: onComponentAction, compact: compact, ); - // When a card is attached it *is* the answer, so drop everything after the + // A tool-backed data card *is* the answer, so drop everything after the // model's lead-in line. The models don't reliably honour the "don't restate // the data" prompt rule, and a restated table/list beneath the card reads as // duplication. Keeping just the first line preserves "Here are your …:". - final text = card == null - ? message.text - : _leadInLine(message.text); + // Model-emitted cards (quick_reply, next_action, deadline_card) instead + // accompany a full prose answer, so their text is kept intact. + final text = card != null && _restatesData(message.component) + ? _leadInLine(message.text) + : message.text; return Align( alignment: Alignment.centerLeft, child: Container( @@ -308,6 +357,8 @@ class _AssistantText extends StatelessWidget { data: text, selectable: true, styleSheet: assistantMarkdownStyle(context), + extensionSet: mathMarkdownExtensionSet(), + builders: mathMarkdownBuilders(), ), ?card, ], @@ -343,6 +394,9 @@ class _StreamingBubble extends StatelessWidget { @override Widget build(BuildContext context) { final reasoning = streaming.reasoning.trim(); + // Hide a trailing `ui` component block while it streams in, so its raw JSON + // never flashes before the reply is committed and the card takes over. + final visibleText = streamingVisibleText(streaming.text); return Align( alignment: Alignment.centerLeft, child: Container( @@ -353,11 +407,13 @@ class _StreamingBubble extends StatelessWidget { children: [ if (reasoning.isNotEmpty) ThinkingTrace(reasoning: reasoning, live: true), - if (streaming.hasText) + if (visibleText.trim().isNotEmpty) MarkdownBody( - data: streaming.text, + data: visibleText, selectable: true, styleSheet: assistantMarkdownStyle(context), + extensionSet: mathMarkdownExtensionSet(), + builders: mathMarkdownBuilders(), ) else if (reasoning.isEmpty) const _TypingDots(), diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index c6682e7..325eb71 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -174,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" + flutter_math_fork: + dependency: "direct main" + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_secure_storage: dependency: "direct main" description: @@ -222,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -361,7 +377,7 @@ packages: source: hosted version: "1.3.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 @@ -400,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" objective_c: dependency: transitive description: @@ -424,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -472,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -504,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -661,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -749,6 +805,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" vector_math: dependency: transitive description: @@ -797,6 +877,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index ce96c4d..f5bf7f3 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: flutter_secure_storage: ^10.3.1 http: ^1.6.0 flutter_markdown_plus: ^1.0.7 + markdown: ^7.2.1 + flutter_math_fork: ^0.7.2 go_router: ^14.8.1 path_provider: ^2.1.5 html: ^0.15.6 From 5c22f31c2550b269774e9af1cde2e1316f9834b8 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 29 Jul 2026 18:52:40 +0200 Subject: [PATCH 6/9] Add GenUI card system for mail/deadlines/etc, adjust local model lifecycle 1. Add closed registry of card widgets (mail, deadlines, academic status, schedule, mensa, talks, campus location, study progress, quick-reply/next-action/custom-view) plus a model-emitted `ui` block parser (generated_ui_message.dart) that decouples card display from which tool ran. 2. Add inline/display LaTeX rendering in chat replies (markdown_math.dart). 3. Wire card payload dispatch through the tool catalog and both LLM provider paths (local + cloud). 4. Tune the on-device LiteRT/AI Core client: proper model memory management, remove duplicate tool loop, add message timeout. --- .../offline/LiteRtLocalPromptClient.java | 429 +++++++++++++++++- .../studyos_agent/AndroidLocalPromptClient.kt | 98 ++++ .../com/studyos/studyos_agent/MainActivity.kt | 76 ++++ flutter_app/lib/src/agent_config_store.dart | 8 + flutter_app/lib/src/agent_llm_provider.dart | 145 +++++- flutter_app/lib/src/models.dart | 22 +- flutter_app/lib/src/native_bridge.dart | 58 +++ flutter_app/lib/src/studyos_tool_catalog.dart | 20 + .../src/widgets/android_ai_core_settings.dart | 1 + .../widgets/local_model_settings_card.dart | 88 ++++ 10 files changed, 921 insertions(+), 24 deletions(-) diff --git a/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java b/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java index ae14db5..c168fa6 100644 --- a/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java +++ b/flutter_app/android/app/src/main/java/com/example/studyOS/offline/LiteRtLocalPromptClient.java @@ -3,6 +3,7 @@ import android.util.Log; import com.google.ai.edge.litertlm.Backend; +import com.google.ai.edge.litertlm.Content; import com.google.ai.edge.litertlm.Contents; import com.google.ai.edge.litertlm.Conversation; import com.google.ai.edge.litertlm.ConversationConfig; @@ -10,11 +11,22 @@ import com.google.ai.edge.litertlm.EngineConfig; import com.google.ai.edge.litertlm.Message; import com.google.ai.edge.litertlm.MessageCallback; +import com.google.ai.edge.litertlm.OpenApiTool; import com.google.ai.edge.litertlm.SamplerConfig; +import com.google.ai.edge.litertlm.ToolCall; +import com.google.ai.edge.litertlm.ToolKt; +import com.google.ai.edge.litertlm.ToolProvider; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; import java.io.File; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -64,6 +76,7 @@ public class LiteRtLocalPromptClient implements AutoCloseable { private String activeSystemInstruction; private String activeBackend; private String activeBackendPreference; + private String activeToolsSignature; private volatile String backendPreference = BACKEND_GPU; /** Receives streamed tokens as they are generated. */ @@ -98,10 +111,351 @@ public synchronized String generateStreaming( String systemInstruction, StreamListener streamListener ) throws Exception { - ensureConversation(modelPath, cacheDir, systemInstruction); + ensureConversation(modelPath, cacheDir, systemInstruction, Collections.emptyList()); return streamSendMessage(prompt, streamListener); } + /** + * Native function-calling first turn (manual mode). Ensures a tool-enabled + * conversation from {@code toolSchemasJson} (OpenAPI function declarations), + * streams {@code prompt}, and returns a structured result map: + * {@code {"type":"tool_calls","calls":[{"name","arguments"(JSON string)}]}} + * or {@code {"type":"text","text"}}. Text fragments of a plain-answer turn are + * streamed to {@code streamListener} as they arrive (a tool-request turn emits + * no user-visible tokens); tool execution stays in the Dart layer, and results + * come back via {@link #continueWithToolResults}. + */ + public synchronized Map generateWithTools( + String modelPath, + String prompt, + String cacheDir, + String systemInstruction, + List toolSchemasJson, + StreamListener streamListener + ) throws Exception { + ensureConversation(modelPath, cacheDir, systemInstruction, toolSchemasJson); + return streamToolTurn( + streamListener, + callback -> conversation.sendMessageAsync( + prompt, callback, Collections.emptyMap())); + } + + /** + * Feeds executed tool results back into the active tool conversation and + * streams the next turn (same shape as {@link #generateWithTools}). Each entry + * is {@code {"name": String, "response": Object}}. + */ + public synchronized Map continueWithToolResults( + List> results, + StreamListener streamListener + ) throws Exception { + if (conversation == null) { + throw new IllegalStateException( + "No active tool conversation. Send a tool message first."); + } + List contents = new ArrayList<>(); + for (Map result : results) { + String name = String.valueOf(result.get("name")); + Object response = result.get("response"); + contents.add(new Content.ToolResponse(name, response == null ? "" : response)); + } + Message toolMessage = Message.Companion.tool(Contents.Companion.of(contents)); + return streamToolTurn( + streamListener, + callback -> conversation.sendMessageAsync( + toolMessage, callback, Collections.emptyMap())); + } + + /** + * Runs one tool-enabled turn with live token streaming. Text fragments are + * streamed to {@code streamListener} as they arrive; any structured tool calls + * the model emits are collected. Returns the structured map the Dart tool loop + * expects: a {@code tool_calls} turn when the model requested tools, else a + * {@code text} turn carrying the streamed final answer. + * + *

This is the streaming analogue of the old synchronous + * {@code conversation.sendMessage} tool path. It reuses the same + * {@link #GENERATION_TIMEOUT_SECONDS} latch/cancel guard as + * {@link #streamSendMessage}, so a native function-calling turn is now bounded + * and cancellable exactly like plain text generation. + */ + private Map streamToolTurn( + StreamListener streamListener, AsyncSend sender) throws Exception { + final StringBuilder fullText = new StringBuilder(); + final List collectedCalls = new ArrayList<>(); + final CountDownLatch latch = new CountDownLatch(1); + final Throwable[] failure = new Throwable[1]; + sender.send(new MessageCallback() { + @Override + public void onMessage(Message message) { + if (message == null) { + return; + } + // Tool calls ride Message.getToolCalls(), not the text contents, so + // a tool-request delta streams no user-visible tokens. Collect calls + // across deltas (mirroring the incremental text stream); the Dart + // loop clears the live buffer before the follow-up answer streams. + List calls = message.getToolCalls(); + if (calls != null && !calls.isEmpty()) { + collectedCalls.addAll(calls); + } + String chunk = textContent(message); + if (chunk.isEmpty()) { + return; + } + fullText.append(chunk); + if (streamListener != null) { + streamListener.onToken(chunk); + } + } + + @Override + public void onDone() { + latch.countDown(); + } + + @Override + public void onError(Throwable throwable) { + failure[0] = throwable; + latch.countDown(); + } + }); + awaitGeneration(latch, failure); + return buildTurnResult(collectedCalls, fullText.toString().trim()); + } + + /** Dispatches an async send on the active conversation with our stream callback. */ + private interface AsyncSend { + void send(MessageCallback callback) throws Exception; + } + + /** + * Builds the structured turn map from a completed stream: a {@code tool_calls} + * turn when the model requested tools, else a {@code text} turn. + */ + private static Map buildTurnResult(List calls, String text) { + Map out = new HashMap<>(); + if (calls != null && !calls.isEmpty()) { + out.put("type", "tool_calls"); + out.put("calls", callsToMaps(calls)); + } else { + out.put("type", "text"); + out.put("text", text); + } + return out; + } + + /** Serializes structured tool calls into the Dart executor's name/arguments shape. */ + private static List> callsToMaps(List calls) { + List> callList = new ArrayList<>(); + for (ToolCall call : calls) { + Map callMap = new HashMap<>(); + callMap.put("name", call.getName()); + callMap.put("arguments", argumentsToJson(call.getArguments())); + callList.add(callMap); + } + return callList; + } + + /** Serializes a tool call's argument map into a JSON string for the Dart executor. */ + private static String argumentsToJson(Map arguments) { + if (arguments == null || arguments.isEmpty()) { + return "{}"; + } + try { + JSONObject object = new JSONObject(); + for (Map.Entry entry : arguments.entrySet()) { + object.put(entry.getKey(), jsonSafe(entry.getValue())); + } + return object.toString(); + } catch (Throwable error) { + Log.w(TAG, "Failed to serialize tool arguments; sending empty object.", error); + return "{}"; + } + } + + /** + * Coerces a value into an {@code org.json}-safe form. Gson elements (which + * LiteRT-LM may hand back) are re-parsed from their JSON text so they are not + * double-encoded; maps/lists recurse; primitives pass through. + */ + private static Object jsonSafe(Object value) { + if (value == null) { + return JSONObject.NULL; + } + if (value instanceof com.google.gson.JsonElement) { + try { + return new JSONTokener(value.toString()).nextValue(); + } catch (Throwable ignored) { + return value.toString(); + } + } + if (value instanceof Map) { + JSONObject object = new JSONObject(); + Map map = (Map) value; + for (Map.Entry entry : map.entrySet()) { + try { + object.put(String.valueOf(entry.getKey()), jsonSafe(entry.getValue())); + } catch (Throwable ignored) { + // Skip un-encodable entries rather than failing the whole call. + } + } + return object; + } + if (value instanceof Iterable) { + JSONArray array = new JSONArray(); + for (Object item : (Iterable) value) { + array.put(jsonSafe(item)); + } + return array; + } + return value; + } + + // ---- Native function-calling probe ------------------------------------- + // A throwaway spike (debug-only) that verifies whether LiteRT-LM 0.13.1's + // manual tool-calling path works on the shipped model: it declares one + // OpenApiTool, disables automaticToolCalling, and checks that the model + // returns a *structured* ToolCall (Message.getToolCalls()) instead of the + // bracketed [TOOL:] text the production path parses. It also round-trips a + // Content.ToolResponse to confirm the model produces a final answer. This is + // deliberately isolated from the cached production conversation and does not + // touch the [TOOL:] loop — see local-inference-architecture memory. + + private static final String PROBE_TOOL_SCHEMA = + "{\"name\":\"read_memories\"," + + "\"description\":\"Read the student's saved long-term memory notes.\"," + + "\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[]}}"; + private static final String PROBE_SYSTEM_INSTRUCTION = + "You are a StudyOS test agent. When the user asks about their saved memory " + + "notes, call the read_memories tool to look them up."; + private static final String PROBE_USER_PROMPT = + "What have I saved in my memory notes? Use the read_memories tool to check."; + private static final String PROBE_TOOL_RESULT = + "{\"memories\":\"Probe succeeded: the student prefers morning study sessions.\"}"; + + /** + * Runs the manual native tool-calling probe against {@code modelPath} and returns a + * human-readable diagnostic report. Builds its own engine/conversation and closes them, + * so the cached production conversation (and its KV cache) is left untouched. Any cached + * production engine is released first to avoid holding two engines in memory at once. + */ + public synchronized String probeToolCall(String modelPath, String cacheDir) throws Exception { + File modelFile = new File(modelPath); + if (!modelFile.exists()) { + return "Model file does not exist: " + modelPath; + } + // Free any cached production engine so the probe engine does not double RAM. + close(); + + Engine probeEngine = null; + Conversation probeConversation = null; + StringBuilder report = new StringBuilder(); + try { + try { + probeEngine = createEngine(modelFile, cacheDir, new Backend.GPU()); + probeEngine.initialize(); + report.append("engine: GPU\n"); + } catch (Throwable gpuError) { + closeQuietly(probeEngine); + probeEngine = createEngine(modelFile, cacheDir, new Backend.CPU()); + probeEngine.initialize(); + report.append("engine: CPU (GPU fallback)\n"); + } + + OpenApiTool readMemoriesTool = new OpenApiTool() { + @Override + public String getToolDescriptionJsonString() { + return PROBE_TOOL_SCHEMA; + } + + @Override + public String execute(String argumentsJson) { + // Never invoked in manual mode (automaticToolCalling = false); + // present only to satisfy the interface. + return PROBE_TOOL_RESULT; + } + }; + + ConversationConfig config = new ConversationConfig( + Contents.Companion.of(PROBE_SYSTEM_INSTRUCTION), + Collections.emptyList(), + List.of(ToolKt.tool(readMemoriesTool)), + new SamplerConfig( + LOCAL_SAMPLER_TOP_K, + LOCAL_SAMPLER_TOP_P, + LOCAL_SAMPLER_TEMPERATURE, + LOCAL_SAMPLER_RANDOM_SEED + ), + false /* automaticToolCalling: manual — hand tool calls back to us */ + ); + probeConversation = probeEngine.createConversation(config); + + Message first = probeConversation.sendMessage( + PROBE_USER_PROMPT, Collections.emptyMap()); + List calls = first.getToolCalls(); + int callCount = calls == null ? 0 : calls.size(); + report.append("tool_calls_returned: ").append(callCount).append('\n'); + + if (callCount == 0) { + report.append("first_response_text: ").append(joinText(first)).append('\n'); + report.append("VERDICT: FAIL — model did not emit a structured tool call.\n"); + return report.toString(); + } + + ToolCall call = calls.get(0); + report.append("call.name: ").append(call.getName()).append('\n'); + report.append("call.arguments: ").append(call.getArguments()).append('\n'); + + // Round-trip a tool result and confirm the model produces a final answer. + Content.ToolResponse toolResponse = + new Content.ToolResponse(call.getName(), PROBE_TOOL_RESULT); + Message toolMessage = Message.Companion.tool(Contents.Companion.of(toolResponse)); + Message finalResp = probeConversation.sendMessage( + toolMessage, Collections.emptyMap()); + report.append("final_answer: ").append(joinText(finalResp)).append('\n'); + report.append("VERDICT: PASS — native function calling works on this model.\n"); + return report.toString(); + } catch (Throwable error) { + report.append("VERDICT: ERROR — ").append(error).append('\n'); + return report.toString(); + } finally { + if (probeConversation != null) { + try { + probeConversation.close(); + } catch (Throwable ignored) { + } + } + closeQuietly(probeEngine); + } + } + + /** Concatenates the text parts of a message, trimmed; ignores non-text content. */ + private static String joinText(Message message) { + return textContent(message).trim(); + } + + /** + * Concatenates the text parts of a message without trimming, so streamed + * chunks keep their leading/trailing spacing. Tool-call content lives on + * {@link Message#getToolCalls()} rather than here, so a pure tool-request delta + * yields the empty string. + */ + private static String textContent(Message message) { + if (message == null + || message.getContents() == null + || message.getContents().getContents() == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Content content : message.getContents().getContents()) { + if (content instanceof Content.Text) { + sb.append(((Content.Text) content).getText()); + } + } + return sb.toString(); + } + private String streamSendMessage(String prompt, StreamListener streamListener) throws Exception { final StringBuilder full = new StringBuilder(); final CountDownLatch latch = new CountDownLatch(1); @@ -131,10 +485,19 @@ public void onError(Throwable throwable) { } }, Collections.emptyMap()); + awaitGeneration(latch, failure); + return full.toString().trim(); + } + + /** + * Blocks until a streamed generation settles, enforcing the shared + * {@link #GENERATION_TIMEOUT_SECONDS} bound. On timeout the in-flight decode is + * cancelled and a {@link TimeoutException} is thrown instead of blocking the + * executor thread forever; a callback failure is rethrown. + */ + private void awaitGeneration(CountDownLatch latch, Throwable[] failure) throws Exception { boolean completed = latch.await(GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); if (!completed) { - // The callback never settled. Cancel the in-flight decode and surface - // a timeout instead of blocking the executor thread forever. cancel(); throw new TimeoutException( "Local generation timed out after " + GENERATION_TIMEOUT_SECONDS + "s."); @@ -145,15 +508,20 @@ public void onError(Throwable throwable) { } throw new RuntimeException(failure[0]); } - return full.toString().trim(); } - private void ensureConversation(String modelPath, String cacheDir, String systemInstruction) - throws Exception { + private void ensureConversation( + String modelPath, + String cacheDir, + String systemInstruction, + List toolSchemasJson + ) throws Exception { + String toolsSignature = toolsSignature(toolSchemasJson); if (conversation != null && modelPath.equals(activeModelPath) && backendPreference.equals(activeBackendPreference) - && Objects.equals(systemInstruction, activeSystemInstruction)) { + && Objects.equals(systemInstruction, activeSystemInstruction) + && Objects.equals(toolsSignature, activeToolsSignature)) { return; } close(); @@ -167,20 +535,60 @@ private void ensureConversation(String modelPath, String cacheDir, String system String instruction = (systemInstruction == null || systemInstruction.isBlank()) ? DEFAULT_SYSTEM_INSTRUCTION : systemInstruction; + List toolProviders = new ArrayList<>(); + if (toolSchemasJson != null) { + for (String schema : toolSchemasJson) { + if (schema != null && !schema.isBlank()) { + toolProviders.add(ToolKt.tool(openApiToolFor(schema))); + } + } + } + // automaticToolCalling = false: even with tools declared, hand every tool + // call back to the Dart loop rather than executing natively. Harmless when + // toolProviders is empty (the plain text-generation path). ConversationConfig config = new ConversationConfig( Contents.Companion.of(instruction), - List.of(), - List.of(), + Collections.emptyList(), + toolProviders, new SamplerConfig( LOCAL_SAMPLER_TOP_K, LOCAL_SAMPLER_TOP_P, LOCAL_SAMPLER_TEMPERATURE, LOCAL_SAMPLER_RANDOM_SEED - ) + ), + false ); conversation = engine.createConversation(config); activeModelPath = modelFile.getAbsolutePath(); activeSystemInstruction = systemInstruction; + activeToolsSignature = toolsSignature; + } + + /** A stable fingerprint of the declared tool schemas, for conversation reuse. */ + private static String toolsSignature(List toolSchemasJson) { + if (toolSchemasJson == null || toolSchemasJson.isEmpty()) { + return ""; + } + return String.join("", toolSchemasJson); + } + + /** + * Wraps one OpenAPI function declaration as an {@link OpenApiTool}. In manual + * mode {@link OpenApiTool#execute} is never invoked (the Dart layer executes + * tools), so it only needs to surface the declaration JSON. + */ + private static OpenApiTool openApiToolFor(final String schemaJson) { + return new OpenApiTool() { + @Override + public String getToolDescriptionJsonString() { + return schemaJson; + } + + @Override + public String execute(String argumentsJson) { + return ""; + } + }; } /** @@ -286,5 +694,6 @@ public synchronized void close() { activeSystemInstruction = null; activeBackend = null; activeBackendPreference = null; + activeToolsSignature = null; } } diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt index d61d810..b28a392 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidLocalPromptClient.kt @@ -127,6 +127,104 @@ class AndroidLocalPromptClient(context: Context) { } } + /** + * Native function-calling first turn (flag-gated). Only the LiteRT-LM path + * supports structured tools; Gemini Nano via ML Kit does not, so a blank + * [modelPath] is surfaced as an error. Text of a plain-answer turn streams + * through [onDelta] as it is generated; the structured turn map + * ({@code tool_calls} or {@code text}) is returned via [onResult]. + */ + fun generateWithTools( + prompt: String, + systemInstruction: String, + modelId: String, + modelPath: String, + backend: String, + toolSchemas: List, + onDelta: (String) -> Unit, + onResult: (Map) -> Unit, + onError: (String) -> Unit, + ) { + executor.execute { + try { + if (modelPath.isBlank()) { + onError( + "Native function calling requires a downloaded LiteRT-LM model.", + ) + return@execute + } + liteRtClient.setBackendPreference(backend) + onResult( + liteRtClient.generateWithTools( + modelPath, + prompt, + appContext.cacheDir.absolutePath, + systemInstruction, + toolSchemas, + object : LiteRtLocalPromptClient.StreamListener { + override fun onToken(token: String) { + onDelta(token) + } + }, + ), + ) + } catch (error: Throwable) { + onError("LiteRT-LM function calling failed: ${error.message}") + } + } + } + + /** + * Feeds executed tool results back into the active native tool conversation, + * streaming the next turn's text through [onDelta] and returning the + * structured turn map via [onResult]. + */ + fun continueWithToolResults( + results: List>, + onDelta: (String) -> Unit, + onResult: (Map) -> Unit, + onError: (String) -> Unit, + ) { + executor.execute { + try { + @Suppress("UNCHECKED_CAST") + onResult( + liteRtClient.continueWithToolResults( + results as List>, + object : LiteRtLocalPromptClient.StreamListener { + override fun onToken(token: String) { + onDelta(token) + } + }, + ), + ) + } catch (error: Throwable) { + onError("LiteRT-LM tool result handling failed: ${error.message}") + } + } + } + + /** + * Debug spike: runs the LiteRT-LM native (manual) tool-calling probe against + * [modelPath] and returns a diagnostic report. Isolated from the production + * generate() path; only meaningful for a downloaded .litertlm model. + */ + fun probeToolCall( + modelPath: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + ) { + executor.execute { + try { + onSuccess( + liteRtClient.probeToolCall(modelPath, appContext.cacheDir.absolutePath), + ) + } catch (error: Throwable) { + onError("Native tool-calling probe failed: ${error.message}") + } + } + } + fun capabilities(): Map { return mapOf( "androidLocalModelProvider" to diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt index 69d9540..56e2d2d 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt @@ -151,6 +151,82 @@ class MainActivity : FlutterActivity() { localPromptClient?.cancel() result.success(null) } + "sendMessageWithTools" -> { + val text = call.argument("text")?.trim().orEmpty() + if (text.isBlank()) { + result.error("empty_message", "Message text must not be empty.", null) + return + } + val toolSchemas = call.argument>("toolSchemas") + ?.map { it.toString() } + ?: emptyList() + if (!nativeInitialized) initializeNativeLayer() + scheduleIdleUnload() + localPromptClient().generateWithTools( + prompt = text, + systemInstruction = call.argument("systemInstruction").orEmpty(), + modelId = call.argument("localModelId").orEmpty(), + modelPath = call.argument("localModelPath").orEmpty(), + backend = call.argument("localBackend").orEmpty(), + toolSchemas = toolSchemas, + onDelta = { token -> emitAssistantDelta(token) }, + onResult = { turn -> + Handler(Looper.getMainLooper()).post { result.success(turn) } + scheduleIdleUnload() + }, + onError = { message -> + emitStatus(message) + Handler(Looper.getMainLooper()).post { + result.error("android_local_model_unavailable", message, null) + } + scheduleIdleUnload() + }, + ) + } + "sendToolResults" -> { + val results = call.argument>("results") + ?.filterIsInstance>() + ?.map { entry -> + entry.entries.associate { (k, v) -> k.toString() to v } + } + ?: emptyList() + localPromptClient().continueWithToolResults( + results = results, + onDelta = { token -> emitAssistantDelta(token) }, + onResult = { turn -> + Handler(Looper.getMainLooper()).post { result.success(turn) } + }, + onError = { message -> + emitStatus(message) + Handler(Looper.getMainLooper()).post { + result.error("android_local_model_unavailable", message, null) + } + }, + ) + } + "probeNativeToolCall" -> { + val modelPath = call.argument("localModelPath")?.trim().orEmpty() + if (modelPath.isBlank()) { + result.error( + "empty_model_path", + "A downloaded LiteRT-LM model path is required for the probe.", + null, + ) + return + } + if (!nativeInitialized) initializeNativeLayer() + localPromptClient().probeToolCall( + modelPath = modelPath, + onSuccess = { report -> + Handler(Looper.getMainLooper()).post { result.success(report) } + }, + onError = { message -> + Handler(Looper.getMainLooper()).post { + result.error("native_tool_probe_failed", message, null) + } + }, + ) + } else -> result.notImplemented() } } diff --git a/flutter_app/lib/src/agent_config_store.dart b/flutter_app/lib/src/agent_config_store.dart index 76b3b0b..aff7e60 100644 --- a/flutter_app/lib/src/agent_config_store.dart +++ b/flutter_app/lib/src/agent_config_store.dart @@ -20,6 +20,8 @@ class AgentConfigStore { static const String _localModelIdKey = 'studyos.agent.localModelId.v1'; static const String _localModelPathKey = 'studyos.agent.localModelPath.v1'; static const String _localBackendKey = 'studyos.agent.localBackend.v1'; + static const String _localToolProtocolKey = + 'studyos.agent.localToolProtocol.v1'; static const String _apiKeyKey = 'studyos.agent.cloudApiKey.v1'; final SharedPreferencesAsync? _preferences; @@ -36,6 +38,7 @@ class AgentConfigStore { final localModelId = await _prefs.getString(_localModelIdKey); final localModelPath = await _prefs.getString(_localModelPathKey); final localBackend = await _prefs.getString(_localBackendKey); + final localToolProtocol = await _prefs.getString(_localToolProtocolKey); final apiKey = await _secure.read(key: _apiKeyKey); if (_hasNoSavedConfig( providerName: providerName, @@ -63,6 +66,7 @@ class AgentConfigStore { localModelId: localModelId ?? const AgentConfig.defaults().localModelId, localModelPath: localModelPath ?? '', localBackend: localBackendFromName(localBackend), + localToolProtocol: localToolProtocolFromName(localToolProtocol), ); } @@ -93,6 +97,10 @@ class AgentConfigStore { await _prefs.setString(_localModelIdKey, config.localModelId.trim()); await _prefs.setString(_localModelPathKey, config.localModelPath.trim()); await _prefs.setString(_localBackendKey, config.localBackend.name); + await _prefs.setString( + _localToolProtocolKey, + config.localToolProtocol.name, + ); } bool _hasNoSavedConfig({ diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 87a3d20..60d24ea 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -132,7 +132,35 @@ class LocalNativeLlmProvider implements AgentLlmProvider { String get displayName => 'Local native model'; @override - Future send(AgentLlmRequest request) async { + Future send(AgentLlmRequest request) { + // Behind a settings flag: the proven bracket `[TOOL:]` text protocol, or + // LiteRT-LM's structured native function calling. Both drive the tool loop + // from Dart (tools execute here, not natively). + return request.config.localToolProtocol == + LocalToolProtocol.nativeFunctionCalling + ? _sendNativeFunctionCalling(request) + : _sendBracket(request); + } + + StudyOsToolContext _toolContextFor( + AgentLlmRequest request, + NativeToolRouter nativeTools, + ) { + return StudyOsToolContext( + promptContext: request.context, + appendMemory: request.appendMemory, + readMemory: request.readMemory, + readSchedule: request.readSchedule, + readAcademicStatus: request.readAcademicStatus, + searchTalks: request.searchTalks, + mailTools: request.mailTools, + nativeTools: nativeTools, + publicStudyTools: request.publicStudyTools, + privateStudyTools: request.privateStudyTools, + ); + } + + Future _sendBracket(AgentLlmRequest request) async { final nativeTools = NativeToolRouter(_bridge); final supportedNativeToolNames = await nativeTools.supportedToolNames(); // The stable system prompt + tool protocol is installed once as the native @@ -149,18 +177,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { localModelPath: request.config.localModelPath, localBackend: request.config.localBackend.name, ); - final toolContext = StudyOsToolContext( - promptContext: request.context, - appendMemory: request.appendMemory, - readMemory: request.readMemory, - readSchedule: request.readSchedule, - readAcademicStatus: request.readAcademicStatus, - searchTalks: request.searchTalks, - mailTools: request.mailTools, - nativeTools: nativeTools, - publicStudyTools: request.publicStudyTools, - privateStudyTools: request.privateStudyTools, - ); + final toolContext = _toolContextFor(request, nativeTools); for (var round = 0; round < _maxToolRounds; round += 1) { final calls = _toolCalls(response); @@ -218,6 +235,108 @@ class LocalNativeLlmProvider implements AgentLlmProvider { return response; } + /// Native function-calling path (experimental, flag-gated). The model returns + /// structured tool calls instead of `[TOOL:]` text; the schema replaces the + /// prose protocol, so only the stable system prompt is installed. The tool + /// loop, execution, and tracing are identical to [_sendBracket] — only the + /// transport differs. Like the bracket path, a plain-answer turn streams its + /// text live via the native `assistantDelta` events (out of band from the + /// structured turn map returned here); [request.onDelta] carries the + /// between-round reset so streamed tokens never linger before a tool follow-up. + Future _sendNativeFunctionCalling(AgentLlmRequest request) async { + final nativeTools = NativeToolRouter(_bridge); + final supportedNativeToolNames = await nativeTools.supportedToolNames(); + final toolSchemas = studyOsToolsForNativeSupport(supportedNativeToolNames) + .map((tool) => tool.toOpenApiToolJson()) + .toList(); + final toolContext = _toolContextFor(request, nativeTools); + + var turn = await _bridge.sendMessageWithTools( + text: _composeFirstTurn( + request.context.ephemeralContext(), + request.userText, + ), + systemInstruction: request.context.stableSystemPrompt(), + toolSchemas: toolSchemas, + localModelId: request.config.localModelId, + localModelPath: request.config.localModelPath, + localBackend: request.config.localBackend.name, + ); + + for (var round = 0; round < _maxToolRounds; round += 1) { + final calls = _nativeToolCalls(turn); + if (calls.isEmpty) return _turnText(turn); + + // The turn resolved into tool calls, not an answer; clear the live buffer + // so nothing lingers before the follow-up answer. Mirrors _sendBracket. + request.onDelta?.call(const AgentStreamDelta(reset: true)); + + final results = >[]; + for (final call in calls) { + final callId = + 'local-${call.name}-${DateTime.now().microsecondsSinceEpoch}'; + request.onToolTrace(_traceForCall(call, 'running', callId: callId)); + final String output; + try { + output = await _toolExecutor.execute( + call.name, + call.arguments, + toolContext, + ); + } on Object catch (error) { + final failedOutput = _toolFailureOutput(error); + request.onToolTrace( + _traceForCall(call, 'failed', callId: callId, output: failedOutput), + ); + results.add({ + 'name': call.name, + 'response': failedOutput, + }); + continue; + } + request.onToolTrace( + _traceForCall(call, 'done', callId: callId, output: output), + ); + results.add({'name': call.name, 'response': output}); + } + + turn = await _bridge.sendToolResults(results); + } + + // Still requesting tools after the round budget: a stuck loop, not an + // answer. Mirror _sendBracket and surface it as an error. + if (_nativeToolCalls(turn).isNotEmpty) { + throw const AgentException( + 'Local tool loop exceeded the maximum number of tool rounds.', + ); + } + return _turnText(turn); + } + + /// Parses a native turn's structured tool calls, keeping only known StudyOS + /// tools. Argument JSON is passed through untouched to the tool executor, + /// which parses it (same contract as the bracket path's arguments string). + List<_LocalToolCall> _nativeToolCalls(Map turn) { + final raw = turn['calls']; + if (raw is! List) return const <_LocalToolCall>[]; + final calls = <_LocalToolCall>[]; + for (final entry in raw) { + if (entry is! Map) continue; + final name = entry['name']?.toString().trim().toLowerCase() ?? ''; + if (name.isEmpty || studyOsToolByName(name) == null) continue; + final arguments = entry['arguments']?.toString(); + calls.add( + _LocalToolCall( + name: name, + arguments: arguments == null || arguments.isEmpty ? '{}' : arguments, + ), + ); + } + return calls; + } + + String _turnText(Map turn) => turn['text']?.toString() ?? ''; + String _localSystemPrompt( String basePrompt, Set supportedNativeToolNames, diff --git a/flutter_app/lib/src/models.dart b/flutter_app/lib/src/models.dart index f703119..4be2b68 100644 --- a/flutter_app/lib/src/models.dart +++ b/flutter_app/lib/src/models.dart @@ -156,6 +156,21 @@ LocalBackend localBackendFromName(String? name) { return name == LocalBackend.cpu.name ? LocalBackend.cpu : LocalBackend.gpu; } +/// How the on-device model routes StudyOS tool calls. +/// +/// [bracket] is the proven text protocol: the model emits `[TOOL:name:args]` +/// text that the Dart layer parses. [nativeFunctionCalling] uses LiteRT-LM's +/// structured function calling (manual mode) — cleaner, but model-template +/// dependent. Defaults to [bracket]; the native path is behind a settings flag. +enum LocalToolProtocol { bracket, nativeFunctionCalling } + +/// Parses a persisted tool-protocol name, defaulting to [LocalToolProtocol.bracket]. +LocalToolProtocol localToolProtocolFromName(String? name) { + return name == LocalToolProtocol.nativeFunctionCalling.name + ? LocalToolProtocol.nativeFunctionCalling + : LocalToolProtocol.bracket; +} + class AgentConfig { const AgentConfig({ required this.provider, @@ -165,6 +180,7 @@ class AgentConfig { required this.localModelId, required this.localModelPath, this.localBackend = LocalBackend.gpu, + this.localToolProtocol = LocalToolProtocol.bracket, }); const AgentConfig.defaults() @@ -174,7 +190,8 @@ class AgentConfig { hasApiKey = false, localModelId = 'platform-default', localModelPath = '', - localBackend = LocalBackend.gpu; + localBackend = LocalBackend.gpu, + localToolProtocol = LocalToolProtocol.bracket; final AgentProvider provider; final String cloudEndpoint; @@ -183,6 +200,7 @@ class AgentConfig { final String localModelId; final String localModelPath; final LocalBackend localBackend; + final LocalToolProtocol localToolProtocol; bool get usesCloud => provider == AgentProvider.cloud; @@ -194,6 +212,7 @@ class AgentConfig { String? localModelId, String? localModelPath, LocalBackend? localBackend, + LocalToolProtocol? localToolProtocol, }) { return AgentConfig( provider: provider ?? this.provider, @@ -203,6 +222,7 @@ class AgentConfig { localModelId: localModelId ?? this.localModelId, localModelPath: localModelPath ?? this.localModelPath, localBackend: localBackend ?? this.localBackend, + localToolProtocol: localToolProtocol ?? this.localToolProtocol, ); } } diff --git a/flutter_app/lib/src/native_bridge.dart b/flutter_app/lib/src/native_bridge.dart index db7196d..0260ed5 100644 --- a/flutter_app/lib/src/native_bridge.dart +++ b/flutter_app/lib/src/native_bridge.dart @@ -223,6 +223,64 @@ class NativeBridge { await _methods.invokeMethod('cancelMessage'); } + /// Sends the first turn of a native function-calling exchange. + /// + /// [toolSchemas] are per-tool OpenAPI function declarations (JSON). The native + /// LiteRT-LM conversation is created with these tools and manual tool calling, + /// so it returns either a structured tool-call request or a final answer: + /// `{'type': 'tool_calls', 'calls': [{'name': String, 'arguments': String}]}` + /// `{'type': 'text', 'text': String}` + /// A plain-answer turn also streams its text live through `assistantDelta` + /// events on the [events] channel (a tool-request turn streams nothing); this + /// map is the settled result. Tool execution stays in Dart; results are + /// returned via [sendToolResults]. + Future> sendMessageWithTools({ + required String text, + String? systemInstruction, + required List toolSchemas, + String? localModelId, + String? localModelPath, + String? localBackend, + }) async { + final result = await _methods.invokeMapMethod( + 'sendMessageWithTools', + { + 'text': text, + 'systemInstruction': systemInstruction, + 'toolSchemas': toolSchemas, + 'localModelId': localModelId, + 'localModelPath': localModelPath, + 'localBackend': localBackend, + }, + ); + return result ?? const {'type': 'text', 'text': ''}; + } + + /// Feeds executed tool results back into the active native tool conversation + /// and returns the next turn (same shape and live `assistantDelta` streaming as + /// [sendMessageWithTools]). Each entry is `{'name': String, 'response': String}`. + Future> sendToolResults( + List> results, + ) async { + final result = await _methods.invokeMapMethod( + 'sendToolResults', + {'results': results}, + ); + return result ?? const {'type': 'text', 'text': ''}; + } + + /// Debug spike: runs the LiteRT-LM native (manual) function-calling probe on + /// [localModelPath] and returns a diagnostic report. Android-only; verifies + /// whether the shipped model emits structured tool calls before committing to + /// migrating the production `[TOOL:]` bracket protocol. + Future probeNativeToolCall({required String localModelPath}) async { + final result = await _methods.invokeMethod( + 'probeNativeToolCall', + {'localModelPath': localModelPath}, + ); + return result ?? 'Native tool-calling probe returned no report.'; + } + String _memoryPreview(String value) { const maxCharacters = 4000; final cleaned = value.trim(); diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index ee6e5ea..95aae43 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'alma_study_tools.dart'; import 'native_tool_router.dart'; import 'private_study_tools.dart'; @@ -17,6 +19,24 @@ class StudyOsToolSpec { final String traceSummary; final Map properties; final List required; + + /// This spec as an OpenAPI-style function declaration — the shape LiteRT-LM's + /// native function calling expects from `OpenApiTool.getToolDescriptionJsonString()`. + /// The catalog's [properties]/[required] are already JSON-schema shaped, so this + /// only wraps them in the `parameters` object. See [toOpenApiToolJson]. + Map toOpenApiFunctionDeclaration() => { + 'name': name, + 'description': description, + 'parameters': { + 'type': 'object', + 'properties': properties, + 'required': required, + }, + }; + + /// The function declaration serialized as JSON, ready to hand to a native + /// `OpenApiTool` for manual (Dart-executed) tool calling. + String toOpenApiToolJson() => jsonEncode(toOpenApiFunctionDeclaration()); } const appendMemoryTool = StudyOsToolSpec( diff --git a/flutter_app/lib/src/widgets/android_ai_core_settings.dart b/flutter_app/lib/src/widgets/android_ai_core_settings.dart index faa92a8..b30fe25 100644 --- a/flutter_app/lib/src/widgets/android_ai_core_settings.dart +++ b/flutter_app/lib/src/widgets/android_ai_core_settings.dart @@ -101,6 +101,7 @@ class _AndroidAiCoreSettingsState extends State { DropdownButtonFormField( key: ValueKey(_selectedId), initialValue: selected.id, + isExpanded: true, decoration: const InputDecoration( labelText: 'Built-in model', prefixIcon: Icon(Icons.android_rounded), diff --git a/flutter_app/lib/src/widgets/local_model_settings_card.dart b/flutter_app/lib/src/widgets/local_model_settings_card.dart index c888ec7..28952e7 100644 --- a/flutter_app/lib/src/widgets/local_model_settings_card.dart +++ b/flutter_app/lib/src/widgets/local_model_settings_card.dart @@ -35,6 +35,7 @@ class _LocalModelSettingsCardState extends State { List> _installedModels = >[]; bool _isDownloadingModel = false; bool _isDeletingModel = false; + bool _isProbingToolCall = false; double? _downloadProgress; int _downloadedBytes = 0; int _downloadTotalBytes = -1; @@ -105,6 +106,7 @@ class _LocalModelSettingsCardState extends State { children: [ DropdownButtonFormField( initialValue: _localModelId, + isExpanded: true, decoration: const InputDecoration( labelText: 'Local model', prefixIcon: Icon(Icons.storage_rounded), @@ -163,6 +165,20 @@ class _LocalModelSettingsCardState extends State { style: Theme.of(context).textTheme.bodySmall, ), ), + const SizedBox(height: StudyOsSpacing.sm), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Native function calling'), + subtitle: const Text( + 'Experimental: structured tool calls instead of the text ' + 'protocol. Falls back automatically if the model lacks tool ' + 'support.', + ), + value: + widget.config.localToolProtocol == + LocalToolProtocol.nativeFunctionCalling, + onChanged: _setToolProtocol, + ), const SizedBox(height: StudyOsSpacing.md), TextField( controller: _customModelUrlController, @@ -220,6 +236,24 @@ class _LocalModelSettingsCardState extends State { ), ), ], + // Debug spike: verify LiteRT-LM native function calling on-device + // before migrating the production [TOOL:] bracket protocol. + if (kDebugMode) ...[ + const SizedBox(height: StudyOsSpacing.sm), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _isProbingToolCall ? null : _probeNativeToolCalling, + icon: _isProbingToolCall + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.science_rounded), + label: const Text('Test native tool calling'), + ), + ), + ], ], ), ], @@ -316,6 +350,60 @@ class _LocalModelSettingsCardState extends State { } } + Future _setToolProtocol(bool useNativeFunctionCalling) async { + final next = useNativeFunctionCalling + ? LocalToolProtocol.nativeFunctionCalling + : LocalToolProtocol.bracket; + if (next == widget.config.localToolProtocol) return; + await widget.onSaveAgentConfig( + widget.config.copyWith(localToolProtocol: next), + null, + ); + _showMessage( + useNativeFunctionCalling + ? 'Local model will use native function calling.' + : 'Local model will use the text tool protocol.', + ); + } + + Future _probeNativeToolCalling() async { + final installed = _installedModelFor(_localModelId); + final modelPath = widget.config.localModelPath.isNotEmpty + ? widget.config.localModelPath + : installed?['path']?.toString() ?? ''; + if (modelPath.isEmpty) { + _showMessage('Download a LiteRT-LM model first to probe tool calling.'); + return; + } + + setState(() => _isProbingToolCall = true); + String report; + try { + report = await widget.nativeBridge.probeNativeToolCall( + localModelPath: modelPath, + ); + } catch (error) { + report = 'Probe failed: $error'; + } finally { + if (mounted) setState(() => _isProbingToolCall = false); + } + if (!mounted) return; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Native tool-calling probe'), + content: SingleChildScrollView(child: SelectableText(report)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + Future _cancelModelDownload() async { await widget.nativeBridge.cancelLocalModelDownload(); _showMessage('Cancelling model download...'); From 1615ad80e63394b58e16adda237cd72f90af5786 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 29 Jul 2026 19:48:47 +0200 Subject: [PATCH 7/9] Add missing files --- flutter_app/lib/src/generated_ui_message.dart | 150 ++++++ .../lib/src/widgets/academic_status_card.dart | 179 +++++++ .../lib/src/widgets/campus_location_card.dart | 229 +++++++++ .../lib/src/widgets/custom_view_card.dart | 436 ++++++++++++++++++ .../lib/src/widgets/deadline_card.dart | 273 +++++++++++ .../src/widgets/deadline_highlight_card.dart | 155 +++++++ .../lib/src/widgets/mail_triage_card.dart | 284 ++++++++++++ .../lib/src/widgets/markdown_math.dart | 66 +++ flutter_app/lib/src/widgets/mensa_card.dart | 176 +++++++ .../lib/src/widgets/next_action_card.dart | 88 ++++ .../lib/src/widgets/quick_reply_card.dart | 93 ++++ .../lib/src/widgets/schedule_card.dart | 205 ++++++++ .../lib/src/widgets/study_progress_card.dart | 187 ++++++++ flutter_app/lib/src/widgets/talk_card.dart | 218 +++++++++ .../test/academic_status_tool_test.dart | 121 +++++ .../agent_llm_provider_native_fc_test.dart | 214 +++++++++ .../test/campus_location_card_test.dart | 100 ++++ flutter_app/test/custom_view_card_test.dart | 332 +++++++++++++ .../test/custom_view_validation_test.dart | 98 ++++ flutter_app/test/deadline_card_test.dart | 149 ++++++ .../test/generated_ui_message_test.dart | 58 +++ .../test/generic_component_cards_test.dart | 120 +++++ flutter_app/test/mail_triage_card_test.dart | 69 +++ flutter_app/test/markdown_math_test.dart | 71 +++ .../test/message_list_component_test.dart | 89 ++++ flutter_app/test/schedule_card_test.dart | 121 +++++ flutter_app/test/settings_layout_test.dart | 75 +++ flutter_app/test/study_mensa_cards_test.dart | 50 ++ .../studyos_tool_openapi_schema_test.dart | 54 +++ .../test/talk_academic_cards_test.dart | 79 ++++ .../test/tool_card_reference_test.dart | 194 ++++++++ 31 files changed, 4733 insertions(+) create mode 100644 flutter_app/lib/src/generated_ui_message.dart create mode 100644 flutter_app/lib/src/widgets/academic_status_card.dart create mode 100644 flutter_app/lib/src/widgets/campus_location_card.dart create mode 100644 flutter_app/lib/src/widgets/custom_view_card.dart create mode 100644 flutter_app/lib/src/widgets/deadline_card.dart create mode 100644 flutter_app/lib/src/widgets/deadline_highlight_card.dart create mode 100644 flutter_app/lib/src/widgets/mail_triage_card.dart create mode 100644 flutter_app/lib/src/widgets/markdown_math.dart create mode 100644 flutter_app/lib/src/widgets/mensa_card.dart create mode 100644 flutter_app/lib/src/widgets/next_action_card.dart create mode 100644 flutter_app/lib/src/widgets/quick_reply_card.dart create mode 100644 flutter_app/lib/src/widgets/schedule_card.dart create mode 100644 flutter_app/lib/src/widgets/study_progress_card.dart create mode 100644 flutter_app/lib/src/widgets/talk_card.dart create mode 100644 flutter_app/test/academic_status_tool_test.dart create mode 100644 flutter_app/test/agent_llm_provider_native_fc_test.dart create mode 100644 flutter_app/test/campus_location_card_test.dart create mode 100644 flutter_app/test/custom_view_card_test.dart create mode 100644 flutter_app/test/custom_view_validation_test.dart create mode 100644 flutter_app/test/deadline_card_test.dart create mode 100644 flutter_app/test/generated_ui_message_test.dart create mode 100644 flutter_app/test/generic_component_cards_test.dart create mode 100644 flutter_app/test/mail_triage_card_test.dart create mode 100644 flutter_app/test/markdown_math_test.dart create mode 100644 flutter_app/test/message_list_component_test.dart create mode 100644 flutter_app/test/schedule_card_test.dart create mode 100644 flutter_app/test/settings_layout_test.dart create mode 100644 flutter_app/test/study_mensa_cards_test.dart create mode 100644 flutter_app/test/studyos_tool_openapi_schema_test.dart create mode 100644 flutter_app/test/talk_academic_cards_test.dart create mode 100644 flutter_app/test/tool_card_reference_test.dart diff --git a/flutter_app/lib/src/generated_ui_message.dart b/flutter_app/lib/src/generated_ui_message.dart new file mode 100644 index 0000000..f31c31a --- /dev/null +++ b/flutter_app/lib/src/generated_ui_message.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +/// Fenced-block marker the model uses to attach a generative-UI component to a +/// reply that did not run a tool. The reply ends with: +/// +/// ```ui +/// {"type": "quick_reply", "title": "...", "body": "...", "arguments": {...}} +/// ``` +/// +/// Kept as a `ui`-tagged code fence so a malformed or partially streamed block +/// degrades to (at worst) a hidden code block rather than raw JSON, and so the +/// opener is cheap to detect while the reply is still streaming in. +final RegExp _uiFence = RegExp(r'```[ \t]*ui[ \t]*\r?\n([\s\S]*?)```'); + +/// Just the fence opener, used to hide everything from the block onward while +/// the reply streams (before the closing fence has arrived). +final RegExp _uiFenceOpener = RegExp(r'```[ \t]*ui\b'); + +/// A committed assistant reply split into its visible [text] and an optional +/// generative-UI [component] payload the model emitted in a trailing `ui` +/// fence. [component] is left unvalidated — the render layer +/// ([GenerativeUiRegistry]) validates and silently drops anything invalid, so a +/// junk payload just yields no card. +class AssistantMessageParts { + const AssistantMessageParts({required this.text, this.component}); + + final String text; + final Map? component; +} + +/// Splits a raw assistant reply into visible prose and an optional model-emitted +/// component payload. The `ui` fence is always removed from [text] whether or +/// not its contents parse, so raw JSON is never shown to the user; the payload +/// is attached only when the fence holds a JSON object. +AssistantMessageParts splitAssistantComponent(String raw) { + final match = _uiFence.firstMatch(raw); + if (match == null) { + return AssistantMessageParts(text: raw); + } + + final text = raw.replaceRange(match.start, match.end, '').trim(); + final component = _decodeComponent(match.group(1) ?? ''); + return AssistantMessageParts(text: text, component: component); +} + +/// The portion of a still-streaming reply that is safe to show: everything +/// before the `ui` fence opener, so the JSON block never flashes on screen as it +/// arrives token by token. Returns [raw] unchanged when no opener is present. +String streamingVisibleText(String raw) { + final match = _uiFenceOpener.firstMatch(raw); + if (match == null) return raw; + return raw.substring(0, match.start).trimRight(); +} + +/// Wire type of a model-emitted reference that asks the app to display a tool's +/// result as its existing card, rather than restating the tool's data inline. +const String toolCardReferenceType = 'tool_card'; + +/// Resolves the payload extracted from a `ui` block into the component to attach +/// to the assistant message. +/// +/// Tool cards are decoupled from tool execution: running `get_study_planner` +/// does NOT surface a planner card on its own. The model must opt a tool result +/// in by ending its reply with a `{"type":"tool_card","tool":""}` +/// reference, which resolves here to that tool's captured payload from +/// [capturedToolComponents] (keyed by tool name). If the model didn't reference +/// a tool — because it called the tool but pivoted away — nothing shows. +/// +/// - A `tool_card` reference → the captured payload for its `tool`, or null when +/// the tool wasn't called this turn or produced no card. +/// - Any other payload (a model-composed A/B component such as `quick_reply` or +/// `custom_view`) → returned unchanged. +/// - null → null. +Map? resolveComponentPayload( + Map? emitted, + Map> capturedToolComponents, +) { + if (emitted == null) return null; + if (emitted['type'] != toolCardReferenceType) return emitted; + final tool = emitted['tool']?.toString(); + if (tool == null || tool.isEmpty) return null; + return capturedToolComponents[tool]; +} + +/// Upper bounds on what still counts as a presentational lead-in (see +/// [isPresentationalLeadIn]). +const int _leadInMaxLines = 2; +const int _leadInMaxChars = 140; +const int _leadInMaxSentences = 1; + +final RegExp _sentenceEnd = RegExp(r'[.!?]+(\s|$)'); + +/// Whether [replyText] reads as a short lead-in that introduces a result (e.g. +/// "Here are your recent emails:") rather than a full answer that has pivoted to +/// another topic. Used to decide whether to surface a tool's captured card when +/// the model didn't emit an explicit reference — small models write the lead-in +/// naturally but forget the machine-readable block. +/// +/// A lead-in is short on all three axes: at most [_leadInMaxLines] lines, +/// [_leadInMaxChars] characters, and [_leadInMaxSentences] sentence. The +/// sentence count catches a multi-sentence answer that still fits the character +/// budget; the character cap catches a single run-on pivot sentence. +bool isPresentationalLeadIn(String replyText) { + final trimmed = replyText.trim(); + if (trimmed.isEmpty) return false; + if (trimmed.length > _leadInMaxChars) return false; + final lineCount = trimmed + .split('\n') + .where((line) => line.trim().isNotEmpty) + .length; + if (lineCount > _leadInMaxLines) return false; + return _sentenceEnd.allMatches(trimmed).length <= _leadInMaxSentences; +} + +/// Decides the component to attach to an assistant message. +/// +/// Tool cards are shown when the reply is *about* a fetched result, detected two +/// ways: an explicit `tool_card` reference the model emitted (precise, picks the +/// exact tool), or — since a small model often omits that block — a short +/// presentational lead-in ([isPresentationalLeadIn]) paired with a tool that +/// produced a card this turn, in which case the most recently captured card is +/// used. A long answer with no reference (the model called a tool but pivoted +/// away) yields no card, keeping the full prose. Composed A/B components +/// ([resolveComponentPayload] passthrough) always win when present. +/// +/// [capturedToolComponents] is insertion-ordered; its last value is the most +/// recent tool card of the turn. +Map? resolveMessageComponent({ + required Map? emitted, + required Map> capturedToolComponents, + required String replyText, +}) { + final direct = resolveComponentPayload(emitted, capturedToolComponents); + if (direct != null) return direct; + if (capturedToolComponents.isEmpty) return null; + if (!isPresentationalLeadIn(replyText)) return null; + return capturedToolComponents.values.last; +} + +Map? _decodeComponent(String body) { + final trimmed = body.trim(); + if (trimmed.isEmpty) return null; + final Object? decoded; + try { + decoded = jsonDecode(trimmed); + } on FormatException { + return null; + } + return decoded is Map ? Map.from(decoded) : null; +} diff --git a/flutter_app/lib/src/widgets/academic_status_card.dart b/flutter_app/lib/src/widgets/academic_status_card.dart new file mode 100644 index 0000000..24b9908 --- /dev/null +++ b/flutter_app/lib/src/widgets/academic_status_card.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders an `academic_status` generative-UI component: exam/course entries +/// grouped by category with a status badge each. Read-only — a deliberate +/// example that the pattern handles no-action cards without an action callback. +class AcademicStatusCard extends StatelessWidget { + const AcademicStatusCard({ + required this.component, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final bool compact; + + @override + Widget build(BuildContext context) { + final grouped = _groupByCategory(component.arguments['entries']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.school_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + for (final group in grouped) ...[ + Padding( + padding: const EdgeInsets.only( + top: StudyOsSpacing.md, + bottom: StudyOsSpacing.xs, + ), + child: Text( + group.category.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: StudyOsColors.textMuted, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ), + for (final entry in group.entries) + _EntryRow(entry: entry), + ], + ], + ), + ), + ), + ), + ); + } + + static List<_CategoryGroup> _groupByCategory(Object? raw) { + if (raw is! List) return const <_CategoryGroup>[]; + final order = []; + final byCategory = >>{}; + for (final item in raw) { + if (item is! Map) continue; + final entry = Map.from(item); + final category = entry['category']?.toString() ?? 'Other'; + if (!byCategory.containsKey(category)) { + order.add(category); + byCategory[category] = >[]; + } + byCategory[category]!.add(entry); + } + return order + .map((category) => _CategoryGroup(category, byCategory[category]!)) + .toList(growable: false); + } +} + +class _CategoryGroup { + const _CategoryGroup(this.category, this.entries); + + final String category; + final List> entries; +} + +class _EntryRow extends StatelessWidget { + const _EntryRow({required this.entry}); + + final Map entry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = entry['title']?.toString() ?? ''; + final status = entry['status']?.toString().trim() ?? ''; + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + ), + ), + ), + if (status.isNotEmpty) ...[ + const SizedBox(width: StudyOsSpacing.sm), + _StatusBadge(status: status), + ], + ], + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final color = _colorFor(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Text( + status, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + static Color _colorFor(String status) { + final normalized = status.toLowerCase(); + if (normalized.contains('pass') || + normalized.contains('bestanden') || + normalized.contains('complete')) { + return StudyOsColors.success; + } + if (normalized.contains('fail') || + normalized.contains('nicht') || + normalized.contains('overdue')) { + return StudyOsColors.warning; + } + return StudyOsColors.accent; + } +} diff --git a/flutter_app/lib/src/widgets/campus_location_card.dart b/flutter_app/lib/src/widgets/campus_location_card.dart new file mode 100644 index 0000000..0b2363a --- /dev/null +++ b/flutter_app/lib/src/widgets/campus_location_card.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `campus_locations` generative-UI component: geocoded places with +/// address and category. "Open in Maps" launches the device maps app for the +/// coordinates; "Ask" sends a follow-up prompt about the place. Both flow +/// through the single [GeneratedComponentAction] callback. +class CampusLocationCard extends StatelessWidget { + const CampusLocationCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final locations = _locations(component.arguments['locations']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.place_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.sm), + for (var i = 0; i < locations.length; i++) ...[ + if (i > 0) + const Divider(height: 1, color: StudyOsColors.border), + _LocationRow(location: locations[i], onAction: onAction), + ], + ], + ), + ), + ), + ), + ); + } + + static List> _locations(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _LocationRow extends StatelessWidget { + const _LocationRow({required this.location, required this.onAction}); + + final Map location; + final ValueChanged? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final name = location['name']?.toString() ?? 'Location'; + final address = location['address']?.toString().trim() ?? ''; + final category = location['category']?.toString().trim() ?? ''; + final latitude = _double(location['latitude']); + final longitude = _double(location['longitude']); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: StudyOsColors.text, + ), + ), + ), + if (category.isNotEmpty) ...[ + const SizedBox(width: StudyOsSpacing.sm), + _CategoryChip(label: category), + ], + ], + ), + if (address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ), + if (onAction != null && latitude != null && longitude != null) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.xs), + child: Wrap( + spacing: StudyOsSpacing.sm, + children: [ + _LocationAction( + icon: Icons.near_me_outlined, + label: 'Open in Maps', + onPressed: () => onAction!( + MapComponentAction( + name: name, + latitude: latitude, + longitude: longitude, + ), + ), + ), + _LocationAction( + icon: Icons.auto_awesome_outlined, + label: 'Ask', + onPressed: () => + onAction!(PromptComponentAction(_askPrompt(name, address))), + ), + ], + ), + ), + ], + ), + ); + } + + String _askPrompt(String name, String address) { + final where = address.isEmpty ? '' : ' ($address)'; + return 'Tell me about $name$where — what it is and how to get there from ' + 'campus.'; + } +} + +class _CategoryChip extends StatelessWidget { + const _CategoryChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: StudyOsColors.accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: StudyOsColors.accent, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _LocationAction extends StatelessWidget { + const _LocationAction({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 2, + ), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: Theme.of(context).textTheme.labelLarge, + ), + ); + } +} + +double? _double(Object? value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? ''); +} diff --git a/flutter_app/lib/src/widgets/custom_view_card.dart b/flutter_app/lib/src/widgets/custom_view_card.dart new file mode 100644 index 0000000..112fe14 --- /dev/null +++ b/flutter_app/lib/src/widgets/custom_view_card.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `custom_view` generative-UI component: a model-composed card built +/// from a small, whitelisted vocabulary of primitive nodes rather than a +/// purpose-built widget. This is the general path for replies that don't map to +/// a fixed card kind (comparisons, checklists, step guides, stat rows). +/// +/// The renderer is deliberately *tolerant*: any node it doesn't recognise, or +/// that is missing the data it needs, is skipped rather than failing the whole +/// card — a weak model that gets one node wrong still gets a useful result. The +/// structural bounds (node count, depth, children-per-container) are enforced up +/// front by [GenerativeUiRegistry]; recursion here is depth-guarded again as +/// defence in depth. Buttons can only emit the existing sealed +/// [GeneratedComponentAction] set, so the safety model (a tap is the user's +/// authorization) is preserved. +class CustomViewCard extends StatelessWidget { + const CustomViewCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final body = component.body.trim(); + final blocks = _buildBlocks( + component.arguments['blocks'], + depth: 1, + context: context, + ); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.dashboard_customize_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + if (body.isNotEmpty) ...[ + const SizedBox(height: StudyOsSpacing.xs), + Text(body, style: theme.textTheme.bodyMedium), + ], + if (blocks.isNotEmpty) ...[ + const SizedBox(height: StudyOsSpacing.sm), + ...blocks, + ], + ], + ), + ), + ), + ), + ); + } + + /// Builds the renderable children of a `blocks` list, dropping any node that + /// yields nothing and inserting vertical spacing between the survivors. + List _buildBlocks( + Object? raw, { + required int depth, + required BuildContext context, + }) { + if (raw is! List) return const []; + final widgets = []; + for (final node in raw) { + final widget = _buildNode(node, depth: depth, context: context); + if (widget == null) continue; + if (widgets.isNotEmpty) { + widgets.add(const SizedBox(height: StudyOsSpacing.sm)); + } + widgets.add(widget); + } + return widgets; + } + + Widget? _buildNode( + Object? raw, { + required int depth, + required BuildContext context, + }) { + if (raw is! Map) return null; + final node = Map.from(raw); + final theme = Theme.of(context); + switch (node['node']?.toString()) { + case 'heading': + final text = _str(node, 'text'); + return text.isEmpty + ? null + : Text(text, style: theme.textTheme.titleMedium); + case 'paragraph': + final text = _str(node, 'text'); + return text.isEmpty + ? null + : Text(text, style: theme.textTheme.bodyLarge); + case 'bullets': + return _bullets(_strList(node['items']), theme); + case 'key_values': + return _keyValues(_mapList(node['rows']), theme); + case 'table': + return _table(node, theme); + case 'stats': + return _stats(_mapList(node['items']), theme); + case 'badges': + return _badges(_mapList(node['items']), theme); + case 'divider': + return const Divider(height: 1, color: StudyOsColors.border); + case customViewContainerNode: + if (depth >= customViewMaxDepth) return null; + final children = _buildBlocks( + node['blocks'], + depth: depth + 1, + context: context, + ); + if (children.isEmpty) return null; + return Padding( + padding: const EdgeInsets.only(left: StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + case 'button': + return _button(node); + default: + return null; + } + } + + Widget? _bullets(List items, ThemeData theme) { + if (items.isEmpty) return null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 7, right: StudyOsSpacing.sm), + child: DecoratedBox( + decoration: const BoxDecoration( + color: StudyOsColors.accent, + shape: BoxShape.circle, + ), + child: const SizedBox.square(dimension: 5), + ), + ), + Expanded(child: Text(item, style: theme.textTheme.bodyMedium)), + ], + ), + ), + ], + ); + } + + Widget? _keyValues(List> rows, ThemeData theme) { + final visible = rows + .where((row) => _str(row, 'label').isNotEmpty || _str(row, 'value').isNotEmpty) + .toList(growable: false); + if (visible.isEmpty) return null; + return Column( + children: [ + for (final row in visible) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + _str(row, 'label'), + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Text( + _str(row, 'value'), + textAlign: TextAlign.right, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget? _table(Map node, ThemeData theme) { + final columns = _strList(node['columns']); + final rawRows = node['rows']; + if (columns.isEmpty || rawRows is! List) return null; + final rows = >[]; + for (final raw in rawRows) { + if (raw is! List) continue; + final cells = [ + for (var i = 0; i < columns.length; i++) + i < raw.length ? (raw[i]?.toString() ?? '') : '', + ]; + rows.add(cells); + } + if (rows.isEmpty) return null; + + TableRow buildRow(List cells, {required bool header}) { + return TableRow( + children: [ + for (final cell in cells) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: StudyOsSpacing.xs, + ), + child: Text( + cell, + style: theme.textTheme.bodySmall?.copyWith( + color: header ? StudyOsColors.text : StudyOsColors.textMuted, + fontWeight: header ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ], + ); + } + + return Table( + border: const TableBorder( + horizontalInside: BorderSide(color: StudyOsColors.border), + ), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + buildRow(columns, header: true), + for (final row in rows) buildRow(row, header: false), + ], + ); + } + + Widget? _stats(List> items, ThemeData theme) { + final visible = items + .where((item) => _str(item, 'value').isNotEmpty) + .toList(growable: false); + if (visible.isEmpty) return null; + return Wrap( + spacing: StudyOsSpacing.xl, + runSpacing: StudyOsSpacing.sm, + children: [ + for (final item in visible) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _str(item, 'value'), + style: theme.textTheme.titleMedium?.copyWith( + fontSize: 22, + color: StudyOsColors.accent, + ), + ), + if (_str(item, 'label').isNotEmpty) + Text( + _str(item, 'label'), + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ], + ), + ], + ); + } + + Widget? _badges(List> items, ThemeData theme) { + final visible = items + .where((item) => _str(item, 'text').isNotEmpty) + .toList(growable: false); + if (visible.isEmpty) return null; + return Wrap( + spacing: StudyOsSpacing.sm, + runSpacing: StudyOsSpacing.sm, + children: [ + for (final item in visible) + _Badge(text: _str(item, 'text'), tone: _str(item, 'tone')), + ], + ); + } + + Widget? _button(Map node) { + final label = _str(node, 'label'); + final action = _actionFrom(node['action']); + if (label.isEmpty || action == null) return null; + return Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + onPressed: onAction == null ? null : () => onAction!(action), + style: OutlinedButton.styleFrom( + foregroundColor: StudyOsColors.accent, + side: const BorderSide(color: StudyOsColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + ), + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(label), + ), + ); + } + + /// Maps a button's `action` object onto the existing sealed action set. Any + /// unknown type or incomplete payload returns null, which drops the button — + /// no new action kinds can be introduced through a custom view. + GeneratedComponentAction? _actionFrom(Object? raw) { + if (raw is! Map) return null; + final action = Map.from(raw); + switch (action['type']?.toString()) { + case 'prompt': + final prompt = _str(action, 'prompt'); + return prompt.isEmpty ? null : PromptComponentAction(prompt); + case 'reminder': + final title = _str(action, 'title'); + final due = DateTime.tryParse(_str(action, 'due'))?.toLocal(); + return title.isEmpty || due == null + ? null + : ReminderComponentAction(title: title, dueAt: due); + case 'map': + final name = _str(action, 'name'); + final latitude = _toDouble(action['latitude']); + final longitude = _toDouble(action['longitude']); + return name.isEmpty || latitude == null || longitude == null + ? null + : MapComponentAction( + name: name, + latitude: latitude, + longitude: longitude, + ); + default: + return null; + } + } + + static String _str(Map node, String key) => + node[key]?.toString().trim() ?? ''; + + static List _strList(Object? raw) { + if (raw is! List) return const []; + return raw + .map((item) => item?.toString().trim() ?? '') + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + + static List> _mapList(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } + + static double? _toDouble(Object? value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString().replaceAll(',', '.') ?? ''); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.text, required this.tone}); + + final String text; + final String tone; + + @override + Widget build(BuildContext context) { + final color = switch (tone.toLowerCase()) { + 'positive' || 'success' => StudyOsColors.success, + 'warning' => StudyOsColors.warning, + 'danger' || 'destructive' => StudyOsColors.destructive, + _ => StudyOsColors.accent, + }; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/deadline_card.dart b/flutter_app/lib/src/widgets/deadline_card.dart new file mode 100644 index 0000000..ead7d47 --- /dev/null +++ b/flutter_app/lib/src/widgets/deadline_card.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `deadline_list` generative-UI component: one row per upcoming +/// deadline with course, due date, and urgency accent. Two actions per row — +/// "Add reminder" fires a native device reminder (a side effect, but always +/// user-initiated), and "Plan block" asks the agent to schedule study time. +/// Both flow through the single [GeneratedComponentAction] callback. +class DeadlineCard extends StatelessWidget { + const DeadlineCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final deadlines = _deadlines(component.arguments['deadlines']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.assignment_late_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.sm), + for (var i = 0; i < deadlines.length; i++) ...[ + if (i > 0) + const Divider(height: 1, color: StudyOsColors.border), + _DeadlineRow(deadline: deadlines[i], onAction: onAction), + ], + ], + ), + ), + ), + ), + ); + } + + static List> _deadlines(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _DeadlineRow extends StatelessWidget { + const _DeadlineRow({required this.deadline, required this.onAction}); + + final Map deadline; + final ValueChanged? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = deadline['title']?.toString() ?? 'Deadline'; + final course = deadline['course']?.toString().trim() ?? ''; + final requirement = deadline['requirement']?.toString().trim() ?? ''; + final due = DateTime.tryParse(deadline['due_at']?.toString() ?? '') + ?.toLocal(); + final urgency = _urgencyFor(due); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5, right: StudyOsSpacing.sm), + child: DecoratedBox( + decoration: BoxDecoration( + color: urgency.color, + shape: BoxShape.circle, + ), + child: const SizedBox.square(dimension: 8), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: StudyOsColors.text, + ), + ), + if (course.isNotEmpty) + Text( + course, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _dueLabel(due, urgency, requirement), + style: theme.textTheme.bodySmall?.copyWith( + color: urgency.color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + if (onAction != null && due != null) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.xs, left: 16), + child: Wrap( + spacing: StudyOsSpacing.sm, + children: [ + _DeadlineAction( + icon: Icons.notifications_active_outlined, + label: 'Add reminder', + onPressed: () => onAction!( + ReminderComponentAction(title: title, dueAt: due), + ), + ), + _DeadlineAction( + icon: Icons.event_note_outlined, + label: 'Plan block', + onPressed: () => onAction!( + PromptComponentAction(_planPrompt(title, course, due)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _planPrompt(String title, String course, DateTime due) { + final courseRef = course.isEmpty ? '' : ' for $course'; + return 'Plan a focused study block$courseRef ahead of the deadline ' + '"$title" (due ${due.toIso8601String()}). Suggest a specific time ' + 'that fits around my schedule.'; + } + + String _dueLabel(DateTime? due, _Urgency urgency, String requirement) { + if (due == null) return requirement.isEmpty ? 'No due date' : requirement; + final suffix = requirement.isEmpty ? '' : ' · $requirement'; + return 'Due ${_formatDue(due)} (${urgency.label})$suffix'; + } + + static String _formatDue(DateTime due) { + const weekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final hh = due.hour.toString().padLeft(2, '0'); + final mm = due.minute.toString().padLeft(2, '0'); + return '${weekdays[due.weekday - 1]} ${due.day} ' + '${months[due.month - 1]}, $hh:$mm'; + } + + static _Urgency _urgencyFor(DateTime? due) { + if (due == null) return const _Urgency(StudyOsColors.textMuted, 'no date'); + final now = DateTime.now(); + if (due.isBefore(now)) return const _Urgency(StudyOsColors.warning, 'overdue'); + final hours = due.difference(now).inHours; + if (hours <= 48) { + return const _Urgency(StudyOsColors.warning, 'soon'); + } + final days = due.difference(now).inDays; + return _Urgency(StudyOsColors.accent, 'in $days days'); + } +} + +class _Urgency { + const _Urgency(this.color, this.label); + + final Color color; + final String label; +} + +class _DeadlineAction extends StatelessWidget { + const _DeadlineAction({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 2, + ), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: Theme.of(context).textTheme.labelLarge, + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/deadline_highlight_card.dart b/flutter_app/lib/src/widgets/deadline_highlight_card.dart new file mode 100644 index 0000000..0059122 --- /dev/null +++ b/flutter_app/lib/src/widgets/deadline_highlight_card.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `deadline_card` generative-UI component: a single highlighted +/// deadline the model calls out mid-conversation (distinct from the tool-backed +/// `deadline_list`, which lists many rows from `get_deadlines`). Shows the +/// course and due date with an urgency accent, plus an "Add reminder" action +/// that fires a native device reminder when the `due` value parses as a date. +class DeadlineHighlightCard extends StatelessWidget { + const DeadlineHighlightCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final course = component.arguments['course']?.toString().trim() ?? ''; + final due = DateTime.tryParse( + component.arguments['due']?.toString() ?? '', + )?.toLocal(); + final body = component.body.trim(); + final accent = _urgencyColor(due); + + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.assignment_late_outlined, + size: 18, + color: accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + component.title, + style: theme.textTheme.labelLarge, + ), + if (course.isNotEmpty) + Text( + course, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ], + ), + ), + ], + ), + if (due != null || body.isNotEmpty) ...[ + const SizedBox(height: StudyOsSpacing.xs), + Text( + due != null ? 'Due ${_formatDue(due)}' : body, + style: theme.textTheme.bodySmall?.copyWith( + color: accent, + fontWeight: FontWeight.w600, + ), + ), + ], + if (onAction != null && due != null) ...[ + const SizedBox(height: StudyOsSpacing.xs), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => onAction!( + ReminderComponentAction( + title: course.isEmpty ? component.title : course, + dueAt: due, + ), + ), + icon: const Icon( + Icons.notifications_active_outlined, + size: 16, + ), + label: const Text('Add reminder'), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 2, + ), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: theme.textTheme.labelLarge, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + static Color _urgencyColor(DateTime? due) { + if (due == null) return StudyOsColors.accent; + final now = DateTime.now(); + if (due.isBefore(now)) return StudyOsColors.warning; + return due.difference(now).inHours <= 48 + ? StudyOsColors.warning + : StudyOsColors.accent; + } + + static String _formatDue(DateTime due) { + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final hh = due.hour.toString().padLeft(2, '0'); + final mm = due.minute.toString().padLeft(2, '0'); + return '${weekdays[due.weekday - 1]} ${due.day} ' + '${months[due.month - 1]}, $hh:$mm'; + } +} diff --git a/flutter_app/lib/src/widgets/mail_triage_card.dart b/flutter_app/lib/src/widgets/mail_triage_card.dart new file mode 100644 index 0000000..2428236 --- /dev/null +++ b/flutter_app/lib/src/widgets/mail_triage_card.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `mail_list` generative-UI component as an inbox triage card: one +/// row per message with sender/subject/preview and two quick actions. Actions +/// don't mutate mail directly — they submit a follow-up prompt through +/// [onAction] (reusing the normal send path), so the agent stays in the loop +/// and anything side-effecting (like a reply) surfaces as a draft to confirm. +class MailTriageCard extends StatelessWidget { + const MailTriageCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + + /// Emits the action a row requested (a prompt to send). Null in read-only + /// contexts such as the settings preview. + final ValueChanged? onAction; + + final bool compact; + + @override + Widget build(BuildContext context) { + final mailbox = component.arguments['mailbox']?.toString() ?? 'INBOX'; + final messages = _messages(component.arguments['messages']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.mail_outline_rounded, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + component.body, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + const SizedBox(height: StudyOsSpacing.sm), + for (var i = 0; i < messages.length; i++) ...[ + if (i > 0) + const Divider(height: 1, color: StudyOsColors.border), + _MailRow( + message: messages[i], + mailbox: mailbox, + onAction: onAction, + ), + ], + ], + ), + ), + ), + ), + ); + } + + static List> _messages(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _MailRow extends StatelessWidget { + const _MailRow({ + required this.message, + required this.mailbox, + required this.onAction, + }); + + final Map message; + final String mailbox; + final ValueChanged? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subject = message['subject']?.toString() ?? '(no subject)'; + final sender = message['sender']?.toString() ?? 'Unknown sender'; + final preview = message['preview']?.toString().trim() ?? ''; + final isUnread = message['is_unread'] == true; + final isBroadcast = message['is_approved_broadcast'] == true; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 6, right: StudyOsSpacing.sm), + child: DecoratedBox( + decoration: BoxDecoration( + color: isUnread + ? StudyOsColors.accent + : Colors.transparent, + shape: BoxShape.circle, + border: isUnread + ? null + : Border.all(color: StudyOsColors.border), + ), + child: const SizedBox.square(dimension: 8), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + sender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: isUnread + ? FontWeight.w700 + : FontWeight.w600, + color: StudyOsColors.text, + ), + ), + ), + if (isBroadcast) const _BroadcastBadge(), + ], + ), + Text( + subject, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + ), + ), + if (preview.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ), + ], + ), + ), + ], + ), + if (onAction != null) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.xs, left: 16), + child: Wrap( + spacing: StudyOsSpacing.sm, + children: [ + _MailAction( + icon: Icons.summarize_outlined, + label: 'Summarize', + onPressed: () => onAction!( + PromptComponentAction(_summarizePrompt(sender, subject)), + ), + ), + _MailAction( + icon: Icons.reply_outlined, + label: 'Draft reply', + onPressed: () => onAction!( + PromptComponentAction(_replyPrompt(sender, subject)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + String get _uidRef { + final uid = message['uid']?.toString(); + return uid == null ? '' : ' (mail uid $uid in $mailbox)'; + } + + String _summarizePrompt(String sender, String subject) { + return 'Summarize the email "$subject" from $sender$_uidRef. ' + 'Open the full message if you need the details.'; + } + + String _replyPrompt(String sender, String subject) { + return 'Draft a reply to the email "$subject" from $sender$_uidRef. ' + 'Show me the draft only — do not send anything.'; + } +} + +class _BroadcastBadge extends StatelessWidget { + const _BroadcastBadge(); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(left: StudyOsSpacing.sm), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: StudyOsColors.success.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Text( + 'Official', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: StudyOsColors.success, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _MailAction extends StatelessWidget { + const _MailAction({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 2, + ), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: Theme.of(context).textTheme.labelLarge, + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/markdown_math.dart b/flutter_app/lib/src/widgets/markdown_math.dart new file mode 100644 index 0000000..ae91efd --- /dev/null +++ b/flutter_app/lib/src/widgets/markdown_math.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:markdown/markdown.dart' as md; + +/// LaTeX math support for the assistant's Markdown replies. Inline math is +/// written `$…$` and display math `$$…$$`; both are parsed into a `math` element +/// and rendered with `flutter_math_fork`. GitHub-flavored features (tables, +/// etc.) are preserved because the extension set extends `gitHubFlavored` rather +/// than replacing it. +/// +/// Invalid TeX never breaks a reply: `Math.tex`'s error fallback renders the raw +/// `$…$` source as plain text instead. + +/// Parses `$…$` / `$$…$$` into a `math` element carrying a `mode` attribute. +class _MathSyntax extends md.InlineSyntax { + _MathSyntax(super.pattern, this.mode); + + final String mode; + + @override + bool onMatch(md.InlineParser parser, Match match) { + final content = (match[1] ?? '').trim(); + if (content.isEmpty) return false; + final element = md.Element.text('math', content); + element.attributes['mode'] = mode; + parser.addNode(element); + return true; + } +} + +/// GitHub-flavored Markdown plus the two math syntaxes. Display (`$$…$$`) is +/// registered before inline (`$…$`) so it wins. The inline pattern forbids a +/// space just inside the delimiters, which keeps stray currency like "$5 and +/// $10" from being read as math. +md.ExtensionSet mathMarkdownExtensionSet() { + final gfm = md.ExtensionSet.gitHubFlavored; + return md.ExtensionSet( + List.of(gfm.blockSyntaxes), + [ + _MathSyntax(r'\$\$(.+?)\$\$', 'display'), + _MathSyntax(r'\$(?! )((?:[^\$\n])+?)(? Text('\$$tex\$', style: preferredStyle), + ); + } +} + +/// Builder map to pass to `MarkdownBody(builders: ...)`. +Map mathMarkdownBuilders() => + {'math': MathElementBuilder()}; diff --git a/flutter_app/lib/src/widgets/mensa_card.dart b/flutter_app/lib/src/widgets/mensa_card.dart new file mode 100644 index 0000000..739f788 --- /dev/null +++ b/flutter_app/lib/src/widgets/mensa_card.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `mensa_menu` generative-UI component: canteen menu lines with their +/// dishes, dietary markers, and student price. Read-only. +class MensaCard extends StatelessWidget { + const MensaCard({ + required this.component, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final bool compact; + + @override + Widget build(BuildContext context) { + final options = _options(component.arguments['options']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.restaurant_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.sm), + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) + const Divider(height: 1, color: StudyOsColors.border), + _OptionRow(option: options[i]), + ], + ], + ), + ), + ), + ), + ); + } + + static List> _options(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _OptionRow extends StatelessWidget { + const _OptionRow({required this.option}); + + final Map option; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final line = option['line']?.toString() ?? 'Menu'; + final price = option['price']?.toString().trim() ?? ''; + final items = (option['items'] as List?) + ?.map((item) => item.toString()) + .where((item) => item.isNotEmpty) + .join(', ') ?? + ''; + final markers = (option['markers'] as List?) + ?.map((marker) => marker.toString()) + .where((marker) => marker.isNotEmpty) + .toList() ?? + const []; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + line, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: StudyOsColors.text, + ), + ), + ), + if (price.isNotEmpty) + Text( + price, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.accent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + if (items.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + items, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + ), + ), + ), + if (markers.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.xs), + child: Wrap( + spacing: StudyOsSpacing.xs, + runSpacing: StudyOsSpacing.xs, + children: markers + .map((marker) => _MarkerChip(label: marker)) + .toList(), + ), + ), + ], + ), + ); + } +} + +class _MarkerChip extends StatelessWidget { + const _MarkerChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final vegetarian = + label.toLowerCase().contains('veg'); // vegan/vegetarisch/vegetarian + final color = vegetarian ? StudyOsColors.success : StudyOsColors.textMuted; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/next_action_card.dart b/flutter_app/lib/src/widgets/next_action_card.dart new file mode 100644 index 0000000..a377077 --- /dev/null +++ b/flutter_app/lib/src/widgets/next_action_card.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `next_action` generative-UI component: a prominent call-to-action +/// the model surfaces when a reply has an obvious next step. Tapping the CTA +/// sends its label back into the chat as a [PromptComponentAction] (e.g. "Open +/// schedule" → the agent then opens the schedule), so the button stays useful +/// without a bespoke route per `action_id`. With no [onAction] the card shows +/// its title and body only. +class NextActionCard extends StatelessWidget { + const NextActionCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cta = component.arguments['cta']?.toString().trim() ?? ''; + final body = component.body.trim(); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.arrow_forward_rounded, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + if (body.isNotEmpty) ...[ + const SizedBox(height: StudyOsSpacing.xs), + Text(body, style: theme.textTheme.bodyMedium), + ], + if (cta.isNotEmpty && onAction != null) ...[ + const SizedBox(height: StudyOsSpacing.md), + Align( + alignment: Alignment.centerLeft, + child: FilledButton( + onPressed: () => onAction!(PromptComponentAction(cta)), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, + ), + ), + child: Text(cta), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/quick_reply_card.dart b/flutter_app/lib/src/widgets/quick_reply_card.dart new file mode 100644 index 0000000..a05889c --- /dev/null +++ b/flutter_app/lib/src/widgets/quick_reply_card.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `quick_reply` generative-UI component: a suggested follow-up the +/// user can tap to send back into the chat. The tap emits a +/// [PromptComponentAction] carrying the `reply` argument, so the suggestion runs +/// as if the user had typed it. With no [onAction] (e.g. the settings preview) +/// the suggestion renders as a plain, non-tappable chip. +class QuickReplyCard extends StatelessWidget { + const QuickReplyCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final reply = component.arguments['reply']?.toString().trim() ?? ''; + final body = component.body.trim(); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (body.isNotEmpty) ...[ + Text(body, style: theme.textTheme.bodyMedium), + const SizedBox(height: StudyOsSpacing.sm), + ], + if (reply.isNotEmpty) + _ReplyChip( + label: reply, + onPressed: onAction == null + ? null + : () => onAction!(PromptComponentAction(reply)), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ReplyChip extends StatelessWidget { + const _ReplyChip({required this.label, this.onPressed}); + + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.reply_rounded, size: 16), + label: Text(label), + style: OutlinedButton.styleFrom( + foregroundColor: StudyOsColors.accent, + side: const BorderSide(color: StudyOsColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + ), + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.sm, + ), + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: Theme.of(context).textTheme.labelLarge, + alignment: Alignment.centerLeft, + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/schedule_card.dart b/flutter_app/lib/src/widgets/schedule_card.dart new file mode 100644 index 0000000..f1ffac1 --- /dev/null +++ b/flutter_app/lib/src/widgets/schedule_card.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `schedule_agenda` generative-UI component: upcoming lectures +/// grouped by day, each with its time range and room. Read-only. +class ScheduleCard extends StatelessWidget { + const ScheduleCard({ + required this.component, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final bool compact; + + @override + Widget build(BuildContext context) { + final days = _groupByDay(component.arguments['events']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.calendar_month_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + for (final day in days) ...[ + Padding( + padding: const EdgeInsets.only( + top: StudyOsSpacing.md, + bottom: StudyOsSpacing.xs, + ), + child: Text( + day.label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: StudyOsColors.textMuted, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ), + for (final event in day.events) _EventRow(event: event), + ], + ], + ), + ), + ), + ), + ); + } + + static List<_DayGroup> _groupByDay(Object? raw) { + if (raw is! List) return const <_DayGroup>[]; + final order = []; + final byDay = {}; + for (final item in raw) { + if (item is! Map) continue; + final event = Map.from(item); + final start = DateTime.tryParse(event['start']?.toString() ?? '') + ?.toLocal(); + if (start == null) continue; + final key = + '${start.year}-${start.month.toString().padLeft(2, '0')}-' + '${start.day.toString().padLeft(2, '0')}'; + final group = byDay.putIfAbsent(key, () { + order.add(key); + return _DayGroup(_dayLabel(start)); + }); + group.events.add(event); + } + return order.map((key) => byDay[key]!).toList(growable: false); + } +} + +class _DayGroup { + _DayGroup(this.label); + + final String label; + final List> events = >[]; +} + +class _EventRow extends StatelessWidget { + const _EventRow({required this.event}); + + final Map event; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = event['title']?.toString() ?? 'Lecture'; + final location = event['location']?.toString().trim() ?? ''; + final start = DateTime.tryParse(event['start']?.toString() ?? '')?.toLocal(); + final end = DateTime.tryParse(event['end']?.toString() ?? '')?.toLocal(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + _timeRange(start, end), + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.accent, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: FontWeight.w600, + ), + ), + if (location.isNotEmpty) + Text( + location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _timeRange(DateTime? start, DateTime? end) { + if (start == null) return ''; + final startText = _time(start); + if (end == null) return startText; + return '$startText–${_time(end)}'; +} + +String _time(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; + +String _dayLabel(DateTime day) { + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${weekdays[day.weekday - 1]} ${day.day} ${months[day.month - 1]}'; +} diff --git a/flutter_app/lib/src/widgets/study_progress_card.dart b/flutter_app/lib/src/widgets/study_progress_card.dart new file mode 100644 index 0000000..062fee1 --- /dev/null +++ b/flutter_app/lib/src/widgets/study_progress_card.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `study_progress` generative-UI component: an overall ECTS progress +/// bar plus a per-module breakdown with mini bars. Read-only. +class StudyProgressCard extends StatelessWidget { + const StudyProgressCard({ + required this.component, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final bool compact; + + @override + Widget build(BuildContext context) { + final modules = _modules(component.arguments['modules']); + final totalEarned = _double(component.arguments['total_earned']); + final totalRequired = _double(component.arguments['total_required']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.donut_large_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + if (totalEarned != null && totalRequired != null) ...[ + const SizedBox(height: StudyOsSpacing.sm), + _ProgressBar( + earned: totalEarned, + required: totalRequired, + label: + 'Overall · ${_trim(totalEarned)} / ' + '${_trim(totalRequired)} ECTS', + emphasized: true, + ), + ], + const SizedBox(height: StudyOsSpacing.sm), + for (final module in modules) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.sm), + child: _ModuleRow(module: module), + ), + ], + ), + ), + ), + ), + ); + } + + static List> _modules(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _ModuleRow extends StatelessWidget { + const _ModuleRow({required this.module}); + + final Map module; + + @override + Widget build(BuildContext context) { + final title = module['title']?.toString() ?? 'Module'; + final earned = _double(module['earned']); + final required = _double(module['required']); + final summary = + module['summary']?.toString() ?? + (earned != null && required != null + ? '${_trim(earned)} / ${_trim(required)} ECTS' + : ''); + if (earned == null || required == null || required <= 0) { + return Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + ), + ), + ), + if (summary.isNotEmpty) + Text( + summary, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ], + ); + } + return _ProgressBar( + earned: earned, + required: required, + label: '$title · $summary', + emphasized: false, + ); + } +} + +class _ProgressBar extends StatelessWidget { + const _ProgressBar({ + required this.earned, + required this.required, + required this.label, + required this.emphasized, + }); + + final double earned; + final double required; + final String label; + final bool emphasized; + + @override + Widget build(BuildContext context) { + final fraction = required <= 0 ? 0.0 : (earned / required).clamp(0.0, 1.0); + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, + ), + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + child: LinearProgressIndicator( + value: fraction, + minHeight: emphasized ? 8 : 6, + backgroundColor: StudyOsColors.background.withValues(alpha: 0.6), + valueColor: AlwaysStoppedAnimation( + fraction >= 1.0 ? StudyOsColors.success : StudyOsColors.accent, + ), + ), + ), + ], + ); + } +} + +double? _double(Object? value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? ''); +} + +String _trim(double value) => + value == value.roundToDouble() ? value.toInt().toString() : value.toString(); diff --git a/flutter_app/lib/src/widgets/talk_card.dart b/flutter_app/lib/src/widgets/talk_card.dart new file mode 100644 index 0000000..1cdd042 --- /dev/null +++ b/flutter_app/lib/src/widgets/talk_card.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +/// Renders a `talk_list` generative-UI component: upcoming Tübingen talks with a +/// date chip, speaker, and location. "Remind me" fires a native reminder ahead +/// of the talk through the shared [GeneratedComponentAction] seam. +class TalkCard extends StatelessWidget { + const TalkCard({ + required this.component, + this.onAction, + this.compact = false, + super.key, + }); + + final GeneratedUiComponent component; + final ValueChanged? onAction; + final bool compact; + + @override + Widget build(BuildContext context) { + final talks = _talks(component.arguments['talks']); + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 620), + margin: EdgeInsets.symmetric(vertical: compact ? 5 : 8), + child: Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.forum_outlined, + size: 18, + color: StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + component.title, + style: theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: StudyOsSpacing.sm), + for (var i = 0; i < talks.length; i++) ...[ + if (i > 0) + const Divider(height: 1, color: StudyOsColors.border), + _TalkRow(talk: talks[i], onAction: onAction), + ], + ], + ), + ), + ), + ), + ); + } + + static List> _talks(Object? raw) { + if (raw is! List) return const >[]; + return raw + .whereType() + .map((item) => Map.from(item)) + .toList(growable: false); + } +} + +class _TalkRow extends StatelessWidget { + const _TalkRow({required this.talk, required this.onAction}); + + final Map talk; + final ValueChanged? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final title = talk['title']?.toString() ?? 'Talk'; + final speaker = talk['speaker']?.toString().trim() ?? ''; + final location = talk['location']?.toString().trim() ?? ''; + final start = DateTime.tryParse(talk['timestamp']?.toString() ?? '') + ?.toLocal(); + final meta = [ + if (speaker.isNotEmpty) speaker, + if (location.isNotEmpty) location, + ].join(' · '); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: StudyOsSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DateChip(start: start), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: StudyOsColors.text, + ), + ), + if (meta.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + meta, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + ), + ), + ), + ], + ), + ), + ], + ), + if (onAction != null && start != null) + Padding( + padding: const EdgeInsets.only(top: StudyOsSpacing.xs, left: 52), + child: TextButton.icon( + onPressed: () => onAction!( + ReminderComponentAction(title: title, dueAt: start), + ), + icon: const Icon(Icons.notifications_active_outlined, size: 16), + label: const Text('Remind me'), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: 2, + ), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: theme.textTheme.labelLarge, + ), + ), + ), + ], + ), + ); + } +} + +class _DateChip extends StatelessWidget { + const _DateChip({required this.start}); + + final DateTime? start; + + static const _months = [ + 'JAN', + 'FEB', + 'MAR', + 'APR', + 'MAY', + 'JUN', + 'JUL', + 'AUG', + 'SEP', + 'OCT', + 'NOV', + 'DEC', + ]; + + @override + Widget build(BuildContext context) { + final start = this.start; + final theme = Theme.of(context); + return Container( + width: 44, + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: StudyOsColors.background.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + border: Border.all(color: StudyOsColors.border), + ), + child: Column( + children: [ + Text( + start == null ? '—' : start.day.toString(), + style: theme.textTheme.titleMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: FontWeight.w700, + ), + ), + Text( + start == null ? '' : _months[start.month - 1], + style: theme.textTheme.labelSmall?.copyWith( + color: StudyOsColors.textMuted, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} diff --git a/flutter_app/test/academic_status_tool_test.dart b/flutter_app/test/academic_status_tool_test.dart new file mode 100644 index 0000000..9bd7d3e --- /dev/null +++ b/flutter_app/test/academic_status_tool_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:studyos_agent/src/academic_models.dart'; +import 'package:studyos_agent/src/academic_repository.dart'; +import 'package:studyos_agent/src/alma_academic_client.dart'; +import 'package:studyos_agent/src/app_shell_controller.dart'; +import 'package:studyos_agent/src/student_profile.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const profile = OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ); + + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + tearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + AppShellController controllerWith(AcademicRepository repository, { + OnboardingProfile? initialProfile = profile, + }) { + final controller = AppShellController( + initialProfile: initialProfile, + initialOnLogout: null, + initialOnSaveProfile: null, + academicRepository: repository, + ); + addTearDown(controller.dispose); + return controller; + } + + test('surfaces the real error instead of the generic unavailable string', () async { + final controller = controllerWith( + _FakeAcademicRepository.throwing( + const AlmaAcademicException('Sign in again to refresh your academic status.'), + ), + ); + + final result = await controller.readAcademicStatusForAgent(); + + expect(result, 'Sign in again to refresh your academic status.'); + expect(result, isNot(contains('not available'))); + }); + + test('a concurrent background refresh no longer masks a fetch as unavailable', () async { + // Reproduces the race: a refresh is already in flight (as initialize() + // starts one) when the tool reader runs. It must await that fetch and + // return the data, not a stale null snapshot. + final repository = _FakeAcademicRepository.snapshot( + _snapshotWith('Machine Learning'), + delay: const Duration(milliseconds: 40), + ); + final controller = controllerWith(repository); + + final inFlight = controller.refreshAcademicStatus(); // background refresh + final result = await controller.readAcademicStatusForAgent(); + await inFlight; + + final decoded = jsonDecode(result) as Map; + final entries = decoded['entries'] as List; + expect(entries, hasLength(1)); + // Both callers shared one fetch rather than racing separate ones. + expect(repository.refreshCalls, 1); + }); + + test('reports a clear message when no profile is signed in', () async { + final controller = controllerWith( + _FakeAcademicRepository.snapshot(_snapshotWith('X')), + initialProfile: null, + ); + + final result = await controller.readAcademicStatusForAgent(); + + expect(result, contains('no student profile')); + }); +} + +AcademicStatusSnapshot _snapshotWith(String title) { + return AcademicStatusSnapshot( + term: 'WS 2026/27', + refreshedAt: DateTime(2026, 7, 22), + entries: [ + AcademicEntry(category: 'Exams', title: title, status: 'Registered'), + ], + ); +} + +class _FakeAcademicRepository extends AcademicRepository { + _FakeAcademicRepository.snapshot(this._snapshot, {this.delay = Duration.zero}) + : _error = null; + _FakeAcademicRepository.throwing(this._error) + : _snapshot = null, + delay = Duration.zero; + + final AcademicStatusSnapshot? _snapshot; + final Object? _error; + final Duration delay; + int refreshCalls = 0; + + @override + Future refresh( + OnboardingProfile profile, { + PdfTextExtractor? extractPdfText, + }) async { + refreshCalls++; + if (delay > Duration.zero) await Future.delayed(delay); + if (_error != null) throw _error; + return _snapshot!; + } +} diff --git a/flutter_app/test/agent_llm_provider_native_fc_test.dart b/flutter_app/test/agent_llm_provider_native_fc_test.dart new file mode 100644 index 0000000..4f98d9c --- /dev/null +++ b/flutter_app/test/agent_llm_provider_native_fc_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/agent_exception.dart'; +import 'package:studyos_agent/src/agent_llm_provider.dart'; +import 'package:studyos_agent/src/mail_repository.dart'; +import 'package:studyos_agent/src/mail_tools.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/native_bridge.dart'; +import 'package:studyos_agent/src/prompt_context.dart'; + +const _nativeFcConfig = AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + localToolProtocol: LocalToolProtocol.nativeFunctionCalling, +); + +AgentLlmRequest _request( + NativeBridge bridge, { + required String userText, + Future Function()? readMemory, + void Function(ToolTrace trace)? onToolTrace, + AgentStreamSink? onDelta, +}) { + return AgentLlmRequest( + config: _nativeFcConfig, + sessions: const [], + activeSessionId: null, + userText: userText, + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + memoryText: '', + appendMemory: (_) async {}, + readMemory: readMemory ?? () async => '', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner(repository: MailRepository.test(), profile: null), + onToolTrace: onToolTrace ?? (_) {}, + onDelta: onDelta, + ); +} + +void main() { + test('native FC path returns a direct text answer without tools', () async { + final bridge = _FakeToolBridge(>[ + {'type': 'text', 'text': 'Hello, no tools needed.'}, + ]); + final provider = LocalNativeLlmProvider(bridge); + + final response = await provider.send( + _request(bridge, userText: 'Hi there'), + ); + + expect(response, 'Hello, no tools needed.'); + // Tools were still declared to the native layer. + expect(bridge.lastToolSchemas, isNotEmpty); + expect( + bridge.lastToolSchemas.any((schema) => schema.contains('read_memories')), + isTrue, + ); + expect(bridge.toolResultBatches, isEmpty); + }); + + test('native FC path executes a tool call and feeds the result back', () async { + final bridge = _FakeToolBridge(>[ + { + 'type': 'tool_calls', + 'calls': [ + {'name': 'read_memories', 'arguments': '{}'}, + ], + }, + {'type': 'text', 'text': 'I used fresh memory.'}, + ]); + final provider = LocalNativeLlmProvider(bridge); + final traces = []; + + final response = await provider.send( + _request( + bridge, + userText: 'What should I remember?', + readMemory: () async => 'Fresh memory from disk', + onToolTrace: traces.add, + ), + ); + + expect(response, 'I used fresh memory.'); + // The executed tool's output was returned to the native layer. + expect(bridge.toolResultBatches, hasLength(1)); + final result = bridge.toolResultBatches.single.single; + expect(result['name'], 'read_memories'); + expect(result['response'], 'Fresh memory from disk'); + // Running + done traces were emitted for the tool. + expect(traces.map((t) => t.status), containsAll(['running', 'done'])); + }); + + test('native FC path resets the live stream before a tool follow-up', () async { + final bridge = _FakeToolBridge(>[ + { + 'type': 'tool_calls', + 'calls': [ + {'name': 'read_memories', 'arguments': '{}'}, + ], + }, + {'type': 'text', 'text': 'Answer from tool results.'}, + ]); + final provider = LocalNativeLlmProvider(bridge); + final deltas = []; + + final response = await provider.send( + _request( + bridge, + userText: 'What should I remember?', + readMemory: () async => 'Fresh memory', + onDelta: deltas.add, + ), + ); + + expect(response, 'Answer from tool results.'); + expect(deltas.where((delta) => delta.reset), hasLength(1)); + }); + + test('native FC path ignores unknown tool names', () async { + final bridge = _FakeToolBridge(>[ + { + 'type': 'tool_calls', + 'calls': [ + {'name': 'not_a_real_tool', 'arguments': '{}'}, + ], + }, + ]); + final provider = LocalNativeLlmProvider(bridge); + + // No known tool to run, so the loop stops without dispatching tool results. + await provider.send(_request(bridge, userText: 'Try a bogus tool')); + + expect(bridge.toolResultBatches, isEmpty); + }); + + test('native FC path throws when tool rounds are exhausted', () async { + // Always ask for a tool: the loop can never resolve to an answer. + final loopingTurn = { + 'type': 'tool_calls', + 'calls': [ + {'name': 'read_memories', 'arguments': '{}'}, + ], + }; + final bridge = _FakeToolBridge(>[ + loopingTurn, + loopingTurn, + loopingTurn, + loopingTurn, + loopingTurn, + ]); + final provider = LocalNativeLlmProvider(bridge); + + await expectLater( + provider.send( + _request( + bridge, + userText: 'Loop forever', + readMemory: () async => 'x', + ), + ), + throwsA(isA()), + ); + }); +} + +class _FakeToolBridge extends NativeBridge { + _FakeToolBridge(this.turns); + + final List> turns; + int _index = 0; + + List lastToolSchemas = const []; + final List>> toolResultBatches = + >>[]; + + Map _next() { + final turn = turns[_index.clamp(0, turns.length - 1)]; + _index += 1; + return turn; + } + + @override + Future> getNativeToolCapabilities() async { + return const {'nativeTools': >[]}; + } + + @override + Future> sendMessageWithTools({ + required String text, + String? systemInstruction, + required List toolSchemas, + String? localModelId, + String? localModelPath, + String? localBackend, + }) async { + lastToolSchemas = toolSchemas; + return _next(); + } + + @override + Future> sendToolResults( + List> results, + ) async { + toolResultBatches.add(results); + return _next(); + } +} diff --git a/flutter_app/test/campus_location_card_test.dart b/flutter_app/test/campus_location_card_test.dart new file mode 100644 index 0000000..84612fc --- /dev/null +++ b/flutter_app/test/campus_location_card_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:studyos_agent/src/app_shell_controller.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/campus_location_card.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + GeneratedUiComponent locationComponent() { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'campus_locations', + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + group('campusMapsUri', () { + test('builds a Google Maps search deep link for coordinates', () { + final uri = campusMapsUri(48.5296, 9.0596); + expect(uri.host, 'www.google.com'); + expect(uri.path, '/maps/search/'); + expect(uri.queryParameters['query'], '48.5296,9.0596'); + }); + }); + + group('CampusLocationCard', () { + testWidgets('renders places with address and category', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CampusLocationCard(component: locationComponent()), + ), + ), + ); + + expect(find.text('2 places'), findsOneWidget); + expect(find.text('Universitätsbibliothek Tübingen'), findsOneWidget); + expect(find.text('library'), findsOneWidget); + }); + + testWidgets('Open in Maps emits a MapComponentAction with coordinates', ( + tester, + ) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CampusLocationCard( + component: locationComponent(), + onAction: actions.add, + ), + ), + ), + ); + + await tester.tap(find.text('Open in Maps').first); + await tester.pump(); + + expect(actions, hasLength(1)); + final action = actions.single as MapComponentAction; + expect(action.name, 'Universitätsbibliothek Tübingen'); + expect(action.latitude, 48.5296); + expect(action.longitude, 9.0596); + }); + }); + + group('AppShellController maps dispatch', () { + test('routes a map action to the injected url launcher', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + final launched = []; + final controller = AppShellController( + initialProfile: null, + initialOnLogout: null, + initialOnSaveProfile: null, + urlLauncher: (uri) async { + launched.add(uri); + return true; + }, + ); + addTearDown(controller.dispose); + + controller.handleComponentAction( + const MapComponentAction( + name: 'Library', + latitude: 48.5296, + longitude: 9.0596, + ), + ); + await Future.delayed(Duration.zero); + + expect(launched, hasLength(1)); + expect(launched.single.queryParameters['query'], '48.5296,9.0596'); + }); + }); +} diff --git a/flutter_app/test/custom_view_card_test.dart b/flutter_app/test/custom_view_card_test.dart new file mode 100644 index 0000000..af98242 --- /dev/null +++ b/flutter_app/test/custom_view_card_test.dart @@ -0,0 +1,332 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/generated_ui_message.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/custom_view_card.dart'; +import 'package:studyos_agent/src/widgets/message_list.dart'; + +void main() { + GeneratedUiComponent componentFor(List blocks) { + final validation = GenerativeUiRegistry.validate({ + 'type': 'custom_view', + 'title': 'View', + 'body': 'Body', + 'arguments': {'blocks': blocks}, + }); + expect( + validation.component, + isNotNull, + reason: validation.errors.join(', '), + ); + return validation.component!; + } + + Widget host( + GeneratedUiComponent component, { + ValueChanged? onAction, + }) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CustomViewCard(component: component, onAction: onAction), + ), + ), + ); + } + + testWidgets('renders text-bearing leaf nodes', (tester) async { + await tester.pumpWidget( + host( + componentFor([ + {'node': 'heading', 'text': 'My heading'}, + {'node': 'paragraph', 'text': 'A paragraph.'}, + { + 'node': 'bullets', + 'items': ['First point', 'Second point'], + }, + ]), + ), + ); + + expect(find.text('My heading'), findsOneWidget); + expect(find.text('A paragraph.'), findsOneWidget); + expect(find.text('First point'), findsOneWidget); + expect(find.text('Second point'), findsOneWidget); + }); + + testWidgets('renders a table with headers and cells', (tester) async { + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'table', + 'columns': ['Aspect', 'A', 'B'], + 'rows': >[ + ['Cost', 'Low', 'High'], + ], + }, + ]), + ), + ); + + expect(find.byType(Table), findsOneWidget); + expect(find.text('Aspect'), findsOneWidget); + expect(find.text('Cost'), findsOneWidget); + expect(find.text('High'), findsOneWidget); + }); + + testWidgets('renders ragged table rows without crashing', (tester) async { + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'table', + 'columns': ['One', 'Two', 'Three'], + 'rows': [ + ['only-one'], // short row is padded + ['a', 'b', 'c', 'd'], // long row is truncated + 'not-a-row', // skipped + ], + }, + ]), + ), + ); + + expect(find.byType(Table), findsOneWidget); + expect(find.text('only-one'), findsOneWidget); + expect(find.text('d'), findsNothing); + }); + + testWidgets('renders stats, badges, key_values and divider', (tester) async { + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'stats', + 'items': >[ + {'value': '42', 'label': 'Points'}, + ], + }, + { + 'node': 'badges', + 'items': >[ + {'text': 'Urgent', 'tone': 'warning'}, + ], + }, + { + 'node': 'key_values', + 'rows': >[ + {'label': 'Due', 'value': 'Friday'}, + ], + }, + {'node': 'divider'}, + ]), + ), + ); + + expect(find.text('42'), findsOneWidget); + expect(find.text('Points'), findsOneWidget); + expect(find.text('Urgent'), findsOneWidget); + expect(find.text('Due'), findsOneWidget); + expect(find.text('Friday'), findsOneWidget); + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('skips unknown and malformed nodes but keeps valid ones', ( + tester, + ) async { + await tester.pumpWidget( + host( + componentFor([ + {'node': 'mystery_widget', 'text': 'nope'}, + {'node': 'bullets'}, // no items → skipped + {'node': 'paragraph', 'text': 'Survivor'}, + ]), + ), + ); + + expect(find.text('nope'), findsNothing); + expect(find.text('Survivor'), findsOneWidget); + }); + + testWidgets('renders a nested group indented', (tester) async { + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'group', + 'blocks': >[ + {'node': 'paragraph', 'text': 'Inside group'}, + ], + }, + ]), + ), + ); + + expect(find.text('Inside group'), findsOneWidget); + }); + + testWidgets('a button with a prompt action dispatches it', (tester) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'button', + 'label': 'Explain', + 'action': { + 'type': 'prompt', + 'prompt': 'Explain option A.', + }, + }, + ]), + onAction: (value) => action = value, + ), + ); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Explain')); + await tester.pump(); + expect(action, isA()); + expect((action! as PromptComponentAction).prompt, 'Explain option A.'); + }); + + testWidgets('a button with a reminder action dispatches it', (tester) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'button', + 'label': 'Remind me', + 'action': { + 'type': 'reminder', + 'title': 'Submit sheet', + 'due': '2026-07-30T18:00:00', + }, + }, + ]), + onAction: (value) => action = value, + ), + ); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Remind me')); + await tester.pump(); + expect(action, isA()); + expect((action! as ReminderComponentAction).title, 'Submit sheet'); + }); + + testWidgets('a button with a map action dispatches it', (tester) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'button', + 'label': 'Open map', + 'action': { + 'type': 'map', + 'name': 'Library', + 'latitude': 48.5296, + 'longitude': 9.0596, + }, + }, + ]), + onAction: (value) => action = value, + ), + ); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Open map')); + await tester.pump(); + expect(action, isA()); + expect((action! as MapComponentAction).name, 'Library'); + }); + + testWidgets('a button with an unknown action type is dropped', ( + tester, + ) async { + await tester.pumpWidget( + host( + componentFor([ + { + 'node': 'button', + 'label': 'Danger', + 'action': {'type': 'delete_everything'}, + }, + {'node': 'paragraph', 'text': 'Safe content'}, + ]), + ), + ); + + expect(find.text('Danger'), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.text('Safe content'), findsOneWidget); + }); + + testWidgets('renderer stops at the depth guard on an over-nested tree', ( + tester, + ) async { + // Built directly, bypassing validation, to prove the renderer guards depth + // even if an invalid tree reaches it. + Map group(Map child) => { + 'node': 'group', + 'blocks': >[child], + }; + var deep = {'node': 'paragraph', 'text': 'DEEPLEAF'}; + for (var i = 0; i < 6; i++) { + deep = group(deep); + } + final component = GeneratedUiComponent( + kind: GeneratedComponentKind.customView, + title: 'View', + body: 'Body', + arguments: { + 'blocks': [ + {'node': 'paragraph', 'text': 'SHALLOW'}, + deep, + ], + }, + ); + + await tester.pumpWidget(host(component)); + + expect(find.text('SHALLOW'), findsOneWidget); + expect(find.text('DEEPLEAF'), findsNothing); + }); + + testWidgets('end to end: a ui block parses, validates and renders', ( + tester, + ) async { + const raw = + 'Here is a comparison:\n' + '```ui\n' + '{"type":"custom_view","title":"Compare","body":"A vs B",' + '"arguments":{"blocks":[{"node":"paragraph","text":"End to end works."}]}}\n' + '```'; + final parts = splitAssistantComponent(raw); + expect(parts.text, 'Here is a comparison:'); + expect(parts.component, isNotNull); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MessageList( + messages: [ + ChatMessage( + author: 'StudyOS Agent', + text: parts.text, + isUser: false, + component: parts.component, + ), + ], + compact: false, + controller: ScrollController(), + ), + ), + ), + ); + + expect(find.byType(CustomViewCard), findsOneWidget); + expect(find.text('End to end works.'), findsOneWidget); + expect(find.text('Here is a comparison:'), findsOneWidget); + }); +} diff --git a/flutter_app/test/custom_view_validation_test.dart b/flutter_app/test/custom_view_validation_test.dart new file mode 100644 index 0000000..effca2c --- /dev/null +++ b/flutter_app/test/custom_view_validation_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; + +void main() { + GeneratedUiValidation validateBlocks(Object? blocks) { + return GenerativeUiRegistry.validate({ + 'type': 'custom_view', + 'title': 'View', + 'body': 'Body', + 'arguments': {'blocks': blocks}, + }); + } + + Map leaf(String text) => { + 'node': 'paragraph', + 'text': text, + }; + + // A chain of [n] nested groups, innermost holding a single leaf. + Map nestedGroups(int n) { + Map current = leaf('deep'); + for (var i = 0; i < n; i++) { + current = { + 'node': 'group', + 'blocks': >[current], + }; + } + return current; + } + + test('the fixture custom_view validates', () { + final fixture = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'custom_view', + ); + final validation = GenerativeUiRegistry.validate(fixture); + expect(validation.errors, isEmpty); + expect(validation.component, isNotNull); + expect(validation.component!.kind, GeneratedComponentKind.customView); + }); + + test('rejects missing, empty, or non-list blocks', () { + expect(validateBlocks(null).isValid, isFalse); + expect(validateBlocks([]).isValid, isFalse); + expect(validateBlocks('not a list').isValid, isFalse); + expect( + validateBlocks(null).errors.single, + 'Missing non-empty list argument: blocks', + ); + }); + + test('accepts a flat valid tree', () { + final validation = validateBlocks(>[ + leaf('one'), + leaf('two'), + ]); + expect(validation.errors, isEmpty); + expect(validation.isValid, isTrue); + }); + + test('rejects a container with more than the child cap', () { + final tooMany = >[ + for (var i = 0; i < customViewMaxChildrenPerContainer + 1; i++) + leaf('n$i'), + ]; + final validation = validateBlocks(tooMany); + expect(validation.isValid, isFalse); + expect(validation.errors.single, contains('children')); + }); + + test('rejects a tree past the total node cap', () { + // 20 groups × (itself + 2 leaves) = 60 nodes, but no container exceeds the + // child cap and nothing nests past depth 2 — isolating the node-count rule. + final blocks = >[ + for (var i = 0; i < 20; i++) + { + 'node': 'group', + 'blocks': >[leaf('a$i'), leaf('b$i')], + }, + ]; + final validation = validateBlocks(blocks); + expect(validation.isValid, isFalse); + expect(validation.errors.single, contains('nodes')); + }); + + test('depth exactly at the cap is allowed', () { + // 3 nested groups → the leaf sits at depth 4 (== customViewMaxDepth). + expect(customViewMaxDepth, 4); + final validation = validateBlocks(>[nestedGroups(3)]); + expect(validation.errors, isEmpty); + expect(validation.isValid, isTrue); + }); + + test('nesting one level past the cap is rejected', () { + final validation = validateBlocks(>[nestedGroups(4)]); + expect(validation.isValid, isFalse); + expect(validation.errors.single, contains('depth')); + }); +} diff --git a/flutter_app/test/deadline_card_test.dart b/flutter_app/test/deadline_card_test.dart new file mode 100644 index 0000000..dfc8278 --- /dev/null +++ b/flutter_app/test/deadline_card_test.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:studyos_agent/src/app_shell_controller.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/native_tool_router.dart'; +import 'package:studyos_agent/src/widgets/deadline_card.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + GeneratedUiComponent deadlineComponent() { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'deadline_list', + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + group('reminderTimeForDeadline', () { + test('defaults to one day before the deadline', () { + final due = DateTime(2026, 12, 11, 18); + final when = reminderTimeForDeadline( + due, + now: DateTime(2026, 12, 1, 9), + ); + expect(when, DateTime(2026, 12, 10, 18)); + }); + + test('steps to one hour before when a day out is already past', () { + final due = DateTime(2026, 12, 11, 18); + final when = reminderTimeForDeadline( + due, + now: DateTime(2026, 12, 11, 10), + ); + expect(when, DateTime(2026, 12, 11, 17)); + }); + + test('never returns a time in the past for imminent deadlines', () { + final now = DateTime(2026, 12, 11, 17, 45); + final due = DateTime(2026, 12, 11, 18); + final when = reminderTimeForDeadline(due, now: now); + expect(when.isAfter(now), isTrue); + }); + }); + + group('DeadlineCard', () { + testWidgets('renders a row per deadline with course and due date', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DeadlineCard(component: deadlineComponent())), + ), + ); + + expect(find.text('2 upcoming deadlines'), findsOneWidget); + expect(find.text('ML exercise sheet 7'), findsOneWidget); + expect(find.text('Machine Learning'), findsOneWidget); + expect(find.textContaining('Due '), findsWidgets); + }); + + testWidgets('Add reminder emits a ReminderComponentAction with the due date', ( + tester, + ) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DeadlineCard( + component: deadlineComponent(), + onAction: actions.add, + ), + ), + ), + ); + + await tester.tap(find.text('Add reminder').first); + await tester.pump(); + + expect(actions, hasLength(1)); + final action = actions.single as ReminderComponentAction; + expect(action.title, 'ML exercise sheet 7'); + expect( + action.dueAt.isAtSameMomentAs( + DateTime.parse('2026-12-11T18:00:00.000Z'), + ), + isTrue, + ); + }); + }); + + group('AppShellController reminder dispatch', () { + test('routes a reminder action to the native create_reminder tool', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + final runner = _RecordingNativeToolRunner('Reminder set for Thursday.'); + final controller = AppShellController( + initialProfile: null, + initialOnLogout: null, + initialOnSaveProfile: null, + nativeToolRunner: runner, + ); + addTearDown(controller.dispose); + controller.createSession(); + + controller.handleComponentAction( + ReminderComponentAction( + title: 'ML exercise sheet 7', + dueAt: DateTime(2026, 12, 11, 18), + ), + ); + await Future.delayed(Duration.zero); + + expect(runner.calls, hasLength(1)); + expect(runner.calls.single.name, nativeCreateReminderToolName); + final args = + jsonDecode(runner.calls.single.arguments) as Map; + expect(args['title'], 'ML exercise sheet 7'); + expect(args['time'], isNotNull); + + // The native tool's result is surfaced back to the user. + final messages = controller.activeSession.messages; + expect(messages.last.text, 'Reminder set for Thursday.'); + }); + }); +} + +class _RecordingNativeToolRunner implements NativeToolRunner { + _RecordingNativeToolRunner(this._result); + + final String _result; + final List<({String name, String arguments})> calls = + <({String name, String arguments})>[]; + + @override + Future> supportedToolNames() async => + {nativeCreateReminderToolName}; + + @override + Future execute(String toolName, String arguments) async { + calls.add((name: toolName, arguments: arguments)); + return _result; + } +} diff --git a/flutter_app/test/generated_ui_message_test.dart b/flutter_app/test/generated_ui_message_test.dart new file mode 100644 index 0000000..5099654 --- /dev/null +++ b/flutter_app/test/generated_ui_message_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/generated_ui_message.dart'; + +void main() { + group('splitAssistantComponent', () { + test('returns the text unchanged when no ui block is present', () { + const raw = 'Here is a plain answer with no card.'; + final parts = splitAssistantComponent(raw); + expect(parts.text, raw); + expect(parts.component, isNull); + }); + + test('extracts a trailing ui block and strips it from the text', () { + const raw = + 'Sure, want a plan?\n' + '```ui\n' + '{"type":"quick_reply","title":"Suggestion","body":"Plan?",' + '"arguments":{"reply":"Plan a review block."}}\n' + '```'; + final parts = splitAssistantComponent(raw); + expect(parts.text, 'Sure, want a plan?'); + expect(parts.component, isNotNull); + expect(parts.component!['type'], 'quick_reply'); + final arguments = parts.component!['arguments'] as Map; + expect(arguments['reply'], 'Plan a review block.'); + }); + + test('strips the block but attaches no component on invalid JSON', () { + const raw = + 'Here you go.\n' + '```ui\n' + 'not valid json {{{\n' + '```'; + final parts = splitAssistantComponent(raw); + expect(parts.text, 'Here you go.'); + expect(parts.component, isNull); + }); + + test('leaves a non-ui code fence untouched', () { + const raw = 'Run this:\n```dart\nvoid main() {}\n```'; + final parts = splitAssistantComponent(raw); + expect(parts.text, raw); + expect(parts.component, isNull); + }); + }); + + group('streamingVisibleText', () { + test('returns the text unchanged before an opener appears', () { + const raw = 'Streaming answer so far'; + expect(streamingVisibleText(raw), raw); + }); + + test('hides everything from the ui fence opener onward', () { + const raw = 'Visible answer\n```ui\n{"type":"quick_re'; + expect(streamingVisibleText(raw), 'Visible answer'); + }); + }); +} diff --git a/flutter_app/test/generic_component_cards_test.dart b/flutter_app/test/generic_component_cards_test.dart new file mode 100644 index 0000000..ac95ff4 --- /dev/null +++ b/flutter_app/test/generic_component_cards_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/deadline_highlight_card.dart'; +import 'package:studyos_agent/src/widgets/message_list.dart'; +import 'package:studyos_agent/src/widgets/next_action_card.dart'; +import 'package:studyos_agent/src/widgets/quick_reply_card.dart'; + +void main() { + Map fixture(String type) { + return generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == type, + ); + } + + Widget hostMessages( + List messages, { + ValueChanged? onAction, + }) { + return MaterialApp( + home: Scaffold( + body: MessageList( + messages: messages, + compact: false, + controller: ScrollController(), + onComponentAction: onAction, + ), + ), + ); + } + + ChatMessage assistant(String text, Map component) { + return ChatMessage( + author: 'StudyOS Agent', + text: text, + isUser: false, + component: component, + ); + } + + testWidgets('quick_reply renders and taps dispatch a prompt action', ( + tester, + ) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + hostMessages( + [assistant('Want a plan?', fixture('quick_reply'))], + onAction: (value) => action = value, + ), + ); + + expect(find.byType(QuickReplyCard), findsOneWidget); + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(action, isA()); + expect( + (action! as PromptComponentAction).prompt, + 'Plan a 45 minute review block around my next lecture.', + ); + }); + + testWidgets('next_action renders its CTA and dispatches it as a prompt', ( + tester, + ) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + hostMessages( + [assistant('You could:', fixture('next_action'))], + onAction: (value) => action = value, + ), + ); + + expect(find.byType(NextActionCard), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Open schedule')); + await tester.pump(); + + expect(action, isA()); + expect((action! as PromptComponentAction).prompt, 'Open schedule'); + }); + + testWidgets('deadline_card renders and offers a reminder action', ( + tester, + ) async { + GeneratedComponentAction? action; + await tester.pumpWidget( + hostMessages( + [assistant('Heads up:', fixture('deadline_card'))], + onAction: (value) => action = value, + ), + ); + + expect(find.byType(DeadlineHighlightCard), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'Add reminder')); + await tester.pump(); + + expect(action, isA()); + }); + + testWidgets('a model-emitted card keeps the full prose answer', ( + tester, + ) async { + await tester.pumpWidget( + hostMessages([ + assistant( + 'Here is a full multi-line answer.\n' + 'It has a second detailed line worth keeping.', + fixture('quick_reply'), + ), + ]), + ); + + // Unlike a tool data-card, the prose under a model card is not trimmed to a + // lead-in — the second line survives. + expect( + find.textContaining('second detailed line', findRichText: true), + findsOneWidget, + ); + }); +} diff --git a/flutter_app/test/mail_triage_card_test.dart b/flutter_app/test/mail_triage_card_test.dart new file mode 100644 index 0000000..f640e0f --- /dev/null +++ b/flutter_app/test/mail_triage_card_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/mail_triage_card.dart'; + +void main() { + GeneratedUiComponent mailComponent() { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'mail_list', + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + testWidgets('renders one row per message with sender and subject', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MailTriageCard(component: mailComponent())), + ), + ); + + expect(find.text('INBOX · 2 unread'), findsOneWidget); + expect(find.text('Prof. Dr. Weber'), findsOneWidget); + expect( + find.text('ML exercise sheet 7 — submission Friday'), + findsOneWidget, + ); + // Official broadcast badge on the approved message. + expect(find.text('Official'), findsOneWidget); + }); + + testWidgets('action buttons submit a non-sending prompt via onAction', ( + tester, + ) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MailTriageCard( + component: mailComponent(), + onAction: actions.add, + ), + ), + ), + ); + + await tester.tap(find.text('Draft reply').first); + await tester.pump(); + + expect(actions, hasLength(1)); + final action = actions.single as PromptComponentAction; + expect(action.prompt, contains('ML exercise sheet 7')); + expect(action.prompt, contains('mail uid 4821')); + // A reply is side-effecting, so the prompt must forbid auto-sending. + expect(action.prompt, contains('do not send')); + }); + + testWidgets('omits action buttons when onAction is null', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MailTriageCard(component: mailComponent())), + ), + ); + + expect(find.text('Summarize'), findsNothing); + expect(find.text('Draft reply'), findsNothing); + }); +} diff --git a/flutter_app/test/markdown_math_test.dart b/flutter_app/test/markdown_math_test.dart new file mode 100644 index 0000000..7c92b7a --- /dev/null +++ b/flutter_app/test/markdown_math_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:studyos_agent/src/widgets/markdown_math.dart'; + +void main() { + Widget host(String data) { + return MaterialApp( + home: Scaffold( + body: MarkdownBody( + data: data, + selectable: true, + extensionSet: mathMarkdownExtensionSet(), + builders: mathMarkdownBuilders(), + ), + ), + ); + } + + testWidgets('renders inline math as a Math widget', (tester) async { + await tester.pumpWidget(host(r'The formula is $x^2 + y^2$ here.')); + expect(find.byType(Math), findsOneWidget); + }); + + testWidgets('renders display math as a Math widget', (tester) async { + await tester.pumpWidget(host(r'$$\frac{a}{b}$$')); + expect(find.byType(Math), findsOneWidget); + }); + + testWidgets('plain text without math renders no Math widget', (tester) async { + await tester.pumpWidget(host('Just a normal sentence with no math.')); + expect(find.byType(Math), findsNothing); + }); + + testWidgets('stray currency is not treated as math', (tester) async { + await tester.pumpWidget(host(r'It costs $5 and $10 total.')); + expect(find.byType(Math), findsNothing); + }); + + testWidgets('GFM tables still render alongside math support', (tester) async { + await tester.pumpWidget( + host('| A | B |\n| - | - |\n| 1 | 2 |'), + ); + expect(find.byType(Table), findsOneWidget); + }); + + test(r'the math syntax parses $ and $$ into math elements', () { + final document = md.Document( + extensionSet: mathMarkdownExtensionSet(), + inlineSyntaxes: const [], + ); + final nodes = document.parseInline(r'inline $a+b$ and $$c^2$$ end'); + final maths = []; + void collect(List list) { + for (final node in list) { + if (node is md.Element) { + if (node.tag == 'math') maths.add(node); + final children = node.children; + if (children != null) collect(children); + } + } + } + + collect(nodes); + expect(maths, hasLength(2)); + expect(maths.first.attributes['mode'], 'inline'); + expect(maths.last.attributes['mode'], 'display'); + }); +} diff --git a/flutter_app/test/message_list_component_test.dart b/flutter_app/test/message_list_component_test.dart new file mode 100644 index 0000000..b695e58 --- /dev/null +++ b/flutter_app/test/message_list_component_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/mail_triage_card.dart'; +import 'package:studyos_agent/src/widgets/message_list.dart'; + +void main() { + Map mailPayload() { + return generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'mail_list', + ); + } + + Widget host(List messages) { + return MaterialApp( + home: Scaffold( + body: MessageList( + messages: messages, + compact: false, + controller: ScrollController(), + ), + ), + ); + } + + testWidgets('assistant message renders its component beneath the text', ( + tester, + ) async { + await tester.pumpWidget( + host([ + ChatMessage( + author: 'StudyOS Agent', + text: 'Here are your recent emails:', + isUser: false, + component: mailPayload(), + ), + ]), + ); + + expect(find.text('Here are your recent emails:'), findsOneWidget); + expect(find.byType(MailTriageCard), findsOneWidget); + + // The card sits below the lead-in text in the vertical layout. + final textY = tester.getTopLeft(find.text('Here are your recent emails:')).dy; + final cardY = tester.getTopLeft(find.byType(MailTriageCard)).dy; + expect(cardY, greaterThan(textY)); + }); + + testWidgets('tool trace rows never render the mail card', (tester) async { + await tester.pumpWidget( + host([ + ChatMessage.toolTrace( + toolName: 'get_recent_mail', + status: 'done', + summary: 'Checked recent mail.', + ), + ]), + ); + + expect(find.byType(MailTriageCard), findsNothing); + expect(find.text('get_recent_mail'), findsOneWidget); + }); + + testWidgets('drops a restated list beneath the card, keeping the lead-in', ( + tester, + ) async { + await tester.pumpWidget( + host([ + ChatMessage( + author: 'StudyOS Agent', + text: + 'Here are your recent emails:\n' + '- ML exercise sheet 7 from Prof. Weber\n' + '- Room change from Studierendensekretariat', + isUser: false, + component: mailPayload(), + ), + ]), + ); + + expect(find.byType(MailTriageCard), findsOneWidget); + expect(find.text('Here are your recent emails:'), findsOneWidget); + // The restated bullet lines are dropped — the card already shows them. + expect( + find.textContaining('from Prof. Weber', findRichText: true), + findsNothing, + ); + }); +} diff --git a/flutter_app/test/schedule_card_test.dart b/flutter_app/test/schedule_card_test.dart new file mode 100644 index 0000000..5168a9b --- /dev/null +++ b/flutter_app/test/schedule_card_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:studyos_agent/src/app_shell_controller.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/timetable_repository.dart'; +import 'package:studyos_agent/src/widgets/schedule_card.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + GeneratedUiComponent scheduleComponent() { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == 'schedule_agenda', + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + group('ScheduleCard', () { + testWidgets('groups lectures by day with time and room', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ScheduleCard(component: scheduleComponent())), + ), + ); + + expect(find.text('Schedule · WS 2026/27'), findsOneWidget); + expect(find.text('Machine Learning'), findsOneWidget); + expect(find.text('Hörsaal 21'), findsOneWidget); + // Two distinct days (9th and 10th) → two upper-cased day headers. + expect(find.textContaining('9 DEC'), findsOneWidget); + expect(find.textContaining('10 DEC'), findsOneWidget); + expect(find.text('10:15–11:45'), findsOneWidget); + }); + }); + + group('readScheduleForAgent', () { + test('emits structured JSON of upcoming events', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + // The timetable refresh reads device world state after fetching; stub the + // native channel so it returns an empty map instead of throwing. + const channel = MethodChannel('studyos/native'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getWorldState') return {}; + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + const profile = OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ); + final future = DateTime.now().add(const Duration(days: 1)); + final repository = _FakeTimetableRepository( + TimetableSnapshot( + refreshedAt: DateTime.now(), + sourceTerm: 'WS 2026/27', + events: [ + LectureEvent( + id: 'ml-1', + title: 'Machine Learning', + start: future, + end: future.add(const Duration(minutes: 90)), + location: 'Hörsaal 21', + ), + ], + ), + ); + final controller = AppShellController( + initialProfile: profile, + initialOnLogout: null, + initialOnSaveProfile: null, + timetableRepository: repository, + ); + addTearDown(controller.dispose); + + final result = await controller.readScheduleForAgent(); + final decoded = jsonDecode(result) as Map; + + expect(decoded['source_term'], 'WS 2026/27'); + final events = decoded['events'] as List; + expect(events, hasLength(1)); + expect((events.first as Map)['title'], 'Machine Learning'); + // Refresh was requested exactly once (no null-snapshot race). + expect(repository.refreshCalls, 1); + }); + }); +} + +class _FakeTimetableRepository extends TimetableRepository { + _FakeTimetableRepository(this._snapshot); + + final TimetableSnapshot _snapshot; + int refreshCalls = 0; + + @override + Future load() async => null; + + @override + Future refresh(OnboardingProfile profile) async { + refreshCalls++; + return _snapshot; + } +} diff --git a/flutter_app/test/settings_layout_test.dart b/flutter_app/test/settings_layout_test.dart new file mode 100644 index 0000000..358b08d --- /dev/null +++ b/flutter_app/test/settings_layout_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/native_bridge.dart'; +import 'package:studyos_agent/src/studyos_theme.dart'; +import 'package:studyos_agent/src/views/settings_view.dart'; + +/// Guards against layout overflow on narrow phones. The settings "Assistant" +/// card nests dropdowns, segmented buttons and a button row; a model dropdown +/// once overflowed its row by 274px (missing `isExpanded`), painting the label +/// across neighbouring widgets. These pump the full view at real phone widths +/// with the LiteRT section expanded and assert no RenderFlex overflows. +void main() { + for (final width in [320, 360]) { + for (final provider in AgentProvider.values) { + testWidgets('settings has no overflow for $provider at ${width}px', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final overflows = []; + final previous = FlutterError.onError; + FlutterError.onError = (details) { + overflows.add(details.exceptionAsString()); + }; + addTearDown(() => FlutterError.onError = previous); + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: Center( + child: ConstrainedBox( + // Mirror the real route scaffold's width clamp + padding. + constraints: const BoxConstraints(maxWidth: 760), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SettingsView( + config: AgentConfig.defaults().copyWith( + provider: provider, + // Non-empty path forces the LiteRT ExpansionTile open so + // its dropdown/segmented/switch content is laid out. + localModelPath: '/data/model.litertlm', + localToolProtocol: + LocalToolProtocol.nativeFunctionCalling, + ), + profile: null, + status: 'Ready', + compactMessages: false, + onLogout: () {}, + onSaveProfile: (_) async {}, + onSaveAgentConfig: (_, _) async {}, + onCompactMessagesChanged: (_) {}, + nativeBridge: NativeBridge(), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + + expect( + overflows, + isEmpty, + reason: 'Settings overflowed at ${width}px:\n${overflows.join('\n')}', + ); + }); + } + } +} diff --git a/flutter_app/test/study_mensa_cards_test.dart b/flutter_app/test/study_mensa_cards_test.dart new file mode 100644 index 0000000..616c4fe --- /dev/null +++ b/flutter_app/test/study_mensa_cards_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/mensa_card.dart'; +import 'package:studyos_agent/src/widgets/study_progress_card.dart'; + +void main() { + GeneratedUiComponent componentOf(String type) { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == type, + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + group('StudyProgressCard', () { + testWidgets('renders an overall bar and per-module rows', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StudyProgressCard(component: componentOf('study_progress')), + ), + ), + ); + + expect(find.text('M.Sc. Machine Learning'), findsOneWidget); + expect(find.textContaining('Overall · 78 / 120 ECTS'), findsOneWidget); + expect(find.textContaining('Core Machine Learning'), findsOneWidget); + // One overall bar + three module bars. + expect(find.byType(LinearProgressIndicator), findsNWidgets(4)); + }); + }); + + group('MensaCard', () { + testWidgets('renders menu lines with items, price and markers', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MensaCard(component: componentOf('mensa_menu'))), + ), + ); + + expect(find.text('Mensa Wilhelmstraße'), findsOneWidget); + expect(find.text('Line 1'), findsOneWidget); + expect(find.text('Gemüse-Lasagne, Blattsalat'), findsOneWidget); + expect(find.text('3,20 €'), findsOneWidget); + expect(find.text('Vegetarisch'), findsOneWidget); + }); + }); +} diff --git a/flutter_app/test/studyos_tool_openapi_schema_test.dart b/flutter_app/test/studyos_tool_openapi_schema_test.dart new file mode 100644 index 0000000..9d53ace --- /dev/null +++ b/flutter_app/test/studyos_tool_openapi_schema_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/studyos_tool_catalog.dart'; + +void main() { + group('StudyOsToolSpec.toOpenApiFunctionDeclaration', () { + test('wraps a no-argument tool with empty properties and required', () { + final decl = readMemoriesTool.toOpenApiFunctionDeclaration(); + + expect(decl['name'], 'read_memories'); + expect(decl['description'], isNotEmpty); + final params = decl['parameters']! as Map; + expect(params['type'], 'object'); + expect(params['properties'], isEmpty); + expect(params['required'], isEmpty); + }); + + test('carries JSON-schema properties and required for a tool with args', () { + final decl = appendMemoryTool.toOpenApiFunctionDeclaration(); + + expect(decl['name'], 'append_memory'); + final params = decl['parameters']! as Map; + final properties = params['properties']! as Map; + expect(properties.containsKey('text'), isTrue); + final textSchema = properties['text']! as Map; + expect(textSchema['type'], 'string'); + expect(textSchema['description'], isNotEmpty); + expect(params['required'], contains('text')); + }); + + test('every catalog tool serializes to valid, well-formed JSON', () { + for (final tool in studyOsTools) { + final json = tool.toOpenApiToolJson(); + final decoded = jsonDecode(json) as Map; + + expect(decoded['name'], tool.name, reason: '${tool.name} name'); + final params = decoded['parameters']! as Map; + expect(params['type'], 'object', reason: '${tool.name} parameters.type'); + + // Every declared required field must exist in properties. + final properties = params['properties']! as Map; + final required = (params['required']! as List).cast(); + for (final field in required) { + expect( + properties.containsKey(field), + isTrue, + reason: '${tool.name}: required "$field" missing from properties', + ); + } + } + }); + }); +} diff --git a/flutter_app/test/talk_academic_cards_test.dart b/flutter_app/test/talk_academic_cards_test.dart new file mode 100644 index 0000000..f28dfde --- /dev/null +++ b/flutter_app/test/talk_academic_cards_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/models.dart'; +import 'package:studyos_agent/src/widgets/academic_status_card.dart'; +import 'package:studyos_agent/src/widgets/talk_card.dart'; + +void main() { + GeneratedUiComponent componentOf(String type) { + final payload = generativeUiFixturePayloads.firstWhere( + (payload) => payload['type'] == type, + ); + return GenerativeUiRegistry.validate(payload).component!; + } + + group('TalkCard', () { + testWidgets('renders talks with speaker and location', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TalkCard(component: componentOf('talk_list'))), + ), + ); + + expect(find.text('2 upcoming talks'), findsOneWidget); + expect( + find.text('Foundation models for scientific discovery'), + findsOneWidget, + ); + expect(find.textContaining('Dr. Amelie Roth'), findsOneWidget); + }); + + testWidgets('Remind me emits a ReminderComponentAction at the talk time', ( + tester, + ) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TalkCard( + component: componentOf('talk_list'), + onAction: actions.add, + ), + ), + ), + ); + + await tester.tap(find.text('Remind me').first); + await tester.pump(); + + expect(actions, hasLength(1)); + final action = actions.single as ReminderComponentAction; + expect(action.title, 'Foundation models for scientific discovery'); + expect( + action.dueAt.isAtSameMomentAs( + DateTime.parse('2026-12-09T16:15:00.000Z'), + ), + isTrue, + ); + }); + }); + + group('AcademicStatusCard', () { + testWidgets('groups entries by category with status badges', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AcademicStatusCard(component: componentOf('academic_status')), + ), + ), + ); + + expect(find.text('Academic status · WS 2026/27'), findsOneWidget); + // Category headers are upper-cased; two exams share one "EXAMS" header. + expect(find.text('EXAMS'), findsOneWidget); + expect(find.text('COURSES'), findsOneWidget); + expect(find.text('Machine Learning — written exam'), findsOneWidget); + expect(find.text('Passed (1.7)'), findsOneWidget); + }); + }); +} diff --git a/flutter_app/test/tool_card_reference_test.dart b/flutter_app/test/tool_card_reference_test.dart new file mode 100644 index 0000000..6134410 --- /dev/null +++ b/flutter_app/test/tool_card_reference_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/generated_ui_message.dart'; + +void main() { + Map plannerCard() => { + 'type': 'study_progress', + 'title': 'M.Sc. Machine Learning', + 'body': '78 / 120 ECTS', + 'arguments': {'modules': []}, + }; + + Map> captured() => >{ + 'get_study_planner': plannerCard(), + }; + + group('resolveComponentPayload', () { + test('a tool_card reference resolves to the captured tool payload', () { + final resolved = resolveComponentPayload( + {'type': 'tool_card', 'tool': 'get_study_planner'}, + captured(), + ); + expect(resolved, plannerCard()); + }); + + test('a reference to a tool not called this turn resolves to null', () { + final resolved = resolveComponentPayload( + {'type': 'tool_card', 'tool': 'get_recent_mail'}, + captured(), + ); + expect(resolved, isNull); + }); + + test('a tool_card reference without a tool name resolves to null', () { + expect( + resolveComponentPayload( + {'type': 'tool_card'}, + captured(), + ), + isNull, + ); + }); + + test('a composed A/B component passes through unchanged', () { + final quickReply = { + 'type': 'quick_reply', + 'title': 'Suggestion', + 'body': 'Plan?', + 'arguments': {'reply': 'Plan a block.'}, + }; + expect(resolveComponentPayload(quickReply, captured()), quickReply); + }); + + test('null stays null', () { + expect(resolveComponentPayload(null, captured()), isNull); + }); + }); + + group('end to end split + resolve', () { + test('an explicit tool_card reference surfaces the captured card', () { + const raw = + 'Here is your study progress:\n' + '```ui\n' + '{"type":"tool_card","tool":"get_study_planner"}\n' + '```'; + final parts = splitAssistantComponent(raw); + final component = resolveComponentPayload(parts.component, captured()); + + expect(parts.text, 'Here is your study progress:'); + expect(component, plannerCard()); + }); + + test('a pivot reply that ran the tool but omits the block shows no card', () { + // The model called get_study_planner while answering something else and + // did not reference it — the decoupling fix: no card, full text kept. + const raw = + 'The Mensa has Gemüse-Lasagne and Rindergulasch today. ' + 'Both are available at lunch.'; + final parts = splitAssistantComponent(raw); + final component = resolveComponentPayload(parts.component, captured()); + + expect(parts.text, raw); + expect(component, isNull); + }); + }); + + group('isPresentationalLeadIn', () { + test('a short single-line lead-in is presentational', () { + expect( + isPresentationalLeadIn('Here is the breakdown of requirements:'), + isTrue, + ); + }); + + test('a two-line lead-in is still presentational', () { + expect(isPresentationalLeadIn('Here you go:\nTake a look:'), isTrue); + }); + + test('a long multi-sentence answer is not a lead-in', () { + expect( + isPresentationalLeadIn( + 'Managing first-semester stress starts with a routine. Block fixed ' + 'study hours, take real breaks, and keep your sleep steady. Talk to ' + 'peers and the student counselling service if it builds up.', + ), + isFalse, + ); + }); + + test('empty or whitespace text is not a lead-in', () { + expect(isPresentationalLeadIn(' \n '), isFalse); + }); + }); + + group('resolveMessageComponent', () { + Map? resolve( + String reply, { + Map? emitted, + Map>? capturedComponents, + }) { + return resolveMessageComponent( + emitted: emitted, + capturedToolComponents: capturedComponents ?? captured(), + replyText: reply, + ); + } + + test('a lead-in with a captured tool card shows the most recent one', () { + expect(resolve('Here is your study progress:'), plannerCard()); + }); + + test('a long pivot answer shows no card even though a tool ran', () { + expect( + resolve( + 'You are in your first semester, so focus on building good habits ' + 'early: attend every lecture, start assignments the day they are set, ' + 'and review notes weekly rather than cramming before exams.', + ), + isNull, + ); + }); + + test('a lead-in with no captured tool card shows nothing', () { + expect( + resolve( + 'Here is your study progress:', + capturedComponents: >{}, + ), + isNull, + ); + }); + + test('a composed A/B component always wins', () { + final quickReply = { + 'type': 'quick_reply', + 'title': 'Suggestion', + 'body': 'Plan?', + 'arguments': {'reply': 'Plan a block.'}, + }; + expect(resolve('A full answer.', emitted: quickReply), quickReply); + }); + + test('a bad reference falls back to the lead-in card', () { + // Model referenced a tool it did not call, but the reply is a lead-in and + // a card was captured — robustness fallback surfaces it anyway. + expect( + resolve( + 'Here is your progress:', + emitted: { + 'type': 'tool_card', + 'tool': 'get_recent_mail', + }, + ), + plannerCard(), + ); + }); + + test('the most recent tool of several is shown for a bare lead-in', () { + final mailCard = { + 'type': 'mail_list', + 'title': 'INBOX', + 'body': '1 message', + 'arguments': {'messages': []}, + }; + final ordered = >{ + 'get_study_planner': plannerCard(), + 'get_recent_mail': mailCard, + }; + expect( + resolve('Here you go:', capturedComponents: ordered), + mailCard, + ); + }); + }); +} From 50458b0c0a00dee63d4d9e39f0d4d108559dd74e Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 29 Jul 2026 21:50:06 +0200 Subject: [PATCH 8/9] dart formatting --- flutter_app/lib/src/agent_llm_provider.dart | 6 +- .../lib/src/generative_ui_registry.dart | 10 +- flutter_app/lib/src/views/settings_view.dart | 4 +- .../lib/src/widgets/academic_status_card.dart | 3 +- .../lib/src/widgets/campus_location_card.dart | 5 +- .../lib/src/widgets/custom_view_card.dart | 10 +- .../lib/src/widgets/deadline_card.dart | 23 ++- .../widgets/local_model_settings_card.dart | 4 +- .../lib/src/widgets/mail_triage_card.dart | 9 +- flutter_app/lib/src/widgets/mensa_card.dart | 17 +-- .../lib/src/widgets/schedule_card.dart | 9 +- .../lib/src/widgets/study_progress_card.dart | 17 ++- flutter_app/lib/src/widgets/talk_card.dart | 5 +- .../test/academic_status_tool_test.dart | 75 +++++----- .../agent_llm_provider_native_fc_test.dart | 125 ++++++++-------- flutter_app/test/agent_llm_provider_test.dart | 138 +++++++++--------- flutter_app/test/deadline_card_test.dart | 130 +++++++++-------- .../test/generative_ui_registry_test.dart | 55 +++---- .../test/generic_component_cards_test.dart | 21 ++- flutter_app/test/mail_view_test.dart | 6 +- flutter_app/test/markdown_math_test.dart | 4 +- .../test/message_list_component_test.dart | 4 +- flutter_app/test/schedule_card_test.dart | 9 +- .../studyos_tool_openapi_schema_test.dart | 31 ++-- .../test/talk_academic_cards_test.dart | 4 +- .../test/tool_card_reference_test.dart | 60 ++++---- 26 files changed, 406 insertions(+), 378 deletions(-) diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 60d24ea..bef7bdd 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -246,9 +246,9 @@ class LocalNativeLlmProvider implements AgentLlmProvider { Future _sendNativeFunctionCalling(AgentLlmRequest request) async { final nativeTools = NativeToolRouter(_bridge); final supportedNativeToolNames = await nativeTools.supportedToolNames(); - final toolSchemas = studyOsToolsForNativeSupport(supportedNativeToolNames) - .map((tool) => tool.toOpenApiToolJson()) - .toList(); + final toolSchemas = studyOsToolsForNativeSupport( + supportedNativeToolNames, + ).map((tool) => tool.toOpenApiToolJson()).toList(); final toolContext = _toolContextFor(request, nativeTools); var turn = await _bridge.sendMessageWithTools( diff --git a/flutter_app/lib/src/generative_ui_registry.dart b/flutter_app/lib/src/generative_ui_registry.dart index dbc7e57..82c30b1 100644 --- a/flutter_app/lib/src/generative_ui_registry.dart +++ b/flutter_app/lib/src/generative_ui_registry.dart @@ -241,7 +241,10 @@ Map? componentPayloadForTool(String toolName, String output) { /// Kept provider-agnostic (pure, no Flutter imports) so both the local and the /// cloud tool loops can attach the result to the tool's [ToolTrace]. It only /// forwards the summary fields the card renders — no message bodies. -Map? mailTriageComponentPayload(String toolName, String output) { +Map? mailTriageComponentPayload( + String toolName, + String output, +) { const producers = {'get_recent_mail', 'search_mail'}; if (!producers.contains(toolName)) return null; @@ -425,10 +428,7 @@ Map? academicStatusComponentPayload( 'type': 'academic_status', 'title': term == null ? 'Academic status' : 'Academic status · $term', 'body': count == 1 ? '1 entry' : '$count entries', - 'arguments': { - 'term': ?term, - 'entries': entries, - }, + 'arguments': {'term': ?term, 'entries': entries}, }; } diff --git a/flutter_app/lib/src/views/settings_view.dart b/flutter_app/lib/src/views/settings_view.dart index 9f1fec4..dd55734 100644 --- a/flutter_app/lib/src/views/settings_view.dart +++ b/flutter_app/lib/src/views/settings_view.dart @@ -244,9 +244,7 @@ class _SettingsViewState extends State { const SizedBox(height: StudyOsSpacing.xl), const _SettingsSection( title: 'Developer', - child: SettingsCard( - children: [GeneratedUiPreviewSection()], - ), + child: SettingsCard(children: [GeneratedUiPreviewSection()]), ), ], ); diff --git a/flutter_app/lib/src/widgets/academic_status_card.dart b/flutter_app/lib/src/widgets/academic_status_card.dart index 24b9908..c1ed785 100644 --- a/flutter_app/lib/src/widgets/academic_status_card.dart +++ b/flutter_app/lib/src/widgets/academic_status_card.dart @@ -67,8 +67,7 @@ class AcademicStatusCard extends StatelessWidget { ), ), ), - for (final entry in group.entries) - _EntryRow(entry: entry), + for (final entry in group.entries) _EntryRow(entry: entry), ], ], ), diff --git a/flutter_app/lib/src/widgets/campus_location_card.dart b/flutter_app/lib/src/widgets/campus_location_card.dart index 0b2363a..5e719ac 100644 --- a/flutter_app/lib/src/widgets/campus_location_card.dart +++ b/flutter_app/lib/src/widgets/campus_location_card.dart @@ -150,8 +150,9 @@ class _LocationRow extends StatelessWidget { _LocationAction( icon: Icons.auto_awesome_outlined, label: 'Ask', - onPressed: () => - onAction!(PromptComponentAction(_askPrompt(name, address))), + onPressed: () => onAction!( + PromptComponentAction(_askPrompt(name, address)), + ), ), ], ), diff --git a/flutter_app/lib/src/widgets/custom_view_card.dart b/flutter_app/lib/src/widgets/custom_view_card.dart index 112fe14..455647c 100644 --- a/flutter_app/lib/src/widgets/custom_view_card.dart +++ b/flutter_app/lib/src/widgets/custom_view_card.dart @@ -170,7 +170,10 @@ class CustomViewCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(top: 7, right: StudyOsSpacing.sm), + padding: const EdgeInsets.only( + top: 7, + right: StudyOsSpacing.sm, + ), child: DecoratedBox( decoration: const BoxDecoration( color: StudyOsColors.accent, @@ -189,7 +192,10 @@ class CustomViewCard extends StatelessWidget { Widget? _keyValues(List> rows, ThemeData theme) { final visible = rows - .where((row) => _str(row, 'label').isNotEmpty || _str(row, 'value').isNotEmpty) + .where( + (row) => + _str(row, 'label').isNotEmpty || _str(row, 'value').isNotEmpty, + ) .toList(growable: false); if (visible.isEmpty) return null; return Column( diff --git a/flutter_app/lib/src/widgets/deadline_card.dart b/flutter_app/lib/src/widgets/deadline_card.dart index ead7d47..fe03f45 100644 --- a/flutter_app/lib/src/widgets/deadline_card.dart +++ b/flutter_app/lib/src/widgets/deadline_card.dart @@ -91,8 +91,9 @@ class _DeadlineRow extends StatelessWidget { final title = deadline['title']?.toString() ?? 'Deadline'; final course = deadline['course']?.toString().trim() ?? ''; final requirement = deadline['requirement']?.toString().trim() ?? ''; - final due = DateTime.tryParse(deadline['due_at']?.toString() ?? '') - ?.toLocal(); + final due = DateTime.tryParse( + deadline['due_at']?.toString() ?? '', + )?.toLocal(); final urgency = _urgencyFor(due); return Padding( @@ -104,7 +105,10 @@ class _DeadlineRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(top: 5, right: StudyOsSpacing.sm), + padding: const EdgeInsets.only( + top: 5, + right: StudyOsSpacing.sm, + ), child: DecoratedBox( decoration: BoxDecoration( color: urgency.color, @@ -192,15 +196,7 @@ class _DeadlineRow extends StatelessWidget { } static String _formatDue(DateTime due) { - const weekdays = [ - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun', - ]; + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const months = [ 'Jan', 'Feb', @@ -224,7 +220,8 @@ class _DeadlineRow extends StatelessWidget { static _Urgency _urgencyFor(DateTime? due) { if (due == null) return const _Urgency(StudyOsColors.textMuted, 'no date'); final now = DateTime.now(); - if (due.isBefore(now)) return const _Urgency(StudyOsColors.warning, 'overdue'); + if (due.isBefore(now)) + return const _Urgency(StudyOsColors.warning, 'overdue'); final hours = due.difference(now).inHours; if (hours <= 48) { return const _Urgency(StudyOsColors.warning, 'soon'); diff --git a/flutter_app/lib/src/widgets/local_model_settings_card.dart b/flutter_app/lib/src/widgets/local_model_settings_card.dart index 28952e7..bf29b32 100644 --- a/flutter_app/lib/src/widgets/local_model_settings_card.dart +++ b/flutter_app/lib/src/widgets/local_model_settings_card.dart @@ -243,7 +243,9 @@ class _LocalModelSettingsCardState extends State { Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( - onPressed: _isProbingToolCall ? null : _probeNativeToolCalling, + onPressed: _isProbingToolCall + ? null + : _probeNativeToolCalling, icon: _isProbingToolCall ? const SizedBox.square( dimension: 18, diff --git a/flutter_app/lib/src/widgets/mail_triage_card.dart b/flutter_app/lib/src/widgets/mail_triage_card.dart index 2428236..da355f5 100644 --- a/flutter_app/lib/src/widgets/mail_triage_card.dart +++ b/flutter_app/lib/src/widgets/mail_triage_card.dart @@ -124,12 +124,13 @@ class _MailRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(top: 6, right: StudyOsSpacing.sm), + padding: const EdgeInsets.only( + top: 6, + right: StudyOsSpacing.sm, + ), child: DecoratedBox( decoration: BoxDecoration( - color: isUnread - ? StudyOsColors.accent - : Colors.transparent, + color: isUnread ? StudyOsColors.accent : Colors.transparent, shape: BoxShape.circle, border: isUnread ? null diff --git a/flutter_app/lib/src/widgets/mensa_card.dart b/flutter_app/lib/src/widgets/mensa_card.dart index 739f788..fa86024 100644 --- a/flutter_app/lib/src/widgets/mensa_card.dart +++ b/flutter_app/lib/src/widgets/mensa_card.dart @@ -6,11 +6,7 @@ import '../studyos_theme.dart'; /// Renders a `mensa_menu` generative-UI component: canteen menu lines with their /// dishes, dietary markers, and student price. Read-only. class MensaCard extends StatelessWidget { - const MensaCard({ - required this.component, - this.compact = false, - super.key, - }); + const MensaCard({required this.component, this.compact = false, super.key}); final GeneratedUiComponent component; final bool compact; @@ -84,12 +80,14 @@ class _OptionRow extends StatelessWidget { final theme = Theme.of(context); final line = option['line']?.toString() ?? 'Menu'; final price = option['price']?.toString().trim() ?? ''; - final items = (option['items'] as List?) + final items = + (option['items'] as List?) ?.map((item) => item.toString()) .where((item) => item.isNotEmpty) .join(', ') ?? ''; - final markers = (option['markers'] as List?) + final markers = + (option['markers'] as List?) ?.map((marker) => marker.toString()) .where((marker) => marker.isNotEmpty) .toList() ?? @@ -155,8 +153,9 @@ class _MarkerChip extends StatelessWidget { @override Widget build(BuildContext context) { - final vegetarian = - label.toLowerCase().contains('veg'); // vegan/vegetarisch/vegetarian + final vegetarian = label.toLowerCase().contains( + 'veg', + ); // vegan/vegetarisch/vegetarian final color = vegetarian ? StudyOsColors.success : StudyOsColors.textMuted; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), diff --git a/flutter_app/lib/src/widgets/schedule_card.dart b/flutter_app/lib/src/widgets/schedule_card.dart index f1ffac1..6ec665b 100644 --- a/flutter_app/lib/src/widgets/schedule_card.dart +++ b/flutter_app/lib/src/widgets/schedule_card.dart @@ -83,8 +83,9 @@ class ScheduleCard extends StatelessWidget { for (final item in raw) { if (item is! Map) continue; final event = Map.from(item); - final start = DateTime.tryParse(event['start']?.toString() ?? '') - ?.toLocal(); + final start = DateTime.tryParse( + event['start']?.toString() ?? '', + )?.toLocal(); if (start == null) continue; final key = '${start.year}-${start.month.toString().padLeft(2, '0')}-' @@ -116,7 +117,9 @@ class _EventRow extends StatelessWidget { final theme = Theme.of(context); final title = event['title']?.toString() ?? 'Lecture'; final location = event['location']?.toString().trim() ?? ''; - final start = DateTime.tryParse(event['start']?.toString() ?? '')?.toLocal(); + final start = DateTime.tryParse( + event['start']?.toString() ?? '', + )?.toLocal(); final end = DateTime.tryParse(event['end']?.toString() ?? '')?.toLocal(); return Padding( diff --git a/flutter_app/lib/src/widgets/study_progress_card.dart b/flutter_app/lib/src/widgets/study_progress_card.dart index 062fee1..bf644ef 100644 --- a/flutter_app/lib/src/widgets/study_progress_card.dart +++ b/flutter_app/lib/src/widgets/study_progress_card.dart @@ -108,17 +108,17 @@ class _ModuleRow extends StatelessWidget { Expanded( child: Text( title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: StudyOsColors.text, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: StudyOsColors.text), ), ), if (summary.isNotEmpty) Text( summary, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: StudyOsColors.textMuted, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: StudyOsColors.textMuted), ), ], ); @@ -183,5 +183,6 @@ double? _double(Object? value) { return double.tryParse(value?.toString() ?? ''); } -String _trim(double value) => - value == value.roundToDouble() ? value.toInt().toString() : value.toString(); +String _trim(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toString(); diff --git a/flutter_app/lib/src/widgets/talk_card.dart b/flutter_app/lib/src/widgets/talk_card.dart index 1cdd042..5f2d3f5 100644 --- a/flutter_app/lib/src/widgets/talk_card.dart +++ b/flutter_app/lib/src/widgets/talk_card.dart @@ -89,8 +89,9 @@ class _TalkRow extends StatelessWidget { final title = talk['title']?.toString() ?? 'Talk'; final speaker = talk['speaker']?.toString().trim() ?? ''; final location = talk['location']?.toString().trim() ?? ''; - final start = DateTime.tryParse(talk['timestamp']?.toString() ?? '') - ?.toLocal(); + final start = DateTime.tryParse( + talk['timestamp']?.toString() ?? '', + )?.toLocal(); final meta = [ if (speaker.isNotEmpty) speaker, if (location.isNotEmpty) location, diff --git a/flutter_app/test/academic_status_tool_test.dart b/flutter_app/test/academic_status_tool_test.dart index 9bd7d3e..38c903c 100644 --- a/flutter_app/test/academic_status_tool_test.dart +++ b/flutter_app/test/academic_status_tool_test.dart @@ -27,7 +27,8 @@ void main() { }); tearDown(() => SharedPreferencesAsyncPlatform.instance = null); - AppShellController controllerWith(AcademicRepository repository, { + AppShellController controllerWith( + AcademicRepository repository, { OnboardingProfile? initialProfile = profile, }) { final controller = AppShellController( @@ -40,39 +41,47 @@ void main() { return controller; } - test('surfaces the real error instead of the generic unavailable string', () async { - final controller = controllerWith( - _FakeAcademicRepository.throwing( - const AlmaAcademicException('Sign in again to refresh your academic status.'), - ), - ); - - final result = await controller.readAcademicStatusForAgent(); - - expect(result, 'Sign in again to refresh your academic status.'); - expect(result, isNot(contains('not available'))); - }); - - test('a concurrent background refresh no longer masks a fetch as unavailable', () async { - // Reproduces the race: a refresh is already in flight (as initialize() - // starts one) when the tool reader runs. It must await that fetch and - // return the data, not a stale null snapshot. - final repository = _FakeAcademicRepository.snapshot( - _snapshotWith('Machine Learning'), - delay: const Duration(milliseconds: 40), - ); - final controller = controllerWith(repository); - - final inFlight = controller.refreshAcademicStatus(); // background refresh - final result = await controller.readAcademicStatusForAgent(); - await inFlight; + test( + 'surfaces the real error instead of the generic unavailable string', + () async { + final controller = controllerWith( + _FakeAcademicRepository.throwing( + const AlmaAcademicException( + 'Sign in again to refresh your academic status.', + ), + ), + ); + + final result = await controller.readAcademicStatusForAgent(); + + expect(result, 'Sign in again to refresh your academic status.'); + expect(result, isNot(contains('not available'))); + }, + ); - final decoded = jsonDecode(result) as Map; - final entries = decoded['entries'] as List; - expect(entries, hasLength(1)); - // Both callers shared one fetch rather than racing separate ones. - expect(repository.refreshCalls, 1); - }); + test( + 'a concurrent background refresh no longer masks a fetch as unavailable', + () async { + // Reproduces the race: a refresh is already in flight (as initialize() + // starts one) when the tool reader runs. It must await that fetch and + // return the data, not a stale null snapshot. + final repository = _FakeAcademicRepository.snapshot( + _snapshotWith('Machine Learning'), + delay: const Duration(milliseconds: 40), + ); + final controller = controllerWith(repository); + + final inFlight = controller.refreshAcademicStatus(); // background refresh + final result = await controller.readAcademicStatusForAgent(); + await inFlight; + + final decoded = jsonDecode(result) as Map; + final entries = decoded['entries'] as List; + expect(entries, hasLength(1)); + // Both callers shared one fetch rather than racing separate ones. + expect(repository.refreshCalls, 1); + }, + ); test('reports a clear message when no profile is signed in', () async { final controller = controllerWith( diff --git a/flutter_app/test/agent_llm_provider_native_fc_test.dart b/flutter_app/test/agent_llm_provider_native_fc_test.dart index 4f98d9c..e02e978 100644 --- a/flutter_app/test/agent_llm_provider_native_fc_test.dart +++ b/flutter_app/test/agent_llm_provider_native_fc_test.dart @@ -65,63 +65,72 @@ void main() { expect(bridge.toolResultBatches, isEmpty); }); - test('native FC path executes a tool call and feeds the result back', () async { - final bridge = _FakeToolBridge(>[ - { - 'type': 'tool_calls', - 'calls': [ - {'name': 'read_memories', 'arguments': '{}'}, - ], - }, - {'type': 'text', 'text': 'I used fresh memory.'}, - ]); - final provider = LocalNativeLlmProvider(bridge); - final traces = []; - - final response = await provider.send( - _request( - bridge, - userText: 'What should I remember?', - readMemory: () async => 'Fresh memory from disk', - onToolTrace: traces.add, - ), - ); - - expect(response, 'I used fresh memory.'); - // The executed tool's output was returned to the native layer. - expect(bridge.toolResultBatches, hasLength(1)); - final result = bridge.toolResultBatches.single.single; - expect(result['name'], 'read_memories'); - expect(result['response'], 'Fresh memory from disk'); - // Running + done traces were emitted for the tool. - expect(traces.map((t) => t.status), containsAll(['running', 'done'])); - }); - - test('native FC path resets the live stream before a tool follow-up', () async { - final bridge = _FakeToolBridge(>[ - { - 'type': 'tool_calls', - 'calls': [ - {'name': 'read_memories', 'arguments': '{}'}, - ], - }, - {'type': 'text', 'text': 'Answer from tool results.'}, - ]); - final provider = LocalNativeLlmProvider(bridge); - final deltas = []; + test( + 'native FC path executes a tool call and feeds the result back', + () async { + final bridge = _FakeToolBridge(>[ + { + 'type': 'tool_calls', + 'calls': [ + {'name': 'read_memories', 'arguments': '{}'}, + ], + }, + {'type': 'text', 'text': 'I used fresh memory.'}, + ]); + final provider = LocalNativeLlmProvider(bridge); + final traces = []; + + final response = await provider.send( + _request( + bridge, + userText: 'What should I remember?', + readMemory: () async => 'Fresh memory from disk', + onToolTrace: traces.add, + ), + ); + + expect(response, 'I used fresh memory.'); + // The executed tool's output was returned to the native layer. + expect(bridge.toolResultBatches, hasLength(1)); + final result = bridge.toolResultBatches.single.single; + expect(result['name'], 'read_memories'); + expect(result['response'], 'Fresh memory from disk'); + // Running + done traces were emitted for the tool. + expect( + traces.map((t) => t.status), + containsAll(['running', 'done']), + ); + }, + ); - final response = await provider.send( - _request( - bridge, - userText: 'What should I remember?', - readMemory: () async => 'Fresh memory', - onDelta: deltas.add, - ), - ); + test( + 'native FC path resets the live stream before a tool follow-up', + () async { + final bridge = _FakeToolBridge(>[ + { + 'type': 'tool_calls', + 'calls': [ + {'name': 'read_memories', 'arguments': '{}'}, + ], + }, + {'type': 'text', 'text': 'Answer from tool results.'}, + ]); + final provider = LocalNativeLlmProvider(bridge); + final deltas = []; + + final response = await provider.send( + _request( + bridge, + userText: 'What should I remember?', + readMemory: () async => 'Fresh memory', + onDelta: deltas.add, + ), + ); - expect(response, 'Answer from tool results.'); - expect(deltas.where((delta) => delta.reset), hasLength(1)); - }); + expect(response, 'Answer from tool results.'); + expect(deltas.where((delta) => delta.reset), hasLength(1)); + }, + ); test('native FC path ignores unknown tool names', () async { final bridge = _FakeToolBridge(>[ @@ -159,11 +168,7 @@ void main() { await expectLater( provider.send( - _request( - bridge, - userText: 'Loop forever', - readMemory: () async => 'x', - ), + _request(bridge, userText: 'Loop forever', readMemory: () async => 'x'), ), throwsA(isA()), ); diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index 8e97120..18b633d 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -217,17 +217,71 @@ void main() { expect(bridge.systemInstructions.toSet(), hasLength(1)); }); + test('local provider keeps stable context in the system instruction and ' + 'ephemeral context on the turn', () async { + final prompts = []; + final bridge = _FakeNativeBridge.sequence([ + 'Plain local response.', + ], prompts: prompts); + final provider = LocalNativeLlmProvider(bridge); + + await provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'How is my day?', + context: const PromptContext( + profile: null, + memory: 'Prefers morning study blocks.', + worldState: {'platform': 'test-device'}, + ), + memoryText: 'Prefers morning study blocks.', + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + ), + ); + + // Stable content is the system instruction; volatile context is not. + expect( + bridge.lastSystemInstruction, + contains('Prefers morning study blocks.'), + ); + expect( + bridge.lastSystemInstruction, + isNot(contains('Current local timestamp')), + ); + + // The volatile per-turn context rides the message with the user text. + expect(prompts.single, contains('Current local timestamp')); + expect(prompts.single, contains('test-device')); + expect(prompts.single, contains('How is my day?')); + }); + test( - 'local provider keeps stable context in the system instruction and ' - 'ephemeral context on the turn', + 'local provider resets the live stream before a tool follow-up', () async { - final prompts = []; final bridge = _FakeNativeBridge.sequence([ - 'Plain local response.', - ], prompts: prompts); + '[TOOL:read_memories:{}]', + 'Answer from tool results.', + ]); final provider = LocalNativeLlmProvider(bridge); + final deltas = []; - await provider.send( + final response = await provider.send( AgentLlmRequest( config: const AgentConfig( provider: AgentProvider.local, @@ -239,85 +293,31 @@ void main() { ), sessions: const [], activeSessionId: null, - userText: 'How is my day?', + userText: 'What should I remember?', context: const PromptContext( profile: null, - memory: 'Prefers morning study blocks.', - worldState: {'platform': 'test-device'}, + memory: '', + worldState: {}, ), - memoryText: 'Prefers morning study blocks.', + memoryText: '', appendMemory: (_) async {}, - readMemory: () async => '', + readMemory: () async => 'Fresh memory from disk', readSchedule: () async => 'No schedule.', mailTools: MailToolRunner( repository: MailRepository.test(), profile: null, ), onToolTrace: (_) {}, + onDelta: deltas.add, ), ); - // Stable content is the system instruction; volatile context is not. - expect( - bridge.lastSystemInstruction, - contains('Prefers morning study blocks.'), - ); - expect( - bridge.lastSystemInstruction, - isNot(contains('Current local timestamp')), - ); - - // The volatile per-turn context rides the message with the user text. - expect(prompts.single, contains('Current local timestamp')); - expect(prompts.single, contains('test-device')); - expect(prompts.single, contains('How is my day?')); + expect(response, 'Answer from tool results.'); + // The bracketed tool directive turn is cleared before the answer streams. + expect(deltas.where((delta) => delta.reset), hasLength(1)); }, ); - test('local provider resets the live stream before a tool follow-up', () async { - final bridge = _FakeNativeBridge.sequence([ - '[TOOL:read_memories:{}]', - 'Answer from tool results.', - ]); - final provider = LocalNativeLlmProvider(bridge); - final deltas = []; - - final response = await provider.send( - AgentLlmRequest( - config: const AgentConfig( - provider: AgentProvider.local, - cloudEndpoint: 'https://example.invalid/v1/chat/completions', - cloudModel: 'test-model', - hasApiKey: false, - localModelId: 'test-local', - localModelPath: '/tmp/model.litertlm', - ), - sessions: const [], - activeSessionId: null, - userText: 'What should I remember?', - context: const PromptContext( - profile: null, - memory: '', - worldState: {}, - ), - memoryText: '', - appendMemory: (_) async {}, - readMemory: () async => 'Fresh memory from disk', - readSchedule: () async => 'No schedule.', - mailTools: MailToolRunner( - repository: MailRepository.test(), - profile: null, - ), - onToolTrace: (_) {}, - onDelta: deltas.add, - ), - ); - - expect(response, 'Answer from tool results.'); - // The bracketed tool directive turn is cleared before the answer streams. - expect(deltas.where((delta) => delta.reset), hasLength(1)); - }); - test('local provider throws when tool rounds are exhausted', () async { final bridge = _FakeNativeBridge('[TOOL:read_memories:{}]'); final provider = LocalNativeLlmProvider(bridge); diff --git a/flutter_app/test/deadline_card_test.dart b/flutter_app/test/deadline_card_test.dart index dfc8278..25656de 100644 --- a/flutter_app/test/deadline_card_test.dart +++ b/flutter_app/test/deadline_card_test.dart @@ -22,10 +22,7 @@ void main() { group('reminderTimeForDeadline', () { test('defaults to one day before the deadline', () { final due = DateTime(2026, 12, 11, 18); - final when = reminderTimeForDeadline( - due, - now: DateTime(2026, 12, 1, 9), - ); + final when = reminderTimeForDeadline(due, now: DateTime(2026, 12, 1, 9)); expect(when, DateTime(2026, 12, 10, 18)); }); @@ -62,71 +59,75 @@ void main() { expect(find.textContaining('Due '), findsWidgets); }); - testWidgets('Add reminder emits a ReminderComponentAction with the due date', ( - tester, - ) async { - final actions = []; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: DeadlineCard( - component: deadlineComponent(), - onAction: actions.add, + testWidgets( + 'Add reminder emits a ReminderComponentAction with the due date', + (tester) async { + final actions = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DeadlineCard( + component: deadlineComponent(), + onAction: actions.add, + ), ), ), - ), - ); + ); - await tester.tap(find.text('Add reminder').first); - await tester.pump(); + await tester.tap(find.text('Add reminder').first); + await tester.pump(); - expect(actions, hasLength(1)); - final action = actions.single as ReminderComponentAction; - expect(action.title, 'ML exercise sheet 7'); - expect( - action.dueAt.isAtSameMomentAs( - DateTime.parse('2026-12-11T18:00:00.000Z'), - ), - isTrue, - ); - }); + expect(actions, hasLength(1)); + final action = actions.single as ReminderComponentAction; + expect(action.title, 'ML exercise sheet 7'); + expect( + action.dueAt.isAtSameMomentAs( + DateTime.parse('2026-12-11T18:00:00.000Z'), + ), + isTrue, + ); + }, + ); }); group('AppShellController reminder dispatch', () { - test('routes a reminder action to the native create_reminder tool', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); - - final runner = _RecordingNativeToolRunner('Reminder set for Thursday.'); - final controller = AppShellController( - initialProfile: null, - initialOnLogout: null, - initialOnSaveProfile: null, - nativeToolRunner: runner, - ); - addTearDown(controller.dispose); - controller.createSession(); - - controller.handleComponentAction( - ReminderComponentAction( - title: 'ML exercise sheet 7', - dueAt: DateTime(2026, 12, 11, 18), - ), - ); - await Future.delayed(Duration.zero); - - expect(runner.calls, hasLength(1)); - expect(runner.calls.single.name, nativeCreateReminderToolName); - final args = - jsonDecode(runner.calls.single.arguments) as Map; - expect(args['title'], 'ML exercise sheet 7'); - expect(args['time'], isNotNull); - - // The native tool's result is surfaced back to the user. - final messages = controller.activeSession.messages; - expect(messages.last.text, 'Reminder set for Thursday.'); - }); + test( + 'routes a reminder action to the native create_reminder tool', + () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + final runner = _RecordingNativeToolRunner('Reminder set for Thursday.'); + final controller = AppShellController( + initialProfile: null, + initialOnLogout: null, + initialOnSaveProfile: null, + nativeToolRunner: runner, + ); + addTearDown(controller.dispose); + controller.createSession(); + + controller.handleComponentAction( + ReminderComponentAction( + title: 'ML exercise sheet 7', + dueAt: DateTime(2026, 12, 11, 18), + ), + ); + await Future.delayed(Duration.zero); + + expect(runner.calls, hasLength(1)); + expect(runner.calls.single.name, nativeCreateReminderToolName); + final args = + jsonDecode(runner.calls.single.arguments) as Map; + expect(args['title'], 'ML exercise sheet 7'); + expect(args['time'], isNotNull); + + // The native tool's result is surfaced back to the user. + final messages = controller.activeSession.messages; + expect(messages.last.text, 'Reminder set for Thursday.'); + }, + ); }); } @@ -138,8 +139,9 @@ class _RecordingNativeToolRunner implements NativeToolRunner { <({String name, String arguments})>[]; @override - Future> supportedToolNames() async => - {nativeCreateReminderToolName}; + Future> supportedToolNames() async => { + nativeCreateReminderToolName, + }; @override Future execute(String toolName, String arguments) async { diff --git a/flutter_app/test/generative_ui_registry_test.dart b/flutter_app/test/generative_ui_registry_test.dart index 554d97a..a396556 100644 --- a/flutter_app/test/generative_ui_registry_test.dart +++ b/flutter_app/test/generative_ui_registry_test.dart @@ -109,22 +109,19 @@ void main() { }); test('search_mail is also treated as a producer', () { - expect( - mailTriageComponentPayload('search_mail', inboxJson()), - isNotNull, - ); + expect(mailTriageComponentPayload('search_mail', inboxJson()), isNotNull); }); test('returns null for non-producer tools', () { - expect( - mailTriageComponentPayload('get_schedule', inboxJson()), - isNull, - ); + expect(mailTriageComponentPayload('get_schedule', inboxJson()), isNull); }); test('returns null for empty inboxes and unparseable output', () { expect( - mailTriageComponentPayload('get_recent_mail', inboxJson(withMessages: false)), + mailTriageComponentPayload( + 'get_recent_mail', + inboxJson(withMessages: false), + ), isNull, ); expect( @@ -181,15 +178,15 @@ void main() { componentPayloadForTool('get_deadlines', deadlinesJson()), isNotNull, ); - expect( - componentPayloadForTool('get_schedule', deadlinesJson()), - isNull, - ); + expect(componentPayloadForTool('get_schedule', deadlinesJson()), isNull); }); test('returns null for empty results and non-deadline tools', () { expect( - deadlineListComponentPayload('get_deadlines', deadlinesJson(withData: false)), + deadlineListComponentPayload( + 'get_deadlines', + deadlinesJson(withData: false), + ), isNull, ); expect( @@ -287,10 +284,7 @@ void main() { ), isNull, ); - expect( - componentPayloadForTool('get_recent_mail', statusJson()), - isNull, - ); + expect(componentPayloadForTool('get_recent_mail', statusJson()), isNull); }); }); @@ -356,10 +350,7 @@ void main() { ), isNull, ); - expect( - componentPayloadForTool('get_deadlines', plannerJson()), - isNull, - ); + expect(componentPayloadForTool('get_deadlines', plannerJson()), isNull); }); }); @@ -384,7 +375,10 @@ void main() { } test('builds a mensa_menu card from get_mensa_options output', () { - final payload = mensaMenuComponentPayload('get_mensa_options', mensaJson()); + final payload = mensaMenuComponentPayload( + 'get_mensa_options', + mensaJson(), + ); expect(payload, isNotNull); final validation = GenerativeUiRegistry.validate(payload!); @@ -403,7 +397,10 @@ void main() { test('empty data and non-mensa tools yield no card', () { expect( - mensaMenuComponentPayload('get_mensa_options', mensaJson(withData: false)), + mensaMenuComponentPayload( + 'get_mensa_options', + mensaJson(withData: false), + ), isNull, ); expect(componentPayloadForTool('search_talks', mensaJson()), isNull); @@ -456,7 +453,10 @@ void main() { ), isNull, ); - expect(componentPayloadForTool('get_mensa_options', locationsJson()), isNull); + expect( + componentPayloadForTool('get_mensa_options', locationsJson()), + isNull, + ); }); }); @@ -506,7 +506,10 @@ void main() { isNull, ); expect( - scheduleAgendaComponentPayload('get_schedule', scheduleJson(withEvents: false)), + scheduleAgendaComponentPayload( + 'get_schedule', + scheduleJson(withEvents: false), + ), isNull, ); expect(componentPayloadForTool('get_deadlines', scheduleJson()), isNull); diff --git a/flutter_app/test/generic_component_cards_test.dart b/flutter_app/test/generic_component_cards_test.dart index ac95ff4..92a3c57 100644 --- a/flutter_app/test/generic_component_cards_test.dart +++ b/flutter_app/test/generic_component_cards_test.dart @@ -43,10 +43,9 @@ void main() { ) async { GeneratedComponentAction? action; await tester.pumpWidget( - hostMessages( - [assistant('Want a plan?', fixture('quick_reply'))], - onAction: (value) => action = value, - ), + hostMessages([ + assistant('Want a plan?', fixture('quick_reply')), + ], onAction: (value) => action = value), ); expect(find.byType(QuickReplyCard), findsOneWidget); @@ -65,10 +64,9 @@ void main() { ) async { GeneratedComponentAction? action; await tester.pumpWidget( - hostMessages( - [assistant('You could:', fixture('next_action'))], - onAction: (value) => action = value, - ), + hostMessages([ + assistant('You could:', fixture('next_action')), + ], onAction: (value) => action = value), ); expect(find.byType(NextActionCard), findsOneWidget); @@ -84,10 +82,9 @@ void main() { ) async { GeneratedComponentAction? action; await tester.pumpWidget( - hostMessages( - [assistant('Heads up:', fixture('deadline_card'))], - onAction: (value) => action = value, - ), + hostMessages([ + assistant('Heads up:', fixture('deadline_card')), + ], onAction: (value) => action = value), ); expect(find.byType(DeadlineHighlightCard), findsOneWidget); diff --git a/flutter_app/test/mail_view_test.dart b/flutter_app/test/mail_view_test.dart index 6e1bdf4..dce4140 100644 --- a/flutter_app/test/mail_view_test.dart +++ b/flutter_app/test/mail_view_test.dart @@ -59,11 +59,7 @@ void main() { expect(repository.forceRefreshCount, 0); - await tester.fling( - find.byType(ListView), - const Offset(0, 400), - 1000, - ); + await tester.fling(find.byType(ListView), const Offset(0, 400), 1000); await tester.pumpAndSettle(); expect(repository.forceRefreshCount, 1); diff --git a/flutter_app/test/markdown_math_test.dart b/flutter_app/test/markdown_math_test.dart index 7c92b7a..5a41895 100644 --- a/flutter_app/test/markdown_math_test.dart +++ b/flutter_app/test/markdown_math_test.dart @@ -40,9 +40,7 @@ void main() { }); testWidgets('GFM tables still render alongside math support', (tester) async { - await tester.pumpWidget( - host('| A | B |\n| - | - |\n| 1 | 2 |'), - ); + await tester.pumpWidget(host('| A | B |\n| - | - |\n| 1 | 2 |')); expect(find.byType(Table), findsOneWidget); }); diff --git a/flutter_app/test/message_list_component_test.dart b/flutter_app/test/message_list_component_test.dart index b695e58..8ec7745 100644 --- a/flutter_app/test/message_list_component_test.dart +++ b/flutter_app/test/message_list_component_test.dart @@ -41,7 +41,9 @@ void main() { expect(find.byType(MailTriageCard), findsOneWidget); // The card sits below the lead-in text in the vertical layout. - final textY = tester.getTopLeft(find.text('Here are your recent emails:')).dy; + final textY = tester + .getTopLeft(find.text('Here are your recent emails:')) + .dy; final cardY = tester.getTopLeft(find.byType(MailTriageCard)).dy; expect(cardY, greaterThan(textY)); }); diff --git a/flutter_app/test/schedule_card_test.dart b/flutter_app/test/schedule_card_test.dart index 5168a9b..433440c 100644 --- a/flutter_app/test/schedule_card_test.dart +++ b/flutter_app/test/schedule_card_test.dart @@ -53,9 +53,7 @@ void main() { return null; }); addTearDown( - () => TestDefaultBinaryMessengerBinding - .instance - .defaultBinaryMessenger + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null), ); @@ -97,7 +95,10 @@ void main() { expect(decoded['source_term'], 'WS 2026/27'); final events = decoded['events'] as List; expect(events, hasLength(1)); - expect((events.first as Map)['title'], 'Machine Learning'); + expect( + (events.first as Map)['title'], + 'Machine Learning', + ); // Refresh was requested exactly once (no null-snapshot race). expect(repository.refreshCalls, 1); }); diff --git a/flutter_app/test/studyos_tool_openapi_schema_test.dart b/flutter_app/test/studyos_tool_openapi_schema_test.dart index 9d53ace..440f5ed 100644 --- a/flutter_app/test/studyos_tool_openapi_schema_test.dart +++ b/flutter_app/test/studyos_tool_openapi_schema_test.dart @@ -16,18 +16,21 @@ void main() { expect(params['required'], isEmpty); }); - test('carries JSON-schema properties and required for a tool with args', () { - final decl = appendMemoryTool.toOpenApiFunctionDeclaration(); + test( + 'carries JSON-schema properties and required for a tool with args', + () { + final decl = appendMemoryTool.toOpenApiFunctionDeclaration(); - expect(decl['name'], 'append_memory'); - final params = decl['parameters']! as Map; - final properties = params['properties']! as Map; - expect(properties.containsKey('text'), isTrue); - final textSchema = properties['text']! as Map; - expect(textSchema['type'], 'string'); - expect(textSchema['description'], isNotEmpty); - expect(params['required'], contains('text')); - }); + expect(decl['name'], 'append_memory'); + final params = decl['parameters']! as Map; + final properties = params['properties']! as Map; + expect(properties.containsKey('text'), isTrue); + final textSchema = properties['text']! as Map; + expect(textSchema['type'], 'string'); + expect(textSchema['description'], isNotEmpty); + expect(params['required'], contains('text')); + }, + ); test('every catalog tool serializes to valid, well-formed JSON', () { for (final tool in studyOsTools) { @@ -36,7 +39,11 @@ void main() { expect(decoded['name'], tool.name, reason: '${tool.name} name'); final params = decoded['parameters']! as Map; - expect(params['type'], 'object', reason: '${tool.name} parameters.type'); + expect( + params['type'], + 'object', + reason: '${tool.name} parameters.type', + ); // Every declared required field must exist in properties. final properties = params['properties']! as Map; diff --git a/flutter_app/test/talk_academic_cards_test.dart b/flutter_app/test/talk_academic_cards_test.dart index f28dfde..8a69823 100644 --- a/flutter_app/test/talk_academic_cards_test.dart +++ b/flutter_app/test/talk_academic_cards_test.dart @@ -59,7 +59,9 @@ void main() { }); group('AcademicStatusCard', () { - testWidgets('groups entries by category with status badges', (tester) async { + testWidgets('groups entries by category with status badges', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/flutter_app/test/tool_card_reference_test.dart b/flutter_app/test/tool_card_reference_test.dart index 6134410..bb6cd77 100644 --- a/flutter_app/test/tool_card_reference_test.dart +++ b/flutter_app/test/tool_card_reference_test.dart @@ -9,33 +9,31 @@ void main() { 'arguments': {'modules': []}, }; - Map> captured() => >{ - 'get_study_planner': plannerCard(), - }; + Map> captured() => + >{'get_study_planner': plannerCard()}; group('resolveComponentPayload', () { test('a tool_card reference resolves to the captured tool payload', () { - final resolved = resolveComponentPayload( - {'type': 'tool_card', 'tool': 'get_study_planner'}, - captured(), - ); + final resolved = resolveComponentPayload({ + 'type': 'tool_card', + 'tool': 'get_study_planner', + }, captured()); expect(resolved, plannerCard()); }); test('a reference to a tool not called this turn resolves to null', () { - final resolved = resolveComponentPayload( - {'type': 'tool_card', 'tool': 'get_recent_mail'}, - captured(), - ); + final resolved = resolveComponentPayload({ + 'type': 'tool_card', + 'tool': 'get_recent_mail', + }, captured()); expect(resolved, isNull); }); test('a tool_card reference without a tool name resolves to null', () { expect( - resolveComponentPayload( - {'type': 'tool_card'}, - captured(), - ), + resolveComponentPayload({ + 'type': 'tool_card', + }, captured()), isNull, ); }); @@ -69,18 +67,21 @@ void main() { expect(component, plannerCard()); }); - test('a pivot reply that ran the tool but omits the block shows no card', () { - // The model called get_study_planner while answering something else and - // did not reference it — the decoupling fix: no card, full text kept. - const raw = - 'The Mensa has Gemüse-Lasagne and Rindergulasch today. ' - 'Both are available at lunch.'; - final parts = splitAssistantComponent(raw); - final component = resolveComponentPayload(parts.component, captured()); - - expect(parts.text, raw); - expect(component, isNull); - }); + test( + 'a pivot reply that ran the tool but omits the block shows no card', + () { + // The model called get_study_planner while answering something else and + // did not reference it — the decoupling fix: no card, full text kept. + const raw = + 'The Mensa has Gemüse-Lasagne and Rindergulasch today. ' + 'Both are available at lunch.'; + final parts = splitAssistantComponent(raw); + final component = resolveComponentPayload(parts.component, captured()); + + expect(parts.text, raw); + expect(component, isNull); + }, + ); }); group('isPresentationalLeadIn', () { @@ -185,10 +186,7 @@ void main() { 'get_study_planner': plannerCard(), 'get_recent_mail': mailCard, }; - expect( - resolve('Here you go:', capturedComponents: ordered), - mailCard, - ); + expect(resolve('Here you go:', capturedComponents: ordered), mailCard); }); }); } From 7f6be04c5e6180985edf140ab02b6e67f0b91a19 Mon Sep 17 00:00:00 2001 From: linuscooper Date: Wed, 29 Jul 2026 22:08:31 +0200 Subject: [PATCH 9/9] fix analyze error --- flutter_app/lib/src/widgets/deadline_card.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flutter_app/lib/src/widgets/deadline_card.dart b/flutter_app/lib/src/widgets/deadline_card.dart index fe03f45..c48f72d 100644 --- a/flutter_app/lib/src/widgets/deadline_card.dart +++ b/flutter_app/lib/src/widgets/deadline_card.dart @@ -220,8 +220,9 @@ class _DeadlineRow extends StatelessWidget { static _Urgency _urgencyFor(DateTime? due) { if (due == null) return const _Urgency(StudyOsColors.textMuted, 'no date'); final now = DateTime.now(); - if (due.isBefore(now)) + if (due.isBefore(now)) { return const _Urgency(StudyOsColors.warning, 'overdue'); + } final hours = due.difference(now).inHours; if (hours <= 48) { return const _Urgency(StudyOsColors.warning, 'soon');