From a51c24030f69f11c96d902e59f7691eb4511c723 Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Tue, 18 Nov 2025 15:39:16 +0700 Subject: [PATCH] Contextual AI --- .../com/example/notebook/api/ApiConstants.kt | 78 ++- .../example/notebook/api/GeminiRepository.kt | 507 +++++++++++------- .../notebook/ui/components/MarkdownText.kt | 452 +++++++++------- 3 files changed, 619 insertions(+), 418 deletions(-) diff --git a/app/src/main/java/com/example/notebook/api/ApiConstants.kt b/app/src/main/java/com/example/notebook/api/ApiConstants.kt index 3fa1e29..fd2cda0 100644 --- a/app/src/main/java/com/example/notebook/api/ApiConstants.kt +++ b/app/src/main/java/com/example/notebook/api/ApiConstants.kt @@ -19,12 +19,76 @@ object ApiConstants { // System instruction untuk AI const val SYSTEM_INSTRUCTION = """ - Kamu adalah asisten AI yang membantu pengguna memahami dokumen mereka. - Tugasmu: - 1. Membuat ringkasan yang jelas dan informatif dari dokumen - 2. Menjawab pertanyaan pengguna berdasarkan isi dokumen - 3. Selalu rujuk informasi yang ada di dokumen - 4. Jika informasi tidak ada di dokumen, katakan dengan jelas - 5. Gunakan bahasa Indonesia yang mudah dipahami + Anda adalah AI assistant yang membantu pengguna memahami dokumen mereka dengan lebih baik. + + KARAKTERISTIK ANDA: + - Ramah, sabar, dan edukatif + - Memberikan penjelasan yang mudah dipahami + - Menggunakan contoh konkret dari dokumen pengguna + - Proaktif dalam memberikan insight tambahan yang relevan + + CARA KERJA ANDA: + + 1. **Untuk pertanyaan langsung tentang isi dokumen:** + - Jawab berdasarkan informasi dari dokumen + - Kutip atau parafrase bagian relevan + - Berikan konteks tambahan jika membantu pemahaman + + 2. **Untuk pertanyaan konseptual yang terkait dengan topik dokumen:** + - Jelaskan konsep dengan bahasa sederhana + - Hubungkan dengan contoh spesifik dari dokumen + - Format: "Secara umum, [konsep] adalah [penjelasan]. Dalam dokumen Anda, hal ini terlihat pada [contoh spesifik dari dokumen]." + + 3. **Untuk pertanyaan yang memerlukan analisis atau perbandingan:** + - Berikan analisis yang informatif + - Gunakan pengetahuan umum untuk konteks + - Selalu kaitkan kembali dengan isi dokumen + - Tunjukkan bagaimana konsep umum diterapkan dalam dokumen + + 4. **Untuk pertanyaan definisi atau istilah:** + - Berikan definisi yang jelas dan akurat + - Jelaskan dengan bahasa yang mudah dipahami + - Tambahkan: "Dalam konteks dokumen Anda tentang [topik], istilah ini merujuk pada [penjelasan spesifik]..." + + 5. **Untuk pertanyaan yang sama sekali tidak terkait:** + - Sampaikan dengan sopan bahwa pertanyaan di luar topik dokumen + - Berikan ringkasan singkat topik dokumen + - Tawarkan untuk membahas topik dalam dokumen + + PRINSIP MENJAWAB: + ✓ Prioritaskan informasi dari dokumen pengguna + ✓ Gunakan pengetahuan umum untuk memperkaya pemahaman + ✓ Selalu hubungkan konsep umum dengan contoh dari dokumen + ✓ Jelas membedakan mana dari dokumen vs pengetahuan umum + ✓ Gunakan format markdown untuk struktur yang jelas + ✓ Berikan jawaban yang komprehensif tapi tidak bertele-tele + + GAYA KOMUNIKASI: + - Bahasa Indonesia yang natural dan profesional + - Hindari jargon kecuali diperlukan (dengan penjelasan) + - Gunakan bullet points, numbering, bold untuk readability + - Friendly tapi tetap informatif + + FORMAT TABEL: + Untuk perbandingan atau data tabular, gunakan markdown table: + | Fitur | Algoritma A | Algoritma B | + |------------|-------------|-------------| + | Akurasi | 95% | 92% | + | Kecepatan | Cepat | Sedang | + + CONTOH INTERAKSI: + + User: "Apa itu algoritma supervised learning?" + Good: "Supervised learning adalah pendekatan machine learning dimana model belajar dari data yang sudah diberi label. **Berdasarkan dokumen Anda**, beberapa contoh algoritma supervised learning yang dibahas adalah: + - **Naive Bayes**: Algoritma klasifikasi berbasis probabilitas + - **Decision Tree**: Menggunakan struktur pohon untuk keputusan + - **K-NN**: Klasifikasi berdasarkan kedekatan dengan data tetangga + + Ketiga algoritma ini termasuk supervised learning karena menggunakan data training yang sudah memiliki label kelas." + + User: "Berapa akurasi terbaik yang bisa dicapai?" + Good: "Berdasarkan dokumen Anda, akurasi terbaik dicapai oleh algoritma Random Forest dengan hasil [X]%. Namun, perlu diingat bahwa akurasi optimal bergantung pada beberapa faktor seperti kualitas data, feature engineering, dan parameter tuning yang digunakan." + + Selamat membantu pengguna! 🚀 """ } \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/api/GeminiRepository.kt b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt index da6c209..49de081 100644 --- a/app/src/main/java/com/example/notebook/api/GeminiRepository.kt +++ b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt @@ -1,137 +1,187 @@ package com.example.notebook.api +import kotlinx.coroutines.delay + /** * Repository untuk handle Gemini API calls + * ENHANCED: Better error handling & retry logic untuk 429 errors */ class GeminiRepository { private val apiService = GeminiApiService.create() + // Retry configuration + private val maxRetries = 3 + private val initialDelayMs = 2000L // 2 seconds + /** - * Generate summary dari text atau file PDF + * Helper function untuk retry dengan exponential backoff + */ + private suspend fun retryWithBackoff( + maxAttempts: Int = maxRetries, + initialDelay: Long = initialDelayMs, + factor: Double = 2.0, + block: suspend () -> T + ): T { + var currentDelay = initialDelay + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: Exception) { + println("⚠️ Attempt ${attempt + 1} failed: ${e.message}") + + // Jika error bukan 429, langsung throw + if (e.message?.contains("429") != true) { + throw e + } + + println("⏳ Waiting ${currentDelay}ms before retry...") + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong() + } + } + return block() // Last attempt + } + + /** + * Generate summary dengan retry logic */ suspend fun generateSummary(text: String): Result { return try { - val prompt = """ - Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia. - Ringkasan harus: - - Mencakup poin-poin utama - - Mudah dipahami - - Panjang sekitar 3-5 paragraf - - Dokumen: - $text - """.trimIndent() + retryWithBackoff { + val prompt = """ + Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia. + Ringkasan harus: + - Mencakup poin-poin utama + - Mudah dipahami + - Panjang sekitar 3-5 paragraf + + Dokumen: + $text + """.trimIndent() - val request = createRequest(prompt) - val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) + val request = createRequest(prompt) + val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) - if (response.isSuccessful) { - val body = response.body() - val textResponse = body?.getTextResponse() + if (response.isSuccessful) { + val body = response.body() + val textResponse = body?.getTextResponse() - if (textResponse != null) { - Result.success(textResponse) + if (textResponse != null) { + return@retryWithBackoff Result.success(textResponse) + } else { + throw Exception("Empty response from API") + } } else { - Result.failure(Exception("Empty response from API")) + val errorBody = response.errorBody()?.string() + when (response.code()) { + 429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...") + else -> throw Exception("API Error: ${response.code()} - $errorBody") + } } - } else { - Result.failure(Exception("API Error: ${response.code()} - ${response.message()}")) } } catch (e: Exception) { - Result.failure(e) + // User-friendly error messages + val friendlyMessage = when { + e.message?.contains("429") == true -> + "Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan." + e.message?.contains("401") == true -> + "API Key tidak valid. Periksa konfigurasi API Key Anda." + e.message?.contains("Network") == true -> + "Tidak ada koneksi internet. Periksa koneksi Anda." + else -> "Error: ${e.message}" + } + Result.failure(Exception(friendlyMessage)) } } /** - * Generate summary dari file PDF (binary) - * Untuk PDF yang image-based atau sulit di-extract - * Optimized untuk mencegah ANR + * Generate summary dari PDF dengan retry logic */ suspend fun generateSummaryFromPdfFile( filePath: String, fileName: String ): Result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { - println("📄 Reading PDF file: $filePath") - val file = java.io.File(filePath) + retryWithBackoff { + println("📄 Reading PDF file: $filePath") + val file = java.io.File(filePath) - // Check file size (max 20MB untuk Gemini) - val fileSizeMB = file.length() / (1024.0 * 1024.0) - if (fileSizeMB > 20) { - return@withContext Result.failure(Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")) - } - - println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)") - - // Read & encode in background - val bytes = file.readBytes() - val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - - println("📄 Base64 encoded, length: ${base64.length}") - - val prompt = """ - Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia. - Ringkasan harus: - - Mencakup semua poin-poin utama - - Mudah dipahami - - Panjang sekitar 3-5 paragraf - - File: $fileName - """.trimIndent() - - val request = GeminiRequest( - contents = listOf( - Content( - parts = listOf( - Part(text = prompt), - Part( - inlineData = InlineData( - mimeType = "application/pdf", - data = base64 - ) - ) - ), - role = "user" - ) - ), - generationConfig = GenerationConfig( - temperature = 0.7, - maxOutputTokens = 2048, - topP = 0.95 - ), - systemInstruction = SystemInstruction( - parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)) - ) - ) - - println("📡 Sending request to Gemini API...") - val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) - - if (response.isSuccessful) { - val body = response.body() - val textResponse = body?.getTextResponse() - - if (textResponse != null) { - println("✅ Gemini response received: ${textResponse.length} chars") - Result.success(textResponse) - } else { - Result.failure(Exception("Empty response from API")) + val fileSizeMB = file.length() / (1024.0 * 1024.0) + if (fileSizeMB > 20) { + throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB") + } + + val bytes = file.readBytes() + val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + + val prompt = """ + Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia. + Ringkasan harus: + - Mencakup semua poin-poin utama + - Mudah dipahami + - Panjang sekitar 3-5 paragraf + + File: $fileName + """.trimIndent() + + val request = GeminiRequest( + contents = listOf( + Content( + parts = listOf( + Part(text = prompt), + Part( + inlineData = InlineData( + mimeType = "application/pdf", + data = base64 + ) + ) + ), + role = "user" + ) + ), + generationConfig = GenerationConfig( + temperature = 0.7, + maxOutputTokens = 2048, + topP = 0.95 + ), + systemInstruction = SystemInstruction( + parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)) + ) + ) + + val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) + + if (response.isSuccessful) { + val body = response.body() + val textResponse = body?.getTextResponse() + if (textResponse != null) { + return@retryWithBackoff Result.success(textResponse) + } else { + throw Exception("Empty response from API") + } + } else { + val errorBody = response.errorBody()?.string() + when (response.code()) { + 429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...") + else -> throw Exception("API Error: ${response.code()} - $errorBody") + } } - } else { - val errorBody = response.errorBody()?.string() - println("❌ API Error: ${response.code()} - $errorBody") - Result.failure(Exception("API Error: ${response.code()} - $errorBody")) } } catch (e: Exception) { - println("❌ Exception in generateSummaryFromPdfFile: ${e.message}") - e.printStackTrace() - Result.failure(e) + val friendlyMessage = when { + e.message?.contains("429") == true -> + "Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan." + e.message?.contains("401") == true -> + "API Key tidak valid. Periksa konfigurasi API Key Anda." + else -> "Error: ${e.message}" + } + Result.failure(Exception(friendlyMessage)) } } /** - * Chat dengan context dokumen + * Chat dengan document - dengan retry logic */ suspend fun chatWithDocument( userMessage: String, @@ -139,45 +189,105 @@ class GeminiRepository { chatHistory: List> = emptyList() ): Result { return try { - // Build context dengan chat history - val contextBuilder = StringBuilder() - contextBuilder.append("Konteks Dokumen:\n$documentContext\n\n") + retryWithBackoff { + val promptBuilder = StringBuilder() - if (chatHistory.isNotEmpty()) { - contextBuilder.append("Riwayat Chat:\n") - chatHistory.forEach { (user, ai) -> - contextBuilder.append("User: $user\n") - contextBuilder.append("AI: $ai\n\n") + promptBuilder.append(""" + KONTEKS DOKUMEN PENGGUNA: + $documentContext + + --- + + """.trimIndent()) + + if (chatHistory.isNotEmpty()) { + promptBuilder.append("RIWAYAT PERCAKAPAN:\n") + chatHistory.takeLast(5).forEach { (user, ai) -> + promptBuilder.append("User: $user\n") + promptBuilder.append("Assistant: $ai\n\n") + } + promptBuilder.append("---\n\n") } - } - contextBuilder.append("Pertanyaan User: $userMessage\n\n") - contextBuilder.append("Jawab berdasarkan dokumen di atas. Jika informasi tidak ada di dokumen, katakan dengan jelas.") + promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n") - val request = createRequest(contextBuilder.toString()) - val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) + promptBuilder.append(""" + PANDUAN MENJAWAB: + + 1. **PRIORITASKAN informasi dari dokumen di atas** untuk menjawab pertanyaan. + + 2. **JIKA pertanyaan terkait dengan topik dokumen** TETAPI membutuhkan penjelasan konsep umum: + - Jelaskan konsep tersebut dengan jelas + - Hubungkan dengan contoh konkret dari dokumen + - Gunakan format: "Berdasarkan dokumen Anda tentang [topik], [penjelasan konsep], dan dalam dokumen ini dijelaskan bahwa [contoh dari dokumen]..." + + 3. **JIKA pertanyaan meminta perbandingan atau analisis** yang memerlukan pengetahuan luar dokumen: + - Berikan analisis yang informatif + - Selalu kaitkan kembali dengan isi dokumen + - Contoh: "Secara umum [konsep], dan ini terlihat dalam dokumen Anda dimana [contoh spesifik]..." + + 4. **JIKA pertanyaan tentang definisi atau konsep dasar** yang disebutkan di dokumen: + - Berikan definisi yang jelas dan mudah dipahami + - Tambahkan konteks dari dokumen: "Dalam dokumen Anda, [topik] dijelaskan sebagai [kutipan/parafrase]..." + + 5. **JIKA pertanyaan meminta saran atau rekomendasi** berdasarkan dokumen: + - Berikan saran yang konstruktif + - Dasarkan pada informasi dari dokumen + - Tambahkan insight tambahan jika relevan + + 6. **JIKA pertanyaan SAMA SEKALI tidak terkait** dengan dokumen: + - Jawab dengan sopan: "Pertanyaan Anda tentang [topik] tidak terkait dengan dokumen yang Anda upload. Dokumen Anda membahas tentang [ringkasan singkat topik dokumen]. Apakah Anda ingin bertanya tentang topik dalam dokumen ini?" + + GAYA BAHASA: + - Gunakan bahasa Indonesia yang natural dan ramah + - Jelaskan dengan cara yang mudah dipahami + - Gunakan contoh konkret dari dokumen ketika relevan + - Jika menggunakan istilah teknis, berikan penjelasan singkat + - Gunakan format markdown untuk readability (bold, list, dll) + + FORMAT TABEL (jika diperlukan): + Untuk data perbandingan atau tabel, gunakan format markdown table: + | Header 1 | Header 2 | Header 3 | + |----------|----------|----------| + | Data 1 | Data 2 | Data 3 | + | Data 4 | Data 5 | Data 6 | + + Jawab sekarang: + """.trimIndent()) - if (response.isSuccessful) { - val body = response.body() - val textResponse = body?.getTextResponse() + val request = createRequest(promptBuilder.toString()) + val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) - if (textResponse != null) { - Result.success(textResponse) + if (response.isSuccessful) { + val body = response.body() + val textResponse = body?.getTextResponse() + if (textResponse != null) { + return@retryWithBackoff Result.success(textResponse) + } else { + throw Exception("Empty response from API") + } } else { - Result.failure(Exception("Empty response from API")) + val errorBody = response.errorBody()?.string() + when (response.code()) { + 429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...") + else -> throw Exception("API Error: ${response.code()} - $errorBody") + } } - } else { - val errorBody = response.errorBody()?.string() - Result.failure(Exception("API Error: ${response.code()} - $errorBody")) } } catch (e: Exception) { - Result.failure(e) + val friendlyMessage = when { + e.message?.contains("429") == true -> + "⏳ Quota API habis. Coba lagi dalam beberapa menit atau upgrade ke paid plan." + e.message?.contains("401") == true -> + "❌ API Key tidak valid. Periksa konfigurasi." + else -> "Error: ${e.message}" + } + Result.failure(Exception(friendlyMessage)) } } /** - * Chat dengan PDF file langsung (untuk scan/image PDF) - * Digunakan ketika text extraction gagal + * Chat dengan PDF file - dengan retry logic */ suspend fun chatWithPdfFile( userMessage: String, @@ -186,109 +296,92 @@ class GeminiRepository { chatHistory: List> = emptyList() ): Result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { - println("📄 Chat with PDF file: $pdfFilePath") - val file = java.io.File(pdfFilePath) + retryWithBackoff { + val file = java.io.File(pdfFilePath) + val fileSizeMB = file.length() / (1024.0 * 1024.0) - // Check file size (max 20MB) - val fileSizeMB = file.length() / (1024.0 * 1024.0) - if (fileSizeMB > 20) { - return@withContext Result.failure( - Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB") - ) - } - - println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)") - - // Read & encode in background - val bytes = file.readBytes() - val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - - println("📄 Base64 encoded, length: ${base64.length}") - - // Build prompt dengan chat history - val promptBuilder = StringBuilder() - promptBuilder.append("Dokumen PDF: $pdfFileName\n\n") - - if (chatHistory.isNotEmpty()) { - promptBuilder.append("Riwayat percakapan sebelumnya:\n") - chatHistory.takeLast(5).forEach { (user, ai) -> - promptBuilder.append("User: $user\n") - promptBuilder.append("Assistant: $ai\n\n") + if (fileSizeMB > 20) { + throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB") } - } - promptBuilder.append("Pertanyaan saat ini: $userMessage\n\n") - promptBuilder.append("Jawab pertanyaan berdasarkan isi dokumen PDF di atas. Gunakan bahasa Indonesia yang jelas dan mudah dipahami.") + val bytes = file.readBytes() + val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - val request = GeminiRequest( - contents = listOf( - Content( - parts = listOf( - Part( - inlineData = InlineData( - mimeType = "application/pdf", - data = base64 - ) + val promptBuilder = StringBuilder() + promptBuilder.append("DOKUMEN PDF: $pdfFileName\n\n") + + if (chatHistory.isNotEmpty()) { + promptBuilder.append("RIWAYAT PERCAKAPAN:\n") + chatHistory.takeLast(5).forEach { (user, ai) -> + promptBuilder.append("User: $user\n") + promptBuilder.append("Assistant: $ai\n\n") + } + promptBuilder.append("---\n\n") + } + + promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n") + promptBuilder.append(""" + PANDUAN MENJAWAB: + + 1. **PRIORITASKAN informasi dari PDF** untuk menjawab pertanyaan. + 2. **JIKA pertanyaan terkait topik PDF** tetapi butuh penjelasan konsep: + - Jelaskan konsep dengan jelas + - Hubungkan dengan konten spesifik dari PDF + 3. Gunakan markdown untuk struktur (bold, list, tabel) + + FORMAT TABEL (jika diperlukan): + | Header 1 | Header 2 | Header 3 | + |----------|----------|----------| + | Data 1 | Data 2 | Data 3 | + + Jawab sekarang: + """.trimIndent()) + + val request = GeminiRequest( + contents = listOf( + Content( + parts = listOf( + Part(inlineData = InlineData(mimeType = "application/pdf", data = base64)), + Part(text = promptBuilder.toString()) ), - Part(text = promptBuilder.toString()) - ), - role = "user" - ) - ), - generationConfig = GenerationConfig( - temperature = 0.7, - maxOutputTokens = 2048, - topP = 0.95 - ), - systemInstruction = SystemInstruction( - parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)) + role = "user" + ) + ), + generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95), + systemInstruction = SystemInstruction(parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))) ) - ) - println("📡 Sending chat request to Gemini API...") - val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) + val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request) - if (response.isSuccessful) { - val body = response.body() - val textResponse = body?.getTextResponse() - - if (textResponse != null) { - println("✅ Chat response received: ${textResponse.length} chars") - Result.success(textResponse) + if (response.isSuccessful) { + val textResponse = response.body()?.getTextResponse() + if (textResponse != null) { + return@retryWithBackoff Result.success(textResponse) + } else { + throw Exception("Empty response") + } } else { - Result.failure(Exception("Empty response from API")) + when (response.code()) { + 429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...") + else -> throw Exception("API Error: ${response.code()}") + } } - } else { - val errorBody = response.errorBody()?.string() - println("❌ API Error: ${response.code()} - $errorBody") - Result.failure(Exception("API Error: ${response.code()} - $errorBody")) } } catch (e: Exception) { - println("❌ Exception in chatWithPdfFile: ${e.message}") - e.printStackTrace() - Result.failure(e) + val friendlyMessage = when { + e.message?.contains("429") == true -> + "⏳ Quota API habis. Tunggu beberapa menit atau upgrade plan." + else -> "Error: ${e.message}" + } + Result.failure(Exception(friendlyMessage)) } } - /** - * Create request object dengan system instruction - */ private fun createRequest(prompt: String): GeminiRequest { return GeminiRequest( - contents = listOf( - Content( - parts = listOf(Part(prompt)), - role = "user" - ) - ), - generationConfig = GenerationConfig( - temperature = 0.7, - maxOutputTokens = 2048, - topP = 0.95 - ), - systemInstruction = SystemInstruction( - parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION)) - ) + contents = listOf(Content(parts = listOf(Part(prompt)), role = "user")), + generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95), + systemInstruction = SystemInstruction(parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION))) ) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/ui/components/MarkdownText.kt b/app/src/main/java/com/example/notebook/ui/components/MarkdownText.kt index e813249..4dbecdf 100644 --- a/app/src/main/java/com/example/notebook/ui/components/MarkdownText.kt +++ b/app/src/main/java/com/example/notebook/ui/components/MarkdownText.kt @@ -1,291 +1,335 @@ package com.example.notebook.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import java.util.Locale /** - * MarkdownText - lightweight markdown renderer tanpa dependency. - * Mendukung: heading (#..), bold (**text**), italic (*text*), inline code (`code`), - * code block (```...```), bullet list (- / * / +), numbered list (1. item), paragraph. + * Composable untuk render markdown text + * Support: **bold**, *italic*, `code`, # heading, numbered list, bullet list, TABLES */ - @Composable fun MarkdownText( markdown: String, modifier: Modifier = Modifier, - textColor: Color = Color(0xFF111827) + color: Color = Color.Black ) { - val elements = parseMarkdown(markdown) - Column(modifier = modifier) { - elements.forEachIndexed { idx, element -> + parseMarkdown(markdown).forEach { element -> when (element) { + is MarkdownElement.Paragraph -> { + Text( + text = buildStyledText(element.text), + color = color, + lineHeight = 20.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + } is MarkdownElement.Heading -> { Text( text = element.text, fontSize = when (element.level) { - 1 -> 22.sp - 2 -> 18.sp - 3 -> 16.sp - else -> 14.sp + 1 -> 24.sp + 2 -> 20.sp + 3 -> 18.sp + else -> 16.sp }, fontWeight = FontWeight.Bold, - color = textColor, - modifier = Modifier.padding(vertical = 6.dp) + color = color ) + Spacer(modifier = Modifier.height(12.dp)) } - - is MarkdownElement.Paragraph -> { - Text( - text = buildInlineAnnotatedString(element.text, textColor), - fontSize = 14.sp, - color = textColor, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - is MarkdownElement.ListItem -> { - Row(modifier = Modifier.padding(bottom = 4.dp, start = 8.dp)) { + Row(modifier = Modifier.padding(start = 16.dp)) { Text( text = if (element.isNumbered) "${element.number}. " else "• ", - fontWeight = FontWeight.SemiBold, - color = textColor, - modifier = Modifier.padding(end = 6.dp) + color = color, + fontWeight = FontWeight.Bold ) Text( - text = buildInlineAnnotatedString(element.text, textColor), - fontSize = 14.sp, - color = textColor, + text = buildStyledText(element.text), + color = color, modifier = Modifier.weight(1f) ) } + Spacer(modifier = Modifier.height(4.dp)) } - is MarkdownElement.CodeBlock -> { Box( modifier = Modifier .fillMaxWidth() - .background(Color(0xFFF5F7FA), shape = RoundedCornerShape(8.dp)) + .background( + color = Color(0xFFF5F5F5), + shape = RoundedCornerShape(8.dp) + ) .padding(12.dp) - .padding(bottom = 8.dp) ) { Text( text = element.code, - fontFamily = FontFamily.Monospace, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 13.sp, - color = Color(0xFF1F2937) + color = Color(0xFF37474F) ) } + Spacer(modifier = Modifier.height(8.dp)) + } + is MarkdownElement.Table -> { + MarkdownTable( + headers = element.headers, + rows = element.rows, + textColor = color + ) + Spacer(modifier = Modifier.height(12.dp)) } - } - // small spacer between blocks (already handled by padding) - optional - if (idx == elements.lastIndex) { - Spacer(modifier = Modifier.height(0.dp)) } } } } -/** ---------- Parser & Inline renderer ---------- **/ +/** + * Render tabel dengan styling yang bagus + */ +@Composable +fun MarkdownTable( + headers: List, + rows: List>, + textColor: Color +) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Color(0xFFE0E0E0), RoundedCornerShape(8.dp)) + .background(Color(0xFFFAFAFA), RoundedCornerShape(8.dp)) + ) { + // Header Row + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF0F0F0)) + .padding(8.dp) + ) { + headers.forEach { header -> + Text( + text = header.trim(), + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = textColor, + textAlign = TextAlign.Center + ) + } + } + + // Data Rows + rows.forEachIndexed { index, row -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (index % 2 == 0) Color.White else Color(0xFFF9F9F9) + ) + .padding(8.dp) + ) { + row.forEach { cell -> + Text( + text = cell.trim(), + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + fontSize = 13.sp, + color = textColor, + textAlign = TextAlign.Center + ) + } + } + } + } +} /** - * Parses markdown string into block elements. + * Build styled text dengan support inline markdown + */ +@Composable +private fun buildStyledText(text: String) = buildAnnotatedString { + var currentIndex = 0 + val processed = mutableSetOf() + + // Regex patterns + val boldPattern = """\*\*(.+?)\*\*""".toRegex() + val italicPattern = """\*(.+?)\*""".toRegex() + val codePattern = """`(.+?)`""".toRegex() + + // Process bold **text** + boldPattern.findAll(text).forEach { match -> + if (processed.none { it.contains(match.range.first) }) { + append(text.substring(currentIndex, match.range.first)) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(match.groupValues[1]) + } + currentIndex = match.range.last + 1 + processed.add(match.range) + } + } + + // Process italic *text* + italicPattern.findAll(text).forEach { match -> + if (processed.none { it.contains(match.range.first) }) { + append(text.substring(currentIndex, match.range.first)) + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(match.groupValues[1]) + } + currentIndex = match.range.last + 1 + processed.add(match.range) + } + } + + // Process inline code `text` + codePattern.findAll(text).forEach { match -> + if (processed.none { it.contains(match.range.first) }) { + append(text.substring(currentIndex, match.range.first)) + withStyle( + SpanStyle( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + background = Color(0xFFF5F5F5), + color = Color(0xFFE91E63) + ) + ) { + append(" ${match.groupValues[1]} ") + } + currentIndex = match.range.last + 1 + processed.add(match.range) + } + } + + // Append remaining text + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } +} + +/** + * Parse markdown string menjadi list of elements + * UPGRADED: Support untuk tabel */ private fun parseMarkdown(markdown: String): List { - val lines = markdown.replace("\r\n", "\n").lines() + val lines = markdown.lines() val elements = mutableListOf() - var i = 0 + while (i < lines.size) { - val raw = lines[i] - val line = raw.trimEnd() + val line = lines[i].trim() - // Skip pure empty lines (but keep grouping paragraphs) - if (line.isBlank()) { - i++ - continue - } + when { + // Tabel (detect by separator line with |---|---|) + line.contains("|") && i + 1 < lines.size && lines[i + 1].trim().matches(Regex("^\\|?\\s*[-:]+\\s*\\|.*$")) -> { + val tableLines = mutableListOf() - // Code fence start - if (line.startsWith("```")) { - val fenceLang = line.removePrefix("```").trim() // unused but could be saved - val codeLines = mutableListOf() - i++ - while (i < lines.size && !lines[i].trimStart().startsWith("```")) { - codeLines.add(lines[i]) - i++ + // Collect all table lines + var tableIndex = i + while (tableIndex < lines.size && lines[tableIndex].contains("|")) { + tableLines.add(lines[tableIndex]) + tableIndex++ + } + + if (tableLines.size >= 2) { + // Parse header + val headerCells = tableLines[0] + .split("|") + .map { it.trim() } + .filter { it.isNotEmpty() } + + // Parse rows (skip separator line) + val dataRows = tableLines.drop(2).map { rowLine -> + rowLine.split("|") + .map { it.trim() } + .filter { it.isNotEmpty() } + }.filter { it.isNotEmpty() } + + elements.add(MarkdownElement.Table(headerCells, dataRows)) + i = tableIndex + continue + } } - // skip the closing ``` - if (i < lines.size && lines[i].trimStart().startsWith("```")) { - i++ + + // Heading + line.startsWith("#") -> { + val level = line.takeWhile { it == '#' }.length + val text = line.dropWhile { it == '#' }.trim() + elements.add(MarkdownElement.Heading(level, text)) } - elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n"))) - continue - } - // Heading: #, ##, ### - if (line.startsWith("#")) { - val hashes = line.takeWhile { it == '#' } - val level = hashes.length.coerceAtMost(6) - val text = line.dropWhile { it == '#' }.trim() - elements.add(MarkdownElement.Heading(level, text)) - i++ - continue - } + // Numbered list + line.matches("""^\d+\.\s+.+""".toRegex()) -> { + val parts = line.split(".", limit = 2) + val number = parts[0].toIntOrNull() ?: 1 + val text = parts.getOrNull(1)?.trim() ?: "" + elements.add(MarkdownElement.ListItem(text, true, number)) + } - // Numbered list: "1. item" - val numberedRegex = """^\s*(\d+)\.\s+(.+)$""".toRegex() - val numberedMatch = numberedRegex.find(line) - if (numberedMatch != null) { - val number = numberedMatch.groupValues[1].toIntOrNull() ?: 1 - val text = numberedMatch.groupValues[2] - elements.add(MarkdownElement.ListItem(text, isNumbered = true, number = number)) - i++ - continue - } + // Bullet list + line.startsWith("•") || line.startsWith("-") || line.startsWith("*") -> { + val text = line.drop(1).trim() + elements.add(MarkdownElement.ListItem(text, false)) + } - // Bullet list: "- item" or "* item" or "+ item" - val bulletRegex = """^\s*[-\*\+]\s+(.+)$""".toRegex() - val bulletMatch = bulletRegex.find(line) - if (bulletMatch != null) { - val text = bulletMatch.groupValues[1] - elements.add(MarkdownElement.ListItem(text, isNumbered = false, number = 0)) - i++ - continue - } + // Code block (multi-line) + line.startsWith("```") -> { + i++ + val codeLines = mutableListOf() + while (i < lines.size && !lines[i].trim().startsWith("```")) { + codeLines.add(lines[i]) + i++ + } + elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n"))) + } - // Paragraph: gather consecutive non-empty, non-block lines into single paragraph - val paraLines = mutableListOf() - paraLines.add(line) + // Empty line + line.isEmpty() -> { + // Skip empty lines + } + + // Regular paragraph + else -> { + elements.add(MarkdownElement.Paragraph(line)) + } + } i++ - while (i < lines.size) { - val nextRaw = lines[i] - val next = nextRaw.trimEnd() - if (next.isBlank()) break - // stop paragraph if next is a block start - if (next.startsWith("```") || next.startsWith("#") || - numberedRegex.matches(next) || bulletRegex.matches(next) - ) { - break - } - paraLines.add(next) - i++ - } - elements.add(MarkdownElement.Paragraph(paraLines.joinToString(" ").trim())) } return elements } /** - * Build AnnotatedString with inline styles: - * - inline code: `code` - * - bold: **bold** - * - italic: *italic* - * - * This is a simple scanner that prioritizes inline code, then bold, then italic. + * Sealed class untuk markdown elements + * UPGRADED: Tambah Table */ -private fun buildInlineAnnotatedString(text: String, defaultColor: Color) = buildAnnotatedString { - var idx = 0 - val len = text.length - - fun safeIndexOf(substr: String, from: Int): Int { - if (from >= len) return -1 - val found = text.indexOf(substr, from) - return found - } - - while (idx < len) { - // Inline code has highest priority: `code` - if (text[idx] == '`') { - val end = safeIndexOf("`", idx + 1) - if (end != -1) { - val content = text.substring(idx + 1, end) - withStyle( - style = SpanStyle( - fontFamily = FontFamily.Monospace, - background = Color(0xFFF3F4F6), - color = Color(0xFF7C3AED) - ) - ) { - append(content) - } - idx = end + 1 - continue - } else { - // no closing backtick, append literal - append(text[idx]) - idx++ - continue - } - } - - // Bold: **text** - if (idx + 1 < len && text[idx] == '*' && text[idx + 1] == '*') { - val end = text.indexOf("**", idx + 2) - if (end != -1) { - val content = text.substring(idx + 2, end) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = defaultColor)) { - append(content) - } - idx = end + 2 - continue - } else { - // no closing, treat as literal - append(text[idx]) - idx++ - continue - } - } - - // Italic: *text* (ensure not part of bold) - if (text[idx] == '*') { - // skip if next is also '*' because that would be bold and handled above - if (idx + 1 < len && text[idx + 1] == '*') { - // handled already - } else { - val end = text.indexOf("*", idx + 1) - if (end != -1) { - val content = text.substring(idx + 1, end) - withStyle(style = SpanStyle(fontStyle = FontStyle.Italic, color = defaultColor)) { - append(content) - } - idx = end + 1 - continue - } else { - append(text[idx]) - idx++ - continue - } - } - } - - // Default: append single char - append(text[idx]) - idx++ - } -} - -/** ---------- Markdown element sealed class ---------- */ private sealed class MarkdownElement { data class Paragraph(val text: String) : MarkdownElement() data class Heading(val level: Int, val text: String) : MarkdownElement() - data class ListItem(val text: String, val isNumbered: Boolean = false, val number: Int = 0) : - MarkdownElement() - + data class ListItem( + val text: String, + val isNumbered: Boolean = false, + val number: Int = 0 + ) : MarkdownElement() data class CodeBlock(val code: String) : MarkdownElement() -} + data class Table( + val headers: List, + val rows: List> + ) : MarkdownElement() +} \ No newline at end of file