348 lines
12 KiB
Dart
348 lines
12 KiB
Dart
import 'package:ai_chat_assistant/utils/common_util.dart';
|
||
import 'package:ai_chat_assistant/widgets/rotating_image.dart';
|
||
import 'package:basic_intl/intl.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import '../enums/message_status.dart';
|
||
import '../models/chat_message.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../services/message_service.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:fluttertoast/fluttertoast.dart';
|
||
|
||
class ChatBubble extends StatefulWidget {
|
||
final ChatMessage message;
|
||
|
||
const ChatBubble({super.key, required this.message});
|
||
|
||
@override
|
||
State<ChatBubble> createState() => _ChatBubbleState();
|
||
}
|
||
|
||
class _ChatBubbleState extends State<ChatBubble> {
|
||
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: 16),
|
||
)
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
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: 'assets/images/thinking_circle.png');
|
||
color = Colors.white;
|
||
break;
|
||
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:
|
||
icon = Image.asset('assets/images/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,
|
||
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),
|
||
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<MessageService>(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: 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');
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: 12),
|
||
child: Image.asset('assets/images/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
|
||
? Image.asset('assets/images/liked2.png',
|
||
width: 22, height: 22)
|
||
: Image.asset('assets/images/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
|
||
? Image.asset('assets/images/disliked2.png',
|
||
width: 22, height: 22)
|
||
: Image.asset('assets/images/disliked1.png',
|
||
width: 22, height: 22)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|