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

415 lines
14 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_bloc/flutter_bloc.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: (_) => AiCubit(),
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: const Text('AI智能体'),
actions: [
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'new_session') {
context.read<AiCubit>().createNewSession();
} else if (value == 'history') {
_showHistoryDialog(context);
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem<String>(value: 'new_session', child: Text('开启新的会话')),
const PopupMenuItem<String>(value: 'history', child: Text('历史会话')),
],
),
],
),
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 || hasThinking;
if (!showThinkingBlock && !hasAnswer) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
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),
),
if (hasAnswer)
_buildSection(
context,
icon: Icons.lightbulb_outline,
title: "最终结论",
content: message.text!,
iconColor: Colors.green.shade700,
),
],
),
);
}
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, 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 ? 'AI正在思考中...' : '输入你的想法...',
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: const Text('Gallery'),
onTap: () {
Navigator.of(context).pop();
context.read<AiCubit>().selectImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.photo_camera),
title: const Text('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: const Text('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: const Text('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)),
),
),
);
}
}