Contextual AI
This commit is contained in:
parent
53472b2768
commit
a51c24030f
@ -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! 🚀
|
||||
"""
|
||||
}
|
||||
@ -1,137 +1,187 @@
|
||||
package com.example.notebook.api
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Repository untuk handle Gemini API calls
|
||||
* ENHANCED: Better error handling & retry logic untuk 429 errors
|
||||
*/
|
||||
class GeminiRepository {
|
||||
|
||||
private val apiService = GeminiApiService.create()
|
||||
|
||||
// Retry configuration
|
||||
private val maxRetries = 3
|
||||
private val initialDelayMs = 2000L // 2 seconds
|
||||
|
||||
/**
|
||||
* Generate summary dari text atau file PDF
|
||||
* Helper function untuk retry dengan exponential backoff
|
||||
*/
|
||||
private suspend fun <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 {
|
||||
val prompt = """
|
||||
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
|
||||
Ringkasan harus:
|
||||
- Mencakup poin-poin utama
|
||||
- Mudah dipahami
|
||||
- Panjang sekitar 3-5 paragraf
|
||||
retryWithBackoff {
|
||||
val prompt = """
|
||||
Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia.
|
||||
Ringkasan harus:
|
||||
- Mencakup poin-poin utama
|
||||
- Mudah dipahami
|
||||
- Panjang sekitar 3-5 paragraf
|
||||
|
||||
Dokumen:
|
||||
$text
|
||||
""".trimIndent()
|
||||
Dokumen:
|
||||
$text
|
||||
""".trimIndent()
|
||||
|
||||
val request = createRequest(prompt)
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
val request = createRequest(prompt)
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
|
||||
if (textResponse != null) {
|
||||
Result.success(textResponse)
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("Empty response from API"))
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("API Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
// User-friendly error messages
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"API Key tidak valid. Periksa konfigurasi API Key Anda."
|
||||
e.message?.contains("Network") == true ->
|
||||
"Tidak ada koneksi internet. Periksa koneksi Anda."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate summary dari file PDF (binary)
|
||||
* Untuk PDF yang image-based atau sulit di-extract
|
||||
* Optimized untuk mencegah ANR
|
||||
* Generate summary dari PDF dengan retry logic
|
||||
*/
|
||||
suspend fun generateSummaryFromPdfFile(
|
||||
filePath: String,
|
||||
fileName: String
|
||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
println("📄 Reading PDF file: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
retryWithBackoff {
|
||||
println("📄 Reading PDF file: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
|
||||
// Check file size (max 20MB untuk Gemini)
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
if (fileSizeMB > 20) {
|
||||
return@withContext Result.failure(Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB"))
|
||||
}
|
||||
|
||||
println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)")
|
||||
|
||||
// Read & encode in background
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
println("📄 Base64 encoded, length: ${base64.length}")
|
||||
|
||||
val prompt = """
|
||||
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
|
||||
Ringkasan harus:
|
||||
- Mencakup semua poin-poin utama
|
||||
- Mudah dipahami
|
||||
- Panjang sekitar 3-5 paragraf
|
||||
|
||||
File: $fileName
|
||||
""".trimIndent()
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(text = prompt),
|
||||
Part(
|
||||
inlineData = InlineData(
|
||||
mimeType = "application/pdf",
|
||||
data = base64
|
||||
)
|
||||
)
|
||||
),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
maxOutputTokens = 2048,
|
||||
topP = 0.95
|
||||
),
|
||||
systemInstruction = SystemInstruction(
|
||||
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
|
||||
)
|
||||
)
|
||||
|
||||
println("📡 Sending request to Gemini API...")
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
|
||||
if (textResponse != null) {
|
||||
println("✅ Gemini response received: ${textResponse.length} chars")
|
||||
Result.success(textResponse)
|
||||
} else {
|
||||
Result.failure(Exception("Empty response from API"))
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
if (fileSizeMB > 20) {
|
||||
throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||
}
|
||||
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
val prompt = """
|
||||
Buatlah ringkasan komprehensif dari dokumen PDF ini dalam bahasa Indonesia.
|
||||
Ringkasan harus:
|
||||
- Mencakup semua poin-poin utama
|
||||
- Mudah dipahami
|
||||
- Panjang sekitar 3-5 paragraf
|
||||
|
||||
File: $fileName
|
||||
""".trimIndent()
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(text = prompt),
|
||||
Part(
|
||||
inlineData = InlineData(
|
||||
mimeType = "application/pdf",
|
||||
data = base64
|
||||
)
|
||||
)
|
||||
),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
maxOutputTokens = 2048,
|
||||
topP = 0.95
|
||||
),
|
||||
systemInstruction = SystemInstruction(
|
||||
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
|
||||
)
|
||||
)
|
||||
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
println("❌ API Error: ${response.code()} - $errorBody")
|
||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ Exception in generateSummaryFromPdfFile: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"Quota API Gemini habis. Silakan tunggu beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"API Key tidak valid. Periksa konfigurasi API Key Anda."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat dengan context dokumen
|
||||
* Chat dengan document - dengan retry logic
|
||||
*/
|
||||
suspend fun chatWithDocument(
|
||||
userMessage: String,
|
||||
@ -139,45 +189,105 @@ class GeminiRepository {
|
||||
chatHistory: List<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()
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
contextBuilder.append("Riwayat Chat:\n")
|
||||
chatHistory.forEach { (user, ai) ->
|
||||
contextBuilder.append("User: $user\n")
|
||||
contextBuilder.append("AI: $ai\n\n")
|
||||
promptBuilder.append("""
|
||||
KONTEKS DOKUMEN PENGGUNA:
|
||||
$documentContext
|
||||
|
||||
---
|
||||
|
||||
""".trimIndent())
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||
promptBuilder.append("User: $user\n")
|
||||
promptBuilder.append("Assistant: $ai\n\n")
|
||||
}
|
||||
promptBuilder.append("---\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
contextBuilder.append("Pertanyaan User: $userMessage\n\n")
|
||||
contextBuilder.append("Jawab berdasarkan dokumen di atas. Jika informasi tidak ada di dokumen, katakan dengan jelas.")
|
||||
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||
|
||||
val request = createRequest(contextBuilder.toString())
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
promptBuilder.append("""
|
||||
PANDUAN MENJAWAB:
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
1. **PRIORITASKAN informasi dari dokumen di atas** untuk menjawab pertanyaan.
|
||||
|
||||
if (textResponse != null) {
|
||||
Result.success(textResponse)
|
||||
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) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response from API")
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("Empty response from API"))
|
||||
val errorBody = response.errorBody()?.string()
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()} - $errorBody")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"⏳ Quota API habis. Coba lagi dalam beberapa menit atau upgrade ke paid plan."
|
||||
e.message?.contains("401") == true ->
|
||||
"❌ API Key tidak valid. Periksa konfigurasi."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat dengan PDF file langsung (untuk scan/image PDF)
|
||||
* Digunakan ketika text extraction gagal
|
||||
* Chat dengan PDF file - dengan retry logic
|
||||
*/
|
||||
suspend fun chatWithPdfFile(
|
||||
userMessage: String,
|
||||
@ -186,109 +296,92 @@ class GeminiRepository {
|
||||
chatHistory: List<Pair<String, String>> = emptyList()
|
||||
): Result<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
println("📄 Chat with PDF file: $pdfFilePath")
|
||||
val file = java.io.File(pdfFilePath)
|
||||
retryWithBackoff {
|
||||
val file = java.io.File(pdfFilePath)
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
|
||||
// Check file size (max 20MB)
|
||||
val fileSizeMB = file.length() / (1024.0 * 1024.0)
|
||||
if (fileSizeMB > 20) {
|
||||
return@withContext Result.failure(
|
||||
Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||
)
|
||||
}
|
||||
|
||||
println("📄 PDF size: ${file.length()} bytes (${String.format("%.2f", fileSizeMB)} MB)")
|
||||
|
||||
// Read & encode in background
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
println("📄 Base64 encoded, length: ${base64.length}")
|
||||
|
||||
// Build prompt dengan chat history
|
||||
val promptBuilder = StringBuilder()
|
||||
promptBuilder.append("Dokumen PDF: $pdfFileName\n\n")
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
promptBuilder.append("Riwayat percakapan sebelumnya:\n")
|
||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||
promptBuilder.append("User: $user\n")
|
||||
promptBuilder.append("Assistant: $ai\n\n")
|
||||
if (fileSizeMB > 20) {
|
||||
throw Exception("File terlalu besar (${String.format("%.2f", fileSizeMB)} MB). Maksimal 20MB")
|
||||
}
|
||||
}
|
||||
|
||||
promptBuilder.append("Pertanyaan saat ini: $userMessage\n\n")
|
||||
promptBuilder.append("Jawab pertanyaan berdasarkan isi dokumen PDF di atas. Gunakan bahasa Indonesia yang jelas dan mudah dipahami.")
|
||||
val bytes = file.readBytes()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(
|
||||
inlineData = InlineData(
|
||||
mimeType = "application/pdf",
|
||||
data = base64
|
||||
)
|
||||
val promptBuilder = StringBuilder()
|
||||
promptBuilder.append("DOKUMEN PDF: $pdfFileName\n\n")
|
||||
|
||||
if (chatHistory.isNotEmpty()) {
|
||||
promptBuilder.append("RIWAYAT PERCAKAPAN:\n")
|
||||
chatHistory.takeLast(5).forEach { (user, ai) ->
|
||||
promptBuilder.append("User: $user\n")
|
||||
promptBuilder.append("Assistant: $ai\n\n")
|
||||
}
|
||||
promptBuilder.append("---\n\n")
|
||||
}
|
||||
|
||||
promptBuilder.append("PERTANYAAN SAAT INI:\n$userMessage\n\n")
|
||||
promptBuilder.append("""
|
||||
PANDUAN MENJAWAB:
|
||||
|
||||
1. **PRIORITASKAN informasi dari PDF** untuk menjawab pertanyaan.
|
||||
2. **JIKA pertanyaan terkait topik PDF** tetapi butuh penjelasan konsep:
|
||||
- Jelaskan konsep dengan jelas
|
||||
- Hubungkan dengan konten spesifik dari PDF
|
||||
3. Gunakan markdown untuk struktur (bold, list, tabel)
|
||||
|
||||
FORMAT TABEL (jika diperlukan):
|
||||
| Header 1 | Header 2 | Header 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
|
||||
Jawab sekarang:
|
||||
""".trimIndent())
|
||||
|
||||
val request = GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(
|
||||
Part(inlineData = InlineData(mimeType = "application/pdf", data = base64)),
|
||||
Part(text = promptBuilder.toString())
|
||||
),
|
||||
Part(text = promptBuilder.toString())
|
||||
),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
maxOutputTokens = 2048,
|
||||
topP = 0.95
|
||||
),
|
||||
systemInstruction = SystemInstruction(
|
||||
parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION))
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95),
|
||||
systemInstruction = SystemInstruction(parts = listOf(Part(text = ApiConstants.SYSTEM_INSTRUCTION)))
|
||||
)
|
||||
)
|
||||
|
||||
println("📡 Sending chat request to Gemini API...")
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
val response = apiService.generateContent(ApiConstants.GEMINI_API_KEY, request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val textResponse = body?.getTextResponse()
|
||||
|
||||
if (textResponse != null) {
|
||||
println("✅ Chat response received: ${textResponse.length} chars")
|
||||
Result.success(textResponse)
|
||||
if (response.isSuccessful) {
|
||||
val textResponse = response.body()?.getTextResponse()
|
||||
if (textResponse != null) {
|
||||
return@retryWithBackoff Result.success(textResponse)
|
||||
} else {
|
||||
throw Exception("Empty response")
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("Empty response from API"))
|
||||
when (response.code()) {
|
||||
429 -> throw Exception("API Error 429: Rate limit exceeded. Retrying...")
|
||||
else -> throw Exception("API Error: ${response.code()}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
println("❌ API Error: ${response.code()} - $errorBody")
|
||||
Result.failure(Exception("API Error: ${response.code()} - $errorBody"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ Exception in chatWithPdfFile: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
val friendlyMessage = when {
|
||||
e.message?.contains("429") == true ->
|
||||
"⏳ Quota API habis. Tunggu beberapa menit atau upgrade plan."
|
||||
else -> "Error: ${e.message}"
|
||||
}
|
||||
Result.failure(Exception(friendlyMessage))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create request object dengan system instruction
|
||||
*/
|
||||
private fun createRequest(prompt: String): GeminiRequest {
|
||||
return GeminiRequest(
|
||||
contents = listOf(
|
||||
Content(
|
||||
parts = listOf(Part(prompt)),
|
||||
role = "user"
|
||||
)
|
||||
),
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
maxOutputTokens = 2048,
|
||||
topP = 0.95
|
||||
),
|
||||
systemInstruction = SystemInstruction(
|
||||
parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION))
|
||||
)
|
||||
contents = listOf(Content(parts = listOf(Part(prompt)), role = "user")),
|
||||
generationConfig = GenerationConfig(temperature = 0.7, maxOutputTokens = 2048, topP = 0.95),
|
||||
systemInstruction = SystemInstruction(parts = listOf(Part(ApiConstants.SYSTEM_INSTRUCTION)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,291 +1,335 @@
|
||||
package com.example.notebook.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* MarkdownText - lightweight markdown renderer tanpa dependency.
|
||||
* Mendukung: heading (#..), bold (**text**), italic (*text*), inline code (`code`),
|
||||
* code block (```...```), bullet list (- / * / +), numbered list (1. item), paragraph.
|
||||
* Composable untuk render markdown text
|
||||
* Support: **bold**, *italic*, `code`, # heading, numbered list, bullet list, TABLES
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
markdown: String,
|
||||
modifier: Modifier = Modifier,
|
||||
textColor: Color = Color(0xFF111827)
|
||||
color: Color = Color.Black
|
||||
) {
|
||||
val elements = parseMarkdown(markdown)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
elements.forEachIndexed { idx, element ->
|
||||
parseMarkdown(markdown).forEach { element ->
|
||||
when (element) {
|
||||
is MarkdownElement.Paragraph -> {
|
||||
Text(
|
||||
text = buildStyledText(element.text),
|
||||
color = color,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
is MarkdownElement.Heading -> {
|
||||
Text(
|
||||
text = element.text,
|
||||
fontSize = when (element.level) {
|
||||
1 -> 22.sp
|
||||
2 -> 18.sp
|
||||
3 -> 16.sp
|
||||
else -> 14.sp
|
||||
1 -> 24.sp
|
||||
2 -> 20.sp
|
||||
3 -> 18.sp
|
||||
else -> 16.sp
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
color = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
is MarkdownElement.Paragraph -> {
|
||||
Text(
|
||||
text = buildInlineAnnotatedString(element.text, textColor),
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
is MarkdownElement.ListItem -> {
|
||||
Row(modifier = Modifier.padding(bottom = 4.dp, start = 8.dp)) {
|
||||
Row(modifier = Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
text = if (element.isNumbered) "${element.number}. " else "• ",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
color = color,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = buildInlineAnnotatedString(element.text, textColor),
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
text = buildStyledText(element.text),
|
||||
color = color,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
is MarkdownElement.CodeBlock -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF5F7FA), shape = RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = Color(0xFFF5F5F5),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = element.code,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF1F2937)
|
||||
color = Color(0xFF37474F)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
is MarkdownElement.Table -> {
|
||||
MarkdownTable(
|
||||
headers = element.headers,
|
||||
rows = element.rows,
|
||||
textColor = color
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
// small spacer between blocks (already handled by padding) - optional
|
||||
if (idx == elements.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** ---------- Parser & Inline renderer ---------- **/
|
||||
/**
|
||||
* Render tabel dengan styling yang bagus
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownTable(
|
||||
headers: List<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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown string into block elements.
|
||||
* Build styled text dengan support inline markdown
|
||||
*/
|
||||
@Composable
|
||||
private fun buildStyledText(text: String) = buildAnnotatedString {
|
||||
var currentIndex = 0
|
||||
val processed = mutableSetOf<IntRange>()
|
||||
|
||||
// Regex patterns
|
||||
val boldPattern = """\*\*(.+?)\*\*""".toRegex()
|
||||
val italicPattern = """\*(.+?)\*""".toRegex()
|
||||
val codePattern = """`(.+?)`""".toRegex()
|
||||
|
||||
// Process bold **text**
|
||||
boldPattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Process italic *text*
|
||||
italicPattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Process inline code `text`
|
||||
codePattern.findAll(text).forEach { match ->
|
||||
if (processed.none { it.contains(match.range.first) }) {
|
||||
append(text.substring(currentIndex, match.range.first))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
background = Color(0xFFF5F5F5),
|
||||
color = Color(0xFFE91E63)
|
||||
)
|
||||
) {
|
||||
append(" ${match.groupValues[1]} ")
|
||||
}
|
||||
currentIndex = match.range.last + 1
|
||||
processed.add(match.range)
|
||||
}
|
||||
}
|
||||
|
||||
// Append remaining text
|
||||
if (currentIndex < text.length) {
|
||||
append(text.substring(currentIndex))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown string menjadi list of elements
|
||||
* UPGRADED: Support untuk tabel
|
||||
*/
|
||||
private fun parseMarkdown(markdown: String): List<MarkdownElement> {
|
||||
val lines = markdown.replace("\r\n", "\n").lines()
|
||||
val lines = markdown.lines()
|
||||
val elements = mutableListOf<MarkdownElement>()
|
||||
|
||||
var i = 0
|
||||
|
||||
while (i < lines.size) {
|
||||
val raw = lines[i]
|
||||
val line = raw.trimEnd()
|
||||
val line = lines[i].trim()
|
||||
|
||||
// Skip pure empty lines (but keep grouping paragraphs)
|
||||
if (line.isBlank()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
when {
|
||||
// Tabel (detect by separator line with |---|---|)
|
||||
line.contains("|") && i + 1 < lines.size && lines[i + 1].trim().matches(Regex("^\\|?\\s*[-:]+\\s*\\|.*$")) -> {
|
||||
val tableLines = mutableListOf<String>()
|
||||
|
||||
// Code fence start
|
||||
if (line.startsWith("```")) {
|
||||
val fenceLang = line.removePrefix("```").trim() // unused but could be saved
|
||||
val codeLines = mutableListOf<String>()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
codeLines.add(lines[i])
|
||||
i++
|
||||
// Collect all table lines
|
||||
var tableIndex = i
|
||||
while (tableIndex < lines.size && lines[tableIndex].contains("|")) {
|
||||
tableLines.add(lines[tableIndex])
|
||||
tableIndex++
|
||||
}
|
||||
|
||||
if (tableLines.size >= 2) {
|
||||
// Parse header
|
||||
val headerCells = tableLines[0]
|
||||
.split("|")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
// Parse rows (skip separator line)
|
||||
val dataRows = tableLines.drop(2).map { rowLine ->
|
||||
rowLine.split("|")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
}.filter { it.isNotEmpty() }
|
||||
|
||||
elements.add(MarkdownElement.Table(headerCells, dataRows))
|
||||
i = tableIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
// skip the closing ```
|
||||
if (i < lines.size && lines[i].trimStart().startsWith("```")) {
|
||||
i++
|
||||
|
||||
// Heading
|
||||
line.startsWith("#") -> {
|
||||
val level = line.takeWhile { it == '#' }.length
|
||||
val text = line.dropWhile { it == '#' }.trim()
|
||||
elements.add(MarkdownElement.Heading(level, text))
|
||||
}
|
||||
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading: #, ##, ###
|
||||
if (line.startsWith("#")) {
|
||||
val hashes = line.takeWhile { it == '#' }
|
||||
val level = hashes.length.coerceAtMost(6)
|
||||
val text = line.dropWhile { it == '#' }.trim()
|
||||
elements.add(MarkdownElement.Heading(level, text))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Numbered list
|
||||
line.matches("""^\d+\.\s+.+""".toRegex()) -> {
|
||||
val parts = line.split(".", limit = 2)
|
||||
val number = parts[0].toIntOrNull() ?: 1
|
||||
val text = parts.getOrNull(1)?.trim() ?: ""
|
||||
elements.add(MarkdownElement.ListItem(text, true, number))
|
||||
}
|
||||
|
||||
// Numbered list: "1. item"
|
||||
val numberedRegex = """^\s*(\d+)\.\s+(.+)$""".toRegex()
|
||||
val numberedMatch = numberedRegex.find(line)
|
||||
if (numberedMatch != null) {
|
||||
val number = numberedMatch.groupValues[1].toIntOrNull() ?: 1
|
||||
val text = numberedMatch.groupValues[2]
|
||||
elements.add(MarkdownElement.ListItem(text, isNumbered = true, number = number))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Bullet list
|
||||
line.startsWith("•") || line.startsWith("-") || line.startsWith("*") -> {
|
||||
val text = line.drop(1).trim()
|
||||
elements.add(MarkdownElement.ListItem(text, false))
|
||||
}
|
||||
|
||||
// Bullet list: "- item" or "* item" or "+ item"
|
||||
val bulletRegex = """^\s*[-\*\+]\s+(.+)$""".toRegex()
|
||||
val bulletMatch = bulletRegex.find(line)
|
||||
if (bulletMatch != null) {
|
||||
val text = bulletMatch.groupValues[1]
|
||||
elements.add(MarkdownElement.ListItem(text, isNumbered = false, number = 0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Code block (multi-line)
|
||||
line.startsWith("```") -> {
|
||||
i++
|
||||
val codeLines = mutableListOf<String>()
|
||||
while (i < lines.size && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
elements.add(MarkdownElement.CodeBlock(codeLines.joinToString("\n")))
|
||||
}
|
||||
|
||||
// Paragraph: gather consecutive non-empty, non-block lines into single paragraph
|
||||
val paraLines = mutableListOf<String>()
|
||||
paraLines.add(line)
|
||||
// Empty line
|
||||
line.isEmpty() -> {
|
||||
// Skip empty lines
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
else -> {
|
||||
elements.add(MarkdownElement.Paragraph(line))
|
||||
}
|
||||
}
|
||||
i++
|
||||
while (i < lines.size) {
|
||||
val nextRaw = lines[i]
|
||||
val next = nextRaw.trimEnd()
|
||||
if (next.isBlank()) break
|
||||
// stop paragraph if next is a block start
|
||||
if (next.startsWith("```") || next.startsWith("#") ||
|
||||
numberedRegex.matches(next) || bulletRegex.matches(next)
|
||||
) {
|
||||
break
|
||||
}
|
||||
paraLines.add(next)
|
||||
i++
|
||||
}
|
||||
elements.add(MarkdownElement.Paragraph(paraLines.joinToString(" ").trim()))
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AnnotatedString with inline styles:
|
||||
* - inline code: `code`
|
||||
* - bold: **bold**
|
||||
* - italic: *italic*
|
||||
*
|
||||
* This is a simple scanner that prioritizes inline code, then bold, then italic.
|
||||
* Sealed class untuk markdown elements
|
||||
* UPGRADED: Tambah Table
|
||||
*/
|
||||
private fun buildInlineAnnotatedString(text: String, defaultColor: Color) = buildAnnotatedString {
|
||||
var idx = 0
|
||||
val len = text.length
|
||||
|
||||
fun safeIndexOf(substr: String, from: Int): Int {
|
||||
if (from >= len) return -1
|
||||
val found = text.indexOf(substr, from)
|
||||
return found
|
||||
}
|
||||
|
||||
while (idx < len) {
|
||||
// Inline code has highest priority: `code`
|
||||
if (text[idx] == '`') {
|
||||
val end = safeIndexOf("`", idx + 1)
|
||||
if (end != -1) {
|
||||
val content = text.substring(idx + 1, end)
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = Color(0xFFF3F4F6),
|
||||
color = Color(0xFF7C3AED)
|
||||
)
|
||||
) {
|
||||
append(content)
|
||||
}
|
||||
idx = end + 1
|
||||
continue
|
||||
} else {
|
||||
// no closing backtick, append literal
|
||||
append(text[idx])
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Bold: **text**
|
||||
if (idx + 1 < len && text[idx] == '*' && text[idx + 1] == '*') {
|
||||
val end = text.indexOf("**", idx + 2)
|
||||
if (end != -1) {
|
||||
val content = text.substring(idx + 2, end)
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = defaultColor)) {
|
||||
append(content)
|
||||
}
|
||||
idx = end + 2
|
||||
continue
|
||||
} else {
|
||||
// no closing, treat as literal
|
||||
append(text[idx])
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* (ensure not part of bold)
|
||||
if (text[idx] == '*') {
|
||||
// skip if next is also '*' because that would be bold and handled above
|
||||
if (idx + 1 < len && text[idx + 1] == '*') {
|
||||
// handled already
|
||||
} else {
|
||||
val end = text.indexOf("*", idx + 1)
|
||||
if (end != -1) {
|
||||
val content = text.substring(idx + 1, end)
|
||||
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic, color = defaultColor)) {
|
||||
append(content)
|
||||
}
|
||||
idx = end + 1
|
||||
continue
|
||||
} else {
|
||||
append(text[idx])
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: append single char
|
||||
append(text[idx])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
/** ---------- Markdown element sealed class ---------- */
|
||||
private sealed class MarkdownElement {
|
||||
data class Paragraph(val text: String) : MarkdownElement()
|
||||
data class Heading(val level: Int, val text: String) : MarkdownElement()
|
||||
data class ListItem(val text: String, val isNumbered: Boolean = false, val number: Int = 0) :
|
||||
MarkdownElement()
|
||||
|
||||
data class ListItem(
|
||||
val text: String,
|
||||
val isNumbered: Boolean = false,
|
||||
val number: Int = 0
|
||||
) : MarkdownElement()
|
||||
data class CodeBlock(val code: String) : MarkdownElement()
|
||||
data class Table(
|
||||
val headers: List<String>,
|
||||
val rows: List<List<String>>
|
||||
) : MarkdownElement()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user