2025-08-12 13:36:42 +08:00
|
|
|
|
import 'package:ai_chat_assistant/utils/common_util.dart';
|
2025-08-13 09:23:09 +08:00
|
|
|
|
import 'package:ai_chat_assistant/widgets/rotating_image.dart';
|
2025-08-12 13:36:42 +08:00
|
|
|
|
import 'package:basic_intl/intl.dart';
|
2025-06-18 11:28:37 +08:00
|
|
|
|
import 'package:flutter/material.dart';
|
2025-08-12 13:36:42 +08:00
|
|
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
|
|
|
|
import '../enums/message_status.dart';
|
2025-06-18 11:28:37 +08:00
|
|
|
|
import '../models/chat_message.dart';
|
2025-08-12 13:36:42 +08:00
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
import '../services/message_service.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
2025-06-18 11:28:37 +08:00
|
|
|
|
|
2025-08-12 13:36:42 +08:00
|
|
|
|
class ChatBubble extends StatefulWidget {
|
2025-06-18 11:28:37 +08:00
|
|
|
|
final ChatMessage message;
|
|
|
|
|
|
|
|
|
|
|
|
const ChatBubble({super.key, required this.message});
|
|
|
|
|
|
|
2025-08-12 13:36:42 +08:00
|
|
|
|
@override
|
|
|
|
|
|
State<ChatBubble> createState() => _ChatBubbleState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _ChatBubbleState extends State<ChatBubble> {
|
|
|
|
|
|
bool _liked = false;
|
|
|
|
|
|
bool _disliked = false;
|
|
|
|
|
|
|
|
|
|
|
|
ChatMessage get message => widget.message;
|
|
|
|
|
|
|
2025-06-18 11:28:37 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2025-08-12 13:36:42 +08:00
|
|
|
|
final isThinking = message.status == MessageStatus.thinking;
|
2025-06-18 11:28:37 +08:00
|
|
|
|
return Align(
|
|
|
|
|
|
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
2025-08-12 13:36:42 +08:00
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment:
|
|
|
|
|
|
message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Container(
|
|
|
|
|
|
constraints: const BoxConstraints(
|
|
|
|
|
|
minWidth: 50,
|
|
|
|
|
|
),
|
|
|
|
|
|
width: isThinking ? double.infinity : null,
|
|
|
|
|
|
child: isThinking
|
|
|
|
|
|
? Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 12, vertical: 12),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: message.isUser
|
|
|
|
|
|
? CommonUtil.commonColor
|
|
|
|
|
|
: Colors.white.withValues(alpha: 0.12),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
borderRadius: message.isUser
|
|
|
|
|
|
? BorderRadius.only(
|
|
|
|
|
|
topLeft: Radius.circular(12),
|
|
|
|
|
|
bottomLeft: Radius.circular(12),
|
|
|
|
|
|
bottomRight: Radius.circular(12),
|
|
|
|
|
|
)
|
|
|
|
|
|
: BorderRadius.only(
|
|
|
|
|
|
topRight: Radius.circular(12),
|
|
|
|
|
|
bottomLeft: Radius.circular(12),
|
|
|
|
|
|
bottomRight: Radius.circular(12),
|
|
|
|
|
|
),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
),
|
|
|
|
|
|
child: _buildAssistantContent(isThinking),
|
|
|
|
|
|
)
|
|
|
|
|
|
: IntrinsicWidth(
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 12, vertical: 12),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: message.isUser
|
|
|
|
|
|
? CommonUtil.commonColor
|
|
|
|
|
|
: Colors.white.withValues(alpha: 0.12),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
borderRadius: message.isUser
|
|
|
|
|
|
? BorderRadius.only(
|
|
|
|
|
|
topLeft: Radius.circular(12),
|
|
|
|
|
|
bottomLeft: Radius.circular(12),
|
|
|
|
|
|
bottomRight: Radius.circular(12),
|
|
|
|
|
|
)
|
|
|
|
|
|
: BorderRadius.only(
|
|
|
|
|
|
topRight: Radius.circular(12),
|
|
|
|
|
|
bottomLeft: Radius.circular(12),
|
|
|
|
|
|
bottomRight: Radius.circular(12),
|
|
|
|
|
|
),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
),
|
|
|
|
|
|
child: message.isUser
|
|
|
|
|
|
? _buildUserContent()
|
|
|
|
|
|
: _buildAssistantContent(isThinking),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-06-18 11:28:37 +08:00
|
|
|
|
),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildUserContent() {
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
if (message.text.trim().isEmpty) ...[
|
|
|
|
|
|
_buildStatusRow(hasBottomMargin: false),
|
|
|
|
|
|
] else ...[
|
|
|
|
|
|
_buildStatusRow(hasBottomMargin: true),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
message.text,
|
2025-08-12 22:17:40 +08:00
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 16),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2025-08-13 09:23:09 +08:00
|
|
|
|
icon = RotatingImage(
|
|
|
|
|
|
imagePath: 'assets/images/thinking_circle.png'
|
|
|
|
|
|
);
|
|
|
|
|
|
color = Colors.white;
|
|
|
|
|
|
break;
|
2025-08-12 13:36:42 +08:00
|
|
|
|
case MessageStatus.executing:
|
|
|
|
|
|
icon = const SizedBox(
|
|
|
|
|
|
width: 16,
|
|
|
|
|
|
height: 16,
|
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue)),
|
|
|
|
|
|
);
|
|
|
|
|
|
color = Colors.white;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case MessageStatus.completed:
|
|
|
|
|
|
case MessageStatus.success:
|
2025-08-13 11:30:53 +08:00
|
|
|
|
icon = Image.asset(
|
|
|
|
|
|
'assets/images/checked.png',
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20
|
|
|
|
|
|
);
|
|
|
|
|
|
color = Colors.white;
|
2025-08-12 13:36:42 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2025-06-18 11:28:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-12 13:36:42 +08:00
|
|
|
|
|
|
|
|
|
|
Widget _buildMarkdownBody() {
|
|
|
|
|
|
return MarkdownBody(
|
|
|
|
|
|
data: message.text,
|
|
|
|
|
|
styleSheet: _markdownStyleSheet,
|
|
|
|
|
|
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: 16, 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),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
const Opacity(
|
2025-08-13 11:30:53 +08:00
|
|
|
|
opacity: 0.08,
|
|
|
|
|
|
child: Divider(color: Colors.white, height: 1),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
),
|
2025-08-13 11:30:53 +08:00
|
|
|
|
const SizedBox(height: 12),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
SizedBox(
|
|
|
|
|
|
height: 24,
|
|
|
|
|
|
child: isThinking
|
|
|
|
|
|
? Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
InkWell(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
Provider.of<MessageService>(context, listen: false)
|
|
|
|
|
|
.abortReply();
|
|
|
|
|
|
},
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Icon(Icons.stop_circle_outlined,
|
2025-08-12 22:17:40 +08:00
|
|
|
|
size: 18, color: CommonUtil.commonColor),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
Intl.getCurrentLocale().startsWith('zh')
|
|
|
|
|
|
? '停止回答'
|
|
|
|
|
|
: 'Stop',
|
|
|
|
|
|
style: const TextStyle(
|
2025-08-12 22:17:40 +08:00
|
|
|
|
color: Colors.white, fontSize: 14)),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
)
|
2025-08-12 13:36:42 +08:00
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
: Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
Intl.getCurrentLocale().startsWith('zh')
|
|
|
|
|
|
? '内容由AI生成,具体以实际情况为准'
|
|
|
|
|
|
: 'Generated by AI, for reference only.',
|
|
|
|
|
|
style:
|
|
|
|
|
|
const TextStyle(fontSize: 12, 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');
|
|
|
|
|
|
},
|
2025-08-12 22:17:40 +08:00
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 12),
|
|
|
|
|
|
child: Image.asset(
|
|
|
|
|
|
'assets/images/copy.png',
|
2025-08-13 11:30:53 +08:00
|
|
|
|
width: 24,
|
|
|
|
|
|
height: 24
|
2025-08-12 22:17:40 +08:00
|
|
|
|
),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
InkWell(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_liked = !_liked;
|
|
|
|
|
|
if (_liked) _disliked = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 12),
|
2025-08-12 22:17:40 +08:00
|
|
|
|
child: _liked
|
|
|
|
|
|
? Image.asset('assets/images/liked2.png',
|
2025-08-13 11:30:53 +08:00
|
|
|
|
width: 24, height: 24)
|
2025-08-12 22:17:40 +08:00
|
|
|
|
: Image.asset('assets/images/liked1.png',
|
2025-08-13 11:30:53 +08:00
|
|
|
|
width: 24, height: 24),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
InkWell(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_disliked = !_disliked;
|
|
|
|
|
|
if (_disliked) _liked = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
child: Padding(
|
2025-08-13 09:23:09 +08:00
|
|
|
|
padding: const EdgeInsets.only(left: 12),
|
|
|
|
|
|
child: _disliked
|
|
|
|
|
|
? Image.asset('assets/images/disliked2.png',
|
2025-08-13 11:30:53 +08:00
|
|
|
|
width: 24, height: 24)
|
2025-08-13 09:23:09 +08:00
|
|
|
|
: Image.asset('assets/images/disliked1.png',
|
2025-08-13 11:30:53 +08:00
|
|
|
|
width: 24, height: 24)),
|
2025-08-12 13:36:42 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|