From a7f770adf4eeef6994020dfeef8a69081d065a5b Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Fri, 14 Nov 2025 14:10:13 +0700 Subject: [PATCH] API Gemini AI Testing --- app/build.gradle.kts | 11 ++ .../com/example/notebook/api/ApiConstants.kt | 30 +++ .../example/notebook/api/GeminiApiService.kt | 48 +++++ .../com/example/notebook/api/GeminiModels.kt | 94 ++++++++++ .../example/notebook/api/GeminiRepository.kt | 112 +++++++++++ .../ui/screen/NotebookDetailScreen.kt | 12 ++ .../notebook/viewmodel/NotebookViewModel.kt | 174 ++++++++++++------ 7 files changed, 426 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/example/notebook/api/ApiConstants.kt create mode 100644 app/src/main/java/com/example/notebook/api/GeminiApiService.kt create mode 100644 app/src/main/java/com/example/notebook/api/GeminiModels.kt create mode 100644 app/src/main/java/com/example/notebook/api/GeminiRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ec57099..30b5cf0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,4 +80,15 @@ dependencies { // ViewModel & Lifecycle implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + + // Retrofit untuk HTTP requests + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + + // OkHttp untuk logging + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // Gson + implementation("com.google.code.gson:gson:2.10.1") } \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/api/ApiConstants.kt b/app/src/main/java/com/example/notebook/api/ApiConstants.kt new file mode 100644 index 0000000..3fa1e29 --- /dev/null +++ b/app/src/main/java/com/example/notebook/api/ApiConstants.kt @@ -0,0 +1,30 @@ +package com.example.notebook.api + +/** + * Constants untuk Gemini API + * + * PENTING: Jangan commit API key ke Git! + * Untuk production, gunakan BuildConfig atau environment variable + */ +object ApiConstants { + + // GANTI INI DENGAN API KEY KAMU + const val GEMINI_API_KEY = "AIzaSyCVYFUMcKqCDKN5Z_vNwT2Z4VHgjJ5V7dI" + + // Endpoint Gemini API + const val BASE_URL = "https://generativelanguage.googleapis.com/" + + // Model yang digunakan (Flash = gratis & cepat) + const val MODEL_NAME = "gemini-2.0-flash" + + // 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 + """ +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/api/GeminiApiService.kt b/app/src/main/java/com/example/notebook/api/GeminiApiService.kt new file mode 100644 index 0000000..1e57b01 --- /dev/null +++ b/app/src/main/java/com/example/notebook/api/GeminiApiService.kt @@ -0,0 +1,48 @@ +package com.example.notebook.api + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Query +import java.util.concurrent.TimeUnit + +/** + * Retrofit interface untuk Gemini API + */ +interface GeminiApiService { + + @POST("v1beta/models/gemini-1.5-flash:generateContent") + suspend fun generateContent( + @Query("key") apiKey: String, + @Body request: GeminiRequest + ): Response + + companion object { + fun create(): GeminiApiService { + // Logger untuk debug + val logger = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + // HTTP Client + val client = OkHttpClient.Builder() + .addInterceptor(logger) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + // Retrofit instance + return Retrofit.Builder() + .baseUrl(ApiConstants.BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(GeminiApiService::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/api/GeminiModels.kt b/app/src/main/java/com/example/notebook/api/GeminiModels.kt new file mode 100644 index 0000000..cbbdc62 --- /dev/null +++ b/app/src/main/java/com/example/notebook/api/GeminiModels.kt @@ -0,0 +1,94 @@ +package com.example.notebook.api + +import com.google.gson.annotations.SerializedName + +/** + * Request model untuk Gemini API + */ +data class GeminiRequest( + @SerializedName("contents") + val contents: List, + + @SerializedName("generationConfig") + val generationConfig: GenerationConfig? = null, + + @SerializedName("systemInstruction") + val systemInstruction: SystemInstruction? = null +) + +data class Content( + @SerializedName("parts") + val parts: List, + + @SerializedName("role") + val role: String = "user" +) + +data class Part( + @SerializedName("text") + val text: String +) + +data class GenerationConfig( + @SerializedName("temperature") + val temperature: Double = 0.7, + + @SerializedName("maxOutputTokens") + val maxOutputTokens: Int = 2048, + + @SerializedName("topP") + val topP: Double = 0.95 +) + +data class SystemInstruction( + @SerializedName("parts") + val parts: List +) + +/** + * Response model dari Gemini API + */ +data class GeminiResponse( + @SerializedName("candidates") + val candidates: List?, + + @SerializedName("error") + val error: ApiError? = null +) + +data class Candidate( + @SerializedName("content") + val content: Content, + + @SerializedName("finishReason") + val finishReason: String?, + + @SerializedName("safetyRatings") + val safetyRatings: List? +) + +data class SafetyRating( + @SerializedName("category") + val category: String, + + @SerializedName("probability") + val probability: String +) + +data class ApiError( + @SerializedName("code") + val code: Int, + + @SerializedName("message") + val message: String, + + @SerializedName("status") + val status: String +) + +/** + * Helper untuk extract text dari response + */ +fun GeminiResponse.getTextResponse(): String? { + return candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/api/GeminiRepository.kt b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt new file mode 100644 index 0000000..b3cc9cb --- /dev/null +++ b/app/src/main/java/com/example/notebook/api/GeminiRepository.kt @@ -0,0 +1,112 @@ +package com.example.notebook.api + +/** + * Repository untuk handle Gemini API calls + */ +class GeminiRepository { + + private val apiService = GeminiApiService.create() + + /** + * Generate summary dari text + */ + suspend fun generateSummary(text: String): Result { + return try { + val prompt = """ + Buatlah ringkasan yang komprehensif dari dokumen berikut dalam bahasa Indonesia. + Ringkasan harus: + - Mencakup poin-poin utama + - Mudah dipahami + - Panjang sekitar 3-5 paragraf + + Dokumen: + $text + """.trimIndent() + + 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 (textResponse != null) { + Result.success(textResponse) + } else { + Result.failure(Exception("Empty response from API")) + } + } else { + Result.failure(Exception("API Error: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Chat dengan context dokumen + */ + suspend fun chatWithDocument( + userMessage: String, + documentContext: String, + chatHistory: List> = emptyList() + ): Result { + return try { + // Build context dengan chat history + val contextBuilder = StringBuilder() + contextBuilder.append("Konteks Dokumen:\n$documentContext\n\n") + + if (chatHistory.isNotEmpty()) { + contextBuilder.append("Riwayat Chat:\n") + chatHistory.forEach { (user, ai) -> + contextBuilder.append("User: $user\n") + contextBuilder.append("AI: $ai\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.") + + val request = createRequest(contextBuilder.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) + } else { + Result.failure(Exception("Empty response from API")) + } + } else { + val errorBody = response.errorBody()?.string() + Result.failure(Exception("API Error: ${response.code()} - $errorBody")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * 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.trimIndent())) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt b/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt index 7fe1710..8571e7a 100644 --- a/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt +++ b/app/src/main/java/com/example/notebook/ui/screen/NotebookDetailScreen.kt @@ -114,6 +114,18 @@ fun NotebookDetailScreen( }, leadingIcon = { Icon(Icons.Default.CloudUpload, null) } ) + if (sources.isNotEmpty()) { + Divider() + DropdownMenuItem( + text = { Text("Generate Summary") }, + onClick = { + viewModel.generateSummary(notebookId) + selectedTab = 0 // Switch ke chat tab + showUploadMenu = false + }, + leadingIcon = { Icon(Icons.Default.Summarize, null) } + ) + } } } } diff --git a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt index f26aa7d..d9ea9a1 100644 --- a/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt +++ b/app/src/main/java/com/example/notebook/viewmodel/NotebookViewModel.kt @@ -3,6 +3,7 @@ package com.example.notebook.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.example.notebook.api.GeminiRepository import com.example.notebook.data.AppDatabase import com.example.notebook.data.NotebookEntity import com.example.notebook.data.NotebookRepository @@ -20,6 +21,7 @@ import kotlinx.coroutines.launch class NotebookViewModel(application: Application) : AndroidViewModel(application) { private val repository: NotebookRepository + private val geminiRepository = GeminiRepository() // State untuk list notebooks private val _notebooks = MutableStateFlow>(emptyList()) @@ -41,6 +43,10 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() + // State error + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + init { val database = AppDatabase.getDatabase(application) repository = NotebookRepository(database.notebookDao()) @@ -49,9 +55,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application // === NOTEBOOK FUNCTIONS === - /** - * Load semua notebooks dari database - */ private fun loadNotebooks() { viewModelScope.launch { repository.getAllNotebooks().collect { notebooks -> @@ -60,15 +63,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Buat notebook baru - */ fun createNotebook(title: String, description: String = "") { viewModelScope.launch { _isLoading.value = true try { val notebookId = repository.createNotebook(title, description) - // Otomatis ter-update karena Flow println("✅ Notebook berhasil dibuat dengan ID: $notebookId") } catch (e: Exception) { println("❌ Error membuat notebook: ${e.message}") @@ -78,9 +77,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Pilih notebook untuk dibuka - */ fun selectNotebook(notebookId: Int) { println("📂 selectNotebook dipanggil dengan ID: $notebookId") viewModelScope.launch { @@ -97,18 +93,12 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Update notebook - */ fun updateNotebook(notebook: NotebookEntity) { viewModelScope.launch { repository.updateNotebook(notebook) } } - /** - * Hapus notebook - */ fun deleteNotebook(notebook: NotebookEntity) { viewModelScope.launch { repository.deleteNotebook(notebook) @@ -117,9 +107,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application // === SOURCE FUNCTIONS === - /** - * Load sources untuk notebook tertentu - */ private fun loadSources(notebookId: Int) { viewModelScope.launch { repository.getSourcesByNotebook(notebookId).collect { sources -> @@ -128,9 +115,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Tambah source baru - */ fun addSource( notebookId: Int, fileName: String, @@ -150,9 +134,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Handle file upload dari URI - */ fun uploadFile(context: android.content.Context, uri: android.net.Uri, notebookId: Int) { viewModelScope.launch { _isLoading.value = true @@ -175,15 +156,11 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Hapus source - */ - fun deleteSource(source: com.example.notebook.data.SourceEntity) { + fun deleteSource(source: SourceEntity) { viewModelScope.launch { _isLoading.value = true try { repository.deleteSource(source) - // Hapus file fisik juga com.example.notebook.utils.FileHelper.deleteFile(source.filePath) println("✅ Source berhasil dihapus: ${source.fileName}") } catch (e: Exception) { @@ -196,9 +173,6 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application // === CHAT FUNCTIONS === - /** - * Load chat history - */ private fun loadChatHistory(notebookId: Int) { viewModelScope.launch { repository.getChatHistory(notebookId).collect { messages -> @@ -207,36 +181,126 @@ class NotebookViewModel(application: Application) : AndroidViewModel(application } } - /** - * Kirim pesan user - */ fun sendUserMessage(notebookId: Int, message: String) { viewModelScope.launch { - repository.sendMessage(notebookId, message, isUserMessage = true) - // TODO: Panggil Gemini API di sini - // Sementara kirim dummy AI response - simulateAIResponse(notebookId, message) + _isLoading.value = true + try { + repository.sendMessage(notebookId, message, isUserMessage = true) + + val documentContext = buildDocumentContext() + + if (documentContext.isEmpty()) { + val reply = "Maaf, belum ada dokumen yang diupload. Silakan upload dokumen terlebih dahulu untuk bisa bertanya." + repository.sendMessage(notebookId, reply, isUserMessage = false) + } else { + val chatHistory = _chatMessages.value + .takeLast(10) + .filter { it.isUserMessage } + .mapNotNull { userMsg -> + val aiMsg = _chatMessages.value.find { + !it.isUserMessage && it.timestamp > userMsg.timestamp + } + if (aiMsg != null) { + Pair(userMsg.message, aiMsg.message) + } else null + } + + val result = geminiRepository.chatWithDocument( + userMessage = message, + documentContext = documentContext, + chatHistory = chatHistory + ) + + result.fold( + onSuccess = { aiResponse -> + repository.sendMessage(notebookId, aiResponse, isUserMessage = false) + println("✅ AI response berhasil: ${aiResponse.take(50)}...") + }, + onFailure = { error -> + val errorMsg = "Maaf, terjadi error: ${error.message}" + repository.sendMessage(notebookId, errorMsg, isUserMessage = false) + println("❌ Error dari Gemini: ${error.message}") + _errorMessage.value = error.message + } + ) + } + } catch (e: Exception) { + println("❌ Error mengirim pesan: ${e.message}") + _errorMessage.value = e.message + } finally { + _isLoading.value = false + } } } - /** - * Simulasi AI response (sementara sebelum Gemini API) - */ - private fun simulateAIResponse(notebookId: Int, userMessage: String) { - viewModelScope.launch { - // Delay simulasi "AI thinking" - kotlinx.coroutines.delay(1000) - val aiResponse = "Ini adalah response sementara untuk: \"$userMessage\"" - repository.sendMessage(notebookId, aiResponse, isUserMessage = false) - } - } - - /** - * Clear chat history - */ fun clearChatHistory(notebookId: Int) { viewModelScope.launch { repository.clearChatHistory(notebookId) } } + + // === GEMINI FUNCTIONS === + + private fun buildDocumentContext(): String { + val context = StringBuilder() + + _sources.value.forEach { source -> + when (source.fileType) { + "Text", "Markdown" -> { + val content = com.example.notebook.utils.FileHelper.readTextFromFile(source.filePath) + if (content != null) { + context.append("=== ${source.fileName} ===\n") + context.append(content) + context.append("\n\n") + } + } + else -> { + context.append("=== ${source.fileName} ===\n") + context.append("[File type ${source.fileType} - content extraction not yet supported]\n\n") + } + } + } + + return context.toString() + } + + fun generateSummary(notebookId: Int) { + viewModelScope.launch { + _isLoading.value = true + try { + val documentContext = buildDocumentContext() + + if (documentContext.isEmpty()) { + _errorMessage.value = "Tidak ada dokumen untuk diringkas" + return@launch + } + + val result = geminiRepository.generateSummary(documentContext) + + result.fold( + onSuccess = { summary -> + repository.sendMessage( + notebookId, + "📝 Ringkasan Dokumen:\n\n$summary", + isUserMessage = false + ) + println("✅ Summary berhasil: ${summary.take(100)}...") + }, + onFailure = { error -> + _errorMessage.value = "Error generate summary: ${error.message}" + println("❌ Error generate summary: ${error.message}") + } + ) + } catch (e: Exception) { + _errorMessage.value = e.message + println("❌ Error: ${e.message}") + } finally { + _isLoading.value = false + } + } + } + + fun clearError() { + _errorMessage.value = null + } } \ No newline at end of file