diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart index 44f90c0..79c599a 100644 --- a/lib/data/api/chat_api.dart +++ b/lib/data/api/chat_api.dart @@ -18,6 +18,15 @@ class ChatStatusUpdate extends ChatStreamEvent { ChatStatusUpdate(this.status); } +/// A single tool call fired during generation. Mirrors the `tool_call` SSE +/// event emitted by `generation_task.py` and the `tool_calls` array persisted +/// on the assistant Message row — same shape either way so the UI can render +/// live chips during streaming and re-render them from storage after reload. +class ChatToolCall extends ChatStreamEvent { + final Map toolCall; + ChatToolCall(this.toolCall); +} + class ChatApi { final Dio _dio; const ChatApi(this._dio); @@ -136,6 +145,14 @@ class ChatApi { } catch (_) { // Ignore malformed status events } + } else if (currentEvent == 'tool_call') { + try { + final obj = json.decode(data) as Map; + final tc = obj['tool_call']; + if (tc is Map) yield ChatToolCall(tc); + } catch (_) { + // Ignore malformed tool_call events + } } } else if (line.isEmpty) { currentEvent = ''; // blank line = SSE event separator diff --git a/lib/data/models/message.dart b/lib/data/models/message.dart index be48d10..1cd51ff 100644 --- a/lib/data/models/message.dart +++ b/lib/data/models/message.dart @@ -8,6 +8,11 @@ class Message { final String status; // "complete" | "generating" final DateTime? createdAt; final Map? metadata; + // Tool invocations attached to this message. Each entry matches the shape + // persisted by the backend (`function`, `arguments`, `result`, `status`) so + // the UI can render the same chips whether they arrive live over SSE or + // from a reload. + final List>? toolCalls; const Message({ this.id, @@ -17,21 +22,39 @@ class Message { this.status = 'complete', this.createdAt, this.metadata, + this.toolCalls, }); - factory Message.fromJson(Map json) => Message( - id: json['id'] as int?, - conversationId: json['conversation_id'] as int, - role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant, - content: json['content'] as String, - status: json['status'] as String? ?? 'complete', - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at'] as String) - : null, - metadata: json['metadata'] as Map?, - ); + factory Message.fromJson(Map json) { + final rawCalls = json['tool_calls']; + List>? parsedCalls; + if (rawCalls is List) { + parsedCalls = [ + for (final tc in rawCalls) + if (tc is Map) tc, + ]; + if (parsedCalls.isEmpty) parsedCalls = null; + } + return Message( + id: json['id'] as int?, + conversationId: json['conversation_id'] as int, + role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant, + content: json['content'] as String, + status: json['status'] as String? ?? 'complete', + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + metadata: json['metadata'] as Map?, + toolCalls: parsedCalls, + ); + } - Message copyWith({String? content, String? status}) => Message( + Message copyWith({ + String? content, + String? status, + List>? toolCalls, + }) => + Message( id: id, conversationId: conversationId, role: role, @@ -39,5 +62,6 @@ class Message { status: status ?? this.status, createdAt: createdAt, metadata: metadata, + toolCalls: toolCalls ?? this.toolCalls, ); } diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart index 26a6d18..8e0bc10 100644 --- a/lib/data/repositories/chat_repository.dart +++ b/lib/data/repositories/chat_repository.dart @@ -1,5 +1,6 @@ import '../api/chat_api.dart'; -export '../api/chat_api.dart' show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate; +export '../api/chat_api.dart' + show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall; import '../models/conversation.dart'; import '../models/message.dart'; diff --git a/lib/providers/briefing_provider.dart b/lib/providers/briefing_provider.dart index c866509..0e9034e 100644 --- a/lib/providers/briefing_provider.dart +++ b/lib/providers/briefing_provider.dart @@ -81,16 +81,24 @@ class BriefingNotifier extends AsyncNotifier { bool streamedContent = false; try { await for (final event in chatApi.streamGeneration(convId)) { - if (event is! ChatTextChunk) continue; - streamedContent = true; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; - final updated = - msgs.last.copyWith(content: msgs.last.content + event.text); - state = AsyncData(current.copyWith( - messages: [...msgs.sublist(0, msgs.length - 1), updated])); + if (event is ChatTextChunk) { + streamedContent = true; + final updated = + msgs.last.copyWith(content: msgs.last.content + event.text); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } else if (event is ChatToolCall) { + final last = msgs.last; + if (last.role != MessageRole.assistant) continue; + final nextCalls = [...?last.toolCalls, event.toolCall]; + final updated = last.copyWith(toolCalls: nextCalls); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } } } catch (_) { // Fall through to polling. @@ -169,16 +177,24 @@ class BriefingNotifier extends AsyncNotifier { bool streamedContent = false; try { await for (final event in chatApi.streamGeneration(convId)) { - if (event is! ChatTextChunk) continue; - streamedContent = true; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; - final updated = - msgs.last.copyWith(content: msgs.last.content + event.text); - state = AsyncData(current.copyWith( - messages: [...msgs.sublist(0, msgs.length - 1), updated])); + if (event is ChatTextChunk) { + streamedContent = true; + final updated = + msgs.last.copyWith(content: msgs.last.content + event.text); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } else if (event is ChatToolCall) { + final last = msgs.last; + if (last.role != MessageRole.assistant) continue; + final nextCalls = [...?last.toolCalls, event.toolCall]; + final updated = last.copyWith(toolCalls: nextCalls); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } } } catch (_) { // Fall through to polling. diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index b633ad4..1ac26cb 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -143,6 +143,18 @@ class MessagesNotifier extends AsyncNotifier> { } else if (event is ChatStatusUpdate) { ref.read(streamingStatusProvider(convId).notifier).state = event.status; + } else if (event is ChatToolCall) { + // Append the tool call to the in-flight assistant message so the + // chip appears live. The reload pass at the end of this function + // will overwrite with the persisted version, which carries the + // same shape — no de-dup needed. + final msgs = state.requireValue; + if (msgs.isEmpty) continue; + final last = msgs.last; + if (last.role != MessageRole.assistant) continue; + final nextCalls = [...?last.toolCalls, event.toolCall]; + final updated = last.copyWith(toolCalls: nextCalls); + state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]); } } } catch (_) { diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 801afc9..64c6266 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/models/message.dart'; +import 'tool_call_chip.dart'; class ChatMessageBubble extends StatelessWidget { final Message message; @@ -19,6 +20,12 @@ class ChatMessageBubble extends StatelessWidget { final isUser = message.role == MessageRole.user; final scheme = Theme.of(context).colorScheme; final isGenerating = message.status == 'generating'; + final toolCalls = message.toolCalls ?? const []; + + // An assistant bubble with no text, no tool calls, and still generating + // falls back to the spinner+status "waiting for the first token" view. + final showSpinnerOnly = + isGenerating && message.content.isEmpty && toolCalls.isEmpty; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, @@ -29,7 +36,6 @@ class ChatMessageBubble extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), decoration: isUser ? BoxDecoration( - // Ghost style: transparent bg, thin border color: Colors.transparent, border: Border.all( color: scheme.primary.withValues(alpha: 0.35), @@ -43,7 +49,6 @@ class ChatMessageBubble extends StatelessWidget { ), ) : BoxDecoration( - // Assistant: elevated surface + left accent border color: scheme.surfaceContainerHighest, border: Border( left: BorderSide(color: scheme.primary, width: 2), @@ -57,46 +62,102 @@ class ChatMessageBubble extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: isGenerating && message.content.isEmpty - ? Row( + child: showSpinnerOnly + ? _buildSpinner(scheme) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: scheme.onSurfaceVariant, + // Accumulated tool-call chips: visible both during + // streaming (fed live over SSE) and after reload (from + // the persisted message.tool_calls array). + if (toolCalls.isNotEmpty) ...[ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final tc in toolCalls) ToolCallChip(toolCall: tc), + ], ), - ), - if (streamingStatus.isNotEmpty) ...[ - const SizedBox(width: 8), - Flexible( - child: Text( - streamingStatus, - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + const SizedBox(height: 6), + ], + // Rolling status line — shows backend stage text + // ("Creating note", "Searching calendar") while the + // model is between tool rounds or just before the + // first token. Stays above any already-streamed text + // so the user can see what's happening mid-turn. + if (isGenerating && streamingStatus.isNotEmpty) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + streamingStatus, + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + ], + if (message.content.isNotEmpty) + MarkdownBody( + data: message.content, + styleSheet: MarkdownStyleSheet( + p: TextStyle( + color: isUser + ? scheme.onSurface.withValues(alpha: 0.75) + : scheme.onSurface, + fontSize: 14, ), ), ), - ], ], - ) - : MarkdownBody( - data: message.content.isEmpty ? '…' : message.content, - styleSheet: MarkdownStyleSheet( - p: TextStyle( - color: isUser - ? scheme.onSurface.withValues(alpha: 0.75) - : scheme.onSurface, - fontSize: 14, - ), - ), ), ), ), ); } + + Widget _buildSpinner(ColorScheme scheme) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.onSurfaceVariant, + ), + ), + if (streamingStatus.isNotEmpty) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + streamingStatus, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ], + ); + } } diff --git a/lib/widgets/tool_call_chip.dart b/lib/widgets/tool_call_chip.dart new file mode 100644 index 0000000..884a18f --- /dev/null +++ b/lib/widgets/tool_call_chip.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +/// Human-readable labels for each backend tool. Kept in sync with +/// `_TOOL_LABELS` in `fabledassistant/services/generation_task.py` so the +/// mobile chips use the same wording as the web ToolCallCard status pills. +const Map _toolLabels = { + 'create_note': 'Created note', + 'update_note': 'Updated note', + 'delete_note': 'Deleted note', + 'create_task': 'Created task', + 'update_task': 'Updated task', + 'delete_task': 'Deleted task', + 'read_note': 'Read note', + 'list_notes': 'Listed notes', + 'list_tasks': 'Searched tasks', + 'search_notes': 'Searched notes', + 'create_event': 'Created event', + 'list_events': 'Searched calendar', + 'search_events': 'Searched calendar', + 'update_event': 'Updated event', + 'delete_event': 'Removed event', + 'list_calendars': 'Listed calendars', + 'search_web': 'Searched the web', + 'research_topic': 'Researched topic', + 'search_images': 'Searched images', + 'create_project': 'Created project', + 'update_project': 'Updated project', + 'list_projects': 'Listed projects', + 'get_project': 'Read project', + 'search_projects': 'Searched projects', + 'create_milestone': 'Created milestone', + 'update_milestone': 'Updated milestone', + 'list_milestones': 'Listed milestones', + 'set_rag_scope': 'Changed knowledge scope', + 'calculate': 'Calculated', + 'read_article': 'Read article', + 'get_profile': 'Read profile', + 'update_profile': 'Updated profile', + 'update_person': 'Updated person', + 'update_place': 'Updated place', + 'add_task_log': 'Logged task progress', +}; + +IconData _iconFor(String fn) { + if (fn.contains('note')) return Icons.sticky_note_2_outlined; + if (fn.contains('task')) return Icons.check_circle_outline; + if (fn.contains('event') || fn.contains('calendar')) { + return Icons.event_outlined; + } + if (fn.contains('project')) return Icons.folder_outlined; + if (fn.contains('milestone')) return Icons.flag_outlined; + if (fn.contains('web') || fn.contains('research') || fn.contains('article')) { + return Icons.public; + } + if (fn.contains('image')) return Icons.image_outlined; + if (fn.contains('person') || fn.contains('profile')) { + return Icons.person_outline; + } + if (fn.contains('place')) return Icons.place_outlined; + if (fn.contains('rag') || fn.contains('scope')) return Icons.tune; + if (fn.contains('calculate')) return Icons.calculate_outlined; + return Icons.auto_awesome; +} + +/// Small status pill rendered inside an assistant message bubble for each +/// tool invocation. Mirrors the web app's ToolCallCard header at a glance — +/// icon + label + success/error tint. +class ToolCallChip extends StatelessWidget { + final Map toolCall; + const ToolCallChip({super.key, required this.toolCall}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final function = (toolCall['function'] as String?) ?? 'tool'; + final status = (toolCall['status'] as String?) ?? 'success'; + final isError = status == 'error'; + final label = _toolLabels[function] ?? function; + + final bg = isError + ? scheme.errorContainer.withValues(alpha: 0.55) + : scheme.primary.withValues(alpha: 0.12); + final fg = isError ? scheme.onErrorContainer : scheme.primary; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: fg.withValues(alpha: 0.35), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_iconFor(function), size: 13, color: fg), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +}