package cc.unitmesh.devins.ui.compose.agent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cc.unitmesh.agent.AgentTask import cc.unitmesh.agent.AgentType import cc.unitmesh.agent.CodeReviewAgent import cc.unitmesh.agent.CodingAgent import cc.unitmesh.agent.external.ExternalCliCodingAgent import cc.unitmesh.agent.external.ExternalCliKind import cc.unitmesh.agent.external.ExternalCliMode import cc.unitmesh.agent.config.McpToolConfigManager import cc.unitmesh.agent.config.McpToolConfigService import cc.unitmesh.agent.config.PreloadingStatus import cc.unitmesh.agent.diff.FileChangeTracker import cc.unitmesh.agent.tool.shell.ShellSessionManager import cc.unitmesh.agent.tool.schema.ToolCategory import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.config.AcpAgentConfig import cc.unitmesh.devins.filesystem.DefaultProjectFileSystem import cc.unitmesh.devins.llm.ChatHistoryManager import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.agent.acp.AcpConnection import cc.unitmesh.devins.ui.compose.agent.acp.createAcpConnection import cc.unitmesh.devins.ui.compose.agent.acp.createConnectionForAgent import cc.unitmesh.devins.ui.compose.agent.acp.isAcpSupported import cc.unitmesh.devins.ui.i18n.LanguageManager import cc.unitmesh.config.ConfigManager import cc.unitmesh.indexer.DomainDictGenerator import cc.unitmesh.llm.LLMService import kotlinx.coroutines.* class CodingAgentViewModel( private val llmService: LLMService?, private val projectPath: String, private val maxIterations: Int = 100, private val chatHistoryManager: ChatHistoryManager? = null // 新增:会话管理 ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val renderer = ComposeRenderer() var currentAgentType by mutableStateOf(AgentType.CODING) private set /** * GUI execution engine selection. * - AUTODEV: our built-in CodingAgent (requires LLM config) * - CLAUDE_CLI / CODEX_CLI: external CLIs (do not require LLM config inside Xiuper) * - ACP agents: external ACP-compliant agents (e.g., Kimi CLI, Claude --acp) */ var currentEngine by mutableStateOf(GuiAgentEngine.AUTODEV) private set /** * Currently selected ACP agent config (non-null when an ACP engine is active). */ var currentAcpAgentConfig by mutableStateOf(null) private set /** * Active ACP connection (lazy-created when an ACP engine is selected). */ private var acpConnection: AcpConnection? = null /** * Configured ACP agents loaded from config. * Key is the agent identifier, value is the config. */ var acpAgents by mutableStateOf>(emptyMap()) private set private var _codingAgent: CodingAgent? = null private var _codeReviewAgent: CodeReviewAgent? = null private var agentInitialized = false var isExecuting by mutableStateOf(false) private set private var currentExecutionJob: Job? = null var mcpPreloadingStatus by mutableStateOf(PreloadingStatus(false, emptyList(), 0)) private set var mcpPreloadingMessage by mutableStateOf("") private set var isTreeViewVisible by mutableStateOf(false) private var cachedToolConfig: cc.unitmesh.agent.config.ToolConfigFile? = null init { // Load historical messages from chatHistoryManager chatHistoryManager?.let { manager -> val messages = manager.getMessages() renderer.loadFromMessages(messages) } if (llmService != null) { scope.launch { startMcpPreloading() } } // Load ACP agent configurations scope.launch { loadAcpAgents() } } /** * Start MCP servers preloading in background */ private suspend fun startMcpPreloading() { try { mcpPreloadingMessage = "Loading MCP servers configuration..." val toolConfig = ConfigManager.loadToolConfig() cachedToolConfig = toolConfig if (toolConfig.mcpServers.isEmpty()) { mcpPreloadingMessage = "No MCP servers configured" return } mcpPreloadingMessage = "Initializing ${toolConfig.mcpServers.size} MCP servers..." // Initialize MCP servers (this will start background preloading) McpToolConfigManager.init(toolConfig) // Monitor preloading status while (McpToolConfigManager.isPreloading()) { mcpPreloadingStatus = McpToolConfigManager.getPreloadingStatus() mcpPreloadingMessage = "Loading MCP servers... (${mcpPreloadingStatus.preloadedServers.size} completed)" delay(500) // Update every 500ms } // Wait a bit more to ensure all status updates are complete delay(1000) // Final status update - force refresh multiple times to ensure we get the latest repeat(3) { mcpPreloadingStatus = McpToolConfigManager.getPreloadingStatus() delay(100) } val preloadedCount = mcpPreloadingStatus.preloadedServers.size val totalCount = toolConfig.mcpServers.filter { !it.value.disabled }.size mcpPreloadingMessage = if (preloadedCount > 0) { "MCP servers loaded successfully ($preloadedCount/$totalCount servers)" } else { "MCP servers initialization completed (no tools loaded)" } } catch (e: Exception) { mcpPreloadingMessage = "Failed to load MCP servers: ${e.message}" } } /** * Initialize the CodingAgent with tool configuration * This must be called before executing any tasks * @throws IllegalStateException if llmService is not configured */ private suspend fun initializeCodingAgent(): CodingAgent { if (llmService == null) { throw IllegalStateException("LLM service is not configured") } if (_codingAgent == null || !agentInitialized) { val toolConfig = ConfigManager.loadToolConfig() val mcpToolConfigService = McpToolConfigService(toolConfig) _codingAgent = createPlatformCodingAgent( projectPath = projectPath, llmService = llmService, maxIterations = maxIterations, renderer = renderer, mcpToolConfigService = mcpToolConfigService ) agentInitialized = true } return _codingAgent!! } fun switchAgent(agentType: AgentType) { if (currentAgentType != agentType) { currentAgentType = agentType } } fun switchEngine(engine: GuiAgentEngine) { if (currentEngine != engine) { currentEngine = engine // Clear ACP agent when switching to a built-in engine if (currentAcpAgentConfig != null) { scope.launch { disconnectAcp() } currentAcpAgentConfig = null } } } /** * Switch to a named ACP agent engine. * @param agentKey the key in the acpAgents map */ fun switchToAcpAgent(agentKey: String) { val config = acpAgents[agentKey] ?: return currentAcpAgentConfig = config // Mark engine as ACP (we reuse AUTODEV but the actual execution will check currentAcpAgentConfig) currentEngine = GuiAgentEngine.ACP } /** * The display name of the currently selected engine (for the dropdown). */ val currentEngineDisplay: String get() = currentAcpAgentConfig?.name ?: currentEngine.displayName /** * Get the full list of available engine display names. * Includes built-in engines + configured ACP agents. */ fun getAvailableEngineOptions(): List { val builtIn = GuiAgentEngine.entries .filter { it != GuiAgentEngine.ACP } .map { it.displayName } val acpNames = acpAgents.values .filter { it.isConfigured() } .map { it.name } return builtIn + acpNames } /** * Handle engine selection from the dropdown (by display name). */ fun selectEngine(displayName: String) { // Check built-in engines first val builtIn = GuiAgentEngine.fromDisplayName(displayName) if (builtIn != null && builtIn != GuiAgentEngine.ACP) { switchEngine(builtIn) return } // Check ACP agents val acpEntry = acpAgents.entries.find { it.value.name == displayName } if (acpEntry != null) { switchToAcpAgent(acpEntry.key) } } /** * Load ACP agents from config file. */ suspend fun loadAcpAgents() { try { val configWrapper = ConfigManager.load() acpAgents = configWrapper.getAcpAgents() } catch (e: Exception) { println("[ACP] Failed to load ACP agent configs: ${e.message}") } } /** * Reload ACP agents (called after config dialog saves). */ fun reloadAcpAgents() { scope.launch { loadAcpAgents() } } private suspend fun disconnectAcp() { try { acpConnection?.disconnect() } catch (_: Exception) {} acpConnection = null } fun isConfigured(): Boolean = llmService != null fun executeTask(task: String, onConfigRequired: (() -> Unit)? = null) { if (isExecuting) { return } // Engine-dependent config check: // - AUTODEV engine requires local LLM config // - External engines (CLI, ACP) do not require Xiuper LLM config if (currentEngine == GuiAgentEngine.AUTODEV && !isConfigured()) { renderer.addUserMessage(task) renderer.renderError("WARNING: LLM model is not configured. Please configure your model to continue.") onConfigRequired?.invoke() return } if (task.trim().startsWith("/")) { handleBuiltinCommand(task.trim(), onConfigRequired) return } isExecuting = true renderer.clearError() renderer.addUserMessage(task) currentExecutionJob = scope.launch { try { val agentTask = AgentTask( requirement = task, projectPath = projectPath, language = LanguageManager.getLanguage().code.uppercase() ) when (currentEngine) { GuiAgentEngine.AUTODEV -> { val codingAgent = initializeCodingAgent() codingAgent.executeTask(agentTask) } GuiAgentEngine.CLAUDE_CLI -> { ExternalCliCodingAgent( projectPath = projectPath, kind = ExternalCliKind.CLAUDE, renderer = renderer, mode = ExternalCliMode.NON_INTERACTIVE ).executeTask(agentTask) } GuiAgentEngine.CODEX_CLI -> { ExternalCliCodingAgent( projectPath = projectPath, kind = ExternalCliKind.CODEX, renderer = renderer, mode = ExternalCliMode.NON_INTERACTIVE ).executeTask(agentTask) } GuiAgentEngine.ACP -> { executeAcpTask(task) } } } catch (e: CancellationException) { renderer.forceStop() // Stop all loading states renderer.renderError("Task cancelled by user") } catch (e: Exception) { renderer.renderError(e.message ?: "Unknown error") } finally { isExecuting = false currentExecutionJob = null saveConversationHistory() } } } /** * Execute a task via ACP agent. * Connects if needed, then sends the prompt with events streamed to the ComposeRenderer. */ private suspend fun executeAcpTask(task: String) { val config = currentAcpAgentConfig ?: throw IllegalStateException("No ACP agent configured") if (!isAcpSupported()) { renderer.renderError("ACP agents are not supported on this platform") return } // Connect if not already connected if (acpConnection?.isConnected != true) { renderer.renderInfo("🔌 Connecting to ${config.name}...") try { disconnectAcp() // Clean up any stale connection val connection = createConnectionForAgent(config) ?: throw IllegalStateException("Failed to create connection for agent") connection.connect(config, projectPath) acpConnection = connection renderer.renderInfo("✅ Connected to ${config.name}") } catch (e: Exception) { renderer.renderError("❌ Failed to connect to ACP agent: ${e.message}") return } } // Send the prompt - events stream directly to ComposeRenderer // Note: Don't call renderLLMResponseStart/End here - AcpClient handles that acpConnection?.prompt(task, renderer) } private suspend fun saveConversationHistory() { chatHistoryManager?.let { manager -> try { // Get timeline snapshot with metadata from renderer val timelineMessages = renderer.getTimelineSnapshot() // Get existing messages count to avoid duplicates val existingMessagesCount = manager.getMessages().size // Only save new messages (USER and ASSISTANT only) val newMessages = timelineMessages .drop(existingMessagesCount) .filter { it.role == MessageRole.USER || it.role == MessageRole.ASSISTANT } // Use ChatHistoryManager's addMessages method to properly save with auto-title if (newMessages.isNotEmpty()) { manager.addMessages(newMessages) } } catch (e: Exception) { println("[ERROR] Failed to save conversation history: ${e.message}") } } } private fun handleBuiltinCommand(command: String, onConfigRequired: (() -> Unit)? = null) { val parts = command.substring(1).trim().split("\\s+".toRegex()) val commandName = parts[0].lowercase() val args = parts.drop(1).joinToString(" ") renderer.addUserMessage(command) when (commandName) { "init" -> { if (!isConfigured()) { renderer.renderError("WARNING: LLM model is not configured. Please configure your model to use /init command.") onConfigRequired?.invoke() return } handleInitCommand(args) } "clear" -> { renderer.clearMessages() chatHistoryManager?.clearCurrentSession() // 同时清空会话历史 _codingAgent?.clearConversation() // 清空 Agent 的对话上下文 FileChangeTracker.clearChanges() // 清空文件变更记录 renderer.renderFinalResult(true, "SUCCESS: Chat history cleared", 0) } "help" -> { val helpText = buildString { appendLine("HELP: Available Commands:") appendLine(" /init [--force] - Initialize project domain dictionary") appendLine(" /clear - Clear chat history") appendLine(" /help - Show this help message") appendLine("") appendLine("TIP: You can also use @ for agents and other DevIns commands") } renderer.renderFinalResult(true, helpText, 0) } else -> { if (!isConfigured()) { renderer.renderError("WARNING: LLM model is not configured. Please configure your model to continue.") onConfigRequired?.invoke() return } isExecuting = true currentExecutionJob = scope.launch { try { val codingAgent = initializeCodingAgent() val agentTask = AgentTask( requirement = command, projectPath = projectPath, language = LanguageManager.getLanguage().code.uppercase() ) codingAgent.executeTask(agentTask) isExecuting = false currentExecutionJob = null } catch (e: Exception) { renderer.renderError(e.message ?: "Unknown error") isExecuting = false currentExecutionJob = null } } } } } /** * Cancel current task */ fun cancelTask() { if (isExecuting && currentExecutionJob != null) { currentExecutionJob?.cancel("Task cancelled by user") currentExecutionJob = null isExecuting = false } // Cancel ACP agent prompt if active if (currentEngine == GuiAgentEngine.ACP && acpConnection?.isConnected == true) { scope.launch { try { acpConnection?.cancel() } catch (e: Exception) { println("[ACP] Cancel failed: ${e.message}") } } } // Also cancel any active shell sessions so "Stop" behaves like a true interrupt. // This prevents long-running commands (e.g. bootRun) from being misclassified as failures. scope.launch { try { val sessions = ShellSessionManager.getActiveSessions() sessions.forEach { session -> ShellSessionManager.markSessionCancelledByUser(session.sessionId) // Best-effort: kill the session; live session monitoring will capture output from ShellSessionManager. session.kill() } } catch (e: Exception) { // Non-fatal: task cancellation should still succeed even if session cleanup fails. renderer.renderInfo("⚠️ Failed to stop shell sessions: ${e.message}") } } } /** * Start a new session, clearing conversation history. * This should be called when user explicitly wants to start fresh. */ fun newSession() { // Cancel any running task before starting a new session if (isExecuting) { cancelTask() } renderer.clearMessages() chatHistoryManager?.createSession() // Clear the agent's conversation history as well _codingAgent?.clearConversation() // Clear file changes from previous session FileChangeTracker.clearChanges() // CRITICAL FIX: Disconnect and reconnect ACP session for new chat // ACP agents maintain conversation context on their side, so we must // explicitly disconnect and reconnect to get a fresh session if (currentEngine == GuiAgentEngine.ACP && currentAcpAgentConfig != null) { scope.launch { try { println("[ACP] Resetting session for new chat...") disconnectAcp() // The next prompt will trigger a fresh connection } catch (e: Exception) { println("[ACP] Failed to reset session: ${e.message}") } } } } /** * Switch to a different session and load its messages. * Note: This clears the agent's conversation context since we're switching sessions. */ fun switchSession(sessionId: String) { chatHistoryManager?.let { manager -> val session = manager.switchSession(sessionId) if (session != null) { // Clear current renderer state renderer.clearMessages() // Clear agent conversation since we're switching to a different session _codingAgent?.clearConversation() // Load messages from the switched session val messages = manager.getMessages() renderer.loadFromMessages(messages) } } } /** * Handle /init command for domain dictionary generation */ private fun handleInitCommand(args: String) { val force = args.contains("--force") scope.launch { try { // Add messages to timeline using the renderer's message system renderer.addUserMessage("/init $args") // Start processing indicator renderer.renderLLMResponseStart() renderer.renderLLMResponseChunk("INFO: Starting domain dictionary generation...") renderer.renderLLMResponseEnd() // Load configuration val configWrapper = ConfigManager.load() val modelConfig = configWrapper.getActiveModelConfig() if (modelConfig == null) { renderer.renderError("ERROR: No LLM configuration found. Please configure your model first.") return@launch } renderer.renderLLMResponseStart() renderer.renderLLMResponseChunk("INFO: Analyzing project code...") renderer.renderLLMResponseEnd() // Create domain dictionary generator val fileSystem = DefaultProjectFileSystem(projectPath) val generator = DomainDictGenerator(fileSystem = fileSystem, modelConfig = modelConfig) // Check if domain dictionary already exists if (!force && fileSystem.exists("prompts/domain.csv")) { renderer.renderError("WARNING: Domain dictionary already exists at prompts/domain.csv\nUse /init --force to regenerate") return@launch } renderer.renderLLMResponseStart() renderer.renderLLMResponseChunk("INFO: Generating domain dictionary with AI...") renderer.renderLLMResponseEnd() // Generate domain dictionary val result = generator.generateAndSave() when (result) { is cc.unitmesh.indexer.GenerationResult.Success -> { renderer.renderLLMResponseStart() renderer.renderLLMResponseChunk("INFO: Saving domain dictionary to prompts/domain.csv...") renderer.renderLLMResponseEnd() renderer.renderFinalResult( true, "SUCCESS: Domain dictionary generated successfully! File saved to prompts/domain.csv", 1 ) } is cc.unitmesh.indexer.GenerationResult.Error -> { renderer.renderError("ERROR: Domain dictionary generation failed: ${result.message}") } } } catch (e: Exception) { renderer.renderError("ERROR: Domain dictionary generation failed: ${e.message}") } } } /** * Clear error state */ fun clearError() { renderer.clearError() } fun getToolLoadingStatus(): ToolLoadingStatus { val toolConfig = cachedToolConfig val subAgentTools = ToolType.byCategory(ToolCategory.SubAgent) val subAgentsEnabled = subAgentTools.size // All sub-agents are always enabled val mcpServersTotal = toolConfig?.mcpServers?.filter { !it.value.disabled }?.size ?: 0 val mcpServersLoaded = mcpPreloadingStatus.preloadedServers.size val mcpToolsEnabled = if (McpToolConfigManager.isPreloading()) { 0 } else { val enabledMcpToolsCount = toolConfig?.enabledMcpTools?.size ?: 0 if (enabledMcpToolsCount > 0) { enabledMcpToolsCount } else { mcpPreloadingStatus.preloadedServers.sumOf { _ -> 0 } } } val mcpToolsTotal = if (McpToolConfigManager.isPreloading()) { 0 } else { McpToolConfigManager.getTotalDiscoveredTools() } return ToolLoadingStatus( subAgentsEnabled = subAgentsEnabled, subAgentsTotal = subAgentTools.size, mcpServersLoaded = mcpServersLoaded, mcpServersTotal = mcpServersTotal, mcpToolsEnabled = mcpToolsEnabled, mcpToolsTotal = mcpToolsTotal, isLoading = McpToolConfigManager.isPreloading() ) } } enum class GuiAgentEngine(val displayName: String) { AUTODEV("AutoDev"), CLAUDE_CLI("Claude"), CODEX_CLI("Codex"), /** * ACP engine - external ACP-compliant agent. * Not shown directly in the dropdown; instead, individual ACP agents are listed by name. */ ACP("ACP"); companion object { fun fromDisplayName(name: String): GuiAgentEngine? { return entries.firstOrNull { it.displayName.equals(name, ignoreCase = true) } } } } /** * Data class to hold tool loading status information */ data class ToolLoadingStatus( val subAgentsEnabled: Int = 0, val subAgentsTotal: Int = 0, val mcpServersLoaded: Int = 0, val mcpServersTotal: Int = 0, val mcpToolsEnabled: Int = 0, val mcpToolsTotal: Int = 0, val isLoading: Boolean = false )