Files
flutterApp/lib/features/ai/presentation/pages/ai_page.dart

475 lines
18 KiB
Dart
Raw 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 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../bloc/ai_cubit.dart';
import '../bloc/ai_state.dart';
import '../../data/models/chat_message.dart';
import '../../data/models/session.dart';
// --- MAIN WIDGET ---
class AiPage extends StatelessWidget {
const AiPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AiCubit(
appUserCubit: context.read<AppUserCubit>(), // ✅ 这里传
),
child: const AiView(),
);
}
}
class AiView extends StatefulWidget {
const AiView({super.key});
@override
State<AiView> createState() => _AiViewState();
}
class _AiViewState extends State<AiView> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).translate('ai.title')),
actions: [
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'new_session') {
context.read<AiCubit>().createNewSession();
} else if (value == 'history') {
_showHistoryDialog(context);
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
PopupMenuItem<String>(value: 'new_session', child: Text(AppLocalizations.of(context).translate('ai.new_session'))),
PopupMenuItem<String>(value: 'history', child: Text(AppLocalizations.of(context).translate('ai.history'))),
],
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: BlocBuilder<AiCubit, AiState>(
builder: (context, state) {
// Scroll to bottom when new messages are added
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
});
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8.0),
controller: _scrollController,
reverse: true,
itemCount: state.messages.length,
itemBuilder: (context, index) => _buildMessageBubble(context, state.messages[index]),
);
},
),
),
_buildInputArea(context),
],
),
),
);
}
Widget _buildMessageBubble(BuildContext context, ChatMessage message) {
final isUserMessage = message.isUserMessage;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0),
child: Row(
mainAxisAlignment: isUserMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isUserMessage) const CircleAvatar(backgroundImage: AssetImage('assets/images/app_logo_black.png'), radius: 16),
const SizedBox(width: 10),
Flexible(child: isUserMessage ? _buildUserMessageContent(context, message) : _buildAiMessageContent(context, message)),
if (isUserMessage) const SizedBox(width: 10), // <-- This was the missing part
if (isUserMessage) const CircleAvatar(child: Icon(Icons.person, size: 20), radius: 16),
],
),
);
}
Widget _buildUserMessageContent(BuildContext context, ChatMessage message) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 14.0),
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(18)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.image != null)
GestureDetector(
onTap: () => _showImagePreview(context, imageFile: message.image!),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.file(message.image!, width: 200, height: 200, fit: BoxFit.cover),
),
)
else if (message.imageBase64 != null)
ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.memory(base64Decode(message.imageBase64!.split(',').last), width: 200, height: 200, fit: BoxFit.cover),
),
if (message.text != null && message.text!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(message.text!, style: const TextStyle(color: Colors.white, fontSize: 16)),
),
],
),
);
}
Widget _buildAiMessageContent(BuildContext context, ChatMessage message) {
final state = context.watch<AiCubit>().state;
final hasThinking = message.thinkingText != null && message.thinkingText!.isNotEmpty;
final hasAnswer = message.text != null && message.text!.isNotEmpty;
final showThinkingBlock = state.status == AiStatus.loading || !hasAnswer;
if (!showThinkingBlock && !hasAnswer) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F7F9), // 更柔和的背景
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showThinkingBlock)
_buildSection(
context,
icon: Icons.psychology_outlined,
title: "深度思考",
content: message.thinkingText,
iconColor: Colors.blue.shade700,
showSpinner: state.status == AiStatus.loading && !hasThinking,
),
if (showThinkingBlock && hasAnswer)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(height: 1, color: Colors.grey.shade300),
),
// ========== 最终结论(带美化 Markdown)==========
if (hasAnswer)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline, size: 20, color: Colors.green.shade700),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).translate('ai.final_conclusion'),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF2D7D46)),
),
],
),
const SizedBox(height: 10),
// ========== Markdown 渲染(超好看样式)==========
Markdown(
data: message.text!,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
styleSheet: MarkdownStyleSheet(
// 正文
p: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF262626)),
// 标题
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
h2: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
// 加粗
strong: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF000000)),
// 斜体
em: const TextStyle(fontStyle: FontStyle.italic, color: Color(0xFF444444)),
// 列表
listBullet: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF262626)),
// 引用
blockquote: const TextStyle(fontSize: 15, color: Color(0xFF555555), height: 1.6),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: Colors.green.shade300, width: 4)),
),
// 代码行
code: TextStyle(fontSize: 14, color: const Color(0xFFD32F2F), backgroundColor: Colors.grey.shade200, height: 1.6),
// 代码块
codeblockDecoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
codeblockPadding: const EdgeInsets.all(10),
// 表格
tableBorder: TableBorder.all(color: Colors.grey.shade300, width: 1),
tableHead: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
tableBody: const TextStyle(fontSize: 14, height: 1.5),
),
),
],
),
),
],
),
);
}
Widget _buildSection(BuildContext context, {required IconData icon, required String title, String? content, Color? iconColor, bool showSpinner = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: iconColor),
const SizedBox(width: 8),
Text(
title == "深度思考" ? AppLocalizations.of(context).translate('ai.deep_thinking') : title,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: iconColor),
),
if (showSpinner) ...[
const SizedBox(width: 8),
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2.0, color: iconColor)),
],
],
),
if (content != null && content.isNotEmpty) ...[
const SizedBox(height: 8),
Text(content, style: const TextStyle(fontSize: 16, color: Colors.black87, height: 1.4)),
],
],
),
);
}
Widget _buildInputArea(BuildContext context) {
final state = context.watch<AiCubit>().state;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state.selectedImageFile != null) _buildImagePreviewThumbnail(context),
Container(
padding: const EdgeInsets.all(8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: SvgPicture.asset('assets/svgs/image_icon.svg', width: 24, height: 24),
onPressed: state.status == AiStatus.loading ? null : () => _handleImageSelection(context),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _textController,
enabled: state.status != AiStatus.loading,
decoration: InputDecoration(
hintText: state.status == AiStatus.loading
? AppLocalizations.of(context).translate('ai.thinking_hint')
: AppLocalizations.of(context).translate('ai.input_hint'),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30.0),
borderSide: const BorderSide(width: 0, style: BorderStyle.none),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceVariant,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onSubmitted: state.status == AiStatus.loading ? null : (text) => _handleSendPressed(context),
),
),
const SizedBox(width: 8),
IconButton(
icon: SvgPicture.asset('assets/svgs/send_icon.svg', width: 24, height: 24),
onPressed: state.status == AiStatus.loading ? null : () => _handleSendPressed(context),
),
],
),
),
],
);
}
Widget _buildImagePreviewThumbnail(BuildContext context) {
final state = context.read<AiCubit>().state;
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0),
child: Align(
alignment: Alignment.centerLeft,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(state.selectedImageFile!, width: 72, height: 72, fit: BoxFit.cover),
),
Positioned(
top: -12,
right: -12,
child: IconButton(
icon: const CircleAvatar(
backgroundColor: Colors.black54,
radius: 12,
child: Icon(Icons.close, color: Colors.white, size: 16),
),
onPressed: () => context.read<AiCubit>().deselectImage(),
),
),
],
),
),
);
}
void _handleSendPressed(BuildContext context) {
final text = _textController.text;
final selectedImageFile = context.read<AiCubit>().state.selectedImageFile;
if (text.isEmpty && selectedImageFile == null) return;
context.read<AiCubit>().analyze(prompt: text, image: selectedImageFile);
_textController.clear();
}
void _handleImageSelection(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (BuildContext _) {
return SafeArea(
child: Wrap(
children: <Widget>[
ListTile(
leading: const Icon(Icons.photo_library),
title: Text(AppLocalizations.of(context).translate('ai.album')),
onTap: () {
Navigator.of(context).pop();
context.read<AiCubit>().selectImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.photo_camera),
title: Text(AppLocalizations.of(context).translate('ai.camera')),
onTap: () {
Navigator.of(context).pop();
context.read<AiCubit>().selectImage(ImageSource.camera);
},
),
],
),
);
},
);
}
void _showImagePreview(BuildContext context, {File? imageFile, String? imageBase64}) {
if (imageFile == null && imageBase64 == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ImagePreviewPage(imageFile: imageFile, imageBase64: imageBase64),
),
);
}
void _showHistoryDialog(BuildContext context) {
final cubit = context.read<AiCubit>();
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text(AppLocalizations.of(context).translate('ai.history')),
content: SizedBox(
width: double.maxFinite,
child: BlocBuilder<AiCubit, AiState>(
bloc: cubit,
builder: (context, state) {
return ListView.builder(
shrinkWrap: true,
itemCount: state.sessions.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(state.sessions[index].title),
onTap: () {
Navigator.of(dialogContext).pop();
cubit.loadSessionHistory(state.sessions[index].sessId);
},
);
},
);
},
),
),
actions: [TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: Text(AppLocalizations.of(context).translate('common.close')))],
);
},
);
}
}
// --- IMAGE PREVIEW PAGE ---
class ImagePreviewPage extends StatelessWidget {
final File? imageFile;
final String? imageBase64;
const ImagePreviewPage({super.key, this.imageFile, this.imageBase64});
@override
Widget build(BuildContext context) {
ImageProvider? imageProvider;
if (imageFile != null) {
imageProvider = FileImage(imageFile!);
} else if (imageBase64 != null) {
imageProvider = MemoryImage(base64Decode(imageBase64!.split(',').last));
}
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(
child: InteractiveViewer(
child: imageProvider != null ? Image(image: imageProvider) : const Text('No Image', style: TextStyle(color: Colors.white)),
),
),
);
}
}