diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt index 14e9af4a68..f05411eabb 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgentManager.kt @@ -24,6 +24,7 @@ import com.tom.rv2ide.artificial.agents.anthropic.Anthropic import com.tom.rv2ide.artificial.agents.grok.Grok import com.tom.rv2ide.artificial.agents.deepseek.DeepSeek import com.tom.rv2ide.artificial.agents.local.LocalLLM +import com.tom.rv2ide.artificial.agents.custom.CustomEndpoint import com.tom.rv2ide.artificial.file.FileWriteResult import com.tom.rv2ide.artificial.parser.SnippetParser import com.tom.rv2ide.artificial.permissions.AIPermissionManager @@ -53,6 +54,7 @@ class AIAgentManager(private val context: Context) { Grok.registerAgent() DeepSeek.registerAgent() LocalLLM.registerAgent() + CustomEndpoint.registerAgent() permissionManager.setFileWriteEnabled(true) permissionManager.setRequireConfirmation(false) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt index 8feae2ae5c..0ceab20fce 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt @@ -143,8 +143,14 @@ class Agents(ctx: Context) { private val localllm_models = arrayOf( "local-model" ) + + // custom: model dibaca dari prefs (user ketik bebas). Fallback contoh 9router. + private fun custom_models(): Array { + val m = sp.getString("ai_agent_custom_model", "").trim() + return if (m.isNotBlank()) arrayOf(m) else arrayOf("cbai/deepseek-v4.1-flash", "oc/muse-spark-1.3-contributor-free", "custom-model") + } - val ai_agents = openai_models + claude_models + gemini_models + deepseek_models + grok_models + localllm_models + val ai_agents = openai_models + claude_models + gemini_models + deepseek_models + grok_models + localllm_models + custom_models() fun getModelsForProvider(providerId: String): Array { return when(providerId) { @@ -154,6 +160,7 @@ class Agents(ctx: Context) { "deepseek" -> deepseek_models "grok" -> grok_models "localllm" -> localllm_models + "custom" -> custom_models() else -> gemini_models } } @@ -166,6 +173,7 @@ class Agents(ctx: Context) { modelName in deepseek_models -> "deepseek" modelName in grok_models -> "grok" modelName in localllm_models -> "localllm" + modelName in custom_models() -> "custom" else -> null } } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt new file mode 100644 index 0000000000..f5e26af2fc --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt @@ -0,0 +1,480 @@ +/* + * This file is part of AndroidCodeStudio. + * + * AndroidCodeStudio is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidCodeStudio is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidCodeStudio. If not, see . +*/ + +package com.tom.rv2ide.artificial.agents.custom + +import android.content.Context +import com.tom.rv2ide.artificial.agents.AIAgent +import com.tom.rv2ide.artificial.agents.AIAgentRegistry +import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.agents.Agents +import com.tom.rv2ide.artificial.rules.WritingRules +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.file.AIFileWriter +import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.exceptions.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import org.json.JSONObject +import org.json.JSONArray + +class CustomEndpoint : AIAgent { + + private var apiKey: String? = null + private val writingRules = WritingRules.Instructions() + private var projectTreeResult: ProjectTreeResult? = null + private var fileWriter: AIFileWriter? = null + private val conversationHistory = mutableListOf() + private val modificationHistory = mutableListOf() + private var currentAttemptCount = 0 + private val maxRetryAttempts = 3 + private var agents: Agents? = null + private var selectedModel: String = "custom-model" + override val providerId = "custom" + override val providerName = "Custom Endpoint" + + companion object { + fun registerAgent() { + AIAgentRegistry.register("custom", object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent { + return CustomEndpoint() + } + + override fun hasValidApiKey(): Boolean { + val apiKey = getApiKey() + return apiKey != null && apiKey.isNotBlank() && apiKey.length > 20 + } + + override fun getApiKey(): String? { + val prefManager = com.tom.rv2ide.preferences.internal.prefManager + return prefManager.getString("ai_agent_custom_api_key", "")?.takeIf { it.isNotBlank() } + } + }) + } + } + + override fun initialize(apiKey: String, context: Context) { + try { + this.apiKey = apiKey + agents = Agents(context) + // custom: model dibaca dari prefs sendiri (bebas ketik, cth: cbai/deepseek-v4.1-flash) + var selectedModel = com.tom.rv2ide.preferences.internal.prefManager + .getString("ai_agent_custom_model", "custom-model").trim() + if (selectedModel.isBlank()) selectedModel = "custom-model" + agents?.setAgent(selectedModel) + agents?.setProvider("custom") + + this.selectedModel = selectedModel + } catch (e: Exception) { + throw e + } + } + + override fun reinitializeWithNewModel(apiKey: String, context: Context) { + initialize(apiKey, context) + } + + override fun setContext(context: Context) { + fileWriter = AIFileWriter(context) + } + + override fun setProjectData(projectTreeResult: ProjectTreeResult) { + this.projectTreeResult = projectTreeResult + } + + override fun clearConversation() { + conversationHistory.clear() + modificationHistory.clear() + currentAttemptCount = 0 + } + + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { + modificationHistory.add( + ModificationAttempt( + timestamp = System.currentTimeMillis(), + filePath = filePath, + previousContent = oldContent, + newContent = newContent, + attemptNumber = currentAttemptCount, + success = success + ) + ) + } + + override fun undoLastModification(): Boolean { + if (modificationHistory.isEmpty()) return false + + val lastMod = modificationHistory.lastOrNull { it.success } ?: return false + + if (lastMod.previousContent != null) { + val result = writeFile(lastMod.filePath, lastMod.previousContent) + if (result is FileWriteResult.Success) { + modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) + return true + } + } else { + try { + File(lastMod.filePath).delete() + modificationHistory.removeAt(modificationHistory.lastIndexOf(lastMod)) + return true + } catch (e: Exception) { + return false + } + } + return false + } + + override fun getModificationHistory(): List { + return modificationHistory.toList() + } + + override fun resetAttemptCount() { + currentAttemptCount = 0 + } + + override fun incrementAttemptCount() { + currentAttemptCount++ + } + + override fun getCurrentAttemptCount(): Int = currentAttemptCount + + override fun canRetry(): Boolean = currentAttemptCount < maxRetryAttempts + + private fun isUserRequestingCorrection(message: String): Boolean { + val correctionKeywords = listOf( + "wrong", "not what", "mistake", "error", "incorrect", + "that's not", "not right", "fix", "undo", "revert", + "different", "try again", "not working" + ) + return correctionKeywords.any { message.lowercase().contains(it) } + } + + override suspend fun generateCode( + prompt: String, + context: String?, + language: String, + projectStructure: String?, + ): Result = + withContext(Dispatchers.IO) { + try { + val key = apiKey + ?: return@withContext Result.failure( + IllegalStateException("DeepSeek service not initialized") + ) + + val fileContents = readRelevantFiles() + val needsCorrection = isUserRequestingCorrection(prompt) + + val fullPrompt = buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { + append(projectTreeResult!!.tree) + append("\n\n") + append("CRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths like '/storage/emulated/0/project' or 'com.example.yourproject'.\n") + append("CRITICAL: Look at the actual paths above and use those EXACT paths.\n\n") + } + + if (fileContents.isNotEmpty()) { + append("=== CURRENT FILES CONTENT ===\n") + fileContents.forEach { (path, content) -> + append("FILE: $path\n") + append("CONTENT:\n") + append(content) + append("\n\n") + } + } + + if (context != null) { + append("=== ADDITIONAL CONTEXT ===\n") + append(context) + append("\n\n") + } + + if (conversationHistory.isNotEmpty()) { + append("=== CONVERSATION HISTORY ===\n") + conversationHistory.forEach { msg -> + append("${msg.role.uppercase()}: ${msg.content}\n\n") + } + } + + if (needsCorrection && modificationHistory.isNotEmpty()) { + append("=== CORRECTION REQUIRED ===\n") + append("The user indicated the previous modification was WRONG.\n") + append("Previous failed attempts:\n") + modificationHistory.takeLast(3).forEach { attempt -> + append("Attempt ${attempt.attemptNumber}: ${attempt.filePath}\n") + append("Result: ${if (attempt.success) "Applied but user rejected" else "Failed"}\n\n") + } + append("You MUST try a DIFFERENT approach. Do NOT repeat the same solution.\n") + append("Analyze what went wrong and provide a better solution.\n\n") + } + + if (currentAttemptCount > 0) { + append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n") + append("This is retry attempt number $currentAttemptCount.\n") + append("Previous attempts did not satisfy the user.\n") + append("Think carefully and provide a different solution.\n\n") + } + + append("=== USER REQUEST ===\n") + append(prompt) + } + + val response = callDeepSeekAPI(key, fullPrompt) + + if (response.isBlank()) { + return@withContext Result.failure(Exception("Empty response from AI")) + } + + conversationHistory.add(ConversationMessage("user", prompt)) + conversationHistory.add(ConversationMessage("assistant", response)) + + if (conversationHistory.size > 20) { + conversationHistory.removeAt(0) + conversationHistory.removeAt(0) + } + + Result.success(response) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun callDeepSeekAPI(apiKey: String, prompt: String): String { + android.util.Log.d("CustomEndpoint", "Starting API call to DeepSeek") + + val baseUrl = com.tom.rv2ide.preferences.internal.prefManager.getString("ai_agent_custom_base_url", "https://api.openai.com/v1").trim().trimEnd('/') + val url = URL("$baseUrl/chat/completions") + val connection = url.openConnection() as HttpURLConnection + + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + + val messages = JSONArray() + + val systemMessage = JSONObject() + systemMessage.put("role", "system") + systemMessage.put("content", writingRules.useThis()) + messages.put(systemMessage) + + val userMessage = JSONObject() + userMessage.put("role", "user") + userMessage.put("content", prompt) + messages.put(userMessage) + + val requestBody = JSONObject() + requestBody.put("model", selectedModel) + requestBody.put("messages", messages) + requestBody.put("temperature", 0.7) + requestBody.put("max_tokens", 4096) + + android.util.Log.d("CustomEndpoint", "Request body: ${requestBody.toString()}") + + connection.outputStream.use { os -> + os.write(requestBody.toString().toByteArray()) + } + + val responseCode = connection.responseCode + android.util.Log.d("CustomEndpoint", "Response code: $responseCode") + + if (responseCode != HttpURLConnection.HTTP_OK) { + val errorStream = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + android.util.Log.e("CustomEndpoint", "Error response: $errorStream") + + try { + val errorJson = JSONObject(errorStream) + val errorObj = errorJson.optJSONObject("error") + val errorMessage = errorObj?.optString("message") ?: errorStream + val errorType = errorObj?.optString("type") ?: "" + val errorCode = errorObj?.optString("code") ?: "" + + android.util.Log.e("CustomEndpoint", "Error type: $errorType, code: $errorCode, message: $errorMessage") + + when { + responseCode == 429 || errorType.contains("rate_limit") || errorCode.contains("rate_limit") -> + throw RateLimitException("DeepSeek rate limit exceeded: $errorMessage") + errorType.contains("insufficient_quota") || errorMessage.contains("quota") || errorMessage.contains("billing") -> + throw QuotaExceededException("DeepSeek quota exceeded: $errorMessage") + errorType.contains("invalid_api_key") || errorCode.contains("invalid_api_key") -> + throw InvalidApiKeyException("Invalid DeepSeek API key: $errorMessage") + responseCode == 401 -> + throw InvalidApiKeyException("DeepSeek authentication failed: $errorMessage") + else -> + throw Exception("DeepSeek API error ($responseCode) - Type: $errorType, Code: $errorCode, Message: $errorMessage") + } + } catch (e: RateLimitException) { + throw e + } catch (e: QuotaExceededException) { + throw e + } catch (e: InvalidApiKeyException) { + throw e + } catch (e: Exception) { + throw Exception("DeepSeek API error ($responseCode): $errorStream") + } + } + + val responseBody = connection.inputStream.bufferedReader().readText() + android.util.Log.d("CustomEndpoint", "Success response received, length: ${responseBody.length}") + + val jsonResponse = JSONObject(responseBody) + + val choices = jsonResponse.getJSONArray("choices") + if (choices.length() > 0) { + val firstChoice = choices.getJSONObject(0) + val message = firstChoice.getJSONObject("message") + return message.getString("content") + } + + throw Exception("No response from DeepSeek API") + } catch (e: RateLimitException) { + android.util.Log.e("CustomEndpoint", "Rate limit exception", e) + throw e + } catch (e: QuotaExceededException) { + android.util.Log.e("CustomEndpoint", "Quota exceeded exception", e) + throw e + } catch (e: InvalidApiKeyException) { + android.util.Log.e("CustomEndpoint", "Invalid API key exception", e) + throw e + } catch (e: java.net.SocketTimeoutException) { + android.util.Log.e("CustomEndpoint", "Timeout exception", e) + throw Exception("DeepSeek request timeout: ${e.message}") + } catch (e: java.net.UnknownHostException) { + android.util.Log.e("CustomEndpoint", "Network exception", e) + throw Exception("Network error - cannot reach DeepSeek: ${e.message}") + } catch (e: Exception) { + android.util.Log.e("CustomEndpoint", "General exception", e) + throw e + } finally { + connection.disconnect() + } + } + + private fun readRelevantFiles(): Map { + val filesContent = mutableMapOf() + val tree = projectTreeResult?.tree ?: return filesContent + + val filePaths = tree.lines().filter { it.isNotBlank() } + + filePaths.forEach { filePath -> + val trimmedPath = filePath.trim() + val file = File(trimmedPath) + + if (file.isFile && + (trimmedPath.endsWith(".kt") || + trimmedPath.endsWith(".java") || + trimmedPath.endsWith(".xml") || + trimmedPath.endsWith(".gradle") || + trimmedPath.endsWith(".gradle.kts")) && + !trimmedPath.contains("/build/") && + !trimmedPath.contains("/.gradle/")) { + try { + val content = file.readText() + filesContent[trimmedPath] = content + } catch (e: Exception) { + } + } + } + + return filesContent + } + + fun readFile(filePath: String): String? { + return try { + if (File(filePath).exists()) { + File(filePath).readText() + } else { + projectTreeResult?.readFileContent(File(filePath).name) + } + } catch (e: Exception) { + null + } + } + + override fun writeFile(filePath: String, content: String): FileWriteResult { + val writer = fileWriter ?: return FileWriteResult.Error("File writer not initialized") + return writer.writeFile(filePath, content, createBackup = true) + } + + fun parseAndApplyModifications(response: String, capturedStates: Map): List { + val modifications = mutableListOf() + val parser = com.tom.rv2ide.artificial.parser.SnippetParser() + + if (response.contains("FILE_TO_MODIFY:")) { + val lines = response.lines() + var currentFile: String? = null + val contentBuilder = StringBuilder() + var inContent = false + + for (line in lines) { + if (line.startsWith("FILE_TO_MODIFY:")) { + if (currentFile != null && contentBuilder.isNotEmpty()) { + val rawContent = contentBuilder.toString().trim() + val cleanedContent = parser.cleanFileContent(rawContent) + val previousContent = capturedStates[currentFile] + val writeResult = writeFile(currentFile, cleanedContent) + + val success = writeResult is FileWriteResult.Success + recordModification(currentFile, previousContent, cleanedContent, success) + + modifications.add(FileModification(currentFile, cleanedContent, writeResult)) + } + + currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() + contentBuilder.clear() + inContent = true + } else if (inContent) { + contentBuilder.append(line).append("\n") + } + } + + if (currentFile != null && contentBuilder.isNotEmpty()) { + val rawContent = contentBuilder.toString().trim() + val cleanedContent = parser.cleanFileContent(rawContent) + val previousContent = capturedStates[currentFile] + val writeResult = writeFile(currentFile, cleanedContent) + + val success = writeResult is FileWriteResult.Success + recordModification(currentFile, previousContent, cleanedContent, success) + + modifications.add(FileModification(currentFile, cleanedContent, writeResult)) + } + } + + return modifications + } + + override fun isInitialized(): Boolean = apiKey != null +} + +data class FileModification( + val filePath: String, + val content: String, + val writeResult: FileWriteResult +) + +data class ConversationMessage( + val role: String, + val content: String +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt index 4b800951da..80797f1cd9 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/secrets/ApiKey.kt @@ -107,9 +107,24 @@ object ApiKey { ).filterValues { it.isNotBlank() } } + // Custom Endpoint API Key + Base URL + Model + fun getCustomApiKey(): String { + return prefManager.getString("ai_agent_custom_api_key", "") + } + fun hasCustomKey(): Boolean { + val key = getCustomApiKey() + return key.isNotBlank() && key.length > 3 + } + fun getCustomBaseUrl(): String { + return prefManager.getString("ai_agent_custom_base_url", "").trim().trimEnd('/') + } + fun getCustomModel(): String { + return prefManager.getString("ai_agent_custom_model", "custom-model").trim() + } + // Check if any API key is configured fun hasAnyApiKey(): Boolean { return hasGeminiKey() || hasOpenAIKey() || hasDeepseekKey() || - hasAnthropicKey() || hasGrokKey() + hasAnthropicKey() || hasGrokKey() || hasCustomKey() } } \ No newline at end of file diff --git a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt index 4912b3da81..23a1594919 100644 --- a/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt +++ b/core/app/src/main/java/com/tom/rv2ide/fragments/sidebar/AIPreferencesFragment.kt @@ -89,10 +89,11 @@ class AIPreferencesFragment( "claude" to "Anthropic Claude", "deepseek" to "DeepSeek", "grok" to "xAI Grok", - "localllm" to "Local LLM" + "localllm" to "Local LLM", + "custom" to "Custom Endpoint (9router/dll)" ) - val allProviderIds = listOf("gemini", "openai", "claude", "deepseek", "grok", "localllm") + val allProviderIds = listOf("gemini", "openai", "claude", "deepseek", "grok", "localllm", "custom") val providerNames = allProviderIds.map { providerMap[it] ?: it } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, providerNames) @@ -126,7 +127,8 @@ class AIPreferencesFragment( "claude" to "Anthropic Claude", "deepseek" to "DeepSeek", "grok" to "xAI Grok", - "localllm" to "Local LLM" + "localllm" to "Local LLM", + "custom" to "Custom Endpoint (9router/dll)" ) val currentProviderId = agents.getProvider() diff --git a/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt b/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt index fe3c8eb8f0..dec42bcb8c 100644 --- a/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt +++ b/core/app/src/main/java/com/tom/rv2ide/preferences/aiAgentPrefExts.kt @@ -51,6 +51,9 @@ private class AIAgentConfig( @IgnoredOnParcel private var openAIApiKeyPref: OpenAIApiKey? = null @IgnoredOnParcel private var anthropicApiKeyPref: AnthropicApiKey? = null @IgnoredOnParcel private var grokApiKeyPref: GrokApiKey? = null + @IgnoredOnParcel private var customApiKeyPref: CustomApiKey? = null + @IgnoredOnParcel private var customBaseUrlPref: CustomBaseUrl? = null + @IgnoredOnParcel private var customModelPref: CustomModel? = null init { val aiAgentEnabled = AIAgentEnabled { isEnabled -> updateApiKeyPreferencesState(isEnabled) } @@ -60,6 +63,9 @@ private class AIAgentConfig( openAIApiKeyPref = OpenAIApiKey() anthropicApiKeyPref = AnthropicApiKey() grokApiKeyPref = GrokApiKey() + customApiKeyPref = CustomApiKey() + customBaseUrlPref = CustomBaseUrl() + customModelPref = CustomModel() addPreference(aiAgentEnabled) addPreference(geminiApiKeyPref!!) @@ -67,6 +73,9 @@ private class AIAgentConfig( addPreference(openAIApiKeyPref!!) addPreference(anthropicApiKeyPref!!) addPreference(grokApiKeyPref!!) + addPreference(customApiKeyPref!!) + addPreference(customBaseUrlPref!!) + addPreference(customModelPref!!) } private fun updateApiKeyPreferencesState(isEnabled: Boolean) { @@ -75,6 +84,9 @@ private class AIAgentConfig( openAIApiKeyPref?.setEnabled(isEnabled) anthropicApiKeyPref?.setEnabled(isEnabled) grokApiKeyPref?.setEnabled(isEnabled) + customApiKeyPref?.setEnabled(isEnabled) + customBaseUrlPref?.setEnabled(isEnabled) + customModelPref?.setEnabled(isEnabled) } } @@ -372,3 +384,138 @@ private class AnthropicApiKey( return if (apiKey.isBlank()) "Click to set API key" else "API Key: ${apiKey.take(8)}..." } } + +@Parcelize +private class CustomApiKey( + override val key: String = "ai_agent_custom_api_key", + override val title: Int = R.string.ai_agent_custom_api_key, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_api_key" + title = context.getString(R.string.ai_agent_custom_api_key) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_api_key", "")) + editText.hint = "sk-... (9router/OpenRouter/dll)" + com.google.android.material.dialog.MaterialAlertDialogBuilder(context) + .setTitle("Custom Endpoint API Key") + .setMessage("API key untuk custom endpoint") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + prefManager.putString("ai_agent_custom_api_key", editText.text.toString().trim()) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create().show() + return true + } + + fun setEnabled(enabled: Boolean) { preference?.isEnabled = enabled } + + private fun getSummaryText(): String { + val v = prefManager.getString("ai_agent_custom_api_key", "") + return if (v.isBlank()) "Click to set API key" else "API Key: ${v.take(8)}..." + } +} + +@Parcelize +private class CustomBaseUrl( + override val key: String = "ai_agent_custom_base_url", + override val title: Int = R.string.ai_agent_custom_base_url, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_base_url" + title = context.getString(R.string.ai_agent_custom_base_url) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_base_url", "https://ai2.bytellm.web.id/v1")) + editText.hint = "https://host/v1" + com.google.android.material.dialog.MaterialAlertDialogBuilder(context) + .setTitle("Custom Endpoint Base URL") + .setMessage("Base URL tanpa /chat/completions") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + prefManager.putString("ai_agent_custom_base_url", editText.text.toString().trim().trimEnd('/')) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create().show() + return true + } + + fun setEnabled(enabled: Boolean) { preference?.isEnabled = enabled } + + private fun getSummaryText(): String { + val v = prefManager.getString("ai_agent_custom_base_url", "") + return if (v.isBlank()) "Click to set Base URL" else v + } +} + +@Parcelize +private class CustomModel( + override val key: String = "ai_agent_custom_model", + override val title: Int = R.string.ai_agent_custom_model, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_model" + title = context.getString(R.string.ai_agent_custom_model) + summary = getSummaryText() + isEnabled = prefManager.getBoolean("ai_agent_enabled", false) + } + return preference!! + } + + override fun onPreferenceClick(preference: Preference): Boolean { + val context = preference.context + val editText = android.widget.EditText(context) + editText.setText(prefManager.getString("ai_agent_custom_model", "cbai/deepseek-v4.1-flash")) + editText.hint = "cbai/deepseek-v4.1-flash" + com.google.android.material.dialog.MaterialAlertDialogBuilder(context) + .setTitle("Custom Endpoint Model") + .setMessage("Nama model (bebas, sesuai provider)") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + prefManager.putString("ai_agent_custom_model", editText.text.toString().trim()) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create().show() + return true + } + + fun setEnabled(enabled: Boolean) { preference?.isEnabled = enabled } + + private fun getSummaryText(): String { + val v = prefManager.getString("ai_agent_custom_model", "") + return if (v.isBlank()) "Click to set Model" else v + } +} diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index b56eec7f6d..3e15dca32a 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -70,6 +70,15 @@ Deepseek API Key + Custom Endpoint API Key + API key untuk custom endpoint (9router/dll) + Enter Custom API Key + Custom Endpoint Base URL + Cth: https://ai2.bytellm.web.id/v1 + Enter Custom Base URL + Custom Endpoint Model + Cth: cbai/deepseek-v4.1-flash + Enter Custom Model Enter your Deepseek API key to use AI features Enter Deepseek API Key