diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt index b35bdac8f2..90a1804343 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/AIAgent.kt @@ -20,6 +20,10 @@ package com.tom.rv2ide.artificial.agents import android.content.Context import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol /* * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null @@ -53,6 +57,34 @@ interface AIAgent { fun writeFile(filePath: String, content: String): FileWriteResult fun isInitialized(): Boolean + + /** Native tool/function-calling support (optional). */ + fun supportsNativeTools(): Boolean = false + + suspend fun generateWithTools( + prompt: String, + context: String?, + language: String, + projectStructure: String?, + tools: List + ): Result { + return generateCode(prompt, context, language, projectStructure).map { text -> + val tc = ToolCallProtocol.parse(text) + if (tc != null) ToolAwareResponse("", listOf(tc)) else ToolAwareResponse(text) + } + } + + suspend fun submitToolResults( + toolResults: List, + tools: List + ): Result { + val prompt = buildString { + append("=== TOOL RESULTS ===\n") + toolResults.forEach { tr -> append("Tool: ${tr.name}\nResult:\n${tr.result}\n\n") } + append("Continue. If you need another tool, call it. Otherwise provide the final answer and use FILE_TO_MODIFY for file edits.") + } + return generateWithTools(prompt, null, "kotlin", null, tools) + } } data class ModificationAttempt( @@ -62,4 +94,4 @@ data class ModificationAttempt( val newContent: String, val attemptNumber: Int = 0, val success: Boolean = false -) \ No newline at end of file +) 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..e45f9e107b 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,24 +24,30 @@ 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.CustomProvider import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor import com.tom.rv2ide.artificial.parser.SnippetParser import com.tom.rv2ide.artificial.permissions.AIPermissionManager +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore import com.tom.rv2ide.artificial.project.awareness.ProjectData -import com.tom.rv2ide.artificial.secrets.ApiKey +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol import java.io.File import kotlinx.coroutines.delay import com.tom.rv2ide.artificial.dialogs.ProviderSwitchDialog -/** - * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null - */ - class AIAgentManager(private val context: Context) { private val snippetParser = SnippetParser() private val permissionManager = AIPermissionManager(context) private var currentProjectRoot: File? = null + private var currentProjectTree: ProjectTreeResult? = null private var currentProviderId: String = "gemini" private var currentAgent: AIAgent? = null private val providerSwitchDialog = ProviderSwitchDialog(context) @@ -53,455 +59,221 @@ class AIAgentManager(private val context: Context) { Grok.registerAgent() DeepSeek.registerAgent() LocalLLM.registerAgent() - + CustomProvider.registerAgent() permissionManager.setFileWriteEnabled(true) permissionManager.setRequireConfirmation(false) - setProvider(currentProviderId) } fun getCurrentAgent(): AIAgent? = currentAgent fun setProvider(providerId: String): Boolean { - android.util.Log.d("AIAgentManager", "setProvider called with: $providerId") - - val factory = AIAgentRegistry.getFactory(providerId) - if (factory == null) { - android.util.Log.e("AIAgentManager", "No factory found for provider: $providerId") - return false - } - - if (!factory.hasValidApiKey()) { - android.util.Log.e("AIAgentManager", "No valid API key for provider: $providerId") - return false - } - + val factory = AIAgentRegistry.getFactory(providerId) ?: return false + if (!factory.hasValidApiKey()) return false currentProviderId = providerId currentAgent = factory.create(context) - android.util.Log.d("AIAgentManager", "Agent created: ${currentAgent != null}") - factory.getApiKey()?.let { apiKey -> - android.util.Log.d("AIAgentManager", "Initializing agent with API key") currentAgent?.initialize(apiKey, context) currentAgent?.setContext(context) - - currentProjectRoot?.let { root -> - val projectData = ProjectData(context) - val projectTree = projectData.showProjectTree(root) - currentAgent?.setProjectData(projectTree) - } - - android.util.Log.d("AIAgentManager", "Agent initialized: ${currentAgent?.isInitialized()}") + currentProjectTree?.let { currentAgent?.setProjectData(it) } } - return currentAgent?.isInitialized() ?: false } fun getCurrentProviderId(): String = currentProviderId + fun getCurrentProviderName(): String = currentAgent?.providerName ?: "Unknown" - fun getCurrentProviderName(): String { - return currentAgent?.providerName ?: "Unknown" - } - - fun getAvailableProviders(): List { - return AIAgentRegistry.getAvailableProviders().mapNotNull { providerId -> + fun getAvailableProviders(): List = + AIAgentRegistry.getAvailableProviders().mapNotNull { providerId -> val factory = AIAgentRegistry.getFactory(providerId) - val agent = factory?.create(context) - agent?.let { - ProviderInfo( - id = it.providerId, - name = it.providerName, - isAvailable = factory.hasValidApiKey() - ) - } + factory?.create(context)?.let { ProviderInfo(id = it.providerId, name = it.providerName, isAvailable = factory.hasValidApiKey()) } } - } fun setProjectRoot(projectPath: String): Boolean { val projectRoot = File(projectPath) if (!projectRoot.exists()) return false - currentProjectRoot = projectRoot - val projectData = ProjectData(context) - val projectTree = projectData.showProjectTree(projectRoot) - + val projectTree = ProjectData(context).showProjectTree(projectRoot) + currentProjectTree = projectTree currentAgent?.setProjectData(projectTree) permissionManager.addAllowedDirectory(projectRoot.absolutePath) - return true } - fun clearConversation() { - currentAgent?.clearConversation() + fun clearConversation() { currentAgent?.clearConversation() } + + private fun collectTools(): List { + val profile = AgentPromptProfileStore.getActive() ?: return emptyList() + val defs = mutableListOf() + if (profile.enableTools) { + val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile.allowCommandExecution, profile.disabledTools.toSet()) + defs += builtIn.definitions() + } + if (profile.enableMcpTools) { + try { + val servers = McpServerStore.load().filter { s -> profile.enabledMcpServerIds.isEmpty() || profile.enabledMcpServerIds.contains(s.id) } + defs += McpToolExecutor(servers).definitions().filterNot { it.name in profile.disabledTools.toSet() } + } catch (e: Exception) { android.util.Log.w("AIAgentManager", "MCP tool discovery failed", e) } + } + return defs.distinctBy { it.name } + } + + private fun executeToolCall(name: String, args: org.json.JSONObject): String { + val profile = AgentPromptProfileStore.getActive() + if (name.startsWith("mcp__")) { + if (profile?.enableMcpTools == false) return "Error: MCP tools disabled" + val servers = McpServerStore.load().filter { s -> profile?.enabledMcpServerIds.isNullOrEmpty() || profile!!.enabledMcpServerIds.contains(s.id) } + return McpToolExecutor(servers).execute(name, args) + } + if (profile?.enableTools == false) return "Error: built-in tools disabled" + val builtIn = BuiltInToolExecutor(context, currentProjectTree, currentProjectRoot, profile?.allowCommandExecution == true, profile?.disabledTools?.toSet() ?: emptySet()) + return builtIn.execute(name, args) } suspend fun executeRequest(userRequest: String, callback: AIAgentCallback) { var success = false var providerSwitched = false - currentAgent?.resetAttemptCount() callback.onProcessing("Analyzing your request...") while (!success && (currentAgent?.canRetry() == true)) { try { val currentAttempt = currentAgent?.getCurrentAttemptCount() ?: 0 - - if (currentAttempt > 0 && !providerSwitched) { - callback.onRetry(currentAttempt, "Thinking differently...") - delay(1000) - } - + if (currentAttempt > 0 && !providerSwitched) { callback.onRetry(currentAttempt, "Thinking differently..."); delay(1000) } val previousFileStates = captureCurrentFileStates() - - val result = currentAgent?.generateCode( - prompt = userRequest, - context = null, - language = "kotlin", - projectStructure = null - ) ?: Result.failure(Exception("No agent initialized")) - - result.fold( - onSuccess = { response -> - - if (response.contains("FILE_TO_MODIFY:")) { - callback.onProcessing("Modifying files...") - val modifications = processModifications(response, previousFileStates, callback) - - if (modifications.isNotEmpty()) { - val allSuccessful = modifications.all { it.writeResult is FileWriteResult.Success } - - if (allSuccessful) { - val results = modifications.map { mod -> - val isNewFile = !previousFileStates.containsKey(mod.filePath) - ModificationResult( - filePath = mod.filePath, - content = mod.content, - success = true, - message = "Modified successfully", - isNewFile = isNewFile - ) - } - - val summary = createSummary(results) - callback.onSuccess(response, results, summary) - success = true - } else { - callback.onProcessing("Some files failed. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } - } else { - callback.onProcessing("No files were modified. Retrying...") - currentAgent?.incrementAttemptCount() - delay(1500) - } - } else { - val summary = ModificationSummary(0, 0, 0, 0, 0, emptyList()) - callback.onTextResponse(response, summary) - success = true + val agent = currentAgent ?: throw Exception("No agent initialized") + val tools = collectTools() + val profile = AgentPromptProfileStore.getActive() + val toolsEnabled = (profile?.enableTools != false || profile?.enableMcpTools != false) && tools.isNotEmpty() + val maxToolIterations = 8 + var toolIterations = 0 + var finalResponse: String? = null + + if (toolsEnabled && agent.supportsNativeTools()) { + var res = agent.generateWithTools(userRequest, null, "kotlin", null, tools).getOrElse { throw it } + while (res.hasToolCalls() && toolIterations < maxToolIterations) { + val results = res.toolCalls.map { tc -> + toolIterations++ + callback.onProcessing("Using tool: ${tc.name} (${toolIterations}/$maxToolIterations)") + val out = try { executeToolCall(tc.name, tc.arguments) } catch (e: Exception) { "Error: ${e.message}" } + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + AIToolResult(tc.name, clipped, tc.id) } - }, - onFailure = { error -> - android.util.Log.e("AIAgentManager", "Error occurred: ${error.message}", error) - - val shouldSwitchProvider = error is com.tom.rv2ide.artificial.exceptions.RateLimitException || - error is com.tom.rv2ide.artificial.exceptions.QuotaExceededException || - error is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException || - error is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException - - if (shouldSwitchProvider && !providerSwitched) { - val currentProviderName = currentAgent?.providerName ?: "Unknown" - val errorMsg = error.message ?: "Unknown error" - - if (providerSwitchDialog.isAutoSwitchEnabled()) { - val alternativeProvider = getAlternativeProvider() - if (alternativeProvider != null) { - callback.onProcessing("āš ļø $currentProviderName: $errorMsg") - callback.onProcessing("šŸ”„ Auto-switching to another provider...") - delay(1500) - - if (setProvider(alternativeProvider)) { - providerSwitched = true - currentAgent?.resetAttemptCount() - - val newProviderName = currentAgent?.providerName ?: "Unknown" - callback.onProcessing("āœ… Switched to $newProviderName") - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("$errorDisplay\n\nāŒ Failed to switch providers.") - success = true - } - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("$errorDisplay\n\nāŒ No alternative providers available.") - success = true - } - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError("PROVIDER_SWITCH_REQUIRED::$errorDisplay") - success = true - } - } else if ((currentAgent?.canRetry() == true) && !providerSwitched) { - callback.onRetry( - currentAgent?.getCurrentAttemptCount() ?: 0, - "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying..." - ) - currentAgent?.incrementAttemptCount() - delay(1500) - } else { - val errorDisplay = formatErrorMessage(error) - callback.onError(errorDisplay) - success = true - } - } - ) - } catch (e: Exception) { - android.util.Log.e("AIAgentManager", "Exception occurred: ${e.message}", e) - - if (currentAgent?.canRetry() == true) { - callback.onRetry( - currentAgent?.getCurrentAttemptCount() ?: 0, - "Exception: ${e.message?.take(50) ?: "Unknown"}. Trying again..." - ) - currentAgent?.incrementAttemptCount() - delay(1500) + res = agent.submitToolResults(results, tools).getOrElse { throw it } + } + finalResponse = if (res.text.isNotBlank()) res.text else if (res.hasToolCalls()) "Tool iteration limit reached." else "" } else { - val errorDisplay = formatErrorMessage(e) - callback.onError(errorDisplay) - success = true + var promptForModel = userRequest + while (toolIterations < maxToolIterations) { + val response = agent.generateCode(promptForModel, null, "kotlin", null).getOrElse { throw it } + val toolCall = if (toolsEnabled) ToolCallProtocol.parse(response) else null + if (toolCall != null) { + toolIterations++ + callback.onProcessing("Using tool: ${toolCall.name} (${toolIterations}/$maxToolIterations)") + val out = try { executeToolCall(toolCall.name, toolCall.arguments) } catch (e: Exception) { "Error: ${e.message}" } + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + promptForModel = "TOOL_RESULT for ${toolCall.name}:\n$clipped\n\nContinue. If you need another tool, emit TOOL_CALL. Otherwise provide the final answer and use FILE_TO_MODIFY for file edits." + continue + } + finalResponse = response + break + } } - } - } - if (!success) { - val attemptCount = currentAgent?.getCurrentAttemptCount() ?: 0 - val agentName = currentAgent?.providerName ?: "No agent initialized" - callback.onError("Failed after $attemptCount attempts with $agentName.\n\nPlease check your API key and try again.") - undoLastModification() + val response = finalResponse ?: "Tool iteration limit reached. Please try a more specific request." + if (response.contains("FILE_TO_MODIFY:")) { + callback.onProcessing("Modifying files...") + val modifications = processModifications(response, previousFileStates, callback) + if (modifications.isNotEmpty()) { + val allSuccessful = modifications.all { it.writeResult is FileWriteResult.Success } + if (allSuccessful) { + val results = modifications.map { mod -> ModificationResult(mod.filePath, mod.content, true, "Modified successfully", !previousFileStates.containsKey(mod.filePath)) } + callback.onSuccess(response, results, createSummary(results)); success = true + } else { callback.onProcessing("Some files failed. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) } + } else { callback.onProcessing("No files were modified. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) } + } else { callback.onTextResponse(response, ModificationSummary(0, 0, 0, 0, 0, emptyList())); success = true } + } catch (error: Exception) { + val shouldSwitchProvider = error is com.tom.rv2ide.artificial.exceptions.RateLimitException || + error is com.tom.rv2ide.artificial.exceptions.QuotaExceededException || + error is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException || + error is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException + if (shouldSwitchProvider && !providerSwitched) { + if (providerSwitchDialog.isAutoSwitchEnabled()) { + val alternativeProvider = getAlternativeProvider() + if (alternativeProvider != null && setProvider(alternativeProvider)) { providerSwitched = true; currentAgent?.resetAttemptCount(); callback.onProcessing("āœ… Switched to ${currentAgent?.providerName ?: "Unknown"}") } + else { callback.onError("${formatErrorMessage(error)}\n\nāŒ Failed to switch providers."); success = true } + } else { callback.onError("PROVIDER_SWITCH_REQUIRED::${formatErrorMessage(error)}"); success = true } + } else if ((currentAgent?.canRetry() == true) && !providerSwitched) { + callback.onRetry(currentAgent?.getCurrentAttemptCount() ?: 0, "Error: ${error.message?.take(50) ?: "Unknown error"}. Retrying..."); currentAgent?.incrementAttemptCount(); delay(1500) + } else { callback.onError(formatErrorMessage(error)); success = true } + } } + if (!success) { callback.onError("Failed after ${currentAgent?.getCurrentAttemptCount() ?: 0} attempts with ${currentAgent?.providerName ?: "No agent initialized"}."); undoLastModification() } } - private fun getAlternativeProvider(): String? { - val availableProviders = AIAgentRegistry.getAvailableProviders() - return availableProviders.firstOrNull { it != currentProviderId } - } + private fun getAlternativeProvider(): String? = AIAgentRegistry.getAvailableProviders().firstOrNull { it != currentProviderId } - private suspend fun processModifications( - response: String, - previousFileStates: Map, - callback: AIAgentCallback - ): List { + private suspend fun processModifications(response: String, previousFileStates: Map, callback: AIAgentCallback): List { val modifications = mutableListOf() val 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 fileName = File(currentFile).name - callback.onFileModifying(currentFile, fileName) - - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = previousFileStates[currentFile] - - val writeResult = currentAgent?.writeFile(currentFile, cleanedContent) - ?: FileWriteResult.Error("No agent initialized") - - val success = writeResult is FileWriteResult.Success - currentAgent?.recordModification(currentFile, previousContent, cleanedContent, success) - - callback.onFileModified(currentFile, fileName, success) - delay(300) - - modifications.add(BaseFileModification(currentFile, cleanedContent, writeResult)) - } - - currentFile = line.substringAfter("FILE_TO_MODIFY:").trim() - contentBuilder.clear() - inContent = true - } else if (inContent) { - contentBuilder.append(line).append("\n") + val lines = response.lines(); var currentFile: String? = null; val contentBuilder = StringBuilder(); var inContent = false + fun flush(file: String?, content: StringBuilder) { + if (file != null && content.isNotEmpty()) { + val fileName = File(file).name; callback.onFileModifying(file, fileName) + val cleanedContent = parser.cleanFileContent(content.toString().trim()) + val previousContent = previousFileStates[file] + val writeResult = currentAgent?.writeFile(file, cleanedContent) ?: FileWriteResult.Error("No agent initialized") + val ok = writeResult is FileWriteResult.Success + currentAgent?.recordModification(file, previousContent, cleanedContent, ok) + callback.onFileModified(file, fileName, ok) + modifications.add(BaseFileModification(file, cleanedContent, writeResult)) } } - - if (currentFile != null && contentBuilder.isNotEmpty()) { - val fileName = File(currentFile).name - callback.onFileModifying(currentFile, fileName) - - val rawContent = contentBuilder.toString().trim() - val cleanedContent = parser.cleanFileContent(rawContent) - val previousContent = previousFileStates[currentFile] - - val writeResult = currentAgent?.writeFile(currentFile, cleanedContent) - ?: FileWriteResult.Error("No agent initialized") - - val success = writeResult is FileWriteResult.Success - currentAgent?.recordModification(currentFile, previousContent, cleanedContent, success) - - callback.onFileModified(currentFile, fileName, success) - delay(300) - - modifications.add(BaseFileModification(currentFile, cleanedContent, writeResult)) + for (line in lines) { + if (line.startsWith("FILE_TO_MODIFY:")) { flush(currentFile, contentBuilder); currentFile = line.substringAfter("FILE_TO_MODIFY:").trim(); contentBuilder.clear(); inContent = true } + else if (inContent) contentBuilder.append(line).append("\n") } + flush(currentFile, contentBuilder) } - return modifications } private fun formatErrorMessage(error: Throwable): String { val errorMessage = error.message ?: "Unknown error occurred" - val stackTrace = error.stackTraceToString().take(500) val providerName = currentAgent?.providerName ?: "Unknown" - return when (error) { - is com.tom.rv2ide.artificial.exceptions.RateLimitException -> - "āš ļø RATE LIMIT EXCEEDED\n\nThe API rate limit has been exceeded.\nPlease wait a few minutes before trying again.\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.QuotaExceededException -> - "āš ļø QUOTA EXCEEDED\n\nYour API quota has been exhausted.\nPlease check your billing or upgrade your plan.\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException -> - "šŸ’³ INSUFFICIENT BALANCE\n\nYour account balance is too low to process this request.\nPlease add credits or upgrade your plan.\n\nProvider: $providerName\n\nDetails: $errorMessage" - is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException -> - "āŒ INVALID API KEY\n\nThe API key is invalid or expired.\nPlease update your API key in the configuration.\n\nDetails: $errorMessage" - is java.net.UnknownHostException -> - "🌐 NETWORK ERROR\n\nCould not connect to the API server.\nPlease check your internet connection.\n\nDetails: $errorMessage" - is java.net.SocketTimeoutException -> - "ā±ļø TIMEOUT ERROR\n\nThe request took too long to complete.\nPlease try again.\n\nDetails: $errorMessage" - is org.json.JSONException -> - "šŸ“„ JSON PARSING ERROR\n\nFailed to parse API response.\nThe API may be experiencing issues.\n\nDetails: $errorMessage" - else -> - "āŒ ERROR OCCURRED\n\nProvider: $providerName\nError Type: ${error.javaClass.simpleName}\n\nMessage: $errorMessage\n\nStack Trace (first 500 chars):\n$stackTrace" + is com.tom.rv2ide.artificial.exceptions.RateLimitException -> "āš ļø RATE LIMIT EXCEEDED\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.QuotaExceededException -> "āš ļø QUOTA EXCEEDED\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.InsufficientBalanceException -> "šŸ’³ INSUFFICIENT BALANCE\n\nProvider: $providerName\n\n$errorMessage" + is com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException -> "āŒ INVALID API KEY\n\n$errorMessage" + is java.net.UnknownHostException -> "🌐 NETWORK ERROR\n\n$errorMessage" + is java.net.SocketTimeoutException -> "ā±ļø TIMEOUT ERROR\n\n$errorMessage" + is org.json.JSONException -> "šŸ“„ JSON PARSING ERROR\n\n$errorMessage" + else -> "āŒ ERROR OCCURRED\n\nProvider: $providerName\nError: ${error.javaClass.simpleName}\n\n$errorMessage" } } - fun showProviderErrorDialogFromFragment( - activity: android.app.Activity, - errorMessage: String, - onProviderSelected: (String) -> Unit - ) { + fun showProviderErrorDialogFromFragment(activity: android.app.Activity, errorMessage: String, onProviderSelected: (String) -> Unit) { val currentProviderName = currentAgent?.providerName ?: "Unknown" - val availableProviders = getAvailableProviders() - .filter { it.id != currentProviderId && it.isAvailable } - .map { Pair(it.id, it.name) } - - providerSwitchDialog.showProviderErrorDialog( - currentProviderName, - errorMessage, - availableProviders, + val availableProviders = getAvailableProviders().filter { it.id != currentProviderId && it.isAvailable }.map { Pair(it.id, it.name) } + providerSwitchDialog.showProviderErrorDialog(currentProviderName, errorMessage, availableProviders, onProviderSelected = { providerId -> - setProvider(providerId) - val agents = Agents(context) - val availableModels = agents.getModelsForProvider(providerId) - if (availableModels.isNotEmpty()) { - agents.setAgent(availableModels[0]) - } - reinitializeWithSelectedModel() - onProviderSelected(providerId) + setProvider(providerId); val agents = Agents(context); val availableModels = agents.getModelsForProvider(providerId) + if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]); reinitializeWithSelectedModel(); onProviderSelected(providerId) }, - onEnableAutoSwitch = { - val alternativeProvider = getAlternativeProvider() - if (alternativeProvider != null) { - setProvider(alternativeProvider) - val agents = Agents(context) - val availableModels = agents.getModelsForProvider(alternativeProvider) - if (availableModels.isNotEmpty()) { - agents.setAgent(availableModels[0]) - } - reinitializeWithSelectedModel() - } - } - ) - } - - fun isAutoSwitchEnabled(): Boolean { - return providerSwitchDialog.isAutoSwitchEnabled() - } - - fun setAutoSwitch(enabled: Boolean) { - providerSwitchDialog.setAutoSwitch(enabled) - } - - private fun createSummary(results: List): ModificationSummary { - val successful = results.count { it.success } - val failed = results.count { !it.success } - val newFiles = results.count { it.isNewFile } - val modifiedFiles = results.count { !it.isNewFile } - - val fileDetails = results.map { result -> - FileDetail( - fileName = File(result.filePath).name, - filePath = result.filePath, - status = if (result.success) FileStatus.SUCCESS else FileStatus.FAILED, - changeType = if (result.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED - ) - } - - return ModificationSummary( - totalFiles = results.size, - successfulFiles = successful, - failedFiles = failed, - newFiles = newFiles, - modifiedFiles = modifiedFiles, - fileDetails = fileDetails + onEnableAutoSwitch = { getAlternativeProvider()?.let { setProvider(it); val agents = Agents(context); val availableModels = agents.getModelsForProvider(it); if (availableModels.isNotEmpty()) agents.setAgent(availableModels[0]); reinitializeWithSelectedModel() } } ) } - - private fun captureCurrentFileStates(): Map { - val states = mutableMapOf() - val projectRoot = currentProjectRoot ?: return states - - if (!projectRoot.exists()) return states - - projectRoot.walkTopDown() - .filter { it.isFile } - .filter { - it.extension in listOf("kt", "java", "xml", "gradle", "kts") && - !it.path.contains("/build/") && - !it.path.contains("/.gradle/") - } - .forEach { file -> - try { - states[file.absolutePath] = file.readText() - } catch (e: Exception) { - } - } - - return states - } - - fun undoLastModification(): Boolean { - return currentAgent?.undoLastModification() ?: false - } - - fun reinitializeWithSelectedModel() { - val factory = AIAgentRegistry.getFactory(currentProviderId) - factory?.getApiKey()?.let { apiKey -> - currentAgent?.reinitializeWithNewModel(apiKey, context) - } - } - - fun getCurrentModelName(): String { - val agents = Agents(context) - return agents.getAgent() - } - fun getConversationHistory(): List { - return currentAgent?.getModificationHistory()?.map { - UnifiedModificationAttempt( - timestamp = it.timestamp, - filePath = it.filePath, - previousContent = it.previousContent, - newContent = it.newContent, - attemptNumber = it.attemptNumber, - success = it.success - ) - } ?: emptyList() - } + fun isAutoSwitchEnabled(): Boolean = providerSwitchDialog.isAutoSwitchEnabled() + fun setAutoSwitch(enabled: Boolean) = providerSwitchDialog.setAutoSwitch(enabled) + private fun createSummary(results: List): ModificationSummary = ModificationSummary(results.size, results.count { it.success }, results.count { !it.success }, results.count { it.isNewFile }, results.count { !it.isNewFile }, results.map { FileDetail(File(it.filePath).name, it.filePath, if (it.success) FileStatus.SUCCESS else FileStatus.FAILED, if (it.isNewFile) ChangeType.CREATED else ChangeType.MODIFIED) }) + private fun captureCurrentFileStates(): Map { val states = mutableMapOf(); val projectRoot = currentProjectRoot ?: return states; if (!projectRoot.exists()) return states; projectRoot.walkTopDown().filter { it.isFile }.filter { it.extension in listOf("kt","java","xml","gradle","kts") && !it.path.contains("/build/") && !it.path.contains("/.gradle/") }.forEach { file -> try { states[file.absolutePath] = file.readText() } catch (_: Exception) {} }; return states } + fun undoLastModification(): Boolean = currentAgent?.undoLastModification() ?: false + fun reinitializeWithSelectedModel() { AIAgentRegistry.getFactory(currentProviderId)?.getApiKey()?.let { currentAgent?.reinitializeWithNewModel(it, context) } } + fun getCurrentModelName(): String = Agents(context).getAgent() + fun getConversationHistory(): List = currentAgent?.getModificationHistory()?.map { UnifiedModificationAttempt(it.timestamp, it.filePath, it.previousContent, it.newContent, it.attemptNumber, it.success) } ?: emptyList() interface AIAgentCallback { fun onProcessing(message: String) @@ -512,52 +284,13 @@ class AIAgentManager(private val context: Context) { fun onError(message: String) fun onRetry(attemptNumber: Int, message: String) } - - data class ModificationResult( - val filePath: String, - val content: String, - val success: Boolean, - val message: String, - val isNewFile: Boolean = false - ) - - data class ModificationSummary( - val totalFiles: Int, - val successfulFiles: Int, - val failedFiles: Int, - val newFiles: Int, - val modifiedFiles: Int, - val fileDetails: List - ) - - data class FileDetail( - val fileName: String, - val filePath: String, - val status: FileStatus, - val changeType: ChangeType - ) - + data class ModificationResult(val filePath: String, val content: String, val success: Boolean, val message: String, val isNewFile: Boolean = false) + data class ModificationSummary(val totalFiles: Int, val successfulFiles: Int, val failedFiles: Int, val newFiles: Int, val modifiedFiles: Int, val fileDetails: List) + data class FileDetail(val fileName: String, val filePath: String, val status: FileStatus, val changeType: ChangeType) enum class FileStatus { SUCCESS, FAILED } enum class ChangeType { CREATED, MODIFIED } - - data class ProviderInfo( - val id: String, - val name: String, - val isAvailable: Boolean - ) + data class ProviderInfo(val id: String, val name: String, val isAvailable: Boolean) } -data class BaseFileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) - -data class UnifiedModificationAttempt( - val timestamp: Long, - val filePath: String, - val previousContent: String?, - val newContent: String, - val attemptNumber: Int = 0, - val success: Boolean = false -) \ No newline at end of file +data class BaseFileModification(val filePath: String, val content: String, val writeResult: FileWriteResult) +data class UnifiedModificationAttempt(val timestamp: Long, val filePath: String, val previousContent: String?, val newContent: String, val attemptNumber: Int = 0, val success: Boolean = 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..5341eae2c4 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,12 @@ class Agents(ctx: Context) { private val localllm_models = arrayOf( "local-model" ) + + private val custom_models = arrayOf( + "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 +158,7 @@ class Agents(ctx: Context) { "deepseek" -> deepseek_models "grok" -> grok_models "localllm" -> localllm_models + "custom" -> custom_models else -> gemini_models } } @@ -166,6 +171,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 } } @@ -177,6 +183,7 @@ class Agents(ctx: Context) { name in claude_models -> "claude" name in deepseek_models -> "deepseek" name in grok_models -> "grok" + name in custom_models -> "custom" else -> sp.getString(PROVIDER_KEY, "gemini") ?: "gemini" } @@ -194,6 +201,7 @@ class Agents(ctx: Context) { "claude" -> "claude-sonnet-4-20250514" "deepseek" -> "deepseek-chat" "grok" -> "grok-beta" + "custom" -> "custom-model" else -> "gemini-2.5-pro" } } @@ -209,4 +217,4 @@ class Agents(ctx: Context) { fun isValidModelForProvider(modelName: String, providerId: String): Boolean { return modelName in getModelsForProvider(providerId) } -} \ No newline at end of file +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt index 03259fc4ee..a17b5b1e79 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/anthropic/Anthropic.kt @@ -21,6 +21,15 @@ 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.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -51,6 +60,9 @@ class Anthropic : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "claude-sonnet-4-20250514" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "claude" override val providerName = "Anthropic Claude" @@ -97,6 +109,7 @@ class Anthropic : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -105,6 +118,7 @@ class Anthropic : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -460,6 +474,110 @@ class Anthropic : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + + private fun anthropicToolsJson(tools: List): JSONArray { + val arr = JSONArray() + tools.forEach { t -> + arr.put(JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("input_schema", try { JSONObject(t.parametersJsonSchema) } catch (_: Exception) { JSONObject().put("type","object").put("properties", JSONObject()) }) + }) + } + return arr + } + + private fun callAnthropicNative(messages: JSONArray, system: String, tools: List?): ToolAwareResponse { + val key = apiKey ?: throw IllegalStateException("Anthropic service not initialized") + val url = URL("https://api.anthropic.com/v1/messages") + val connection = url.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("x-api-key", key) + connection.setRequestProperty("anthropic-version", "2023-06-01") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 90000 + val requestBody = JSONObject().apply { + put("model", selectedModel) + put("max_tokens", 4096) + put("system", system) + put("messages", messages) + if (!tools.isNullOrEmpty()) put("tools", anthropicToolsJson(tools)) + } + connection.outputStream.use { it.write(requestBody.toString().toByteArray()) } + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + val errorStream = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + throw Exception("Anthropic API error ($responseCode): $errorStream") + } + val jsonResponse = JSONObject(connection.inputStream.bufferedReader().readText()) + val content = jsonResponse.optJSONArray("content") ?: JSONArray() + val sb = StringBuilder() + val calls = mutableListOf() + for (i in 0 until content.length()) { + val block = content.optJSONObject(i) ?: continue + when (block.optString("type")) { + "text" -> sb.append(block.optString("text")) + "tool_use" -> { + val name = block.optString("name") + val input = block.optJSONObject("input") ?: JSONObject() + if (name.isNotBlank()) calls.add(AIToolCall(name, input, block.toString(), block.optString("id").ifBlank { null })) + } + } + } + val text = sb.toString() + if (calls.isEmpty()) ToolCallProtocol.parse(text)?.let { calls.add(it) } + return ToolAwareResponse(text, calls) + } finally { connection.disconnect() } + } + + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = callAnthropicNative(nativeMessages, composeSystemPrompt(ctx, false), tools) + // store assistant message with content blocks + val assistantContent = JSONArray() + if (res.text.isNotBlank()) assistantContent.put(JSONObject().put("type","text").put("text", res.text)) + res.toolCalls.forEach { tc -> assistantContent.put(JSONObject().put("type","tool_use").put("id", tc.id ?: "toolu_${tc.name}_${System.nanoTime()}").put("name", tc.name).put("input", tc.arguments)) } + if (assistantContent.length() > 0) nativeMessages.put(JSONObject().put("role","assistant").put("content", assistantContent)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + val resultBlocks = JSONArray() + toolResults.forEach { tr -> resultBlocks.put(JSONObject().put("type","tool_result").put("tool_use_id", tr.toolCallId ?: tr.name).put("content", tr.result)) } + nativeMessages.put(JSONObject().put("role","user").put("content", resultBlocks)) + val res = callAnthropicNative(nativeMessages, composeSystemPrompt(ctx, false), tools) + val assistantContent = JSONArray() + if (res.text.isNotBlank()) assistantContent.put(JSONObject().put("type","text").put("text", res.text)) + res.toolCalls.forEach { tc -> assistantContent.put(JSONObject().put("type","tool_use").put("id", tc.id ?: "toolu_${tc.name}_${System.nanoTime()}").put("name", tc.name).put("input", tc.arguments)) } + if (assistantContent.length() > 0) nativeMessages.put(JSONObject().put("role","assistant").put("content", assistantContent)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt new file mode 100644 index 0000000000..308461c31a --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/custom/CustomProvider.kt @@ -0,0 +1,234 @@ +/* + * 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.Agents +import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.file.AIFileWriter +import com.tom.rv2ide.artificial.file.FileWriteResult +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.profiles.CustomProviderProfileStore +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.rules.WritingRules +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import java.io.File +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +class CustomProvider : AIAgent { + private var baseUrl: String? = null + private var modelName: String? = null + private var apiKey: String? = null + private val httpClient = OkHttpClient.Builder().connectTimeout(60, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS).writeTimeout(60, TimeUnit.SECONDS).build() + 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 appContext: Context? = null + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + override val providerId = "custom" + override val providerName = "Custom Provider" + + companion object { + fun registerAgent() { + AIAgentRegistry.register("custom", object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent = CustomProvider() + override fun hasValidApiKey(): Boolean { val p = CustomProviderProfileStore.getActive(); return p != null && p.baseUrl.isNotBlank() && p.modelName.isNotBlank() } + override fun getApiKey(): String? { val p = CustomProviderProfileStore.getActive() ?: return null; return if (p.apiKey.isBlank()) "custom" else p.apiKey } + }) + } + private fun buildEndpoint(baseUrl: String): String { + val trimmed = baseUrl.trim().trimEnd('/') + return when { trimmed.contains("/chat/completions") -> trimmed; trimmed.endsWith("/v1") -> "$trimmed/chat/completions"; else -> "$trimmed/v1/chat/completions" } + } + } + + override fun initialize(apiKey: String, context: Context) { + appContext = context.applicationContext + val profile = CustomProviderProfileStore.getActive() + baseUrl = profile?.baseUrl; modelName = profile?.modelName; this.apiKey = profile?.apiKey + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Custom provider not configured. Add/select a custom provider profile.") + } + override fun isInitialized(): Boolean = !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() + override fun reinitializeWithNewModel(apiKey: String, context: Context) { initialize(apiKey, context) } + override fun setContext(context: Context) { appContext = context.applicationContext; fileWriter = AIFileWriter(context) } + override fun setProjectData(projectTreeResult: ProjectTreeResult) { this.projectTreeResult = projectTreeResult } + override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0; clearNative() } + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, 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 (_: Exception) {} } + return false + } + override fun getModificationHistory(): List = 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 = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working").any { message.lowercase().contains(it) } + private fun composeSystemPrompt(context: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + + private fun buildUserPrompt(prompt: String, context: String?): String { + val fileContents = readRelevantFiles() + val needsCorrection = isUserRequestingCorrection(prompt) + return buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { append(projectTreeResult!!.tree); append("\n\nCRITICAL: Use ONLY the paths shown above. Do NOT make up fake paths.\n\n") } + if (fileContents.isNotEmpty()) { append("=== CURRENT FILES CONTENT ===\n"); fileContents.forEach { (path, content) -> append("FILE: $path\nCONTENT:\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.takeLast(8).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(3000)}\n\n") } } + if (needsCorrection && modificationHistory.isNotEmpty()) { append("=== CORRECTION REQUIRED ===\nTry a COMPLETELY DIFFERENT approach.\n\n") } + if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n\n") + append("=== USER REQUEST ===\n").append(prompt) + } + } + + override suspend fun generateCode(prompt: String, context: String?, language: String, projectStructure: String?): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative() + val response = callChat(buildMessages(composeSystemPrompt(ctx, includeTextToolDefs = true), buildUserPrompt(prompt, context)), tools = null) + conversationHistory.add(ConversationMessage("user", prompt)); conversationHistory.add(ConversationMessage("assistant", response.text)) + while (conversationHistory.size > 20) conversationHistory.removeAt(0) + Result.success(response.text) + } catch (e: Exception) { Result.failure(e) } + } + + override fun supportsNativeTools(): Boolean = true + + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role", "system").put("content", composeSystemPrompt(ctx, includeTextToolDefs = false))) + nativeMessages.put(JSONObject().put("role", "user").put("content", buildUserPrompt(prompt, context))) + val res = callChat(nativeMessages, tools) + if (res.hasToolCalls()) nativeMessages.put(assistantToolCallMessage(res)) else nativeMessages.put(JSONObject().put("role","assistant").put("content", res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n") { it.result }, null, "kotlin", null, tools) + toolResults.forEach { tr -> + nativeMessages.put(JSONObject().apply { + put("role", "tool") + put("tool_call_id", tr.toolCallId ?: tr.name) + put("content", tr.result) + }) + } + val res = callChat(nativeMessages, tools) + if (res.hasToolCalls()) nativeMessages.put(assistantToolCallMessage(res)) else nativeMessages.put(JSONObject().put("role","assistant").put("content", res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + + private fun assistantToolCallMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { + put("role", "assistant"); put("content", res.text) + val arr = JSONArray() + res.toolCalls.forEach { tc -> + arr.put(JSONObject().apply { + put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}") + put("type", "function") + put("function", JSONObject().apply { put("name", tc.name); put("arguments", tc.arguments.toString()) }) + }) + } + put("tool_calls", arr) + } + + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + private fun buildMessages(system: String, user: String): JSONArray = JSONArray().apply { put(JSONObject().put("role","system").put("content", system)); put(JSONObject().put("role","user").put("content", user)) } + + private fun callChat(messages: JSONArray, tools: List?): ToolAwareResponse { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Custom provider not configured") + val requestBody = JSONObject().apply { + put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false) + if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice", "auto") } + } + val requestBuilder = Request.Builder().url(buildEndpoint(baseUrl!!)).post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type", "application/json") + val key = apiKey?.trim(); if (!key.isNullOrEmpty()) requestBuilder.addHeader("Authorization", "Bearer $key") + val response = try { httpClient.newCall(requestBuilder.build()).execute() } catch (e: Exception) { throw Exception("Connection error: ${e.message}", e) } + if (!response.isSuccessful) { val err = response.body?.string() ?: "Unknown error"; throw Exception("Custom provider error ${response.code}: $err") } + val body = response.body?.string().orEmpty(); if (body.isBlank()) throw Exception("Empty response from custom provider") + val json = JSONObject(body); val msg = json.getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val text = msg.optString("content", "") + val toolCallsArr = msg.optJSONArray("tool_calls") + val calls = mutableListOf() + if (toolCallsArr != null) { + for (i in 0 until toolCallsArr.length()) { + val tc = toolCallsArr.optJSONObject(i) ?: continue + val fn = tc.optJSONObject("function") ?: continue + val name = fn.optString("name"); if (name.isBlank()) continue + val argsStr = fn.optString("arguments", "{}") + val args = try { JSONObject(if (argsStr.isBlank()) "{}" else argsStr) } catch (_: Exception) { JSONObject() } + calls.add(AIToolCall(name, args, tc.toString(), tc.optString("id").ifBlank { null })) + } + } + if (calls.isEmpty()) { val parsed = ToolCallProtocol.parse(text); if (parsed != null) calls.add(parsed) } + return ToolAwareResponse(text = text, toolCalls = calls) + } + + private fun readRelevantFiles(): Map { + val filesContent = mutableMapOf(); val tree = projectTreeResult?.tree ?: return filesContent + tree.lines().filter { it.isNotBlank() }.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 { filesContent[trimmedPath] = file.readText() } catch (_: Exception) {} + } + } + return filesContent + } + 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) } +} +data class ConversationMessage(val role: String, val content: String) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt index e660c9f1bb..4c93898e87 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/deepseek/DeepSeek.kt @@ -21,6 +21,18 @@ 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.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -32,8 +44,6 @@ 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 DeepSeek : AIAgent { @@ -47,6 +57,9 @@ class DeepSeek : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "deepseek-chat" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "deepseek" override val providerName = "DeepSeek" @@ -93,6 +106,7 @@ class DeepSeek : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -101,6 +115,7 @@ class DeepSeek : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -465,6 +480,44 @@ class DeepSeek : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.deepseek.com/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.deepseek.com/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt index 440250a465..aec3ef3cd4 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/google/Gemini.kt @@ -23,6 +23,19 @@ import com.google.ai.client.generativeai.type.content 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.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL import com.tom.rv2ide.artificial.secrets.ApiKey import com.tom.rv2ide.artificial.services.ArtificialService import com.tom.rv2ide.artificial.rules.WritingRules @@ -41,6 +54,11 @@ import com.tom.rv2ide.artificial.agents.Agents class Gemini : AIAgent { private var generativeModel: GenerativeModel? = null + private var apiKey: String? = null + private var selectedModel: String = "gemini-2.5-pro" + private var appContext: Context? = null + private val nativeContents = JSONArray() + private var nativeToolsActive = false private val writingRules = WritingRules.Instructions() private var projectTreeResult: ProjectTreeResult? = null private var fileWriter: AIFileWriter? = null @@ -86,6 +104,8 @@ class Gemini : AIAgent { agents?.setProvider("gemini") } + this.apiKey = apiKey + this.selectedModel = selectedModel generativeModel = GenerativeModel( modelName = selectedModel, apiKey = apiKey, @@ -101,6 +121,7 @@ class Gemini : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -109,6 +130,7 @@ class Gemini : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeContents.length() > 0) nativeContents.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -393,6 +415,108 @@ class Gemini : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeContents.length() > 0) nativeContents.remove(0) } + + private fun geminiToolsJson(tools: List): JSONArray { + val decls = JSONArray() + tools.forEach { t -> + decls.put(JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("parameters", try { JSONObject(t.parametersJsonSchema) } catch (_: Exception) { JSONObject().put("type","object").put("properties", JSONObject()) }) + }) + } + return JSONArray().put(JSONObject().put("functionDeclarations", decls)) + } + + private fun callGeminiNative(contents: JSONArray, system: String, tools: List?): ToolAwareResponse { + val key = apiKey ?: throw IllegalStateException("Gemini not initialized") + val model = selectedModel.ifBlank { "gemini-2.5-pro" } + val endpoint = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent?key=$key" + val connection = URL(endpoint).openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + connection.connectTimeout = 30000 + connection.readTimeout = 90000 + val body = JSONObject().apply { + put("contents", contents) + put("systemInstruction", JSONObject().put("parts", JSONArray().put(JSONObject().put("text", system)))) + if (!tools.isNullOrEmpty()) put("tools", geminiToolsJson(tools)) + put("generationConfig", JSONObject().put("temperature", 0.7)) + } + connection.outputStream.use { it.write(body.toString().toByteArray()) } + val code = connection.responseCode + val text = (if (code in 200..299) connection.inputStream else connection.errorStream)?.bufferedReader()?.readText().orEmpty() + if (code !in 200..299) throw Exception("Gemini API error ($code): $text") + val json = JSONObject(text) + val parts = json.optJSONArray("candidates")?.optJSONObject(0) + ?.optJSONObject("content")?.optJSONArray("parts") ?: JSONArray() + val sb = StringBuilder() + val calls = mutableListOf() + for (i in 0 until parts.length()) { + val p = parts.optJSONObject(i) ?: continue + if (p.has("text")) sb.append(p.optString("text")) + if (p.has("functionCall")) { + val fc = p.optJSONObject("functionCall") ?: continue + val name = fc.optString("name") + val args = fc.optJSONObject("args") ?: JSONObject() + if (name.isNotBlank()) calls.add(AIToolCall(name, args, fc.toString(), null)) + } + } + val out = sb.toString() + if (calls.isEmpty()) ToolCallProtocol.parse(out)?.let { calls.add(it) } + return ToolAwareResponse(out, calls) + } finally { connection.disconnect() } + } + + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + clearNative(); nativeToolsActive = true + nativeContents.put(JSONObject().put("role","user").put("parts", JSONArray().put(JSONObject().put("text", prompt)))) + val res = callGeminiNative(nativeContents, composeSystemPrompt(ctx, false), tools) + // store model response + val modelParts = JSONArray() + if (res.text.isNotBlank()) modelParts.put(JSONObject().put("text", res.text)) + res.toolCalls.forEach { tc -> modelParts.put(JSONObject().put("functionCall", JSONObject().put("name", tc.name).put("args", tc.arguments))) } + if (modelParts.length() > 0) nativeContents.put(JSONObject().put("role","model").put("parts", modelParts)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + val parts = JSONArray() + toolResults.forEach { tr -> + parts.put(JSONObject().put("functionResponse", JSONObject().put("name", tr.name).put("response", JSONObject().put("name", tr.name).put("content", tr.result)))) + } + nativeContents.put(JSONObject().put("role","user").put("parts", parts)) + val res = callGeminiNative(nativeContents, composeSystemPrompt(ctx, false), tools) + val modelParts = JSONArray() + if (res.text.isNotBlank()) modelParts.put(JSONObject().put("text", res.text)) + res.toolCalls.forEach { tc -> modelParts.put(JSONObject().put("functionCall", JSONObject().put("name", tc.name).put("args", tc.arguments))) } + if (modelParts.length() > 0) nativeContents.put(JSONObject().put("role","model").put("parts", modelParts)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = generativeModel != null } @@ -405,4 +529,4 @@ data class FileModification( data class ConversationMessage( val role: String, val content: String -) \ No newline at end of file +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt index f7b92d4bab..7721a6b065 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/grok/Grok.kt @@ -21,6 +21,18 @@ 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.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import org.json.JSONArray +import org.json.JSONObject import com.tom.rv2ide.artificial.agents.Agents import com.tom.rv2ide.artificial.rules.WritingRules import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult @@ -32,8 +44,6 @@ 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 Grok : AIAgent { @@ -47,6 +57,9 @@ class Grok : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "grok-beta" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "grok" override val providerName = "xAI Grok" @@ -93,6 +106,7 @@ class Grok : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -101,6 +115,7 @@ class Grok : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -465,6 +480,44 @@ class Grok : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.x.ai/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("Not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.x.ai/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } @@ -477,4 +530,4 @@ data class FileModification( data class ConversationMessage( val role: String, val content: String -) \ No newline at end of file +) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt index 602f6ed706..bf190dc300 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/local/LocalLLM.kt @@ -18,37 +18,39 @@ package com.tom.rv2ide.artificial.agents.local import android.content.Context +import com.tom.rv2ide.app.BaseApplication 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.secrets.ApiKey -import com.tom.rv2ide.artificial.rules.WritingRules -import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.agents.ModificationAttempt import com.tom.rv2ide.artificial.file.AIFileWriter import com.tom.rv2ide.artificial.file.FileWriteResult -import com.tom.rv2ide.managers.PreferenceManager -import com.tom.rv2ide.app.BaseApplication +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import com.tom.rv2ide.artificial.rules.WritingRules +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import java.io.File +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.MediaType.Companion.toMediaType -import org.json.JSONObject import org.json.JSONArray -import java.util.concurrent.TimeUnit +import org.json.JSONObject class LocalLLM : AIAgent { - private var baseUrl: String? = null private var modelName: String? = null - private val httpClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() + private val httpClient = OkHttpClient.Builder().connectTimeout(60, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS).writeTimeout(60, TimeUnit.SECONDS).build() private val writingRules = WritingRules.Instructions() private var projectTreeResult: ProjectTreeResult? = null private var fileWriter: AIFileWriter? = null @@ -56,382 +58,97 @@ class LocalLLM : AIAgent { private val modificationHistory = mutableListOf() private var currentAttemptCount = 0 private val maxRetryAttempts = 3 - private var agents: Agents? = null + private var appContext: Context? = null + private val nativeMessages = JSONArray() + private var nativeToolsActive = false override val providerId = "localllm" override val providerName = "Local LLM" companion object { - fun registerAgent() { - AIAgentRegistry.register("localllm", object : AIAgentRegistry.AgentFactory { - override fun create(context: Context): AIAgent { - return LocalLLM() - } - - override fun hasValidApiKey(): Boolean { - val prefs = BaseApplication.getBaseInstance().prefManager - val url = prefs.getString("local_llm_base_url", null) - val model = prefs.getString("local_llm_model_name", null) - android.util.Log.d("LocalLLM", "hasValidApiKey check: url=$url, model=$model") - return url != null && url.isNotEmpty() && model != null && model.isNotEmpty() - } - - override fun getApiKey(): String? { - return "local" - } - }) - } - } - - override fun initialize(apiKey: String, context: Context) { - try { - agents = Agents(context) - val prefs = BaseApplication.getBaseInstance().prefManager - baseUrl = prefs.getString("local_llm_base_url", null) - modelName = prefs.getString("local_llm_model_name", null) - - android.util.Log.d("LocalLLM", "Initialized with baseUrl=$baseUrl, model=$modelName") - - if (baseUrl == null || baseUrl!!.isEmpty() || modelName == null || modelName!!.isEmpty()) { - throw IllegalStateException("Local LLM not configured. Please set base URL and model name.") - } - } catch (e: Exception) { - throw e - } - } - - override fun isInitialized(): Boolean = baseUrl != null && baseUrl!!.isNotEmpty() && modelName != null && modelName!!.isNotEmpty() - - - 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 - } + fun registerAgent() { + AIAgentRegistry.register("localllm", object : AIAgentRegistry.AgentFactory { + override fun create(context: Context): AIAgent = LocalLLM() + override fun hasValidApiKey(): Boolean { val prefs = BaseApplication.getBaseInstance().prefManager; val url = prefs.getString("local_llm_base_url", null); val model = prefs.getString("local_llm_model_name", null); return !url.isNullOrEmpty() && !model.isNullOrEmpty() } + override fun getApiKey(): String? = "local" + }) } - return false - } - - override fun getModificationHistory(): List { - return modificationHistory.toList() - } - - override fun resetAttemptCount() { - currentAttemptCount = 0 - } - - override fun incrementAttemptCount() { - currentAttemptCount++ } + override fun initialize(apiKey: String, context: Context) { + appContext = context.applicationContext + val prefs = BaseApplication.getBaseInstance().prefManager + baseUrl = prefs.getString("local_llm_base_url", null); modelName = prefs.getString("local_llm_model_name", null) + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Local LLM not configured. Please set base URL and model name.") + } + override fun isInitialized(): Boolean = !baseUrl.isNullOrEmpty() && !modelName.isNullOrEmpty() + override fun reinitializeWithNewModel(apiKey: String, context: Context) { initialize(apiKey, context) } + override fun setContext(context: Context) { appContext = context.applicationContext; fileWriter = AIFileWriter(context) } + override fun setProjectData(projectTreeResult: ProjectTreeResult) { this.projectTreeResult = projectTreeResult } + override fun clearConversation() { conversationHistory.clear(); modificationHistory.clear(); currentAttemptCount = 0; clearNative() } + override fun recordModification(filePath: String, oldContent: String?, newContent: String, success: Boolean) { modificationHistory.add(ModificationAttempt(System.currentTimeMillis(), filePath, oldContent, newContent, currentAttemptCount, 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 (_: Exception) {} }; return false } + override fun getModificationHistory(): List = 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 { - if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) { - return@withContext Result.failure( - IllegalStateException("Local LLM not configured") - ) - } - - 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 messages = JSONArray() - messages.put(JSONObject().apply { - put("role", "system") - put("content", writingRules.useThis()) - }) - messages.put(JSONObject().apply { - put("role", "user") - put("content", fullPrompt) - }) - - val requestBody = JSONObject().apply { - put("model", modelName) - put("messages", messages) - put("temperature", 0.7) - put("stream", false) - } - - val request = Request.Builder() - .url("$baseUrl/v1/chat/completions") - .post(requestBody.toString().toRequestBody("application/json".toMediaType())) - .build() - - val response = try { - httpClient.newCall(request).execute() - } catch (e: Exception) { - val errorMessage = e.message ?: "" - when { - errorMessage.contains("timeout") || - errorMessage.contains("connect") -> - throw com.tom.rv2ide.artificial.exceptions.RateLimitException( - "Connection timeout. Please check your local server." - ) - else -> throw e - } - } - - if (!response.isSuccessful) { - val errorBody = response.body?.string() ?: "Unknown error" - return@withContext Result.failure( - Exception("Local LLM error ${response.code}: $errorBody") - ) - } - - val responseBody = response.body?.string() ?: "" - val jsonResponse = JSONObject(responseBody) - - val generatedResponse = jsonResponse - .getJSONArray("choices") - .getJSONObject(0) - .getJSONObject("message") - .getString("content") - - if (generatedResponse.isBlank()) { - return@withContext Result.failure(Exception("Empty response from Local LLM")) - } - - conversationHistory.add(ConversationMessage("user", prompt)) - conversationHistory.add(ConversationMessage("assistant", generatedResponse)) - - if (conversationHistory.size > 20) { - conversationHistory.removeAt(0) - conversationHistory.removeAt(0) - } - - Result.success(generatedResponse) - } catch (e: com.tom.rv2ide.artificial.exceptions.RateLimitException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.QuotaExceededException) { - Result.failure(e) - } catch (e: com.tom.rv2ide.artificial.exceptions.InvalidApiKeyException) { - Result.failure(e) - } catch (e: Exception) { - Result.failure(e) - } - } - - 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) { - } - } + private fun isUserRequestingCorrection(message: String): Boolean = listOf("wrong","not what","mistake","error","incorrect","that's not","not right","fix","undo","revert","different","try again","not working").any { message.lowercase().contains(it) } + private fun composeSystemPrompt(context: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(context, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun buildUserPrompt(prompt: String, context: String?): String { + val fileContents = readRelevantFiles(); val needsCorrection = isUserRequestingCorrection(prompt) + return buildString { + append("=== PROJECT STRUCTURE (THESE ARE THE EXACT PATHS YOU MUST USE) ===\n") + if (projectTreeResult != null) { append(projectTreeResult!!.tree); append("\n\nCRITICAL: Use ONLY the paths shown above.\n\n") } + if (fileContents.isNotEmpty()) { append("=== CURRENT FILES CONTENT ===\n"); fileContents.forEach { (p,c)-> append("FILE: $p\nCONTENT:\n").append(c).append("\n\n") } } + if (context != null) append("=== ADDITIONAL CONTEXT ===\n").append(context).append("\n\n") + if (conversationHistory.isNotEmpty()) { append("=== CONVERSATION HISTORY ===\n"); conversationHistory.takeLast(8).forEach { msg -> append("${msg.role.uppercase()}: ${msg.content.take(3000)}\n\n") } } + if (needsCorrection && modificationHistory.isNotEmpty()) append("=== CORRECTION REQUIRED ===\nTry a COMPLETELY DIFFERENT approach.\n\n") + if (currentAttemptCount > 0) append("=== RETRY ATTEMPT $currentAttemptCount/$maxRetryAttempts ===\n\n") + append("=== USER REQUEST ===\n").append(prompt) } - - 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 suspend fun generateCode(prompt: String, context: String?, language: String, projectStructure: String?): Result = withContext(Dispatchers.IO) { + try { val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")); clearNative(); val res = callChat(buildMessages(composeSystemPrompt(ctx,true), buildUserPrompt(prompt,context)), null); conversationHistory.add(ConversationMessage("user", prompt)); conversationHistory.add(ConversationMessage("assistant", res.text)); while (conversationHistory.size>20) conversationHistory.removeAt(0); Result.success(res.text) } catch (e: Exception) { Result.failure(e) } } - 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) + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")); clearNative(); nativeToolsActive=true; nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))); nativeMessages.put(JSONObject().put("role","user").put("content", buildUserPrompt(prompt,context))); val res = callChat(nativeMessages, tools); nativeMessages.put(if (res.hasToolCalls()) assistantToolCallMessage(res) else JSONObject().put("role","assistant").put("content",res.text)); Result.success(res) } catch (e: Exception) { Result.failure(e) } } - - 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 suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools); toolResults.forEach { tr -> nativeMessages.put(JSONObject().put("role","tool").put("tool_call_id", tr.toolCallId ?: tr.name).put("content", tr.result)) }; val res = callChat(nativeMessages, tools); nativeMessages.put(if (res.hasToolCalls()) assistantToolCallMessage(res) else JSONObject().put("role","assistant").put("content",res.text)); Result.success(res) } catch (e: Exception) { Result.failure(e) } } + private fun assistantToolCallMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { put("role","assistant"); put("content",res.text); val arr = JSONArray(); res.toolCalls.forEach { tc -> arr.put(JSONObject().apply { put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}"); put("type","function"); put("function", JSONObject().put("name",tc.name).put("arguments",tc.arguments.toString())) }) }; put("tool_calls",arr) } + private fun clearNative() { nativeToolsActive=false; while(nativeMessages.length()>0) nativeMessages.remove(0) } + private fun buildMessages(system: String, user: String): JSONArray = JSONArray().apply { put(JSONObject().put("role","system").put("content",system)); put(JSONObject().put("role","user").put("content",user)) } + private fun callChat(messages: JSONArray, tools: List?): ToolAwareResponse { + if (baseUrl.isNullOrEmpty() || modelName.isNullOrEmpty()) throw IllegalStateException("Local LLM not configured") + val requestBody = JSONObject().apply { put("model", modelName); put("messages", messages); put("temperature", 0.7); put("stream", false); if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice","auto") } } + val req = Request.Builder().url("${baseUrl!!.trimEnd('/')}/v1/chat/completions").post(requestBody.toString().toRequestBody("application/json".toMediaType())).addHeader("Content-Type","application/json").build() + val response = httpClient.newCall(req).execute() + if (!response.isSuccessful) throw Exception("Local LLM error ${response.code}: ${response.body?.string() ?: "Unknown"}") + val body = response.body?.string().orEmpty(); val json = JSONObject(body); val msg = json.getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val text = msg.optString("content",""); val calls = mutableListOf() + val arr = msg.optJSONArray("tool_calls") + if (arr != null) for (i in 0 until arr.length()) { val tc = arr.optJSONObject(i) ?: continue; val fn = tc.optJSONObject("function") ?: continue; val n = fn.optString("name"); if (n.isBlank()) continue; val a = try { JSONObject(fn.optString("arguments","{}").ifBlank{"{}"}) } catch (_: Exception){ JSONObject() }; calls.add(AIToolCall(n,a,tc.toString(), tc.optString("id").ifBlank{null})) } + if (calls.isEmpty()) { ToolCallProtocol.parse(text)?.let { calls.add(it) } } + return ToolAwareResponse(text, calls) + } + private fun readRelevantFiles(): Map { val filesContent = mutableMapOf(); val tree = projectTreeResult?.tree ?: return filesContent; tree.lines().filter { it.isNotBlank() }.forEach { filePath -> val t=filePath.trim(); val f=File(t); if (f.isFile && (t.endsWith(".kt")||t.endsWith(".java")||t.endsWith(".xml")||t.endsWith(".gradle")||t.endsWith(".gradle.kts")) && !t.contains("/build/") && !t.contains("/.gradle/")) try { filesContent[t]=f.readText() } catch (_: Exception) {} }; return filesContent } + override fun writeFile(filePath: String, content: String): FileWriteResult = fileWriter?.writeFile(filePath, content, createBackup=true) ?: FileWriteResult.Error("File writer not initialized") } - -data class ConversationMessage( - val role: String, - val content: String -) - -data class FileModification( - val filePath: String, - val content: String, - val writeResult: FileWriteResult -) \ No newline at end of file +data class ConversationMessage(val role: String, val content: String) diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt index b110aa14d5..06f6201206 100644 --- a/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/agents/openai/OpenAI.kt @@ -36,6 +36,16 @@ import com.tom.rv2ide.artificial.agents.AIAgent import com.tom.rv2ide.artificial.agents.AIAgentRegistry import com.tom.rv2ide.artificial.secrets.ApiKey import com.tom.rv2ide.artificial.agents.ModificationAttempt +import com.tom.rv2ide.artificial.tools.AIToolCall +import com.tom.rv2ide.artificial.tools.AIToolDef +import com.tom.rv2ide.artificial.tools.AIToolResult +import com.tom.rv2ide.artificial.tools.OpenAICompatNative +import com.tom.rv2ide.artificial.tools.ToolAwareResponse +import com.tom.rv2ide.artificial.tools.ToolCallProtocol +import com.tom.rv2ide.artificial.tools.BuiltInToolExecutor +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.mcp.McpServerStore +import com.tom.rv2ide.artificial.mcp.McpToolExecutor /* * @author Mohammed-baqer-null @ https://github.com/Mohammed-baqer-null @@ -53,6 +63,9 @@ class OpenAI : AIAgent { private val maxRetryAttempts = 3 private var agents: Agents? = null private var selectedModel: String = "gpt-4o" + private val nativeMessages = JSONArray() + private var nativeToolsActive = false + private var appContext: Context? = null override val providerId = "openai" override val providerName = "OpenAI" @@ -102,6 +115,7 @@ class OpenAI : AIAgent { } override fun setContext(context: Context) { + appContext = context.applicationContext fileWriter = AIFileWriter(context) } @@ -110,6 +124,7 @@ class OpenAI : AIAgent { } override fun clearConversation() { + nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) conversationHistory.clear() modificationHistory.clear() currentAttemptCount = 0 @@ -477,6 +492,44 @@ class OpenAI : AIAgent { return modifications } + + private fun composeSystemPrompt(ctx: Context, includeTextToolDefs: Boolean): String { + val sb = StringBuilder(writingRules.useThis()) + val agentProfile = AgentPromptProfileStore.getActive() + val extra = agentProfile?.systemPrompt?.trim().orEmpty() + if (extra.isNotEmpty()) sb.append("\n\n=== AGENT PROFILE INSTRUCTIONS ===\n").append(extra).append("\n") + if (includeTextToolDefs && agentProfile?.enableTools != false) { + val builtIn = BuiltInToolExecutor(ctx, projectTreeResult, null, agentProfile?.allowCommandExecution == true, agentProfile?.disabledTools?.toSet() ?: emptySet()) + val mcpDefs = if (agentProfile?.enableMcpTools != false) { try { val servers = McpServerStore.load().filter { s -> agentProfile?.enabledMcpServerIds.isNullOrEmpty() || agentProfile!!.enabledMcpServerIds.contains(s.id) }; McpToolExecutor(servers).definitions().filterNot { it.name in (agentProfile?.disabledTools?.toSet() ?: emptySet()) } } catch (_: Exception) { emptyList() } } else emptyList() + sb.append(ToolCallProtocol.formatDefinitions(builtIn.definitions() + mcpDefs)) + } + return sb.toString() + } + private fun clearNative() { nativeToolsActive = false; while (nativeMessages.length() > 0) nativeMessages.remove(0) } + override fun supportsNativeTools(): Boolean = true + override suspend fun generateWithTools(prompt: String, context: String?, language: String, projectStructure: String?, tools: List): Result = withContext(Dispatchers.IO) { + try { + val ctx = appContext ?: return@withContext Result.failure(IllegalStateException("No context")) + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("OpenAI service not initialized")) + clearNative(); nativeToolsActive = true + nativeMessages.put(JSONObject().put("role","system").put("content", composeSystemPrompt(ctx,false))) + nativeMessages.put(JSONObject().put("role","user").put("content", prompt)) + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.openai.com/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override suspend fun submitToolResults(toolResults: List, tools: List): Result = withContext(Dispatchers.IO) { + try { + val key = apiKey ?: return@withContext Result.failure(IllegalStateException("OpenAI service not initialized")) + if (!nativeToolsActive) return@withContext generateWithTools(toolResults.joinToString("\n"){it.result}, null, "kotlin", null, tools) + toolResults.forEach { nativeMessages.put(OpenAICompatNative.toolResultMessage(it)) } + val res = OpenAICompatNative.call(OpenAICompatNative.CallConfig("https://api.openai.com/v1/chat/completions", key, selectedModel), nativeMessages, tools) + nativeMessages.put(if (res.hasToolCalls()) OpenAICompatNative.assistantToolCallsMessage(res) else JSONObject().put("role","assistant").put("content",res.text)) + Result.success(res) + } catch (e: Exception) { Result.failure(e) } + } + override fun isInitialized(): Boolean = apiKey != null } diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt new file mode 100644 index 0000000000..47912f05fa --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/AgentProfilesDialog.kt @@ -0,0 +1,113 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.content.Context +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.tom.rv2ide.artificial.profiles.AgentPromptProfile +import com.tom.rv2ide.artificial.profiles.AgentPromptProfileStore +import com.tom.rv2ide.artificial.profiles.SkillPresets + +object AgentProfilesDialog { + fun show(context: Context, onChanged: () -> Unit = {}) { + val (activeId, profiles) = AgentPromptProfileStore.load() + val names = profiles.map { "${it.name}${if (it.id == activeId) " (active)" else ""}" }.toTypedArray() + arrayOf("+ Create new profile", "+ Add prebuilt skill") + MaterialAlertDialogBuilder(context) + .setTitle("Agent Profiles & Skills") + .setItems(names) { d, which -> + d.dismiss() + when { + which < profiles.size -> selectOrEdit(context, profiles[which], onChanged) + which == profiles.size -> edit(context, null, onChanged) + else -> showPresets(context, onChanged) + } + } + .setNegativeButton("Close", null) + .show() + } + + private fun showPresets(context: Context, onChanged: () -> Unit) { + val presets = SkillPresets.all() + MaterialAlertDialogBuilder(context) + .setTitle("Prebuilt Skills") + .setItems(presets.map { it.name }.toTypedArray()) { d, which -> + d.dismiss() + AgentPromptProfileStore.addPreset(presets[which]) + Toast.makeText(context, "Skill added", Toast.LENGTH_SHORT).show() + onChanged(); show(context, onChanged) + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun selectOrEdit(context: Context, profile: AgentPromptProfile, onChanged: () -> Unit) { + MaterialAlertDialogBuilder(context) + .setTitle(profile.name) + .setItems(arrayOf("Activate", "Edit", "Tools/Capabilities", "Duplicate", "Delete")) { d, which -> + d.dismiss() + when (which) { + 0 -> { AgentPromptProfileStore.setActive(profile.id); Toast.makeText(context, "Activated", Toast.LENGTH_SHORT).show(); onChanged() } + 1 -> edit(context, profile, onChanged) + 2 -> editTools(context, profile, onChanged) + 3 -> { AgentPromptProfileStore.upsert(profile.copy(id = java.util.UUID.randomUUID().toString(), name = profile.name + " (copy)")); onChanged(); show(context, onChanged) } + 4 -> { AgentPromptProfileStore.delete(profile.id); onChanged(); show(context, onChanged) } + } + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun editTools(context: Context, profile: AgentPromptProfile, onChanged: () -> Unit) { + val tools = SkillPresets.knownToolNames() + val checked = tools.map { it !in profile.disabledTools }.toBooleanArray() + val labels = tools.map { + when (it) { + "run_command" -> "run_command (risky, also requires opt-in)" + else -> it + } + }.toTypedArray() + MaterialAlertDialogBuilder(context) + .setTitle("Tools/Capabilities: ${profile.name}") + .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } + .setPositiveButton("Save") { _, _ -> + val disabled = tools.filterIndexed { i, _ -> !checked[i] } + AgentPromptProfileStore.upsert(profile.copy(disabledTools = disabled)) + onChanged(); selectOrEdit(context, AgentPromptProfileStore.load().second.firstOrNull { it.id == profile.id } ?: profile, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> selectOrEdit(context, profile, onChanged) } + .show() + } + + private fun edit(context: Context, existing: AgentPromptProfile?, onChanged: () -> Unit) { + val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(48, 24, 48, 0) } + val nameInput = TextInputEditText(context).apply { setText(existing?.name ?: "New Profile") } + val promptInput = TextInputEditText(context).apply { setText(existing?.systemPrompt ?: ""); minLines = 4; maxLines = 10; hint = "Extra system prompt / instructions" } + val toolsSwitch = MaterialSwitch(context).apply { text = "Enable built-in tools/skills"; isChecked = existing?.enableTools ?: true } + val mcpSwitch = MaterialSwitch(context).apply { text = "Enable MCP tools"; isChecked = existing?.enableMcpTools ?: true } + val cmdSwitch = MaterialSwitch(context).apply { text = "Allow command execution (risky, opt-in)"; isChecked = existing?.allowCommandExecution ?: false } + container.addView(TextInputLayout(context).apply { hint = "Profile name"; addView(nameInput) }) + container.addView(TextInputLayout(context).apply { hint = "System prompt"; addView(promptInput); setPadding(0, 12, 0, 0) }) + container.addView(toolsSwitch); container.addView(mcpSwitch); container.addView(cmdSwitch) + val scroll = ScrollView(context).apply { addView(container) } + MaterialAlertDialogBuilder(context) + .setTitle(if (existing == null) "Create Agent Profile" else "Edit Agent Profile") + .setView(scroll) + .setPositiveButton("Save") { _, _ -> + val p = (existing ?: AgentPromptProfile(name = "Profile")).copy( + name = nameInput.text?.toString()?.trim().orEmpty().ifBlank { "Profile" }, + systemPrompt = promptInput.text?.toString().orEmpty(), + enableTools = toolsSwitch.isChecked, + enableMcpTools = mcpSwitch.isChecked, + allowCommandExecution = cmdSwitch.isChecked, + ) + AgentPromptProfileStore.upsert(p, makeActive = existing == null) + onChanged(); show(context, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> show(context, onChanged) } + .show() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt new file mode 100644 index 0000000000..f785a8ad4f --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/CustomProviderConfigDialog.kt @@ -0,0 +1,148 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.app.AlertDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.tom.rv2ide.R +import com.tom.rv2ide.artificial.profiles.CustomProviderProfile +import com.tom.rv2ide.artificial.profiles.CustomProviderProfileStore + +class CustomProviderConfigDialog( + private val onSave: (baseUrl: String, modelName: String, apiKey: String) -> Unit +) : BottomSheetDialogFragment() { + + private lateinit var profileSelectorLayout: TextInputLayout + private lateinit var profileSelector: AutoCompleteTextView + private lateinit var profileNameInput: TextInputEditText + private lateinit var baseUrlInput: TextInputEditText + private lateinit var modelNameInput: TextInputEditText + private lateinit var apiKeyInput: TextInputEditText + private lateinit var saveButton: MaterialButton + private lateinit var deleteButton: MaterialButton + private lateinit var cancelButton: MaterialButton + + private var profiles: MutableList = mutableListOf() + private var activeId: String? = null + private var selectedId: String? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_custom_provider_config, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + profileSelectorLayout = view.findViewById(R.id.profileSelectorLayout) + profileSelector = view.findViewById(R.id.profileSelector) + profileNameInput = view.findViewById(R.id.profileNameInput) + baseUrlInput = view.findViewById(R.id.baseUrlInput) + modelNameInput = view.findViewById(R.id.modelNameInput) + apiKeyInput = view.findViewById(R.id.apiKeyInput) + saveButton = view.findViewById(R.id.saveButton) + deleteButton = view.findViewById(R.id.deleteButton) + cancelButton = view.findViewById(R.id.cancelButton) + + reload() + setupSelector() + + saveButton.setOnClickListener { + val name = profileNameInput.text.toString().trim().ifBlank { "Custom" } + val baseUrl = baseUrlInput.text.toString().trim() + val modelName = modelNameInput.text.toString().trim() + val apiKey = apiKeyInput.text.toString().trim() + if (baseUrl.isEmpty() || modelName.isEmpty()) { + Toast.makeText(requireContext(), "Base URL and model name are required", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + val current = profiles.firstOrNull { it.id == selectedId } + val profile = (current ?: CustomProviderProfile(name = name, baseUrl = baseUrl, modelName = modelName, apiKey = apiKey)).copy( + name = name, baseUrl = baseUrl, modelName = modelName, apiKey = apiKey + ) + val saved = CustomProviderProfileStore.upsert(profile, makeActive = true) + selectedId = saved.id + reload() + setupSelector() + onSave(baseUrl, modelName, apiKey) + Toast.makeText(requireContext(), "Profile saved and activated", Toast.LENGTH_SHORT).show() + } + + deleteButton.setOnClickListener { + val id = selectedId ?: return@setOnClickListener + if (profiles.size <= 1) { + Toast.makeText(requireContext(), "Keep at least one profile", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + CustomProviderProfileStore.delete(id) + reload() + setupSelector() + } + + view.findViewById(R.id.newProfileButton)?.setOnClickListener { + selectedId = null + profileNameInput.setText("New Profile") + baseUrlInput.setText("https://api.openai.com") + modelNameInput.setText("gpt-4o-mini") + apiKeyInput.setText("") + profileSelector.setText("(New Profile)", false) + } + + cancelButton.setOnClickListener { dismiss() } + } + + private fun reload() { + val (aid, list) = CustomProviderProfileStore.load() + activeId = aid + profiles = list.toMutableList() + if (profiles.isEmpty()) { + val def = CustomProviderProfile(name = "Default", baseUrl = "https://api.openai.com", modelName = "gpt-4o-mini") + CustomProviderProfileStore.save(def.id, listOf(def)) + profiles = mutableListOf(def) + activeId = def.id + } + if (selectedId == null || profiles.none { it.id == selectedId }) selectedId = activeId ?: profiles.first().id + bindSelected() + } + + private fun setupSelector() { + val names = profiles.map { "${it.name}${if (it.id == activeId) " (active)" else ""}" } + listOf("+ New profile") + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, names) + profileSelector.setAdapter(adapter) + val idx = profiles.indexOfFirst { it.id == selectedId } + if (idx >= 0) profileSelector.setText(names[idx], false) + profileSelector.setOnItemClickListener { _, _, pos, _ -> + if (pos >= profiles.size) { + selectedId = null + profileNameInput.setText("New Profile") + baseUrlInput.setText("https://api.openai.com") + modelNameInput.setText("gpt-4o-mini") + apiKeyInput.setText("") + profileSelector.setText("(New Profile)", false) + } else { + selectedId = profiles[pos].id + CustomProviderProfileStore.setActive(profiles[pos].id) + activeId = profiles[pos].id + bindSelected() + setupSelector() + val p = profiles[pos] + onSave(p.baseUrl, p.modelName, p.apiKey) + } + } + } + + private fun bindSelected() { + val p = profiles.firstOrNull { it.id == selectedId } ?: profiles.firstOrNull() ?: return + profileNameInput.setText(p.name) + baseUrlInput.setText(p.baseUrl) + modelNameInput.setText(p.modelName) + apiKeyInput.setText(p.apiKey) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt new file mode 100644 index 0000000000..d6bd87f4b0 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/dialogs/McpServersDialog.kt @@ -0,0 +1,91 @@ +package com.tom.rv2ide.artificial.dialogs + +import android.content.Context +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.tom.rv2ide.artificial.mcp.McpPresets +import com.tom.rv2ide.artificial.mcp.McpServerConfig +import com.tom.rv2ide.artificial.mcp.McpServerStore + +object McpServersDialog { + fun show(context: Context, onChanged: () -> Unit = {}) { + val servers = McpServerStore.load() + val names = servers.map { "${if (it.enabled) "āœ…" else "āøļø"} ${it.name}" }.toTypedArray() + arrayOf("+ Add MCP server", "+ Add prebuilt MCP preset") + MaterialAlertDialogBuilder(context) + .setTitle("MCP Servers") + .setMessage("HTTP MCP servers for agent tools. Prebuilt presets (GitHub, Firebase, Stitch, Vercel, Cloudflare, Supabase) can be added and configured.") + .setItems(names) { d, which -> + d.dismiss() + when { + which < servers.size -> manage(context, servers[which], onChanged) + which == servers.size -> edit(context, null, onChanged) + else -> showPresets(context, onChanged) + } + } + .setNegativeButton("Close", null) + .show() + } + + private fun showPresets(context: Context, onChanged: () -> Unit) { + val presets = McpPresets.all() + MaterialAlertDialogBuilder(context) + .setTitle("Prebuilt MCP Presets") + .setItems(presets.map { it.name }.toTypedArray()) { d, which -> + d.dismiss() + McpServerStore.addPreset(presets[which]) + Toast.makeText(context, "Preset added (edit to configure)", Toast.LENGTH_SHORT).show() + onChanged(); show(context, onChanged) + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun manage(context: Context, server: McpServerConfig, onChanged: () -> Unit) { + MaterialAlertDialogBuilder(context) + .setTitle(server.name) + .setMessage(server.url) + .setItems(arrayOf(if (server.enabled) "Disable" else "Enable", "Edit", "Delete")) { d, which -> + d.dismiss() + when (which) { + 0 -> { McpServerStore.upsert(server.copy(enabled = !server.enabled)); onChanged(); show(context, onChanged) } + 1 -> edit(context, server, onChanged) + 2 -> { McpServerStore.delete(server.id); onChanged(); show(context, onChanged) } + } + } + .setNegativeButton("Back") { _, _ -> show(context, onChanged) } + .show() + } + + private fun edit(context: Context, existing: McpServerConfig?, onChanged: () -> Unit) { + val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(48, 24, 48, 0) } + fun field(hint: String, value: String): TextInputEditText { + val e = TextInputEditText(context).apply { setText(value) } + container.addView(TextInputLayout(context).apply { this.hint = hint; addView(e); setPadding(0, 8, 0, 0) }) + return e + } + val name = field("Name", existing?.name ?: "") + val url = field("URL (HTTP MCP endpoint)", existing?.url ?: "https://") + val headers = field("Headers (JSON, optional)", if (existing?.headers.isNullOrEmpty()) "" else org.json.JSONObject(existing!!.headers).toString()) + val scroll = ScrollView(context).apply { addView(container) } + MaterialAlertDialogBuilder(context) + .setTitle(if (existing == null) "Add MCP Server" else "Edit MCP Server") + .setView(scroll) + .setPositiveButton("Save") { _, _ -> + val n = name.text?.toString()?.trim().orEmpty().ifBlank { "MCP Server" } + val u = url.text?.toString()?.trim().orEmpty() + val h = mutableMapOf() + try { + val ht = headers.text?.toString()?.trim().orEmpty() + if (ht.isNotBlank()) { val obj = org.json.JSONObject(ht); obj.keys().forEach { k -> h[k] = obj.optString(k) } } + } catch (_: Exception) { Toast.makeText(context, "Headers JSON ignored (invalid)", Toast.LENGTH_SHORT).show() } + McpServerStore.upsert((existing ?: McpServerConfig(name = n, url = u)).copy(name = n, url = u, headers = h)) + onChanged(); show(context, onChanged) + } + .setNegativeButton("Cancel") { _, _ -> show(context, onChanged) } + .show() + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt new file mode 100644 index 0000000000..63eeff37b0 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpClient.kt @@ -0,0 +1,146 @@ +package com.tom.rv2ide.artificial.mcp + +import com.tom.rv2ide.artificial.tools.AIToolDef +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * MCP client with HTTP JSON-RPC + basic Streamable HTTP compatibility. + * Handles session IDs (Mcp-Session-Id), SSE/JSON responses, and common header patterns. + */ +class McpClient(private val config: McpServerConfig) { + private val http = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + private val nextId = AtomicInteger(1) + @Volatile private var initialized = false + @Volatile private var sessionId: String? = null + @Volatile private var protocolVersion: String = "2025-03-26" + + fun listTools(): List { + ensureInitialized() + val res = rpc("tools/list", JSONObject()) ?: return emptyList() + val tools = res.optJSONObject("result")?.optJSONArray("tools") ?: res.optJSONArray("tools") ?: return emptyList() + val out = mutableListOf() + for (i in 0 until tools.length()) { + val t = tools.optJSONObject(i) ?: continue + val name = t.optString("name"); if (name.isBlank()) continue + val desc = t.optString("description", "") + val schema = t.optJSONObject("inputSchema")?.toString() ?: "{\"type\":\"object\",\"properties\":{}}" + out.add(AIToolDef(name = "mcp__${safe(config.name)}__${name}", description = "[MCP:${config.name}] $desc", parametersJsonSchema = schema)) + } + return out + } + + fun callTool(toolName: String, arguments: JSONObject): String { + ensureInitialized() + val actual = if (toolName.startsWith("mcp__")) toolName.split("__").getOrNull(2) ?: toolName.substringAfterLast("__") else toolName + val params = JSONObject().put("name", actual).put("arguments", arguments) + val res = rpc("tools/call", params) ?: return "Error: no response from MCP server" + if (res.has("error")) return "MCP error: ${res.opt("error")}" + val result = res.optJSONObject("result") ?: return res.toString() + val content = result.optJSONArray("content") + if (content != null) { + val sb = StringBuilder() + for (i in 0 until content.length()) { + val c = content.optJSONObject(i) ?: continue + when (c.optString("type")) { + "text" -> sb.append(c.optString("text")) + "resource" -> sb.append(c.optJSONObject("resource")?.optString("text") ?: c.toString()) + else -> sb.append(c.toString()) + } + if (i < content.length() - 1) sb.append("\n") + } + val text = sb.toString() + if (text.isNotBlank()) return text + } + if (result.has("text")) return result.optString("text") + if (result.has("structuredContent")) return result.opt("structuredContent").toString() + return result.toString() + } + + @Synchronized + private fun ensureInitialized() { + if (initialized) return + val params = JSONObject().apply { + put("protocolVersion", protocolVersion) + put("capabilities", JSONObject().apply { put("roots", JSONObject().put("listChanged", false)) }) + put("clientInfo", JSONObject().put("name", "AndroidCodeStudio").put("version", "1.0")) + } + val res = rpc("initialize", params) + res?.optJSONObject("result")?.optString("protocolVersion")?.takeIf { it.isNotBlank() }?.let { protocolVersion = it } + try { notify("notifications/initialized", JSONObject()) } catch (_: Exception) {} + initialized = true + } + + private fun rpc(method: String, params: JSONObject): JSONObject? { + val body = JSONObject().apply { + put("jsonrpc", "2.0"); put("id", nextId.getAndIncrement()); put("method", method); put("params", params) + } + return post(body) + } + + private fun notify(method: String, params: JSONObject) { + val body = JSONObject().apply { put("jsonrpc", "2.0"); put("method", method); put("params", params) } + try { post(body) } catch (_: Exception) {} + } + + private fun post(body: JSONObject): JSONObject? { + if (config.url.isBlank()) return null + val reqBuilder = Request.Builder() + .url(config.url.trim()) + .post(body.toString().toRequestBody("application/json".toMediaType())) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json, text/event-stream") + .addHeader("MCP-Protocol-Version", protocolVersion) + sessionId?.let { reqBuilder.addHeader("Mcp-Session-Id", it) } + config.headers.forEach { (k, v) -> if (k.isNotBlank()) reqBuilder.addHeader(k, v) } + http.newCall(reqBuilder.build()).execute().use { resp -> + resp.header("Mcp-Session-Id")?.takeIf { it.isNotBlank() }?.let { sessionId = it } + val text = resp.body?.string().orEmpty() + if (resp.code == 202 || resp.code == 204 || text.isBlank()) { + return if (resp.isSuccessful) JSONObject().put("result", JSONObject()) else JSONObject().put("error", "HTTP ${resp.code}") + } + val ct = resp.header("Content-Type") ?: "" + if (ct.contains("text/event-stream") || text.startsWith("event:") || text.startsWith("data:")) { + val dataLines = text.lines().filter { it.startsWith("data:") }.map { it.removePrefix("data:").trim() }.filter { it.isNotBlank() && it != "[DONE]" } + for (line in dataLines.asReversed()) { + try { val o = JSONObject(line); if (o.has("result") || o.has("error") || o.has("jsonrpc")) return o } catch (_: Exception) {} + } + return try { JSONObject(dataLines.lastOrNull() ?: "{}") } catch (_: Exception) { JSONObject().put("error", text.take(500)) } + } + return try { JSONObject(text) } catch (_: Exception) { JSONObject().put("error", text.take(500)) } + } + } + + private fun safe(s: String) = s.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(40) +} + +class McpToolExecutor(private val servers: List) : com.tom.rv2ide.artificial.tools.AIToolExecutor { + private val clients = servers.filter { it.enabled && it.url.isNotBlank() }.map { it to McpClient(it) } + + override fun definitions(): List = clients.flatMap { (cfg, client) -> + try { client.listTools() } catch (e: Exception) { + android.util.Log.w("McpToolExecutor", "Failed to list tools for ${cfg.name}", e) + emptyList() + } + } + + override fun execute(name: String, arguments: JSONObject): String { + if (!name.startsWith("mcp__")) return "Error: not an MCP tool" + val parts = name.split("__") + val serverName = parts.getOrNull(1) ?: return "Error: invalid MCP tool name" + val (_, client) = clients.firstOrNull { safe(it.first.name) == serverName || it.first.name == serverName } + ?: return "Error: MCP server not found/enabled: $serverName" + return try { client.callTool(name, arguments) } catch (e: Exception) { "MCP error: ${e.message}" } + } + + private fun safe(s: String) = s.replace(Regex("[^a-zA-Z0-9_-]"), "_").take(40) +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt new file mode 100644 index 0000000000..7ec971366c --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/mcp/McpModels.kt @@ -0,0 +1,81 @@ +package com.tom.rv2ide.artificial.mcp + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class McpServerConfig( + val id: String = UUID.randomUUID().toString(), + val name: String, + val url: String, + val enabled: Boolean = true, + val headers: Map = emptyMap(), + val preset: String? = null, +) + +object McpPresets { + fun all(): List = listOf( + McpServerConfig(id = "preset-github", name = "GitHub (preset)", url = "https://api.githubcopilot.com/mcp/", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "github"), + McpServerConfig(id = "preset-firebase", name = "Firebase (preset)", url = "https://", enabled = false, preset = "firebase"), + McpServerConfig(id = "preset-stitch", name = "Stitch (preset)", url = "https://", enabled = false, preset = "stitch"), + McpServerConfig(id = "preset-vercel", name = "Vercel (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "vercel"), + McpServerConfig(id = "preset-cloudflare", name = "Cloudflare (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "cloudflare"), + McpServerConfig(id = "preset-supabase", name = "Supabase (preset)", url = "https://", enabled = false, headers = mapOf("Authorization" to "Bearer "), preset = "supabase"), + McpServerConfig(id = "preset-custom", name = "Custom MCP (template)", url = "https://", enabled = false, preset = "custom"), + ) +} + +object McpServerStore { + private const val KEY = "ai_agent_mcp_servers_v1" + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): List { + val raw = prefs().getString(KEY, null) ?: return emptyList() + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + val name = o.optString("name").ifBlank { return@mapNotNull null } + val headers = mutableMapOf() + val h = o.optJSONObject("headers") + h?.keys()?.forEach { k -> headers[k] = h.optString(k) } + McpServerConfig( + id = o.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + url = o.optString("url", ""), + enabled = o.optBoolean("enabled", true), + headers = headers, + preset = o.optString("preset").ifBlank { null }, + ) + } + } catch (_: Exception) { emptyList() } + } + + @Synchronized + fun save(servers: List) { + val arr = JSONArray() + servers.forEach { s -> + arr.put(JSONObject().apply { + put("id", s.id); put("name", s.name); put("url", s.url); put("enabled", s.enabled) + put("headers", JSONObject(s.headers)); put("preset", s.preset ?: "") + }) + } + prefs().putString(KEY, arr.toString()) + } + + fun upsert(server: McpServerConfig) { + val list = load().toMutableList() + val idx = list.indexOfFirst { it.id == server.id } + if (idx >= 0) list[idx] = server else list.add(server) + save(list) + } + + fun delete(id: String) = save(load().filterNot { it.id == id }) + + fun addPreset(preset: McpServerConfig) { + val copy = preset.copy(id = UUID.randomUUID().toString(), enabled = false) + upsert(copy) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt new file mode 100644 index 0000000000..bdd36d5139 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/AgentPromptProfile.kt @@ -0,0 +1,98 @@ +package com.tom.rv2ide.artificial.profiles + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class AgentPromptProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val systemPrompt: String = "", + val enableTools: Boolean = true, + val enableMcpTools: Boolean = true, + val allowCommandExecution: Boolean = false, + val enabledMcpServerIds: List = emptyList(), + val disabledTools: List = emptyList(), + val skillPreset: String? = null, +) + +object SkillPresets { + fun all(): List = listOf( + AgentPromptProfile(id = "skill-file-reader", name = "Skill: File Reader", systemPrompt = "Focus on reading and understanding project files. Use read_file/list_files/search_project/get_project_tree before making claims. Prefer citing exact paths and line numbers.", enableTools = true, enableMcpTools = false, disabledTools = listOf("modify_file","run_command"), skillPreset = "file_reader"), + AgentPromptProfile(id = "skill-command-run", name = "Skill: Command Run (opt-in)", systemPrompt = "You may use run_command only when explicitly enabled and necessary. Prefer safe, read-only commands. Explain what a command does before running it.", enableTools = true, enableMcpTools = false, allowCommandExecution = false, skillPreset = "command_run"), + AgentPromptProfile(id = "skill-file-modification", name = "Skill: File Modification", systemPrompt = "Focus on precise code/file edits. Read relevant files first when needed, then produce FILE_TO_MODIFY blocks with complete, correct file contents. Preserve unrelated code.", enableTools = true, enableMcpTools = false, disabledTools = listOf("run_command"), skillPreset = "file_modification"), + AgentPromptProfile(id = "skill-stitch", name = "Skill: Stitch", systemPrompt = "You are a UI/product implementation assistant for Stitch-style workflows. Turn requirements into clear implementation steps and Android/UI code changes. Use available MCP/tools if configured. When editing files, use FILE_TO_MODIFY with exact project paths.", enableTools = true, enableMcpTools = true, skillPreset = "stitch"), + AgentPromptProfile(id = "skill-design", name = "Skill: Design", systemPrompt = "You are a software design assistant. Produce clear architecture, UX, and implementation guidance. Prefer practical Android/Gradle/Kotlin patterns. When the user asks for file changes, output FILE_TO_MODIFY blocks.", enableTools = true, enableMcpTools = true, skillPreset = "design"), + AgentPromptProfile(id = "skill-custom", name = "Skill: Custom", systemPrompt = "", enableTools = true, enableMcpTools = true, skillPreset = "custom"), + ) + fun knownToolNames() = listOf("read_file","list_files","search_project","get_project_tree","modify_file","run_command") +} + +object AgentPromptProfileStore { + private const val KEY = "ai_agent_prompt_profiles_v1" + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): Pair> { + ensureDefault() + val raw = prefs().getString(KEY, null) ?: return null to emptyList() + return try { + val obj = JSONObject(raw) + val activeId = obj.optString("activeId").ifBlank { null } + val arr = obj.optJSONArray("profiles") ?: JSONArray() + val profiles = mutableListOf() + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) ?: continue + val name = p.optString("name"); if (name.isBlank()) continue + fun strList(key: String): List { val a = p.optJSONArray(key) ?: return emptyList(); return (0 until a.length()).map { a.optString(it) }.filter { it.isNotBlank() } } + profiles.add(AgentPromptProfile( + id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + systemPrompt = p.optString("systemPrompt", ""), + enableTools = p.optBoolean("enableTools", true), + enableMcpTools = p.optBoolean("enableMcpTools", true), + allowCommandExecution = p.optBoolean("allowCommandExecution", false), + enabledMcpServerIds = strList("enabledMcpServerIds"), + disabledTools = strList("disabledTools"), + skillPreset = p.optString("skillPreset").ifBlank { null }, + )) + } + activeId to profiles + } catch (_: Exception) { null to emptyList() } + } + + @Synchronized + fun save(activeId: String?, profiles: List) { + val obj = JSONObject() + obj.put("activeId", activeId ?: "") + val arr = JSONArray() + profiles.forEach { p -> arr.put(JSONObject().apply { + put("id", p.id); put("name", p.name); put("systemPrompt", p.systemPrompt) + put("enableTools", p.enableTools); put("enableMcpTools", p.enableMcpTools) + put("allowCommandExecution", p.allowCommandExecution) + put("enabledMcpServerIds", JSONArray(p.enabledMcpServerIds)) + put("disabledTools", JSONArray(p.disabledTools)) + put("skillPreset", p.skillPreset ?: "") + }) } + obj.put("profiles", arr) + prefs().putString(KEY, obj.toString()) + } + + fun getActive(): AgentPromptProfile? { val (activeId, profiles) = load(); return profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() } + fun upsert(profile: AgentPromptProfile, makeActive: Boolean = false): AgentPromptProfile { + val (activeId, profiles) = load(); val mutable = profiles.toMutableList(); val idx = mutable.indexOfFirst { it.id == profile.id } + if (idx >= 0) mutable[idx] = profile else mutable.add(profile) + save(if (makeActive || activeId == null) profile.id else activeId, mutable); return profile + } + fun delete(id: String) { val (activeId, profiles) = load(); if (profiles.size <= 1) return; val remaining = profiles.filterNot { it.id == id }; save(if (activeId == id) remaining.firstOrNull()?.id else activeId, remaining) } + fun setActive(id: String) { val (_, profiles) = load(); if (profiles.any { it.id == id }) save(id, profiles) } + fun addPreset(preset: AgentPromptProfile): AgentPromptProfile = upsert(preset.copy(id = UUID.randomUUID().toString()), makeActive = false) + + @Synchronized + private fun ensureDefault() { + if (prefs().getString(KEY, null) != null) return + val def = AgentPromptProfile(name = "Default", systemPrompt = "", enableTools = true, enableMcpTools = true) + save(def.id, listOf(def)) + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt new file mode 100644 index 0000000000..91eab878a3 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/profiles/CustomProviderProfile.kt @@ -0,0 +1,124 @@ +package com.tom.rv2ide.artificial.profiles + +import com.tom.rv2ide.app.BaseApplication +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +data class CustomProviderProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val baseUrl: String, + val modelName: String, + val apiKey: String = "", +) + +object CustomProviderProfileStore { + private const val KEY = "ai_agent_custom_profiles_v1" + private const val LEGACY_BASE_URL = "ai_agent_custom_base_url" + private const val LEGACY_MODEL = "ai_agent_custom_model_name" + private const val LEGACY_API_KEY = "ai_agent_custom_api_key" + + private fun prefs() = BaseApplication.getBaseInstance().prefManager + + @Synchronized + fun load(): Pair> { + migrateLegacyIfNeeded() + val raw = prefs().getString(KEY, null) ?: return null to emptyList() + return try { + val obj = JSONObject(raw) + val activeId = obj.optString("activeId").ifBlank { null } + val arr = obj.optJSONArray("profiles") ?: JSONArray() + val profiles = mutableListOf() + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) ?: continue + val name = p.optString("name"); if (name.isBlank()) continue + profiles.add( + CustomProviderProfile( + id = p.optString("id").ifBlank { UUID.randomUUID().toString() }, + name = name, + baseUrl = p.optString("baseUrl", ""), + modelName = p.optString("modelName", ""), + apiKey = p.optString("apiKey", ""), + ) + ) + } + activeId to profiles + } catch (_: Exception) { + null to emptyList() + } + } + + @Synchronized + fun save(activeId: String?, profiles: List) { + val obj = JSONObject() + obj.put("activeId", activeId ?: "") + val arr = JSONArray() + profiles.forEach { p -> + arr.put( + JSONObject().apply { + put("id", p.id) + put("name", p.name) + put("baseUrl", p.baseUrl) + put("modelName", p.modelName) + put("apiKey", p.apiKey) + } + ) + } + obj.put("profiles", arr) + prefs().putString(KEY, obj.toString()) + syncLegacy(activeId, profiles) + } + + fun getActive(): CustomProviderProfile? { + val (activeId, profiles) = load() + if (profiles.isEmpty()) return null + return profiles.firstOrNull { it.id == activeId } ?: profiles.first() + } + + fun upsert(profile: CustomProviderProfile, makeActive: Boolean = false): CustomProviderProfile { + val (activeId, profiles) = load() + val mutable = profiles.toMutableList() + val idx = mutable.indexOfFirst { it.id == profile.id } + if (idx >= 0) mutable[idx] = profile else mutable.add(profile) + val newActive = if (makeActive || activeId == null) profile.id else activeId + save(newActive, mutable) + return profile + } + + fun delete(id: String) { + val (activeId, profiles) = load() + val remaining = profiles.filterNot { it.id == id } + val newActive = if (activeId == id) remaining.firstOrNull()?.id else activeId + save(newActive, remaining) + } + + fun setActive(id: String) { + val (_, profiles) = load() + if (profiles.any { it.id == id }) save(id, profiles) + } + + @Synchronized + private fun migrateLegacyIfNeeded() { + if (prefs().getString(KEY, null) != null) return + val baseUrl = prefs().getString(LEGACY_BASE_URL, "") ?: "" + val model = prefs().getString(LEGACY_MODEL, "") ?: "" + val apiKey = prefs().getString(LEGACY_API_KEY, "") ?: "" + if (baseUrl.isBlank() && model.isBlank() && apiKey.isBlank()) return + val profile = + CustomProviderProfile( + name = "Default", + baseUrl = baseUrl, + modelName = model.ifBlank { "gpt-4o-mini" }, + apiKey = apiKey, + ) + save(profile.id, listOf(profile)) + } + + private fun syncLegacy(activeId: String?, profiles: List) { + val active = profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() + prefs().putString(LEGACY_BASE_URL, active?.baseUrl ?: "") + prefs().putString(LEGACY_MODEL, active?.modelName ?: "") + prefs().putString(LEGACY_API_KEY, active?.apiKey ?: "") + } +} 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..1829899c86 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 @@ -78,6 +78,29 @@ object ApiKey { val key = getGrokApiKey() return key.isNotBlank() && key.length > 20 } + + // Custom Provider (OpenAI-compatible) + fun getCustomApiKey(): String { + return prefManager.getString("ai_agent_custom_api_key", "") + } + + fun getCustomBaseUrl(): String { + return prefManager.getString("ai_agent_custom_base_url", "") + } + + fun getCustomModelName(): String { + return prefManager.getString("ai_agent_custom_model_name", "") + } + + fun hasCustomKey(): Boolean { + // Custom provider can work without an API key; configuration is URL + model. + val key = getCustomApiKey() + return key.isNotBlank() + } + + fun hasCustomProviderConfig(): Boolean { + return getCustomBaseUrl().isNotBlank() && getCustomModelName().isNotBlank() + } // Legacy methods for backward compatibility @Deprecated("Use getGeminiApiKey() instead", ReplaceWith("getGeminiApiKey()")) @@ -93,6 +116,7 @@ object ApiKey { if (hasDeepseekKey()) providers.add("Deepseek") if (hasAnthropicKey()) providers.add("Anthropic") if (hasGrokKey()) providers.add("Grok") + if (hasCustomProviderConfig()) providers.add("Custom") return providers } @@ -103,13 +127,14 @@ object ApiKey { "openai" to getOpenAIApiKey(), "deepseek" to getDeepseekApiKey(), "anthropic" to getAnthropicApiKey(), - "grok" to getGrokApiKey() + "grok" to getGrokApiKey(), + "custom" to getCustomApiKey() ).filterValues { it.isNotBlank() } } // Check if any API key is configured fun hasAnyApiKey(): Boolean { return hasGeminiKey() || hasOpenAIKey() || hasDeepseekKey() || - hasAnthropicKey() || hasGrokKey() + hasAnthropicKey() || hasGrokKey() || hasCustomProviderConfig() } -} \ No newline at end of file +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt new file mode 100644 index 0000000000..4d602c9323 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/AITools.kt @@ -0,0 +1,169 @@ +package com.tom.rv2ide.artificial.tools + +import android.content.Context +import com.tom.rv2ide.artificial.file.AIFileWriter +import com.tom.rv2ide.artificial.project.awareness.ProjectTreeResult +import java.io.File +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +data class AIToolDef( + val name: String, + val description: String, + val parametersJsonSchema: String, +) + +data class AIToolCall(val name: String, val arguments: JSONObject, val raw: String = "", val id: String? = null) +data class AIToolResult(val name: String, val result: String, val toolCallId: String? = null) +data class ToolAwareResponse(val text: String = "", val toolCalls: List = emptyList()) { + fun hasToolCalls() = toolCalls.isNotEmpty() +} + +interface AIToolExecutor { + fun definitions(): List + fun execute(name: String, arguments: JSONObject): String +} + +class BuiltInToolExecutor( + private val context: Context, + private var projectTree: ProjectTreeResult? = null, + private var projectRoot: File? = null, + private val allowCommandExecution: Boolean = false, + private val disabledTools: Set = emptySet(), +) : AIToolExecutor { + private val writer = AIFileWriter(context) + + fun updateProject(tree: ProjectTreeResult?, root: File?) { + projectTree = tree + projectRoot = root + } + + override fun definitions(): List { + val defs = mutableListOf( + AIToolDef("read_file", "Read a UTF-8 text file from the project. Use exact paths.", """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""), + AIToolDef("list_files", "List files under a directory path (non-recursive).", """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""), + AIToolDef("search_project", "Search for text in project source files. Returns matching path:line previews.", """{"type":"object","properties":{"query":{"type":"string"},"maxResults":{"type":"integer"}},"required":["query"]}"""), + AIToolDef("get_project_tree", "Get the project file tree/structure.", """{"type":"object","properties":{}}"""), + AIToolDef("modify_file", "Create or overwrite a project text file. Prefer FILE_TO_MODIFY blocks for normal edits; this tool is for targeted agentic operations.", """{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}"""), + ) + if (allowCommandExecution) { + defs.add(AIToolDef("run_command", "Run a shell command in the project directory (opt-in, use with caution). Returns stdout/stderr and exit code.", """{"type":"object","properties":{"command":{"type":"string"},"timeoutMs":{"type":"integer"}},"required":["command"]}""")) + } + return defs.filterNot { it.name in disabledTools } + } + + override fun execute(name: String, arguments: JSONObject): String { + if (name in disabledTools) return "Error: tool '$name' is disabled by the active agent profile." + if (name == "run_command" && !allowCommandExecution) return "Error: command execution is disabled by the active agent profile." + return try { + when (name) { + "read_file" -> { + val path = arguments.optString("path") + if (path.isBlank()) return "Error: path is required" + val f = File(path) + if (!f.exists() || !f.isFile) return "Error: file not found: $path" + val text = f.readText() + if (text.length > 12000) text.take(12000) + "\n...[truncated]..." else text + } + "list_files" -> { + val path = arguments.optString("path") + val f = File(path) + if (!f.exists() || !f.isDirectory) return "Error: directory not found: $path" + f.listFiles()?.sortedBy { it.name }?.joinToString("\n") { if (it.isDirectory) "${it.name}/" else it.name } ?: "" + } + "search_project" -> { + val query = arguments.optString("query") + if (query.isBlank()) return "Error: query is required" + val max = arguments.optInt("maxResults", 30).coerceIn(1, 100) + val root = projectRoot ?: return "Error: no project root" + val results = mutableListOf() + root.walkTopDown() + .filter { it.isFile && it.extension in listOf("kt","java","xml","gradle","kts","json","md","properties") } + .filter { !it.path.contains("/build/") && !it.path.contains("/.gradle/") && !it.path.contains("/.git/") } + .forEach { file -> + if (results.size >= max) return@forEach + try { file.readLines().forEachIndexed { idx, line -> + if (results.size >= max) return@forEachIndexed + if (line.contains(query, ignoreCase = true)) results.add("${file.absolutePath}:${idx + 1}: ${line.trim().take(200)}") + } } catch (_: Exception) {} + } + if (results.isEmpty()) "No matches." else results.joinToString("\n") + } + "get_project_tree" -> projectTree?.tree ?: "No project tree available." + "modify_file" -> { + val path = arguments.optString("path") + val content = arguments.optString("content") + if (path.isBlank()) return "Error: path is required" + when (val r = writer.writeFile(path, content, createBackup = true)) { + is com.tom.rv2ide.artificial.file.FileWriteResult.Success -> "OK: wrote ${r.path}" + is com.tom.rv2ide.artificial.file.FileWriteResult.Error -> "Error: ${r.message}" + else -> "Error: failed to write file" + } + } + "run_command" -> { + val command = arguments.optString("command") + if (command.isBlank()) return "Error: command is required" + val timeoutMs = arguments.optLong("timeoutMs", 15000L).coerceIn(1000L, 60000L) + val workdir = projectRoot ?: File(context.filesDir.path) + val proc = ProcessBuilder("sh", "-c", command).directory(workdir).redirectErrorStream(true).start() + val finished = proc.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + if (!finished) { proc.destroyForcibly(); return "Error: command timed out after ${timeoutMs}ms" } + val out = proc.inputStream.bufferedReader().readText() + val clipped = if (out.length > 8000) out.take(8000) + "\n...[truncated]" else out + "exit=${proc.exitValue()}\n$clipped" + } + else -> "Error: unknown tool '$name'" + } + } catch (e: Exception) { "Error: ${e.message}" } + } +} + +object ToolCallProtocol { + private val patterns = listOf( + Regex("""TOOL_CALL:\s*(\{.*\})""", RegexOption.DOT_MATCHES_ALL), + Regex("""```tool_call\s*\n(\{.*?\})\s*\n```""", RegexOption.DOT_MATCHES_ALL), + Regex("""```json\s*\n(\{\s*"(?:name|tool)"\s*:.*?\})\s*\n```""", RegexOption.DOT_MATCHES_ALL), + ) + + fun parse(response: String): AIToolCall? { + for (re in patterns) { + val m = re.find(response) ?: continue + val jsonText = m.groupValues[1].trim() + try { + val obj = JSONObject(jsonText) + val name = obj.optString("name").ifBlank { obj.optString("tool") } + if (name.isBlank()) continue + val args = obj.optJSONObject("arguments") ?: obj.optJSONObject("args") ?: try { JSONObject(obj.optString("arguments", "{}")) } catch (_: Exception) { JSONObject() } + return AIToolCall(name, args ?: JSONObject(), m.value, obj.optString("id").ifBlank { null }) + } catch (_: Exception) {} + } + return null + } + + fun formatDefinitions(tools: List): String { + if (tools.isEmpty()) return "" + val sb = StringBuilder() + sb.append("\n=== AVAILABLE TOOLS / SKILLS ===\n") + sb.append("You can use tools to gather information or perform allowed actions.\n") + sb.append("To call a tool, output ONLY:\nTOOL_CALL: {\"name\":\"tool_name\",\"arguments\":{...}}\n\n") + sb.append("After TOOL_RESULT, continue. When done, respond normally (use FILE_TO_MODIFY for file edits).\n\n") + tools.forEach { t -> sb.append("- ${t.name}: ${t.description}\n parameters: ${t.parametersJsonSchema}\n") } + sb.append("\n") + return sb.toString() + } + + fun openAIToolsJson(tools: List): org.json.JSONArray { + val arr = org.json.JSONArray() + tools.forEach { t -> + arr.put(org.json.JSONObject().apply { + put("type", "function") + put("function", org.json.JSONObject().apply { + put("name", t.name) + put("description", t.description) + put("parameters", try { org.json.JSONObject(t.parametersJsonSchema) } catch (_: Exception) { org.json.JSONObject().put("type","object").put("properties", org.json.JSONObject()) }) + }) + }) + } + return arr + } +} diff --git a/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt new file mode 100644 index 0000000000..dd59e1e462 --- /dev/null +++ b/core/app/src/main/java/com/tom/rv2ide/artificial/tools/OpenAICompatNative.kt @@ -0,0 +1,48 @@ +package com.tom.rv2ide.artificial.tools + +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +object OpenAICompatNative { + data class CallConfig(val endpoint: String, val apiKey: String?, val model: String, val extraHeaders: Map = emptyMap()) + + fun call(cfg: CallConfig, messages: JSONArray, tools: List?): ToolAwareResponse { + val body = JSONObject().apply { + put("model", cfg.model); put("messages", messages); put("temperature", 0.7) + if (!tools.isNullOrEmpty()) { put("tools", ToolCallProtocol.openAIToolsJson(tools)); put("tool_choice", "auto") } + } + val conn = URL(cfg.endpoint).openConnection() as HttpURLConnection + try { + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + cfg.apiKey?.takeIf { it.isNotBlank() }?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + cfg.extraHeaders.forEach { (k,v) -> if (k.isNotBlank()) conn.setRequestProperty(k,v) } + conn.doOutput = true; conn.connectTimeout = 30000; conn.readTimeout = 90000 + conn.outputStream.use { it.write(body.toString().toByteArray()) } + val code = conn.responseCode + val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.bufferedReader()?.readText().orEmpty() + if (code !in 200..299) throw Exception("API error $code: $text") + val msg = JSONObject(text).getJSONArray("choices").getJSONObject(0).getJSONObject("message") + val content = msg.optString("content", "") + val calls = mutableListOf() + val arr = msg.optJSONArray("tool_calls") + if (arr != null) for (i in 0 until arr.length()) { + val tc = arr.optJSONObject(i) ?: continue; val fn = tc.optJSONObject("function") ?: continue + val n = fn.optString("name"); if (n.isBlank()) continue + val a = try { JSONObject(fn.optString("arguments","{}").ifBlank{"{}"}) } catch (_: Exception){ JSONObject() } + calls.add(AIToolCall(n,a,tc.toString(), tc.optString("id").ifBlank{null})) + } + if (calls.isEmpty()) ToolCallProtocol.parse(content)?.let { calls.add(it) } + return ToolAwareResponse(content, calls) + } finally { conn.disconnect() } + } + + fun assistantToolCallsMessage(res: ToolAwareResponse): JSONObject = JSONObject().apply { + put("role","assistant"); put("content", res.text) + val arr = JSONArray(); res.toolCalls.forEach { tc -> arr.put(JSONObject().apply { put("id", tc.id ?: "call_${tc.name}_${System.nanoTime()}"); put("type","function"); put("function", JSONObject().put("name",tc.name).put("arguments",tc.arguments.toString())) }) }; put("tool_calls", arr) + } + fun toolResultMessage(tr: AIToolResult): JSONObject = JSONObject().put("role","tool").put("tool_call_id", tr.toolCallId ?: tr.name).put("content", tr.result) +} 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..94bfcb9d3d 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 @@ -22,6 +22,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.tom.rv2ide.artificial.dialogs.LocalLLMConfigDialog +import com.tom.rv2ide.artificial.dialogs.CustomProviderConfigDialog +import com.tom.rv2ide.artificial.dialogs.AgentProfilesDialog +import com.tom.rv2ide.artificial.dialogs.McpServersDialog class AIPreferencesFragment( private val aiAgent: AIAgentManager, @@ -56,6 +59,7 @@ class AIPreferencesFragment( setupProviderDropdown() setupModelDropdown() setupToggles() + setupAgentCapabilityButtons(view) updateCurrentStatus() startCompletionStateMonitoring() } @@ -89,10 +93,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 Provider" ) - 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) @@ -104,10 +109,10 @@ class AIPreferencesFragment( val selectedProviderId = allProviderIds[position] val selectedProviderName = providerNames[position] - if (selectedProviderId == "localllm") { - showLocalLLMConfigDialog(selectedProviderName) - } else { - handleProviderChange(selectedProviderId, selectedProviderName) + when (selectedProviderId) { + "localllm" -> showLocalLLMConfigDialog(selectedProviderName) + "custom" -> showCustomProviderConfigDialog(selectedProviderName) + else -> handleProviderChange(selectedProviderId, selectedProviderName) } } } @@ -118,6 +123,13 @@ class AIPreferencesFragment( } dialog.show(parentFragmentManager, "LocalLLMConfigDialog") } + + private fun showCustomProviderConfigDialog(providerName: String) { + val dialog = CustomProviderConfigDialog { baseUrl, modelName, apiKey -> + handleProviderChange("custom", providerName) + } + dialog.show(parentFragmentManager, "CustomProviderConfigDialog") + } private fun updateProviderDropdownSelection() { val providerMap = mapOf( @@ -126,7 +138,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 Provider" ) val currentProviderId = agents.getProvider() @@ -147,6 +160,7 @@ class AIPreferencesFragment( "deepseek" -> "DeepSeek" "grok" -> "xAI Grok" "localllm" -> "Local LLM" + "custom" -> "Custom Provider" else -> currentProvider.uppercase() } @@ -335,4 +349,9 @@ class AIPreferencesFragment( stopCompletionStateMonitoring() super.onDestroyView() } -} \ No newline at end of file + + private fun setupAgentCapabilityButtons(view: View) { + view.findViewById(R.id.agentProfilesButton)?.setOnClickListener { AgentProfilesDialog.show(requireContext()) } + view.findViewById(R.id.mcpServersButton)?.setOnClickListener { McpServersDialog.show(requireContext()) } + } +} 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..9e3a44acaf 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 customBaseUrlPref: CustomBaseUrl? = null + @IgnoredOnParcel private var customModelNamePref: CustomModelName? = null + @IgnoredOnParcel private var customApiKeyPref: CustomApiKey? = null init { val aiAgentEnabled = AIAgentEnabled { isEnabled -> updateApiKeyPreferencesState(isEnabled) } @@ -60,6 +63,9 @@ private class AIAgentConfig( openAIApiKeyPref = OpenAIApiKey() anthropicApiKeyPref = AnthropicApiKey() grokApiKeyPref = GrokApiKey() + customBaseUrlPref = CustomBaseUrl() + customModelNamePref = CustomModelName() + customApiKeyPref = CustomApiKey() addPreference(aiAgentEnabled) addPreference(geminiApiKeyPref!!) @@ -67,6 +73,9 @@ private class AIAgentConfig( addPreference(openAIApiKeyPref!!) addPreference(anthropicApiKeyPref!!) addPreference(grokApiKeyPref!!) + addPreference(customBaseUrlPref!!) + addPreference(customModelNamePref!!) + addPreference(customApiKeyPref!!) } private fun updateApiKeyPreferencesState(isEnabled: Boolean) { @@ -75,6 +84,9 @@ private class AIAgentConfig( openAIApiKeyPref?.setEnabled(isEnabled) anthropicApiKeyPref?.setEnabled(isEnabled) grokApiKeyPref?.setEnabled(isEnabled) + customBaseUrlPref?.setEnabled(isEnabled) + customModelNamePref?.setEnabled(isEnabled) + customApiKeyPref?.setEnabled(isEnabled) } } @@ -372,3 +384,165 @@ private class AnthropicApiKey( return if (apiKey.isBlank()) "Click to set API key" else "API Key: ${apiKey.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", "")) + editText.hint = "https://api.openai.com" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider Base URL") + .setMessage("Enter the OpenAI-compatible base URL") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val value = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_base_url", value) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val value = prefManager.getString("ai_agent_custom_base_url", "") + return if (value.isBlank()) "Click to set base URL" else value + } +} + +@Parcelize +private class CustomModelName( + override val key: String = "ai_agent_custom_model_name", + override val title: Int = R.string.ai_agent_custom_model_name, +) : BasePreference() { + + @IgnoredOnParcel private var preference: Preference? = null + + override fun onCreatePreference(context: Context): Preference { + preference = + androidx.preference.Preference(context).apply { + key = "ai_agent_custom_model_name" + title = context.getString(R.string.ai_agent_custom_model_name) + 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_name", "")) + editText.hint = "gpt-4o-mini" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider Model") + .setMessage("Enter the model name") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val value = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_model_name", value) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val value = prefManager.getString("ai_agent_custom_model_name", "") + return if (value.isBlank()) "Click to set model name" else value + } +} + +@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 = "Enter API key (optional)" + + val dialog = + com.google.android.material.dialog + .MaterialAlertDialogBuilder(context) + .setTitle("Custom Provider API Key") + .setMessage("Enter an API key if your endpoint requires one") + .setView(editText) + .setPositiveButton("Save") { _, _ -> + val apiKey = editText.text.toString().trim() + prefManager.putString("ai_agent_custom_api_key", apiKey) + preference.summary = getSummaryText() + } + .setNegativeButton("Cancel", null) + .create() + + dialog.show() + return true + } + + fun setEnabled(enabled: Boolean) { + preference?.isEnabled = enabled + } + + private fun getSummaryText(): String { + val apiKey = prefManager.getString("ai_agent_custom_api_key", "") + return if (apiKey.isBlank()) "Click to set API key (optional)" else "API Key: ${apiKey.take(8)}..." + } +} diff --git a/core/app/src/main/res/layout/dialog_custom_provider_config.xml b/core/app/src/main/res/layout/dialog_custom_provider_config.xml new file mode 100644 index 0000000000..40f1fd0dd8 --- /dev/null +++ b/core/app/src/main/res/layout/dialog_custom_provider_config.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/app/src/main/res/layout/fragment_ai_preferences.xml b/core/app/src/main/res/layout/fragment_ai_preferences.xml index 6bd6157bb0..792f436524 100644 --- a/core/app/src/main/res/layout/fragment_ai_preferences.xml +++ b/core/app/src/main/res/layout/fragment_ai_preferences.xml @@ -173,6 +173,41 @@ + + + + + + + + + + - \ No newline at end of file + diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index b56eec7f6d..9afc60a796 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -86,6 +86,11 @@ Grok API Key + + Custom Provider Base URL + Custom Provider Model Name + Custom Provider API Key + ACS Components Missing This application appears to be incomplete or unofficial, as it is missing the required ACS build system. For full functionality, please install the official Android Code Studio from a trusted source.