Contextual AI
This commit is contained in:
parent
53472b2768
commit
a51c24030f
@ -19,12 +19,76 @@ object ApiConstants {
|
|||||||
|
|
||||||
// System instruction untuk AI
|
// System instruction untuk AI
|
||||||
const val SYSTEM_INSTRUCTION = """
|
const val SYSTEM_INSTRUCTION = """
|
||||||
Kamu adalah asisten AI yang membantu pengguna memahami dokumen mereka.
|
Anda adalah AI assistant yang membantu pengguna memahami dokumen mereka dengan lebih baik.
|
||||||
Tugasmu:
|
|
||||||
1. Membuat ringkasan yang jelas dan informatif dari dokumen
|
KARAKTERISTIK ANDA:
|
||||||
2. Menjawab pertanyaan pengguna berdasarkan isi dokumen
|
- Ramah, sabar, dan edukatif
|
||||||
3. Selalu rujuk informasi yang ada di dokumen
|
- Memberikan penjelasan yang mudah dipahami
|
||||||
4. Jika informasi tidak ada di dokumen, katakan dengan jelas
|
- Menggunakan contoh konkret dari dokumen pengguna
|
||||||
5. Gunakan bahasa Indonesia yang mudah dipahami
|
- 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! 🚀
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
@ -1,17 +1,54 @@
|
|||||||
package com.example.notebook.api
|
package com.example.notebook.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository untuk handle Gemini API calls
|
* Repository untuk handle Gemini API calls
|
||||||
|
* ENHANCED: Better error handling & retry logic untuk 429 errors
|
||||||
*/
|
*/
|
||||||
class GeminiRepository {
|
class GeminiRepository {
|
||||||
|
|
||||||
private val apiService = GeminiApiService.create()
|
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 <T> 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<String> {
|
suspend fun generateSummary(text: String): Result<String> {
|
||||||
return try {
|
return try {
|
||||||
|
retryWithBackoff {
|
||||||
val prompt = """
|
val prompt = """
|
||||||
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
|
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
|
||||||
Ringkasan harus:
|
Ringkasan harus:
|
||||||
@ -31,45 +68,53 @@ class GeminiRepository {
|
|||||||
val textResponse = body?.getTextResponse()
|
val textResponse = body?.getTextResponse()
|
||||||
|
|
||||||
if (textResponse != null) {
|
if (textResponse != null) {
|
||||||
Result.success(textResponse)
|
return@retryWithBackoff Result.success(textResponse)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Empty response from API"))
|
throw Exception("Empty response from API")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}"))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
* Generate summary dari PDF dengan retry logic
|
||||||
* Untuk PDF yang image-based atau sulit di-extract
|
|
||||||
* Optimized untuk mencegah ANR
|
|
||||||
*/
|
*/
|
||||||
suspend fun generateSummaryFromPdfFile(
|
suspend fun generateSummaryFromPdfFile(
|
||||||
filePath: String,
|
filePath: String,
|
||||||
fileName: String
|
fileName: String
|
||||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
retryWithBackoff {
|
||||||
println("📄 Reading PDF file: $filePath")
|
println("📄 Reading PDF file: $filePath")
|
||||||
val file = java.io.File(filePath)
|
val file = java.io.File(filePath)
|
||||||
|
|
||||||
// Check file size (max 20MB untuk Gemini)
|
|
||||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||||
if (fileSizeMB > 20) {
|
if (fileSizeMB > 20) {
|
||||||
return@withContext Result.failure(Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB"))
|
throw 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 bytes = file.readBytes()
|
||||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||||
|
|
||||||
println("📄 Base64 encoded, length: ${base64.length}")
|
|
||||||
|
|
||||||
val prompt = """
|
val prompt = """
|
||||||
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
|
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
|
||||||
Ringkasan harus:
|
Ringkasan harus:
|
||||||
@ -105,33 +150,38 @@ class GeminiRepository {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
println("📡 Sending 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) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body()
|
val body = response.body()
|
||||||
val textResponse = body?.getTextResponse()
|
val textResponse = body?.getTextResponse()
|
||||||
|
|
||||||
if (textResponse != null) {
|
if (textResponse != null) {
|
||||||
println("✅ Gemini response received: ${textResponse.length} chars")
|
return@retryWithBackoff Result.success(textResponse)
|
||||||
Result.success(textResponse)
|
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Empty response from API"))
|
throw Exception("Empty response from API")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
println("❌ API Error: ${response.code()} - $errorBody")
|
when (response.code()) {
|
||||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||||
|
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("❌ Exception in generateSummaryFromPdfFile: ${e.message}")
|
val friendlyMessage = when {
|
||||||
e.printStackTrace()
|
e.message?.contains("429") == true ->
|
||||||
Result.failure(e)
|
"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(
|
suspend fun chatWithDocument(
|
||||||
userMessage: String,
|
userMessage: String,
|
||||||
@ -139,45 +189,105 @@ class GeminiRepository {
|
|||||||
chatHistory: List<Pair<String, String>> = emptyList()
|
chatHistory: List<Pair<String, String>> = emptyList()
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
return try {
|
return try {
|
||||||
// Build context dengan chat history
|
retryWithBackoff {
|
||||||
val contextBuilder = StringBuilder()
|
val promptBuilder = StringBuilder()
|
||||||
contextBuilder.append("Konteks Dokumen:\n$documentContext\n\n")
|
|
||||||
|
promptBuilder.append("""
|
||||||
|
KONTEKS DOKUMEN PENGGUNA:
|
||||||
|
$documentContext
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
if (chatHistory.isNotEmpty()) {
|
if (chatHistory.isNotEmpty()) {
|
||||||
contextBuilder.append("Riwayat Chat:\n")
|
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||||
chatHistory.forEach { (user, ai) ->
|
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||||
contextBuilder.append("User: $user\n")
|
promptBuilder.append("User: $user\n")
|
||||||
contextBuilder.append("AI: $ai\n\n")
|
promptBuilder.append("Assistant: $ai\n\n")
|
||||||
}
|
}
|
||||||
|
promptBuilder.append("---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBuilder.append("Pertanyaan User: $userMessage\n\n")
|
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||||
contextBuilder.append("Jawab berdasarkan dokumen di atas. Jika informasi tidak ada di dokumen, katakan dengan jelas.")
|
|
||||||
|
|
||||||
val request = createRequest(contextBuilder.toString())
|
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())
|
||||||
|
|
||||||
|
val request = createRequest(promptBuilder.toString())
|
||||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body()
|
val body = response.body()
|
||||||
val textResponse = body?.getTextResponse()
|
val textResponse = body?.getTextResponse()
|
||||||
|
|
||||||
if (textResponse != null) {
|
if (textResponse != null) {
|
||||||
Result.success(textResponse)
|
return@retryWithBackoff Result.success(textResponse)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Empty response from API"))
|
throw Exception("Empty response from API")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
when (response.code()) {
|
||||||
|
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||||
|
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
* Chat dengan PDF file - dengan retry logic
|
||||||
* Digunakan ketika text extraction gagal
|
|
||||||
*/
|
*/
|
||||||
suspend fun chatWithPdfFile(
|
suspend fun chatWithPdfFile(
|
||||||
userMessage: String,
|
userMessage: String,
|
||||||
@ -186,109 +296,92 @@ class GeminiRepository {
|
|||||||
chatHistory: List<Pair<String, String>> = emptyList()
|
chatHistory: List<Pair<String, String>> = emptyList()
|
||||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
println("📄 Chat with PDF file: $pdfFilePath")
|
retryWithBackoff {
|
||||||
val file = java.io.File(pdfFilePath)
|
val file = java.io.File(pdfFilePath)
|
||||||
|
|
||||||
// Check file size (max 20MB)
|
|
||||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||||
|
|
||||||
if (fileSizeMB > 20) {
|
if (fileSizeMB > 20) {
|
||||||
return@withContext Result.failure(
|
throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||||
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 bytes = file.readBytes()
|
||||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
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()
|
val promptBuilder = StringBuilder()
|
||||||
promptBuilder.append("Dokumen PDF: $pdfFileName\n\n")
|
promptBuilder.append("DOKUMEN PDF: $pdfFileName\n\n")
|
||||||
|
|
||||||
if (chatHistory.isNotEmpty()) {
|
if (chatHistory.isNotEmpty()) {
|
||||||
promptBuilder.append("Riwayat percakapan sebelumnya:\n")
|
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||||
promptBuilder.append("User: $user\n")
|
promptBuilder.append("User: $user\n")
|
||||||
promptBuilder.append("Assistant: $ai\n\n")
|
promptBuilder.append("Assistant: $ai\n\n")
|
||||||
}
|
}
|
||||||
|
promptBuilder.append("---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
promptBuilder.append("Pertanyaan saat ini: $userMessage\n\n")
|
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||||
promptBuilder.append("Jawab pertanyaan berdasarkan isi dokumen PDF di atas. Gunakan bahasa Indonesia yang jelas dan mudah dipahami.")
|
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(
|
val request = GeminiRequest(
|
||||||
contents = listOf(
|
contents = listOf(
|
||||||
Content(
|
Content(
|
||||||
parts = listOf(
|
parts = listOf(
|
||||||
Part(
|
Part(inlineData = InlineData(mimeType = "application/pdf", data = base64)),
|
||||||
inlineData = InlineData(
|
|
||||||
mimeType = "application/pdf",
|
|
||||||
data = base64
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Part(text = promptBuilder.toString())
|
Part(text = promptBuilder.toString())
|
||||||
),
|
),
|
||||||
role = "user"
|
role = "user"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
generationConfig = GenerationConfig(
|
generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95),
|
||||||
temperature = 0.7,
|
systemInstruction = SystemInstruction(parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)))
|
||||||
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) {
|
if (response.isSuccessful) {
|
||||||
val body = response.body()
|
val textResponse = response.body()?.getTextResponse()
|
||||||
val textResponse = body?.getTextResponse()
|
|
||||||
|
|
||||||
if (textResponse != null) {
|
if (textResponse != null) {
|
||||||
println("✅ Chat response received: ${textResponse.length} chars")
|
return@retryWithBackoff Result.success(textResponse)
|
||||||
Result.success(textResponse)
|
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("Empty response from API"))
|
throw Exception("Empty response")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
when (response.code()) {
|
||||||
println("❌ API Error: ${response.code()} - $errorBody")
|
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
else -> throw Exception("API Error: ${response.code()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("❌ Exception in chatWithPdfFile: ${e.message}")
|
val friendlyMessage = when {
|
||||||
e.printStackTrace()
|
e.message?.contains("429") == true ->
|
||||||
Result.failure(e)
|
"⏳ 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 {
|
private fun createRequest(prompt: String): GeminiRequest {
|
||||||
return GeminiRequest(
|
return GeminiRequest(
|
||||||
contents = listOf(
|
contents = listOf(Content(parts = listOf(Part(prompt)), role = "user")),
|
||||||
Content(
|
generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95),
|
||||||
parts = listOf(Part(prompt)),
|
systemInstruction = SystemInstruction(parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION)))
|
||||||
role = "user"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
generationConfig = GenerationConfig(
|
|
||||||
temperature = 0.7,
|
|
||||||
maxOutputTokens = 2048,
|
|
||||||
topP = 0.95
|
|
||||||
),
|
|
||||||
systemInstruction = SystemInstruction(
|
|
||||||
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,291 +1,335 @@
|
|||||||
package com.example.notebook.ui.components
|
package com.example.notebook.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MarkdownText - lightweight markdown renderer tanpa dependency.
|
* Composable untuk render markdown text
|
||||||
* Mendukung: heading (#..), bold (**text**), italic (*text*), inline code (`code`),
|
* Support: **bold**, *italic*, `code`, # heading, numbered list, bullet list, TABLES
|
||||||
* code block (```...```), bullet list (- / * / +), numbered list (1. item), paragraph.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText(
|
fun MarkdownText(
|
||||||
markdown: String,
|
markdown: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
textColor: Color = Color(0xFF111827)
|
color: Color = Color.Black
|
||||||
) {
|
) {
|
||||||
val elements = parseMarkdown(markdown)
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
elements.forEachIndexed { idx, element ->
|
parseMarkdown(markdown).forEach { element ->
|
||||||
when (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 -> {
|
is MarkdownElement.Heading -> {
|
||||||
Text(
|
Text(
|
||||||
text = element.text,
|
text = element.text,
|
||||||
fontSize = when (element.level) {
|
fontSize = when (element.level) {
|
||||||
1 -> 22.sp
|
1 -> 24.sp
|
||||||
2 -> 18.sp
|
2 -> 20.sp
|
||||||
3 -> 16.sp
|
3 -> 18.sp
|
||||||
else -> 14.sp
|
else -> 16.sp
|
||||||
},
|
},
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor,
|
color = color
|
||||||
modifier = Modifier.padding(vertical = 6.dp)
|
|
||||||
)
|
)
|
||||||
|
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 -> {
|
is MarkdownElement.ListItem -> {
|
||||||
Row(modifier = Modifier.padding(bottom = 4.dp, start = 8.dp)) {
|
Row(modifier = Modifier.padding(start = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = if (element.isNumbered) "${element.number}. " else "• ",
|
text = if (element.isNumbered) "${element.number}. " else "• ",
|
||||||
fontWeight = FontWeight.SemiBold,
|
color = color,
|
||||||
color = textColor,
|
fontWeight = FontWeight.Bold
|
||||||
modifier = Modifier.padding(end = 6.dp)
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = buildInlineAnnotatedString(element.text, textColor),
|
text = buildStyledText(element.text),
|
||||||
fontSize = 14.sp,
|
color = color,
|
||||||
color = textColor,
|
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
is MarkdownElement.CodeBlock -> {
|
is MarkdownElement.CodeBlock -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xFFF5F7FA), shape = RoundedCornerShape(8.dp))
|
.background(
|
||||||
|
color = Color(0xFFF5F5F5),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
.padding(bottom = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = element.code,
|
text = element.code,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||||
fontSize = 13.sp,
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tabel dengan styling yang bagus
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownTable(
|
||||||
|
headers: List<String>,
|
||||||
|
rows: List<List<String>>,
|
||||||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// small spacer between blocks (already handled by padding) - optional
|
|
||||||
if (idx == elements.lastIndex) {
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ---------- Parser & Inline renderer ---------- **/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses markdown string into block elements.
|
* Build styled text dengan support inline markdown
|
||||||
*/
|
*/
|
||||||
private fun parseMarkdown(markdown: String): List<MarkdownElement> {
|
@Composable
|
||||||
val lines = markdown.replace("\r\n", "\n").lines()
|
private fun buildStyledText(text: String) = buildAnnotatedString {
|
||||||
val elements = mutableListOf<MarkdownElement>()
|
var currentIndex = 0
|
||||||
|
val processed = mutableSetOf<IntRange>()
|
||||||
|
|
||||||
var i = 0
|
// Regex patterns
|
||||||
while (i < lines.size) {
|
val boldPattern = """\*\*(.+?)\*\*""".toRegex()
|
||||||
val raw = lines[i]
|
val italicPattern = """\*(.+?)\*""".toRegex()
|
||||||
val line = raw.trimEnd()
|
val codePattern = """`(.+?)`""".toRegex()
|
||||||
|
|
||||||
// Skip pure empty lines (but keep grouping paragraphs)
|
// Process bold **text**
|
||||||
if (line.isBlank()) {
|
boldPattern.findAll(text).forEach { match ->
|
||||||
i++
|
if (processed.none { it.contains(match.range.first) }) {
|
||||||
continue
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code fence start
|
// Process italic *text*
|
||||||
if (line.startsWith("```")) {
|
italicPattern.findAll(text).forEach { match ->
|
||||||
val fenceLang = line.removePrefix("```").trim() // unused but could be saved
|
if (processed.none { it.contains(match.range.first) }) {
|
||||||
val codeLines = mutableListOf<String>()
|
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<MarkdownElement> {
|
||||||
|
val lines = markdown.lines()
|
||||||
|
val elements = mutableListOf<MarkdownElement>()
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = lines[i].trim()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Tabel (detect by separator line with |---|---|)
|
||||||
|
line.contains("|") && i + 1 < lines.size && lines[i + 1].trim().matches(Regex("^\\|?\\s*[-:]+\\s*\\|.*$")) -> {
|
||||||
|
val tableLines = mutableListOf<String>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading
|
||||||
|
line.startsWith("#") -> {
|
||||||
|
val level = line.takeWhile { it == '#' }.length
|
||||||
|
val text = line.dropWhile { it == '#' }.trim()
|
||||||
|
elements.add(MarkdownElement.Heading(level, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet list
|
||||||
|
line.startsWith("•") || line.startsWith("-") || line.startsWith("*") -> {
|
||||||
|
val text = line.drop(1).trim()
|
||||||
|
elements.add(MarkdownElement.ListItem(text, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code block (multi-line)
|
||||||
|
line.startsWith("```") -> {
|
||||||
i++
|
i++
|
||||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
val codeLines = mutableListOf<String>()
|
||||||
|
while (i < lines.size && !lines[i].trim().startsWith("```")) {
|
||||||
codeLines.add(lines[i])
|
codeLines.add(lines[i])
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
// skip the closing ```
|
|
||||||
if (i < lines.size && lines[i].trimStart().startsWith("```")) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
|
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heading: #, ##, ###
|
// Empty line
|
||||||
if (line.startsWith("#")) {
|
line.isEmpty() -> {
|
||||||
val hashes = line.takeWhile { it == '#' }
|
// Skip empty lines
|
||||||
val level = hashes.length.coerceAtMost(6)
|
|
||||||
val text = line.dropWhile { it == '#' }.trim()
|
|
||||||
elements.add(MarkdownElement.Heading(level, text))
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numbered list: "1. item"
|
// Regular paragraph
|
||||||
val numberedRegex = """^\s*(\d+)\.\s+(.+)$""".toRegex()
|
else -> {
|
||||||
val numberedMatch = numberedRegex.find(line)
|
elements.add(MarkdownElement.Paragraph(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: "- 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paragraph: gather consecutive non-empty, non-block lines into single paragraph
|
|
||||||
val paraLines = mutableListOf<String>()
|
|
||||||
paraLines.add(line)
|
|
||||||
i++
|
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
|
return elements
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build AnnotatedString with inline styles:
|
* Sealed class untuk markdown elements
|
||||||
* - inline code: `code`
|
* UPGRADED: Tambah Table
|
||||||
* - bold: **bold**
|
|
||||||
* - italic: *italic*
|
|
||||||
*
|
|
||||||
* This is a simple scanner that prioritizes inline code, then bold, then italic.
|
|
||||||
*/
|
*/
|
||||||
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 {
|
private sealed class MarkdownElement {
|
||||||
data class Paragraph(val text: String) : MarkdownElement()
|
data class Paragraph(val text: String) : MarkdownElement()
|
||||||
data class Heading(val level: Int, 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) :
|
data class ListItem(
|
||||||
MarkdownElement()
|
val text: String,
|
||||||
|
val isNumbered: Boolean = false,
|
||||||
|
val number: Int = 0
|
||||||
|
) : MarkdownElement()
|
||||||
data class CodeBlock(val code: String) : MarkdownElement()
|
data class CodeBlock(val code: String) : MarkdownElement()
|
||||||
|
data class Table(
|
||||||
|
val headers: List<String>,
|
||||||
|
val rows: List<List<String>>
|
||||||
|
) : MarkdownElement()
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user