From c3b3e417a76953e09848a2cca1a0eb95ceb160a8 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 14:00:47 +0000 Subject: [PATCH 01/15] feat: custom AI endpoint (base URL + key + model bebas) - Provider baru 'Custom Endpoint' di dropdown AI Agent - Base URL / API Key / Model bisa diisi bebas via Preferences - OpenAI-compatible, cocok ke 9router/OpenRouter/vLLM/lokal - Default contoh: 9router (ai2.bytellm.web.id) --- .../artificial/agents/AIAgentManager.kt | 2 + .../tom/rv2ide/artificial/agents/Agents.kt | 10 +- .../agents/custom/CustomEndpoint.kt | 480 ++++++++++++++++++ .../tom/rv2ide/artificial/secrets/ApiKey.kt | 17 +- .../sidebar/AIPreferencesFragment.kt | 8 +- .../tom/rv2ide/preferences/aiAgentPrefExts.kt | 147 ++++++ .../resources/src/main/res/values/strings.xml | 9 + 7 files changed, 668 insertions(+), 5 deletions(-) create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt 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 From c9673df1ba6da1d353e26e64762a47b48b192ea5 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 16:22:15 +0000 Subject: [PATCH 02/15] fix: keep desugar*FileDependencies enabled (mergeExtDexRelease needs its output) --- core/app/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/app/build.gradle.kts b/core/app/build.gradle.kts index be200a7f62..0d05f0ae99 100755 --- a/core/app/build.gradle.kts +++ b/core/app/build.gradle.kts @@ -43,7 +43,9 @@ buildscript { } tasks.configureEach { - if (name.contains("desugar", ignoreCase = true)) { + if (name.contains("desugar", ignoreCase = true) + && !name.contains("desugarReleaseFileDependencies", ignoreCase = true) + && !name.contains("desugarDebugFileDependencies", ignoreCase = true)) { enabled = false } } From 4ad7e43b43874643ccd43e2b7cb757fdce89161a Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 16:38:28 +0000 Subject: [PATCH 03/15] fix: null-safety custom_models prefs read --- .../src/main/java/com/tom/rv2ide/artificial/agents/Agents.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0ceab20fce..57181169ff 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 @@ -146,7 +146,7 @@ class Agents(ctx: Context) { // 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() + 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") } From 8196076ad00b12f9661cd20997f2ec062f128dcf Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 17:00:03 +0000 Subject: [PATCH 04/15] fix: debug build pakai debug key bawaan (tanpa keystore custom) --- core/app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/app/build.gradle.kts b/core/app/build.gradle.kts index 0d05f0ae99..3f14250d42 100755 --- a/core/app/build.gradle.kts +++ b/core/app/build.gradle.kts @@ -99,7 +99,8 @@ android { buildTypes { debug { - signingConfig = signingConfigs.getByName("custom") + // fork: debug pakai debug key bawaan (tanpa keystore custom) + signingConfig = signingConfigs.getByName("debug") } release { From 0ef666ec9e5b2bd6fff698f3ab60526576507b37 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 17:33:53 +0000 Subject: [PATCH 05/15] feat: dialog config custom endpoint saat dipilih (mirip LocalLLM + field API key) --- .../dialogs/CustomEndpointConfigDialog.kt | 70 +++++++++++++++ .../sidebar/AIPreferencesFragment.kt | 9 ++ .../layout/dialog_custom_endpoint_config.xml | 89 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt create mode 100644 core/app/src/main/res/layout/dialog_custom_endpoint_config.xml diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt new file mode 100644 index 0000000000..93b0f55362 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt @@ -0,0 +1,70 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.textfield.TextInputEditText +import com.tom.rv2ide.R +import com.tom.rv2ide.app.BaseApplication + +class CustomEndpointConfigDialog( + private val onSave: (baseUrl: String, apiKey: ***, modelName: String) -> Unit +) : BottomSheetDialogFragment() { + + private lateinit var baseUrlInput: TextInputEditText + private lateinit var apiKeyInput: TextInputEditText + private lateinit var modelNameInput: TextInputEditText + private lateinit var saveButton: Button + private lateinit var cancelButton: Button + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_custom_endpoint_config, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + baseUrlInput = view.findViewById(R.id.baseUrlInput) + apiKeyInput = view.findViewById(R.id.customApiKeyInput) + modelNameInput = view.findViewById(R.id.customModelNameInput) + saveButton = view.findViewById(R.id.saveButton) + cancelButton = view.findViewById(R.id.cancelButton) + + loadSavedConfig() + + saveButton.setOnClickListener { + val baseUrl = baseUrlInput.text.toString().trim().trimEnd('/') + val apiKey = ***.toString().trim() + val modelName = modelNameInput.text.toString().trim() + + if (baseUrl.isNotEmpty() && apiKey.isNotEmpty() && modelName.isNotEmpty()) { + saveConfig(baseUrl, apiKey, modelName) + onSave(baseUrl, apiKey, modelName) + dismiss() + } + } + + cancelButton.setOnClickListener { dismiss() } + } + + private fun loadSavedConfig() { + val prefs = BaseApplication.getBaseInstance().prefManager + baseUrlInput.setText(prefs.getString("ai_agent_custom_base_url", "https://ai2.bytellm.web.id/v1")) + apiKeyInput.setText(prefs.getString("ai_agent_custom_api_key", "")) + modelNameInput.setText(prefs.getString("ai_agent_custom_model", "cbai/deepseek-v4.1-flash")) + } + + private fun saveConfig(baseUrl: String, apiKey: ***, modelName: String) { + val prefs = BaseApplication.getBaseInstance().prefManager + prefs.putString("ai_agent_custom_base_url", baseUrl) + prefs.putString("ai_agent_custom_api_key", apiKey) + prefs.putString("ai_agent_custom_model", modelName) + } +} 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 23a1594919..a102ae83bc 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 @@ -107,12 +107,21 @@ class AIPreferencesFragment( if (selectedProviderId == "localllm") { showLocalLLMConfigDialog(selectedProviderName) + } else if (selectedProviderId == "custom") { + showCustomEndpointConfigDialog(selectedProviderName) } else { handleProviderChange(selectedProviderId, selectedProviderName) } } } + private fun showCustomEndpointConfigDialog(providerName: String) { + val dialog = com.tom.rv2ide.artificial.dialogs.CustomEndpointConfigDialog { _, _, _ -> + handleProviderChange("custom", providerName) + } + dialog.show(parentFragmentManager, "CustomEndpointConfigDialog") + } + private fun showLocalLLMConfigDialog(providerName: String) { val dialog = LocalLLMConfigDialog { baseUrl, modelName -> handleProviderChange("localllm", providerName) diff --git a/core/app/src/main/res/layout/dialog_custom_endpoint_config.xml b/core/app/src/main/res/layout/dialog_custom_endpoint_config.xml new file mode 100644 index 0000000000..a892cfae05 --- /dev/null +++ b/core/app/src/main/res/layout/dialog_custom_endpoint_config.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 4292142d98c922bc7625ff6dbb73d0d0c5d3d8c3 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 18:29:12 +0000 Subject: [PATCH 06/15] fix: ganti *** literal jadi String di dialog custom --- .../rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt index 93b0f55362..0f94fb00bc 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt @@ -11,7 +11,7 @@ import com.tom.rv2ide.R import com.tom.rv2ide.app.BaseApplication class CustomEndpointConfigDialog( - private val onSave: (baseUrl: String, apiKey: ***, modelName: String) -> Unit + private val onSave: (baseUrl: String, apiKey: String, modelName: String) -> Unit ) : BottomSheetDialogFragment() { private lateinit var baseUrlInput: TextInputEditText @@ -41,7 +41,7 @@ class CustomEndpointConfigDialog( saveButton.setOnClickListener { val baseUrl = baseUrlInput.text.toString().trim().trimEnd('/') - val apiKey = ***.toString().trim() + val apiKey = String.toString().trim() val modelName = modelNameInput.text.toString().trim() if (baseUrl.isNotEmpty() && apiKey.isNotEmpty() && modelName.isNotEmpty()) { @@ -61,7 +61,7 @@ class CustomEndpointConfigDialog( modelNameInput.setText(prefs.getString("ai_agent_custom_model", "cbai/deepseek-v4.1-flash")) } - private fun saveConfig(baseUrl: String, apiKey: ***, modelName: String) { + private fun saveConfig(baseUrl: String, apiKey: String, modelName: String) { val prefs = BaseApplication.getBaseInstance().prefManager prefs.putString("ai_agent_custom_base_url", baseUrl) prefs.putString("ai_agent_custom_api_key", apiKey) From 124f684fa6aa118729efcaca40471f972864d63d Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 19:46:44 +0000 Subject: [PATCH 07/15] fix: ambil apiKey dari input dialog, bukan String.toString() --- .../tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt index 0f94fb00bc..46e94fafcd 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomEndpointConfigDialog.kt @@ -41,7 +41,7 @@ class CustomEndpointConfigDialog( saveButton.setOnClickListener { val baseUrl = baseUrlInput.text.toString().trim().trimEnd('/') - val apiKey = String.toString().trim() + val apiKey = apiKeyInput.text.toString().trim() val modelName = modelNameInput.text.toString().trim() if (baseUrl.isNotEmpty() && apiKey.isNotEmpty() && modelName.isNotEmpty()) { From 681f7438e9dab13a9a88bca41497f9e6c27b93bd Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 20:48:05 +0000 Subject: [PATCH 08/15] feat: eksekusi tool_call mkdir/writefile + persist chat history SharedPrefs + hint format --- .../agents/custom/CustomEndpoint.kt | 133 +++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) 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 index f5e26af2fc..30536be71c 100644 --- 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 @@ -82,6 +82,7 @@ class CustomEndpoint : AIAgent { agents?.setProvider("custom") this.selectedModel = selectedModel + restoreHistory() } catch (e: Exception) { throw e } @@ -101,6 +102,7 @@ class CustomEndpoint : AIAgent { override fun clearConversation() { conversationHistory.clear() + try { prefs().putString(histKey, "") } catch (_: Exception) { } modificationHistory.clear() currentAttemptCount = 0 } @@ -243,8 +245,16 @@ class CustomEndpoint : AIAgent { return@withContext Result.failure(Exception("Empty response from AI")) } + // eksekusi tool_call XML (mkdir/writefile) sebelum disimpan ke history + val toolResults = parseAndExecuteToolCalls(response) + val storedResponse = if (toolResults.isNotEmpty()) { + response + "\n\n[TOOL RESULTS]\n" + toolResults.joinToString("\n") { r -> + "- ${r.function}: ${if (r.success) "OK" else "FAIL"} — ${r.message}" + } + } else response conversationHistory.add(ConversationMessage("user", prompt)) - conversationHistory.add(ConversationMessage("assistant", response)) + conversationHistory.add(ConversationMessage("assistant", storedResponse)) + persistHistory() if (conversationHistory.size > 20) { conversationHistory.removeAt(0) @@ -276,7 +286,7 @@ class CustomEndpoint : AIAgent { val systemMessage = JSONObject() systemMessage.put("role", "system") - systemMessage.put("content", writingRules.useThis()) + systemMessage.put("content", writingRules.useThis() + CUSTOM_TOOLS_HINT) messages.put(systemMessage) val userMessage = JSONObject() @@ -466,6 +476,125 @@ class CustomEndpoint : AIAgent { } override fun isInitialized(): Boolean = apiKey != null + + companion object { + private const val CUSTOM_TOOLS_HINT = """ + +[ TOOL CALLS — BISA DIEKSEKUSI LANGSUNG ] +Selain FILE_TO_MODIFY, kamu juga bisa memakai format ini (langsung dijalankan app, hasilnya balik ke kamu): + +isi file... +Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk file yang sama). mkdir hanya untuk folder. writefile untuk tulis file baru/penuh. +""" + } + + // ---- persist chat history ke SharedPreferences (max 40 pesan, survive restart) ---- + private val histKey: String get() = "ai_agent_custom_history" + + private fun prefs() = com.tom.rv2ide.preferences.internal.prefManager + + private fun persistHistory() { + try { + val take = conversationHistory.takeLast(40) + val json = org.json.JSONArray() + for (m in take) { + json.put(org.json.JSONObject().put("role", m.role).put("content", m.content.take(4000))) + } + prefs().putString(histKey, json.toString()) + } catch (_: Exception) { } + } + + private fun restoreHistory() { + try { + val raw = prefs().getString(histKey, "") + if (raw.isBlank()) return + val json = org.json.JSONArray(raw) + conversationHistory.clear() + for (i in 0 until minOf(json.length(), 40)) { + val o = json.getJSONObject(i) + conversationHistory.add(ConversationMessage(o.optString("role", "user"), o.optString("content", ""))) + } + } catch (_: Exception) { } + } + + // ---- tool_call XML: + writefile ---- + private val toolCallRegex = Regex("\\s*(.*?)\\s*", RegexOption.DOT_MATCHES_ALL) + private val paramRegex = Regex("/]*?)(?:/>|>(.*?))", RegexOption.DOT_MATCHES_ALL) + private val attrRegex = Regex("(\\w+)=([^\\s]+)") + + data class ToolCallResult( + val function: String, + val success: Boolean, + val message: String + ) + + fun parseAndExecuteToolCalls(response: String): List { + val results = mutableListOf() + for (m in toolCallRegex.findAll(response)) { + val fn = m.groupValues[1].lowercase() + val inner = m.groupValues[2] + try { + when (fn) { + "bash" -> { + val params = parseParams(inner) + val cmd = (params["command"] ?: params["cmd"] ?: "").trim() + // hanya izinkan mkdir -p (aman, tanpa shell injection) + val mk = Regex("""mkdir\\s+(?:-p\\s+)?([\\w/.-]+)""").find(cmd) + if (mk != null) { + val dir = java.io.File(mk.groupValues[1]) + val ok = dir.mkdirs() || dir.isDirectory + results.add(ToolCallResult(fn, ok, if (ok) "mkdir ok: ${dir.path}" else "mkdir failed: ${dir.path}")) + } else { + // dukung + val p = params["mkdir"] ?: params["path"] ?: "" + if (p.isNotBlank()) { + val dir = java.io.File(p) + val ok = dir.mkdirs() || dir.isDirectory + results.add(ToolCallResult(fn, ok, if (ok) "mkdir ok: ${dir.path}" else "mkdir failed: ${dir.path}")) + } else { + results.add(ToolCallResult(fn, false, "bash: hanya mkdir yang didukung, cmd ditolak")) + } + } + } + "writefile", "write_file", "write" -> { + val params = parseParams(inner) + val path = (params["path"] ?: params["file"] ?: "").trim() + val content = params["content"] ?: params["text"] ?: "" + if (path.isBlank()) { + results.add(ToolCallResult(fn, false, "writefile: path kosong")) + } else { + val wr = writeFile(path, content) + val ok = wr is FileWriteResult.Success + if (ok) recordModification(path, null, content, true) + results.add(ToolCallResult(fn, ok, if (ok) "wrote: $path" else "write failed: $path")) + } + } + else -> results.add(ToolCallResult(fn, false, "fungsi tidak didukung: $fn")) + } + } catch (e: Exception) { + results.add(ToolCallResult(fn, false, "error: ${e.message}")) + } + } + return results + } + + private fun parseParams(inner: String): Map { + val out = mutableMapOf() + for (pm in paramRegex.findAll(inner)) { + val key = pm.groupValues[1].lowercase() + val attrStr = pm.groupValues[2] + val body = pm.groupValues[3] + val attrs = attrRegex.findAll(attrStr).associate { it.groupValues[1].lowercase() to it.groupValues[2] } + if (key == "mkdir" && attrs.containsKey("path")) { + out["mkdir"] = attrs["path"] ?: "" + } else { + out[key] = if (body.isNotBlank()) body.trim() else (attrs["value"] ?: attrs.values.firstOrNull() ?: "") + } + // atribut path generik (mis. jarang, tapi tampung) + if (attrs.containsKey("path") && !out.containsKey("path")) out["path"] = attrs["path"] ?: "" + } + return out + } } data class FileModification( From 18ab9b13d4a15030d5cd1195006c7e63363de780 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 20:52:32 +0000 Subject: [PATCH 09/15] feat: tool_call readfile (baca isi file, preview 3000 char) --- .../artificial/agents/custom/CustomEndpoint.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 30536be71c..c90315f8c2 100644 --- 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 @@ -484,6 +484,7 @@ class CustomEndpoint : AIAgent { Selain FILE_TO_MODIFY, kamu juga bisa memakai format ini (langsung dijalankan app, hasilnya balik ke kamu): isi file... + Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk file yang sama). mkdir hanya untuk folder. writefile untuk tulis file baru/penuh. """ } @@ -556,6 +557,21 @@ Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk fil } } } + "readfile", "read_file", "read", "cat" -> { + val params = parseParams(inner) + val path = (params["path"] ?: params["file"] ?: "").trim() + if (path.isBlank()) { + results.add(ToolCallResult(fn, false, "readfile: path kosong")) + } else { + val text = readFile(path) + if (text != null) { + val preview = if (text.length > 3000) text.take(3000) + "\n...[dipotong, total ${text.length} char]" else text + results.add(ToolCallResult(fn, true, "read $path (${text.length} char):\n$preview")) + } else { + results.add(ToolCallResult(fn, false, "read failed / tidak ada: $path")) + } + } + } "writefile", "write_file", "write" -> { val params = parseParams(inner) val path = (params["path"] ?: params["file"] ?: "").trim() From 24280ba54f52fce7cc9faebee8db788cb0ba9b1f Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 21:18:19 +0000 Subject: [PATCH 10/15] fix: nested FileModification+ConversationMessage dalam class (hindari duplicate kapt) --- .../agents/custom/CustomEndpoint.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 index c90315f8c2..8dc798ec2a 100644 --- 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 @@ -529,6 +529,17 @@ Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk fil val message: String ) + data class FileModification( + val filePath: String, + val content: String, + val writeResult: FileWriteResult + ) + + data class ConversationMessage( + val role: String, + val content: String + ) + fun parseAndExecuteToolCalls(response: String): List { val results = mutableListOf() for (m in toolCallRegex.findAll(response)) { @@ -540,7 +551,7 @@ Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk fil val params = parseParams(inner) val cmd = (params["command"] ?: params["cmd"] ?: "").trim() // hanya izinkan mkdir -p (aman, tanpa shell injection) - val mk = Regex("""mkdir\\s+(?:-p\\s+)?([\\w/.-]+)""").find(cmd) + val mk = Regex("mkdir\\s+(?:-p\\s+)?([\\w/.-]+)").find(cmd) if (mk != null) { val dir = java.io.File(mk.groupValues[1]) val ok = dir.mkdirs() || dir.isDirectory @@ -613,13 +624,4 @@ Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk fil } } -data class FileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) -data class ConversationMessage( - val role: String, - val content: String -) From 8e0f840998a5d48061d2b8905ea21f18720aa417 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 21:31:34 +0000 Subject: [PATCH 11/15] ci: bersihkan kapt stubs sebelum build (hindari duplicate class cache) --- .github/workflows/asm_build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/asm_build.yml b/.github/workflows/asm_build.yml index ee2efda313..5bacbf0df5 100644 --- a/.github/workflows/asm_build.yml +++ b/.github/workflows/asm_build.yml @@ -82,6 +82,9 @@ jobs: echo "signing.keyAlias=androidcs" >> local.properties echo "signing.keyPassword=${{ secrets.SIGNING_SK_PASSWORD }}" >> local.properties + - name: Clean kapt stubs (hindari duplicate class cache basi) + run: rm -rf core/app/build/tmp/kapt3 core/app/build/generated/source/kapt* + - name: Assemble APK env: SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} From 37f2c7082e41fe5bd19c16489d0a8960a116fe82 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 22:16:05 +0000 Subject: [PATCH 12/15] fix: hint string tanpa triple-quote+em-dash (rusak kapt stub generator) --- .../artificial/agents/custom/CustomEndpoint.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 index 8dc798ec2a..a7fe15ff20 100644 --- 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 @@ -478,15 +478,12 @@ class CustomEndpoint : AIAgent { override fun isInitialized(): Boolean = apiKey != null companion object { - private const val CUSTOM_TOOLS_HINT = """ - -[ TOOL CALLS — BISA DIEKSEKUSI LANGSUNG ] -Selain FILE_TO_MODIFY, kamu juga bisa memakai format ini (langsung dijalankan app, hasilnya balik ke kamu): - -isi file... - -Aturan: SATU format per aksi (jangan campur FILE_TO_MODIFY + tool_call untuk file yang sama). mkdir hanya untuk folder. writefile untuk tulis file baru/penuh. -""" + private val CUSTOM_TOOLS_HINT = "\n[ TOOL CALLS ]\n" + + "Format eksekusi langsung (hasil balik ke kamu):\n" + + "mkdir: \n" + + "tulis: isi\n" + + "baca: \n" + + "SATU format per aksi. mkdir folder saja, writefile file baru/penuh.\n" } // ---- persist chat history ke SharedPreferences (max 40 pesan, survive restart) ---- From 40aca7cb5328a351ab34c55e47222a0d68169612 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 22:44:47 +0000 Subject: [PATCH 13/15] fix: gabung 2 companion object jadi 1 (kapt 1.9 rusak parse 2 companion) --- .../artificial/agents/custom/CustomEndpoint.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index a7fe15ff20..fc5d9e48f1 100644 --- 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 @@ -51,6 +51,13 @@ class CustomEndpoint : AIAgent { override val providerName = "Custom Endpoint" companion object { + private val CUSTOM_TOOLS_HINT = "\n[ TOOL CALLS ]\n" + + "Format eksekusi langsung (hasil balik ke kamu):\n" + + "mkdir: mkdir path=/absolut/folder\n" + + "tulis: writefile path=/absolut/f.kt + content\n" + + "baca: readfile path=/absolut/f.kt\n" + + "SATU format per aksi. mkdir folder saja, writefile file baru/penuh.\n" + fun registerAgent() { AIAgentRegistry.register("custom", object : AIAgentRegistry.AgentFactory { override fun create(context: Context): AIAgent { @@ -477,14 +484,7 @@ class CustomEndpoint : AIAgent { override fun isInitialized(): Boolean = apiKey != null - companion object { - private val CUSTOM_TOOLS_HINT = "\n[ TOOL CALLS ]\n" + - "Format eksekusi langsung (hasil balik ke kamu):\n" + - "mkdir: \n" + - "tulis: isi\n" + - "baca: \n" + - "SATU format per aksi. mkdir folder saja, writefile file baru/penuh.\n" - } + // ---- persist chat history ke SharedPreferences (max 40 pesan, survive restart) ---- private val histKey: String get() = "ai_agent_custom_history" From 2c08484391f7850801efbcc8c7aa19792e57954c Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 23:00:20 +0000 Subject: [PATCH 14/15] fix: histKey getter jadi fungsi biasa (kapt 1.9 gagal parse custom getter) --- .../tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index fc5d9e48f1..01923c8ad0 100644 --- 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 @@ -109,7 +109,7 @@ class CustomEndpoint : AIAgent { override fun clearConversation() { conversationHistory.clear() - try { prefs().putString(histKey, "") } catch (_: Exception) { } + try { prefs().putString(histKey(), "") } catch (_: Exception) { } modificationHistory.clear() currentAttemptCount = 0 } @@ -487,7 +487,7 @@ class CustomEndpoint : AIAgent { // ---- persist chat history ke SharedPreferences (max 40 pesan, survive restart) ---- - private val histKey: String get() = "ai_agent_custom_history" + private fun histKey(): String = "ai_agent_custom_history" private fun prefs() = com.tom.rv2ide.preferences.internal.prefManager @@ -498,13 +498,13 @@ class CustomEndpoint : AIAgent { for (m in take) { json.put(org.json.JSONObject().put("role", m.role).put("content", m.content.take(4000))) } - prefs().putString(histKey, json.toString()) + prefs().putString(histKey(), json.toString()) } catch (_: Exception) { } } private fun restoreHistory() { try { - val raw = prefs().getString(histKey, "") + val raw = prefs().getString(histKey(), "") if (raw.isBlank()) return val json = org.json.JSONArray(raw) conversationHistory.clear() From f7671225538560c5797af782f2e494c0527fbc54 Mon Sep 17 00:00:00 2001 From: ByteAgent Date: Tue, 13 Oct 2026 23:22:25 +0000 Subject: [PATCH 15/15] fix: hapus em-dash + named catch param (kapt 1.9 stub gagal parse) --- .../tom/rv2ide/artificial/agents/custom/CustomEndpoint.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 01923c8ad0..f86016187a 100644 --- 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 @@ -109,7 +109,7 @@ class CustomEndpoint : AIAgent { override fun clearConversation() { conversationHistory.clear() - try { prefs().putString(histKey(), "") } catch (_: Exception) { } + try { prefs().putString(histKey(), "") } catch (ignored: Exception) { } modificationHistory.clear() currentAttemptCount = 0 } @@ -256,7 +256,7 @@ class CustomEndpoint : AIAgent { val toolResults = parseAndExecuteToolCalls(response) val storedResponse = if (toolResults.isNotEmpty()) { response + "\n\n[TOOL RESULTS]\n" + toolResults.joinToString("\n") { r -> - "- ${r.function}: ${if (r.success) "OK" else "FAIL"} — ${r.message}" + "- ${r.function}: ${if (r.success) "OK" else "FAIL"} - ${r.message}" } } else response conversationHistory.add(ConversationMessage("user", prompt)) @@ -499,7 +499,7 @@ class CustomEndpoint : AIAgent { json.put(org.json.JSONObject().put("role", m.role).put("content", m.content.take(4000))) } prefs().putString(histKey(), json.toString()) - } catch (_: Exception) { } + } catch (ignored: Exception) { } } private fun restoreHistory() { @@ -512,7 +512,7 @@ class CustomEndpoint : AIAgent { val o = json.getJSONObject(i) conversationHistory.add(ConversationMessage(o.optString("role", "user"), o.optString("content", ""))) } - } catch (_: Exception) { } + } catch (ignored: Exception) { } } // ---- tool_call XML: + writefile ----