flutter_rag 0.1.0
flutter_rag: ^0.1.0 copied to clipboard
A pure Dart Retrieval-Augmented Generation (RAG) engine for Flutter apps. Supports document ingestion, vector search, and LLM integration for building AI knowledge chatbots.
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_rag/flutter_rag.dart';
const String openAIApiKey =
"";
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Knowledge Assistant',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const RagChatScreen(),
);
}
}
class ChatMessage {
final String text;
final bool isUser;
ChatMessage({required this.text, required this.isUser});
}
class RagChatScreen extends StatefulWidget {
const RagChatScreen({super.key});
@override
State<RagChatScreen> createState() => _RagChatScreenState();
}
class _RagChatScreenState extends State<RagChatScreen> {
RagEngine? _ragEngine;
OpenAIEmbeddings? _embeddings;
OpenAILLM? _llm;
InMemoryVectorStore? _vectorStore;
final TextEditingController _questionController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isLoading = false;
bool _isReady = false;
String _status = "Initializing AI...";
final List<ChatMessage> _messages = [];
final List<String> _uploadedFiles = [];
String _formatError(Object error) {
final errorStr = error.toString();
if (errorStr.contains('401') || errorStr.contains('Incorrect API key')) {
return "Invalid OpenAI API key. Please check your API key in main.dart.";
} else if (errorStr.contains('404')) {
return "Service not found. Please check your connection or endpoint.";
} else if (errorStr.contains('SocketException') ||
errorStr.contains('Failed host lookup')) {
return "Network error. Please check your internet connection.";
} else {
final firstLine = errorStr.split('\n').first;
return firstLine.length > 120
? '${firstLine.substring(0, 120)}...'
: firstLine;
}
}
@override
void initState() {
super.initState();
_initializeAI();
_messages.add(
ChatMessage(
text: "Hello! Upload a PDF or CSV document to start asking questions.",
isUser: false,
),
);
}
@override
void dispose() {
_questionController.dispose();
_scrollController.dispose();
_embeddings?.dispose();
_llm?.dispose();
super.dispose();
}
Future<void> _initializeAI() async {
setState(() {
_isLoading = true;
});
try {
_embeddings = OpenAIEmbeddings(apiKey: openAIApiKey);
_vectorStore = InMemoryVectorStore(embeddings: _embeddings!);
_llm = OpenAILLM(apiKey: openAIApiKey);
final retriever = VectorStoreRetriever(
vectorStore: _vectorStore!,
searchK: 3,
);
_ragEngine = RagEngine(retriever: retriever, llm: _llm!);
setState(() {
_isReady = true;
_status = "AI Engine Ready";
});
} catch (e) {
setState(() {
_status = "Initialization error";
_messages.add(
ChatMessage(
text: "Initialization Error: ${_formatError(e)}",
isUser: false,
),
);
});
}
setState(() {
_isLoading = false;
});
}
Future<void> _uploadFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'csv'],
);
if (result == null || result.files.single.path == null) return;
final filePath = result.files.single.path!;
final fileName = result.files.single.name;
final extension = result.files.single.extension;
setState(() {
_isLoading = true;
_status = "Processing $fileName...";
});
try {
List<Document> docs = [];
if (extension == "pdf") {
final loader = PdfLoader(filePath);
docs = await loader.load();
}
if (extension == "csv") {
final loader = CsvLoader(filePath);
docs = await loader.load();
}
final splitter = RecursiveCharacterTextSplitter(
chunkSize: 500,
chunkOverlap: 50,
);
final chunks = splitter.splitDocuments(docs);
final texts = chunks.map((e) => e.pageContent).toList();
await _vectorStore!.addTexts(texts: texts);
setState(() {
if (!_uploadedFiles.contains(fileName)) {
_uploadedFiles.add(fileName);
}
_status = "Ready";
_messages.add(
ChatMessage(
text:
"Successfully processed $fileName. You can now ask questions about it.",
isUser: false,
),
);
});
_scrollToBottom();
} catch (e) {
setState(() {
_status = "Error processing file";
_messages.add(
ChatMessage(
text: "Failed to process file: ${_formatError(e)}",
isUser: false,
),
);
});
_scrollToBottom();
}
setState(() {
_isLoading = false;
});
}
Future<void> _askQuestion() async {
final question = _questionController.text.trim();
if (question.isEmpty) return;
_questionController.clear();
FocusScope.of(context).unfocus(); // Dismiss keyboard
setState(() {
_messages.add(ChatMessage(text: question, isUser: true));
_isLoading = true;
_status = "Thinking...";
});
_scrollToBottom();
try {
final response = await _ragEngine!.invoke(question);
setState(() {
_messages.add(ChatMessage(text: response.answer, isUser: false));
_status = "Ready";
});
} catch (e) {
setState(() {
_messages.add(
ChatMessage(text: "Error: ${_formatError(e)}", isUser: false),
);
_status = "Error occurred";
});
}
setState(() {
_isLoading = false;
});
_scrollToBottom();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Knowledge Assistant",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
if (_status.isNotEmpty)
Text(
_status,
style: TextStyle(
fontSize: 12,
color: _isLoading ? Colors.orange[200] : Colors.white70,
fontWeight: FontWeight.normal,
),
),
],
),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
elevation: 1,
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(Icons.upload_file),
tooltip: 'Upload Document',
onPressed: _isReady && !_isLoading ? _uploadFile : null,
),
),
],
),
body: SafeArea(
child: Column(
children: [
if (_uploadedFiles.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Icon(
Icons.library_books,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"${_uploadedFiles.length} uploaded: ${_uploadedFiles.join(', ')}",
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
],
),
),
),
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return _buildMessageBubble(message);
},
),
),
if (_isLoading)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 10),
Text(
_status,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
],
),
),
_buildInputArea(),
],
),
),
);
}
Widget _buildMessageBubble(ChatMessage message) {
return Align(
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
decoration: BoxDecoration(
color: message.isUser
? Theme.of(context).colorScheme.primary
: Colors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
bottomRight: Radius.circular(message.isUser ? 4 : 16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
message.text,
style: TextStyle(
color: message.isUser ? Colors.white : Colors.black87,
fontSize: 15,
height: 1.4,
),
),
),
);
}
Widget _buildInputArea() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _questionController,
decoration: InputDecoration(
hintText: "Type your question...",
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.grey[100],
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
isDense: true,
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) {
if (_isReady && !_isLoading) {
_askQuestion();
}
},
),
),
const SizedBox(width: 8),
Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: _isReady && !_isLoading
? Theme.of(context).colorScheme.primary
: Colors.grey[300],
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(
Icons.send_rounded,
color: Colors.white,
size: 20,
),
onPressed: _isReady && !_isLoading ? _askQuestion : null,
),
),
],
),
);
}
}