Contextual AI

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2025-11-18 15:39:16 +07:00
parent 53472b2768
commit a51c24030f
3 changed files with 619 additions and 418 deletions

View File

@ -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! 🚀
""" """
} }

View File

@ -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))
)
) )
} }
} }

View File

@ -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()
} }