import 'dart:math' show min; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/message.dart'; import '../providers/api_client_provider.dart'; import '../providers/settings_provider.dart'; import 'tool_call_chip.dart'; class ChatMessageBubble extends ConsumerWidget { final Message message; final String streamingStatus; const ChatMessageBubble({ super.key, required this.message, this.streamingStatus = '', }); @override Widget build(BuildContext context, WidgetRef ref) { final isUser = message.role == MessageRole.user; final scheme = Theme.of(context).colorScheme; final serverUrl = ref.watch(serverUrlProvider) ?? ''; final dio = ref.watch(dioProvider); 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, child: Container( constraints: BoxConstraints( maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480), ), margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), // Illuminated Transcript pattern (mirrors web's ChatMessage.vue): // - User bubble: transparent bg, neutral Pewter border, only the // bottom-right corner clipped (the "from-me" tail). // - Assistant bubble: card surface, 2px accent left edge (the // "illuminated capital"), accent-tinted glow shadow + depth // shadow, only the bottom-left corner clipped. decoration: isUser ? BoxDecoration( color: Colors.transparent, border: Border.all(color: scheme.outline, width: 1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(16), bottomRight: Radius.circular(4), ), ) : BoxDecoration( color: scheme.surface, border: Border( left: BorderSide(color: scheme.primary, width: 2), ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), boxShadow: [ BoxShadow( color: scheme.primary.withValues(alpha: 0.14), blurRadius: 28, offset: const Offset(0, 4), ), BoxShadow( color: Colors.black.withValues(alpha: 0.4), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: showSpinnerOnly ? _buildSpinner(scheme) : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // 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), ], ), 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, imageBuilder: (uri, title, alt) { return _AuthImage( uri: uri, alt: alt, serverUrl: serverUrl, dio: dio, ); }, 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, ), ), ), ], ], ); } } class _AuthImage extends StatefulWidget { final Uri uri; final String? alt; final String serverUrl; final Dio dio; const _AuthImage({ required this.uri, this.alt, required this.serverUrl, required this.dio, }); @override State<_AuthImage> createState() => _AuthImageState(); } class _AuthImageState extends State<_AuthImage> { late Future _future; @override void initState() { super.initState(); _future = _fetchImage(); } Future _fetchImage() async { var url = widget.uri.toString(); if (url.startsWith('/')) { url = '${widget.serverUrl}$url'; } final response = await widget.dio.get>( url, options: Options(responseType: ResponseType.bytes), ); return Uint8List.fromList(response.data!); } @override Widget build(BuildContext context) { return FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox( height: 100, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } if (snapshot.hasError || !snapshot.hasData) { return Text(widget.alt ?? 'Image failed to load'); } return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( snapshot.data!, fit: BoxFit.contain, errorBuilder: (_, _, _) => Text(widget.alt ?? 'Image failed to load'), ), ); }, ); } }