import 'package:ai_chat_assistant/utils/common_util.dart'; import 'package:ai_chat_assistant/widgets/rotating_image.dart'; import 'package:ai_chat_core/ai_chat_core.dart'; import 'package:t_basic_intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:provider/provider.dart'; import '../services/message_service.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import '../utils/assets_util.dart'; class ChatBubble extends StatefulWidget { final ChatMessage message; const ChatBubble({super.key, required this.message}); @override State createState() => _ChatBubbleState(); } class _ChatBubbleState extends State { bool _liked = false; bool _disliked = false; ChatMessage get message => widget.message; @override Widget build(BuildContext context) { final isThinking = message.status == MessageStatus.thinking; final content = Container( constraints: const BoxConstraints(minWidth: 50), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: message.isUser ? CommonUtil.commonColor : Colors.white.withOpacity(0.12), borderRadius: message.isUser ? const BorderRadius.only( topLeft: Radius.circular(12), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ) : const BorderRadius.only( topRight: Radius.circular(12), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ), ), child: message.isUser ? _buildUserContent() : _buildAssistantContent(isThinking), ); // 用户气泡做宽度自适应处理 final wrappedContent = message.isUser ? ConstrainedBox( constraints: const BoxConstraints( maxWidth: double.infinity, ), child: IntrinsicWidth(child: content), ) : content; return Align( alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, child: Column( crossAxisAlignment: message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [wrappedContent], ), ); } Widget _buildUserContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (message.text.trim().isEmpty) ...[ _buildStatusRow(hasBottomMargin: false), ] else ...[ _buildStatusRow(hasBottomMargin: true), Text( message.text, style: TextStyle(color: Colors.white, fontSize: 14), ) ], ], ); } Widget _buildAssistantContent(bool isThinking) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildStatusRow(hasBottomMargin: message.text.trim().isNotEmpty), if (message.text.trim().isNotEmpty) _buildMarkdownBody(), _buildDisclaimer(isThinking), ], ); } Widget _buildStatusRow({required bool hasBottomMargin}) { Widget icon; Color color; switch (message.status) { case MessageStatus.listening: case MessageStatus.recognizing: case MessageStatus.thinking: icon = RotatingImage(imagePath: 'thinking_circle.png'); color = Colors.white; break; case MessageStatus.executing: icon = const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.blue)), ); color = Colors.white; break; case MessageStatus.completed: case MessageStatus.success: icon = AssetsUtil.getImageWidget( 'checked.png', width: 20, height: 20, ); color = Colors.white; break; case MessageStatus.failure: icon = const Icon(Icons.error, size: 16, color: Colors.red); color = Colors.red; break; case MessageStatus.aborted: icon = const Icon(Icons.block, size: 16, color: Colors.grey); color = Colors.grey; break; default: return const SizedBox.shrink(); } return _statusRow( icon: icon, color: color, hasBottomMargin: hasBottomMargin); } Widget _statusRow({ required Widget icon, required Color color, required bool hasBottomMargin, Color? backgroundColor, BorderRadius? borderRadius, }) { return Container( margin: hasBottomMargin ? const EdgeInsets.only(bottom: 6) : null, decoration: backgroundColor != null ? BoxDecoration( color: backgroundColor, borderRadius: borderRadius ?? BorderRadius.circular(0)) : null, padding: backgroundColor != null ? const EdgeInsets.all(6) : null, child: Row( mainAxisAlignment: message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ icon, const SizedBox(width: 6), Text( Intl.getCurrentLocale().startsWith('zh') ? message.status.chinese : message.status.english, style: TextStyle( fontSize: 16, color: color, fontWeight: FontWeight.bold), ), ], ), ); } Widget _buildMarkdownBody() { return MarkdownBody( data: message.text, styleSheet: _markdownStyleSheet, imageBuilder: (uri, title, alt) { return Image.network( uri.toString(), errorBuilder: (context, error, stackTrace) { return const SizedBox.shrink(); }, ); }, // sizedImageBuilder: (config) { // return Image.network( // config.uri.toString(), // width: config.width, // height: config.height, // errorBuilder: (context, error, stackTrace) { // return const SizedBox.shrink(); // }, // ); // }, onTapLink: (text, href, title) { // todo }, ); } static final MarkdownStyleSheet _markdownStyleSheet = MarkdownStyleSheet( p: const TextStyle(fontSize: 14, color: Colors.white, height: 1.5), h1: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), h2: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), h3: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), h4: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), h5: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), h6: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), strong: const TextStyle( color: Colors.white, fontSize: 15, height: 1.5, fontWeight: FontWeight.bold), em: const TextStyle( color: Colors.white, fontSize: 12, height: 1.5, fontStyle: FontStyle.italic), code: const TextStyle( fontSize: 16, backgroundColor: Color(0xFFF5F5F5), color: Colors.white), codeblockDecoration: BoxDecoration( color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4)), blockquote: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), blockquoteDecoration: BoxDecoration( border: Border(left: BorderSide(color: Colors.grey, width: 4))), listBullet: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), tableHead: const TextStyle( color: Colors.white, fontSize: 12, height: 1.5, fontWeight: FontWeight.bold), tableBody: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), tableBorder: TableBorder.all(color: Colors.grey, width: 1), tableCellsPadding: const EdgeInsets.all(8), tableColumnWidth: const FlexColumnWidth(), ); Widget _buildDisclaimer(bool isThinking) { if (!isThinking && message.status != MessageStatus.completed) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 6), const Opacity( opacity: 0.08, child: Divider(color: Colors.white, height: 1), ), const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(minHeight: 24), child: isThinking ? Row( mainAxisAlignment: MainAxisAlignment.end, children: [ InkWell( onTap: () { Provider.of(context, listen: false) .abortReply(); }, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.stop_circle_outlined, size: 18, color: CommonUtil.commonColor), const SizedBox(width: 4), Text( Intl.getCurrentLocale().startsWith('zh') ? '停止回答' : 'Stop', style: const TextStyle( color: Colors.white, fontSize: 14)), ], ), ) ], ) : Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( Intl.getCurrentLocale().startsWith('zh') ? '内容由AI生成,具体以实际情况为准' : 'Generated by AI, for reference only.', style: const TextStyle(fontSize: 11, color: Colors.grey), ), ), Row( children: [ InkWell( onTap: () async { await Clipboard.setData(ClipboardData( text: CommonUtil.cleanText(message.text, false))); Fluttertoast.showToast( msg: Intl.getCurrentLocale().startsWith('zh') ? '已复制到剪贴板' : 'Copied to clipboard'); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: AssetsUtil.getImageWidget('copy.png', width: 22, height: 22), ), ), InkWell( onTap: () { setState(() { _liked = !_liked; if (_liked) _disliked = false; }); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: _liked ? AssetsUtil.getImageWidget('liked2.png', width: 22, height: 22) : AssetsUtil.getImageWidget('liked1.png', width: 22, height: 22), ), ), InkWell( onTap: () { setState(() { _disliked = !_disliked; if (_disliked) _liked = false; }); }, child: Padding( padding: const EdgeInsets.only(left: 12), child: _disliked ? AssetsUtil.getImageWidget('disliked2.png', width: 22, height: 22) : AssetsUtil.getImageWidget('disliked1.png', width: 22, height: 22)), ), ], ), ], ), ), ], ); } }