Menambahkan kecerdasan ke CatatanKu: integrasi LLM cloud dengan token aman, menjalankan model AI lokal di perangkat pengguna, dan merancang fitur AI — rangkum otomatis dan chat dengan catatan — dengan UI yang tidak pernah membeku

Di episode 1 kita menyebut AI lokal sebagai alasan desktop makin relevan di 2026. Sekarang kita buktikan. Aplikasi desktop punya posisi unik untuk AI: ia bisa membundel atau menjalankan model lokal, mengakses data pengguna secara penuh, dan menampilkan interaksi yang panjang tanpa dibatasi tab browser.
Episode ini menambahkan AI ke CatatanKu dengan dua jalur: LLM cloud (kekuatan model besar, dengan token dikelola lewat pola episode 18-19) dan model lokal (privasi penuh, tanpa jaringan). Fitur yang kita bangun: rangkuman otomatis catatan dan chat dengan catatan (RAG sederhana). Kita juga membahas pola UI untuk stream output yang panjang — tanpa membekukan aplikasi.
| Aspek | LLM Cloud | Model Lokal |
|---|---|---|
| Kualitas | Model besar (GPT/Claude/Gemini) | Tergantung model (umumnya lebih kecil) |
| Privasi | Data keluar perangkat | Data tetap lokal |
| Jaringan | Wajib | Opsional |
| Biaya | Per token | Gratis, tapi pakai CPU/RAM |
| Integrasi | API + token | Runtime model di dalam aplikasi |
Strategi CatatanKu: default ke cloud jika pengguna memilih dan menyetujui; opsi model lokal untuk mode offline/private. Keduanya dibungkus di balik satu antarmuka ai/complete — pola abstraction yang sama seperti storage di episode 8.
Konsisten dengan episode 18: LLM dipanggil dari backend, token disimpan di keychain (episode 19), dan webview tidak pernah melihat kunci:
#[tauri::command]
async fn summarize_note(app: AppHandle, note_id: String) -> Result<String, String> {
let token = get_llm_token(&app)?; // keychain
let content = load_note(&app, ¬e_id)?; // database
let prompt = format!(
"Rangkum catatan berikut dalam 3 poin:\n\n{content}"
);
let client = reqwest::Client::new();
let resp = client
.post("https://api.catatanku.app/ai/complete")
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "model": "default", "messages": [
{ "role": "user", "content": prompt }
]}))
.send().await.map_err(|e| e.to_string())?;
resp.json::<Completion>().await.map_err(|e| e.to_string())
.map(|c| c.choices[0].message.content.clone())
}Pola yang sama di Electron: net.request dari main process dengan token dari safeStorage/keytar. Token LLM tidak pernah ada di renderer — jika webview dikompromi (episode 18), kunci AI tidak ikut bocor.
Note
Untuk provider eksternal (OpenAI, Anthropic, Gemini), panggil API mereka langsung dari backend kalian — jangan pernah memaparkan API key pengguna ke UI. Pertimbangkan juga melewati server kalian sendiri agar bisa membatasi rate, mengaudit, dan menambah kebijakan privasi terpusat.
Untuk pengguna yang menuntut privasi penuh (atau bekerja offline), CatatanKu menyediakan model lokal. Pilihan praktisnya:
http://localhost:11434.use ort::{Session, inputs};
#[tauri::command]
async fn local_summarize(content: String) -> Result<String, String> {
let model = Session::builder()
.commit_from_file("models/rangkum.onnx")
.map_err(|e| e.to_string())?;
// tokenize + infer di thread terpisah (episode 9)
let out = model.run(inputs!["input" => tokens]?)
.map_err(|e| e.to_string())?;
decode_output(out)
}Menjalankan model di thread utama = UI membeku beberapa detik. Selalu jalankan inference di worker/isolate (episode 9) dan stream hasil bertahap.
Model lokal berukuran 100 MB-8 GB. Keputusan desain: bundling (aplikasi besar, siap pakai) vs download on demand (aplikasi kecil, unduh saat fitur AI lokal diminta) vs bring your own model (pengguna memilih jalur model). CatatanKu memakai download on demand — konsisten dengan strategi update episode 13 dan menghindari installer raksasa.
Fitur pertama: tombol "Rangkum" di editor menghasilkan ringkasan catatan. Logika-nya: ambil isi catatan (backend), kirim prompt ke LLM (cloud atau lokal), tampilkan hasil dalam panel dengan status streaming.
Fitur kedua lebih menarik: pengguna bertanya "apa poin penting dari catatan minggu lalu?" Jawaban butuh konteks dari banyak catatan — pola Retrieval-Augmented Generation (RAG):
1. Pengguna bertanya → embedding query (model embedding lokal)
2. Cari catatan relevan: cosine similarity atas embedding tersimpan
3. Gabungkan: top-k catatan + pertanyaan → prompt ke LLM
4. Jawab dengan konteks catatan yang relevanEmbedding catatan dihitung di latar belakang (episode 9) saat indexing FTS (episode 8) — satu pipeline yang mengisi vektor store lokal (SQLite dengan ekstensi vektor, atau penyimpanan terpisah).
LLM menghasilkan teks token per token — menunggu selesai sebelum menampilkan = UI diam selama puluhan detik. Pola yang benar: streaming event dari backend ke UI, update bertahap:
#[tauri::command]
async fn chat(app: AppHandle, query: String) -> Result<(), String> {
let stream = llm_stream(app, query).await?;
let mut acc = String::new();
for chunk in stream {
acc.push_str(&chunk);
let _ = app.emit("ai:chunk", chunk);
}
let _ = app.emit("ai:done", acc);
Ok(())
}await listen<string>("ai:chunk", ({ payload }) => {
setAnswer((prev) => prev + payload); // render tiap chunk
});Throttling tetap berlaku (episode 9): render chunk dalam batch kecil, jangan per karakter per event pada teks panjang.
Inti yang harus dibawa pulang:
Di episode 22 selanjutnya kita menyempurnakan offline-first CatatanKu: local-first & sync — CRDT, sinkronisasi antar perangkat, dan kolaborasi offline. Sampai jumpa di episode 22!