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

View File

@ -1,17 +1,54 @@
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 <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> {
return try {
retryWithBackoff {
val prompt = """
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
Ringkasan harus:
@ -31,45 +68,53 @@ class GeminiRepository {
val textResponse = body?.getTextResponse()
if (textResponse != null) {
Result.success(textResponse)
return@retryWithBackoff Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
throw Exception("Empty response from API")
}
} 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) {
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<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
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"))
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 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:
@ -105,33 +150,38 @@ class GeminiRepository {
)
)
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)
return@retryWithBackoff Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
throw Exception("Empty response from API")
}
} else {
val errorBody = response.errorBody()?.string()
println("❌ API Error: ${response.code()} - $errorBody")
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) {
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<Pair<String, String>> = emptyList()
): Result<String> {
return try {
// Build context dengan chat history
val contextBuilder = StringBuilder()
contextBuilder.append("Konteks Dokumen:\n$documentContext\n\n")
retryWithBackoff {
val promptBuilder = StringBuilder()
promptBuilder.append("""
KONTEKS DOKUMEN PENGGUNA:
$documentContext
---
""".trimIndent())
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("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())
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)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
if (textResponse != null) {
Result.success(textResponse)
return@retryWithBackoff Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
throw Exception("Empty response from API")
}
} else {
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) {
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<Pair<String, String>> = emptyList()
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
println("📄 Chat with PDF file: $pdfFilePath")
retryWithBackoff {
val file = java.io.File(pdfFilePath)
// 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")
)
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 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")
promptBuilder.append("DOKUMEN PDF: $pdfFileName\n\n")
if (chatHistory.isNotEmpty()) {
promptBuilder.append("Riwayat percakapan sebelumnya:\n")
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: $userMessage\n\n")
promptBuilder.append("Jawab pertanyaan berdasarkan isi dokumen PDF di atas. Gunakan bahasa Indonesia yang jelas dan mudah dipahami.")
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(inlineData = InlineData(mimeType = "application/pdf", data = base64)),
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))
)
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)
if (response.isSuccessful) {
val body = response.body()
val textResponse = body?.getTextResponse()
val textResponse = response.body()?.getTextResponse()
if (textResponse != null) {
println("✅ Chat response received: ${textResponse.length} chars")
Result.success(textResponse)
return@retryWithBackoff Result.success(textResponse)
} else {
Result.failure(Exception("Empty response from API"))
throw Exception("Empty response")
}
} else {
val errorBody = response.errorBody()?.string()
println("❌ API Error: ${response.code()} - $errorBody")
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()}")
}
}
}
} 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)))
)
}
}

View File

@ -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))
}
}
}
}
}
/**
* 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> {
val lines = markdown.replace("\r\n", "\n").lines()
val elements = mutableListOf<MarkdownElement>()
@Composable
private fun buildStyledText(text: String) = buildAnnotatedString {
var currentIndex = 0
val processed = mutableSetOf<IntRange>()
var i = 0
while (i < lines.size) {
val raw = lines[i]
val line = raw.trimEnd()
// Regex patterns
val boldPattern = """\*\*(.+?)\*\*""".toRegex()
val italicPattern = """\*(.+?)\*""".toRegex()
val codePattern = """`(.+?)`""".toRegex()
// Skip pure empty lines (but keep grouping paragraphs)
if (line.isBlank()) {
i++
continue
// 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)
}
}
// Code fence start
if (line.startsWith("```")) {
val fenceLang = line.removePrefix("```").trim() // unused but could be saved
val codeLines = mutableListOf<String>()
// 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<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++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
val codeLines = mutableListOf<String>()
while (i < lines.size && !lines[i].trim().startsWith("```")) {
codeLines.add(lines[i])
i++
}
// skip the closing ```
if (i < lines.size && lines[i].trimStart().startsWith("```")) {
i++
}
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
// Empty line
line.isEmpty() -> {
// Skip empty lines
}
// 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
// Regular paragraph
else -> {
elements.add(MarkdownElement.Paragraph(line))
}
// 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++
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<String>,
val rows: List<List<String>>
) : MarkdownElement()
}