Tampilan
Bab 34 — AI Chatbot dengan Gemini
Menambahkan asisten AI ke aplikasi kini relatif sederhana. Yang membedakan chatbot yang terasa bagus dari yang terasa murahan bukan modelnya, melainkan detail pengalaman: respons yang mengalir kata per kata, riwayat yang tersimpan, penanganan kegagalan yang anggun, dan batas pemakaian yang masuk akal.
Bab ini membangun semuanya.
Menyiapkan Gemini
yaml
# pubspec.yaml
dependencies:
google_generative_ai: ^0.4.6
cloud_firestore: ^5.5.0
image_picker: ^1.1.2Ambil kunci API di aistudio.google.com → Get API key.
Kunci API tidak boleh ada di dalam aplikasi
Ini bukan seperti apiKey Firebase yang memang publik. Kunci Gemini memberi akses penuh ke akunmu, dan aplikasi Flutter bisa dibongkar. Siapa pun yang mengekstrak kuncinya bisa memakai kuotamu.
Untuk belajar, --dart-define sudah cukup:
bash
flutter run --dart-define=GEMINI_API_KEY=AIza...Untuk produksi, panggil Gemini lewat Cloud Function — kunci tetap di server, aplikasi hanya memanggil fungsimu. Ini dibahas di bagian akhir bab.
Panggilan pertama
dart
// lib/data/services/layanan_gemini.dart
import 'dart:io';
import 'package:google_generative_ai/google_generative_ai.dart';
const _kunciApi = String.fromEnvironment('GEMINI_API_KEY');
class KesalahanAi implements Exception {
KesalahanAi(this.pesan);
final String pesan;
@override
String toString() => pesan;
}
class LayananGemini {
LayananGemini() : _model = _buatModel();
final GenerativeModel _model;
ChatSession? _sesi;
static GenerativeModel _buatModel() {
return GenerativeModel(
model: 'gemini-2.0-flash',
apiKey: _kunciApi,
// Instruksi sistem menentukan kepribadian & batasan asisten
systemInstruction: Content.system(
'Kamu adalah asisten ramah di dalam aplikasi jajak pendapat '
'berbahasa Indonesia. Jawab dengan singkat, jelas, dan sopan. '
'Kalau ditanya hal di luar topik aplikasi, jawab seperlunya lalu '
'arahkan kembali ke fitur aplikasi. Jangan pernah mengarang data '
'tentang polling tertentu.',
),
generationConfig: GenerationConfig(
temperature: 0.7, // 0 = konsisten, 1 = kreatif
maxOutputTokens: 800,
topP: 0.95,
),
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.medium),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.medium),
],
);
}
/// Mulai sesi baru, opsional dengan riwayat sebelumnya.
void mulaiSesi({List<Content> riwayat = const []}) {
_sesi = _model.startChat(history: riwayat);
}
/// Kirim pesan dan tunggu jawaban lengkap.
Future<String> kirim(String pesan) async {
_sesi ??= _model.startChat();
try {
final respons = await _sesi!.sendMessage(Content.text(pesan));
final teks = respons.text;
if (teks == null || teks.isEmpty) {
throw KesalahanAi('Asisten tidak memberikan jawaban.');
}
return teks;
} on GenerativeAIException catch (e) {
throw KesalahanAi(_terjemahkan(e));
}
}
/// Kirim pesan dan terima jawaban bertahap — terasa jauh lebih hidup.
Stream<String> kirimBertahap(String pesan) async* {
_sesi ??= _model.startChat();
try {
final aliran = _sesi!.sendMessageStream(Content.text(pesan));
await for (final potongan in aliran) {
final teks = potongan.text;
if (teks != null && teks.isNotEmpty) yield teks;
}
} on GenerativeAIException catch (e) {
throw KesalahanAi(_terjemahkan(e));
}
}
/// Kirim pesan beserta gambar.
Future<String> kirimDenganGambar(String pesan, File gambar) async {
try {
final bytes = await gambar.readAsBytes();
final respons = await _model.generateContent([
Content.multi([
TextPart(pesan),
DataPart('image/jpeg', bytes),
]),
]);
return respons.text ?? 'Tidak ada jawaban.';
} on GenerativeAIException catch (e) {
throw KesalahanAi(_terjemahkan(e));
}
}
String _terjemahkan(GenerativeAIException e) {
final pesan = e.message.toLowerCase();
if (pesan.contains('api key')) {
return 'Konfigurasi asisten bermasalah. Hubungi pengembang.';
}
if (pesan.contains('quota') || pesan.contains('rate')) {
return 'Asisten sedang sibuk. Coba lagi sebentar lagi.';
}
if (pesan.contains('safety') || pesan.contains('blocked')) {
return 'Pertanyaan itu tidak bisa dijawab. Coba tanyakan hal lain.';
}
if (pesan.contains('network') || pesan.contains('connection')) {
return 'Periksa koneksi internetmu.';
}
return 'Asisten sedang bermasalah. Coba lagi nanti.';
}
}Beberapa hal yang layak diperhatikan:
systemInstruction adalah cara paling efektif mengendalikan perilaku asisten. Ia jauh lebih andal daripada menyisipkan instruksi di setiap pesan pengguna.
ChatSession menyimpan riwayat percakapan secara otomatis, sehingga asisten mengingat konteks tanpa kamu mengirim ulang seluruh percakapan.
temperature menentukan variasi jawaban. Untuk asisten bantuan, 0.3–0.7 biasanya tepat. Untuk penulisan kreatif, lebih tinggi.
Model pesan
dart
// lib/data/models/pesan_chat.dart
import 'package:cloud_firestore/cloud_firestore.dart';
enum PengirimPesan { pengguna, asisten }
enum StatusPesan { terkirim, mengetik, gagal }
class PesanChat {
const PesanChat({
required this.id,
required this.teks,
required this.pengirim,
required this.padaWaktu,
this.status = StatusPesan.terkirim,
this.urlGambar,
});
final String id;
final String teks;
final PengirimPesan pengirim;
final DateTime padaWaktu;
final StatusPesan status;
final String? urlGambar;
bool get dariPengguna => pengirim == PengirimPesan.pengguna;
factory PesanChat.dariDokumen(
DocumentSnapshot<Map<String, dynamic>> doc,
) {
final data = doc.data()!;
return PesanChat(
id: doc.id,
teks: data['teks'] as String? ?? '',
pengirim: data['dariAi'] == true
? PengirimPesan.asisten
: PengirimPesan.pengguna,
padaWaktu:
(data['padaWaktu'] as Timestamp?)?.toDate() ?? DateTime.now(),
urlGambar: data['urlGambar'] as String?,
);
}
Map<String, dynamic> toMap() => {
'teks': teks,
'dariAi': pengirim == PengirimPesan.asisten,
'padaWaktu': Timestamp.fromDate(padaWaktu),
'urlGambar': urlGambar,
};
PesanChat copyWith({String? teks, StatusPesan? status}) {
return PesanChat(
id: id,
teks: teks ?? this.teks,
pengirim: pengirim,
padaWaktu: padaWaktu,
status: status ?? this.status,
urlGambar: urlGambar,
);
}
}Menyimpan riwayat di Firestore
dart
// lib/data/repositories/chat_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/pesan_chat.dart';
import '../services/layanan_gemini.dart';
class ChatRepository {
ChatRepository({
required LayananGemini gemini,
FirebaseFirestore? firestore,
}) : _gemini = gemini,
_db = firestore ?? FirebaseFirestore.instance;
final LayananGemini _gemini;
final FirebaseFirestore _db;
CollectionReference<Map<String, dynamic>> _koleksi(String uid) =>
_db.collection('chats').doc(uid).collection('messages');
/// Aliran riwayat waktu nyata.
Stream<List<PesanChat>> aliranRiwayat(String uid, {int batas = 100}) {
return _koleksi(uid)
.orderBy('padaWaktu', descending: true)
.limit(batas)
.snapshots()
.map((s) => s.docs.map(PesanChat.dariDokumen).toList());
}
/// Muat riwayat lalu siapkan sesi Gemini dengan konteksnya.
Future<void> siapkanSesi(String uid) async {
final snapshot = await _koleksi(uid)
.orderBy('padaWaktu', descending: true)
.limit(20) // batasi konteks — token itu mahal
.get();
// Firestore mengembalikan urutan terbaru dulu; Gemini butuh kronologis
final pesan = snapshot.docs.map(PesanChat.dariDokumen).toList().reversed;
final riwayat = <Content>[];
for (final p in pesan) {
riwayat.add(
p.dariPengguna
? Content.text(p.teks)
: Content.model([TextPart(p.teks)]),
);
}
_gemini.mulaiSesi(riwayat: riwayat);
}
Future<void> simpan(String uid, PesanChat pesan) async {
await _koleksi(uid).doc(pesan.id).set(pesan.toMap());
}
Stream<String> kirimBertahap(String teks) =>
_gemini.kirimBertahap(teks);
Future<void> bersihkanRiwayat(String uid) async {
final snapshot = await _koleksi(uid).get();
final batch = _db.batch();
for (final doc in snapshot.docs) {
batch.delete(doc.reference);
}
await batch.commit();
}
}Batasi jumlah riwayat yang dikirim
limit(20) di siapkanSesi bukan detail sepele. Setiap pesan dalam riwayat ikut dihitung sebagai token input pada setiap permintaan berikutnya.
Percakapan 200 pesan yang dikirim utuh setiap kali akan menghabiskan kuota dengan sangat cepat, dan responsnya melambat. Dua puluh pesan terakhir biasanya cukup untuk menjaga konteks.
ViewModel chat
dart
// lib/providers/penyedia_chat.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../data/models/pesan_chat.dart';
import '../data/repositories/chat_repository.dart';
import '../data/services/layanan_gemini.dart';
const _uuid = Uuid();
class PenyediaChat extends ChangeNotifier {
PenyediaChat({required ChatRepository repo, required String uid})
: _repo = repo,
_uid = uid;
final ChatRepository _repo;
final String _uid;
final List<PesanChat> _pesan = [];
bool _asistenMengetik = false;
bool _siap = false;
String? _kesalahan;
List<PesanChat> get pesan => List.unmodifiable(_pesan);
bool get asistenMengetik => _asistenMengetik;
bool get siap => _siap;
String? get kesalahan => _kesalahan;
Future<void> muatRiwayat() async {
final snapshot = await _repo.aliranRiwayat(_uid).first;
_pesan
..clear()
..addAll(snapshot.reversed); // kronologis
await _repo.siapkanSesi(_uid);
_siap = true;
notifyListeners();
}
Future<void> kirim(String teks) async {
final bersih = teks.trim();
if (bersih.isEmpty || _asistenMengetik) return;
_kesalahan = null;
// 1. Tampilkan pesan pengguna segera
final pesanPengguna = PesanChat(
id: _uuid.v4(),
teks: bersih,
pengirim: PengirimPesan.pengguna,
padaWaktu: DateTime.now(),
);
_pesan.add(pesanPengguna);
// 2. Siapkan wadah kosong untuk jawaban yang akan mengalir
final idJawaban = _uuid.v4();
final pesanAsisten = PesanChat(
id: idJawaban,
teks: '',
pengirim: PengirimPesan.asisten,
padaWaktu: DateTime.now(),
status: StatusPesan.mengetik,
);
_pesan.add(pesanAsisten);
_asistenMengetik = true;
notifyListeners();
// 3. Simpan pesan pengguna tanpa menunggu
unawaited(_repo.simpan(_uid, pesanPengguna));
// 4. Terima jawaban bertahap
final penampung = StringBuffer();
final indeks = _pesan.length - 1;
try {
await for (final potongan in _repo.kirimBertahap(bersih)) {
penampung.write(potongan);
_pesan[indeks] = _pesan[indeks].copyWith(teks: penampung.toString());
notifyListeners();
}
_pesan[indeks] = _pesan[indeks].copyWith(status: StatusPesan.terkirim);
unawaited(_repo.simpan(_uid, _pesan[indeks]));
} on KesalahanAi catch (e) {
_kesalahan = e.pesan;
_pesan[indeks] = _pesan[indeks].copyWith(
teks: e.pesan,
status: StatusPesan.gagal,
);
} finally {
_asistenMengetik = false;
notifyListeners();
}
}
Future<void> ulangiTerakhir() async {
// Buang jawaban gagal dan pesan pengguna yang memicunya
if (_pesan.length < 2) return;
final terakhirPengguna = _pesan[_pesan.length - 2];
if (!terakhirPengguna.dariPengguna) return;
_pesan.removeLast();
_pesan.removeLast();
notifyListeners();
await kirim(terakhirPengguna.teks);
}
Future<void> bersihkan() async {
await _repo.bersihkanRiwayat(_uid);
_pesan.clear();
await _repo.siapkanSesi(_uid);
notifyListeners();
}
}Pola wadah kosong yang diisi bertahap itu yang membuat efek "mengetik" bekerja. Setiap potongan yang tiba memperbarui teks pesan yang sudah ada di daftar, dan notifyListeners() membuat UI menggambar ulang.
Antarmuka chat
dart
// lib/presentation/screens/layar_chat.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/penyedia_chat.dart';
import '../widgets/gelembung_pesan.dart';
class LayarChat extends StatefulWidget {
const LayarChat({super.key});
@override
State<LayarChat> createState() => _LayarChatState();
}
class _LayarChatState extends State<LayarChat> {
final _pengendaliTeks = TextEditingController();
final _pengendaliGulir = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<PenyediaChat>().muatRiwayat();
});
}
@override
void dispose() {
_pengendaliTeks.dispose();
_pengendaliGulir.dispose();
super.dispose();
}
void _gulirKeBawah() {
if (!_pengendaliGulir.hasClients) return;
_pengendaliGulir.animateTo(
_pengendaliGulir.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
Future<void> _kirim() async {
final teks = _pengendaliTeks.text;
if (teks.trim().isEmpty) return;
_pengendaliTeks.clear();
await context.read<PenyediaChat>().kirim(teks);
if (mounted) _gulirKeBawah();
}
@override
Widget build(BuildContext context) {
final penyedia = context.watch<PenyediaChat>();
final skema = Theme.of(context).colorScheme;
// Gulir otomatis saat jawaban mengalir
WidgetsBinding.instance.addPostFrameCallback((_) {
if (penyedia.asistenMengetik) _gulirKeBawah();
});
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
CircleAvatar(
radius: 16,
child: Icon(Icons.auto_awesome, size: 18),
),
SizedBox(width: 12),
Text('Asisten'),
],
),
actions: [
PopupMenuButton<String>(
onSelected: (nilai) {
if (nilai == 'bersihkan') _konfirmasiBersihkan();
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'bersihkan',
child: Text('Hapus Riwayat'),
),
],
),
],
),
body: Column(
children: [
Expanded(
child: !penyedia.siap
? const Center(child: CircularProgressIndicator())
: penyedia.pesan.isEmpty
? _SaranAwal(
saatDipilih: (teks) {
_pengendaliTeks.text = teks;
_kirim();
},
)
: ListView.builder(
controller: _pengendaliGulir,
padding: const EdgeInsets.all(16),
itemCount: penyedia.pesan.length,
itemBuilder: (ctx, i) => GelembungPesan(
pesan: penyedia.pesan[i],
saatUlangi: penyedia.ulangiTerakhir,
),
),
),
// Kolom input
SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: BoxDecoration(
color: skema.surface,
border: Border(
top: BorderSide(color: skema.outlineVariant),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _pengendaliTeks,
minLines: 1,
maxLines: 5,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => _kirim(),
decoration: InputDecoration(
hintText: 'Tulis pesan...',
filled: true,
fillColor: skema.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: penyedia.asistenMengetik ? null : _kirim,
icon: penyedia.asistenMengetik
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
),
],
),
),
),
],
),
);
}
Future<void> _konfirmasiBersihkan() async {
final ya = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Hapus riwayat?'),
content: const Text('Seluruh percakapan akan dihapus permanen.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Hapus'),
),
],
),
);
if (ya == true && mounted) {
await context.read<PenyediaChat>().bersihkan();
}
}
}
/// Saran pertanyaan saat percakapan masih kosong —
/// mengurangi kebingungan "aku harus tanya apa".
class _SaranAwal extends StatelessWidget {
const _SaranAwal({required this.saatDipilih});
final void Function(String) saatDipilih;
static const _saran = [
'Bagaimana cara membuat polling?',
'Apa itu polling unggulan?',
'Bantu buatkan ide polling tentang teknologi',
'Bagaimana cara melihat siapa yang memberi suara?',
];
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_awesome, size: 48),
const SizedBox(height: 16),
Text(
'Ada yang bisa dibantu?',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
for (final teks in _saran)
ActionChip(
label: Text(teks),
onPressed: () => saatDipilih(teks),
),
],
),
],
),
),
);
}
}Gelembung pesan
dart
// lib/presentation/widgets/gelembung_pesan.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../data/models/pesan_chat.dart';
class GelembungPesan extends StatelessWidget {
const GelembungPesan({
super.key,
required this.pesan,
required this.saatUlangi,
});
final PesanChat pesan;
final Future<void> Function() saatUlangi;
@override
Widget build(BuildContext context) {
final skema = Theme.of(context).colorScheme;
final dariPengguna = pesan.dariPengguna;
// Jawaban masih kosong dan sedang mengetik
if (!dariPengguna &&
pesan.teks.isEmpty &&
pesan.status == StatusPesan.mengetik) {
return const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: _IndikatorMengetik(),
),
);
}
return Align(
alignment:
dariPengguna ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.78,
),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: pesan.status == StatusPesan.gagal
? skema.errorContainer
: dariPengguna
? skema.primary
: skema.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(dariPengguna ? 16 : 4),
bottomRight: Radius.circular(dariPengguna ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(
pesan.teks,
style: TextStyle(
color: pesan.status == StatusPesan.gagal
? skema.onErrorContainer
: dariPengguna
? skema.onPrimary
: skema.onSurface,
height: 1.4,
),
),
if (!dariPengguna && pesan.status == StatusPesan.terkirim)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 16),
visualDensity: VisualDensity.compact,
tooltip: 'Salin',
onPressed: () {
Clipboard.setData(
ClipboardData(text: pesan.teks),
);
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
const SnackBar(
content: Text('Disalin'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
),
if (pesan.status == StatusPesan.gagal)
TextButton.icon(
onPressed: saatUlangi,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Coba lagi'),
),
],
),
),
),
);
}
}
/// Tiga titik yang berdenyut bergantian.
class _IndikatorMengetik extends StatefulWidget {
const _IndikatorMengetik();
@override
State<_IndikatorMengetik> createState() => _IndikatorMengetikState();
}
class _IndikatorMengetikState extends State<_IndikatorMengetik>
with SingleTickerProviderStateMixin {
late final AnimationController _pengendali;
@override
void initState() {
super.initState();
_pengendali = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_pengendali.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final skema = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: skema.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: AnimatedBuilder(
animation: _pengendali,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
// Geser fase tiap titik agar bergantian
final fase = (_pengendali.value + i * 0.25) % 1.0;
final naik = fase < 0.5 ? fase * 2 : (1 - fase) * 2;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Transform.translate(
offset: Offset(0, -4 * naik),
child: CircleAvatar(
radius: 4,
backgroundColor:
skema.onSurface.withValues(alpha: 0.4 + 0.4 * naik),
),
),
);
}),
);
},
),
);
}
}Menghasilkan konten dengan AI
Selain menjawab pertanyaan, Gemini bisa membantu pengguna membuat konten. Untuk aplikasi polling, membuatkan opsi jawaban.
dart
// lib/data/services/layanan_gemini.dart — tambahan
Future<List<String>> saranOpsiPolling(String judul) async {
final prompt = '''
Buatkan 4 opsi jawaban untuk jajak pendapat berjudul: "$judul"
Aturan:
- Setiap opsi maksimal 30 karakter
- Opsi harus saling berbeda dan mencakup kemungkinan utama
- Gunakan Bahasa Indonesia
- Jawab HANYA dengan JSON array, tanpa penjelasan apa pun
Contoh format: ["Opsi A", "Opsi B", "Opsi C", "Opsi D"]
''';
try {
final respons = await _model.generateContent([Content.text(prompt)]);
var teks = respons.text?.trim() ?? '';
// Model kadang membungkus jawaban dengan ```json ... ```
teks = teks
.replaceAll(RegExp(r'^```(?:json)?\s*'), '')
.replaceAll(RegExp(r'\s*```$'), '')
.trim();
final daftar = json.decode(teks) as List<dynamic>;
return daftar
.map((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.take(4)
.toList();
} catch (_) {
// Kalau format tidak sesuai, jangan gagalkan alur —
// kembalikan daftar kosong dan biarkan pengguna mengisi sendiri
return [];
}
}Selalu antisipasi keluaran yang tidak sesuai format
Model bahasa tidak menjamin keluarannya valid JSON, meskipun kamu memintanya dengan tegas. Ia bisa menambahkan penjelasan, membungkus dengan blok kode, atau memakai tanda kutip tunggal.
Karena itu: bersihkan keluarannya, bungkus dengan try/catch, dan selalu sediakan jalur cadangan yang tidak menggagalkan seluruh alur.
Gemini juga mendukung mode keluaran terstruktur lewat responseSchema di GenerationConfig, yang jauh lebih andal untuk kebutuhan ini.
Memakainya di formulir:
dart
Future<void> _mintaSaran() async {
final judul = _pengendaliJudul.text.trim();
if (judul.length < 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tulis judul polling terlebih dahulu.')),
);
return;
}
setState(() => _memuatSaran = true);
final saran = await _gemini.saranOpsiPolling(judul);
if (!mounted) return;
setState(() {
_memuatSaran = false;
if (saran.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal membuat saran. Coba lagi.')),
);
return;
}
for (var i = 0; i < saran.length && i < _pengendaliOpsi.length; i++) {
_pengendaliOpsi[i].text = saran[i];
}
});
}Perhatikan bahwa saran mengisi kolom yang bisa diedit, bukan langsung disimpan. Pengguna tetap memegang kendali — prinsip penting untuk fitur AI.
Alur lengkap
Menjaga kunci API di produksi
Untuk aplikasi sungguhan, pindahkan panggilan Gemini ke Cloud Function.
javascript
// functions/index.js
const { onCall, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const kunciGemini = defineSecret('GEMINI_API_KEY');
exports.tanyaAsisten = onCall(
{ secrets: [kunciGemini], maxInstances: 10 },
async (request) => {
// Hanya pengguna yang sudah masuk
if (!request.auth) {
throw new HttpsError('unauthenticated', 'Silakan masuk terlebih dahulu.');
}
const pesan = request.data?.pesan;
if (typeof pesan !== 'string' || pesan.trim().length === 0) {
throw new HttpsError('invalid-argument', 'Pesan tidak boleh kosong.');
}
if (pesan.length > 2000) {
throw new HttpsError('invalid-argument', 'Pesan terlalu panjang.');
}
const genAI = new GoogleGenerativeAI(kunciGemini.value());
const model = genAI.getGenerativeModel({
model: 'gemini-2.0-flash',
systemInstruction:
'Kamu adalah asisten ramah di aplikasi jajak pendapat berbahasa ' +
'Indonesia. Jawab singkat dan sopan.',
});
const hasil = await model.generateContent(pesan);
return { jawaban: hasil.response.text() };
}
);Memanggilnya dari Flutter:
dart
import 'package:cloud_functions/cloud_functions.dart';
Future<String> tanyaLewatServer(String pesan) async {
final fungsi = FirebaseFunctions.instanceFor(region: 'asia-southeast1')
.httpsCallable('tanyaAsisten');
try {
final hasil = await fungsi.call<Map<String, dynamic>>({'pesan': pesan});
return hasil.data['jawaban'] as String;
} on FirebaseFunctionsException catch (e) {
throw KesalahanAi(
switch (e.code) {
'unauthenticated' => 'Silakan masuk terlebih dahulu.',
'invalid-argument' => e.message ?? 'Pesan tidak valid.',
'resource-exhausted' => 'Batas pemakaian tercapai.',
_ => 'Asisten sedang bermasalah.',
},
);
}
}Keuntungannya melampaui keamanan kunci: kamu bisa menerapkan batas pemakaian per pengguna, mencatat penggunaan, dan mengganti model tanpa merilis ulang aplikasi.
Membatasi pemakaian
Panggilan AI berbiaya. Batasi agar satu pengguna tidak menghabiskan kuota.
javascript
// functions/index.js — tambahan di dalam tanyaAsisten
const { getFirestore, FieldValue, Timestamp } = require('firebase-admin/firestore');
const db = getFirestore();
const uid = request.auth.uid;
const refKuota = db.doc(`quotas/${uid}`);
const snap = await refKuota.get();
const sekarang = Date.now();
const data = snap.data() || {};
const reset = data.resetPada?.toMillis?.() ?? 0;
// Reset penghitung setiap 24 jam
if (sekarang > reset) {
await refKuota.set({
jumlah: 1,
resetPada: Timestamp.fromMillis(sekarang + 24 * 60 * 60 * 1000),
});
} else {
if ((data.jumlah ?? 0) >= 50) {
throw new HttpsError(
'resource-exhausted',
'Batas 50 pesan per hari tercapai. Coba lagi besok.'
);
}
await refKuota.update({ jumlah: FieldValue.increment(1) });
}Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Asisten Bertopik Sempit — ⭐⭐ · 45–60 menit
Tantangan: Buat satu asisten dengan ruang lingkup sempit — pemandu tanaman hias, penjelas istilah keuangan, apa pun — yang menolak dengan sopan kalau ditanya di luar topiknya. Lalu tambahkan satu tombol yang meminta model mengembalikan daftar terstruktur, dan buat aplikasi tetap masuk akal ketika model mengembalikan format yang salah.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ ┌─────────────────────────────┐ │
│ │ Kamu: Cara rawat kaktus? │ │
│ │ Asisten: Siram sebulan │ │
│ │ sekali saat tanah kering... │ │
│ │ Kamu: Siapa presiden RI? │ │
│ │ Asisten: Maaf, aku hanya │ │
│ │ bisa bantu soal tanaman │ │
│ │ hias 🌵 │ │
│ └─────────────────────────────┘ │
│ [Ketik pesan...] [Kirim] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Jawaban "Siram sebulan sekali..." muncul kata demi kata (bukan sekaligus), dan pertanyaan di luar topik ditolak dengan sopan alih- alih dijawab asal.
Kriteria selesai:
- Kepribadian dan batasan diatur lewat
systemInstruction, bukan dengan menempelkan instruksi ke setiap pesan pengguna. - Jawaban mengalir kata demi kata dengan
sendMessageStream, dan pesan pengguna muncul seketika tanpa menunggu jawaban. - Jumlah pesan riwayat yang dikirim ke model dibatasi, dan kamu bisa menjelaskan biayanya kalau tidak dibatasi.
- Keluaran terstruktur dibersihkan dari pembungkus blok kode, dibungkus
try/catch, dan punya jalur cadangan yang tidak membuat aplikasi berhenti. - Kunci API tidak ditulis di dalam kode.
Petunjuk: Pola implementasi streaming yang membuat semuanya jauh lebih sederhana: tambahkan wadah pesan kosong ke daftar begitu permintaan dikirim, lalu isi isinya bertahap sambil memberi tahu pendengar. Kamu tidak perlu logika khusus untuk "sedang mengetik" — wadah kosong itu sendiri sudah bisa menjadi indikatornya. Untuk keluaran terstruktur, jangan percaya pada instruksi seketat apa pun: model sering membungkus JSON-nya dalam blok kode berpagar, kadang menambahkan kalimat pengantar, dan sesekali mengembalikan sesuatu yang bukan JSON sama sekali. Membersihkan pagar blok kode lalu membungkus jsonDecode dengan try/catch menangani hampir semua kasus; sisanya butuh jalur cadangan.
Variasi 2: Pengurai Struk — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun fitur yang menerima teks struk belanja yang berantakan — ditempel pengguna atau hasil pindaian — dan meminta model mengubahnya menjadi data terstruktur: daftar item dengan harga, subtotal, pajak, dan total. Hasilnya mengisi formulir yang bisa diedit, bukan langsung disimpan.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Tempel teks struk: │
│ [___________________________] │
│ [ Urai dengan AI ] │
│ │
│ Hasil (bisa diedit): │
│ Kopi Susu [Rp 20.000] │
│ Roti Bakar [Rp 15.000] │
│ Subtotal Rp 35.000 │
│ ⚠ Total di struk (Rp 40.000) │
│ tidak cocok — periksa lagi │
│ [ Simpan ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Skema keluaran dinyatakan tegas ke model, dan hasilnya divalidasi terhadap skema itu sebelum dipakai.
- Total yang tidak cocok dengan jumlah itemnya ditandai untuk pengguna, bukan diterima diam-diam.
- Kegagalan penguraian menampilkan teks aslinya supaya pengguna bisa mengoreksi manual.
- Hasil AI mengisi formulir yang bisa diedit, dan pengguna harus menyetujui sebelum disimpan.
Petunjuk: Kriteria kedua adalah lapisan pertahanan yang murah dan sangat berguna: model bisa saja salah membaca satu angka, dan penjumlahan sederhana di sisimu langsung menangkapnya. Prinsip yang lebih besar ada di kriteria keempat — konten hasil AI sebaiknya menjadi usulan, bukan keputusan. Pengguna yang tetap memegang kendali akan memaafkan kesalahan sesekali; pengguna yang datanya diubah diam-diam tidak.
Variasi 3: Anggaran Token — ⭐⭐⭐ · 45–60 menit
Tantangan: Tambahkan pengendalian biaya ke asisten yang sudah kamu buat: batas pemakaian harian per pengguna, ringkasan riwayat percakapan yang panjang supaya tidak seluruhnya dikirim ulang, dan tampilan sisa kuota yang jujur.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Sisa kuota hari ini: 12/50 pesan │
│ ┌─────────────────────────────┐ │
│ │ 📋 Ringkasan 40 pesan lalu: │ │
│ │ "Membahas rencana liburan │ │
│ │ ke Bali bulan depan..." │ │
│ │ Kamu: Lanjut soal hotelnya │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Riwayat yang melewati ambang tertentu diringkas menjadi satu pesan ringkasan, dan percakapan tetap terasa berkesinambungan.
- Batas harian ditegakkan dan tidak bisa dilewati hanya dengan menutup lalu membuka aplikasi.
- Pengguna melihat sisa kuotanya sebelum kehabisan, bukan setelah ditolak.
- Kamu bisa memperkirakan biaya satu percakapan panjang, dalam angka.
Petunjuk: Biaya yang sering mengejutkan orang adalah bahwa seluruh riwayat dikirim ulang di setiap permintaan — percakapan lima puluh pesan berarti permintaan kelima puluh membayar untuk empat puluh sembilan pesan sebelumnya. Meringkas riwayat lama menjadi satu paragraf memangkasnya drastis dengan biaya kehilangan detail. Untuk kriteria kedua, batas yang disimpan hanya di perangkat bisa diakali; kalau ini benar-benar penting, penegakannya harus di server.
Variasi 4: Kunci di Balik Server — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Pindahkan seluruh pemanggilan Gemini dari aplikasi ke Cloud Function, sehingga kunci API tidak pernah ada di perangkat sama sekali. Pertahankan streaming supaya pengalaman penggunanya tidak berubah.
Kriteria selesai:
- Aplikasi tidak lagi memuat kunci API dalam bentuk apa pun.
- Streaming tetap bekerja — jawaban tetap mengalir, bukan datang sekaligus.
- Fungsi menolak permintaan dari pengguna yang belum masuk.
- Ada pembatasan laju di sisi server supaya satu akun tidak bisa mengirim ratusan permintaan per menit.
Petunjuk: --dart-define sering disalahpahami sebagai cara mengamankan kunci; ia hanya memindahkan kunci dari kode sumber ke berkas build, dan kunci itu tetap ada di dalam aplikasi yang bisa dibongkar siapa pun. Satu-satunya cara kunci benar-benar aman adalah tidak pernah mengirimkannya ke perangkat. Streaming lewat fungsi butuh respons yang mengalir — cari dukungan streaming pada platform fungsi yang kamu pakai, atau pakai koneksi yang tetap terbuka.
Ikhtisar
- Jangan taruh kunci Gemini di dalam aplikasi untuk produksi — aplikasi bisa dibongkar. Panggil lewat Cloud Function.
systemInstructionadalah cara paling andal mengendalikan kepribadian dan batasan asisten — jauh lebih baik daripada menyisipkan instruksi di setiap pesan.ChatSessionmenyimpan riwayat otomatis, tetapi batasi jumlah pesan yang dikirim — setiap pesan lama ikut dihitung sebagai token input pada setiap permintaan.sendMessageStreammembuat jawaban mengalir kata demi kata. Ini perbedaan terbesar antara chatbot yang terasa hidup dan yang terasa lambat.- Pola implementasinya: tambahkan wadah kosong ke daftar, lalu isi bertahap sambil memanggil
notifyListeners(). - Tampilkan pesan pengguna segera, jangan menunggu jawaban.
- Terjemahkan kegagalan API menjadi pesan yang bisa ditindaklanjuti, dan sediakan tombol "Coba lagi" pada jawaban yang gagal.
- Model tidak menjamin keluaran valid JSON meskipun diminta tegas. Bersihkan blok kode, bungkus
try/catch, dan sediakan jalur cadangan. - Konten hasil AI sebaiknya mengisi kolom yang bisa diedit, bukan langsung disimpan — pengguna tetap memegang kendali.
- Tampilkan saran pertanyaan saat percakapan kosong, agar pengguna tahu apa yang bisa ditanyakan.
- Batasi pemakaian per pengguna di server, atau satu orang bisa menghabiskan kuotamu.
Berikutnya: Bab 35 — Google AdMob.
Transkrip asli
Disintesis dari 4_flutter_ai-chatbot-n-firebase/12_building-an-ai-chatbot.md (9 video: Chatbot UI I & II, Gemini AI Integration, Messages Integration, Chat Image Support I & II, AI Chat History, Loading Chat Window with Previous Messages, Streamline Poll Creation using AI) dan 11_project-branding-and-documentation.md (video 3–4: Chatbot UI Design). Lihat PDF Firebase & AI.