Files
ai_chat_assistant/lib/widgets/chat_bubble.dart
guangfei.zhao a7ed838c92 feat: 新增ai_chat_core,将core 和 widget 分离(未完成)
迁移了 models enums, utils, http 封装,还有一些extensions;service 只迁移了 sse service
2025-09-29 18:33:46 +08:00

365 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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: 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<Color>(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<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: 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)),
),
],
),
],
),
),
],
);
}
}