415 lines
14 KiB
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)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|