package cc.unitmesh.devins.ui.compose.agent import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType import cc.unitmesh.agent.Platform import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.config.CloudStorageConfig import cc.unitmesh.config.ConfigManager import cc.unitmesh.devins.ui.base.ResizableSplitPane import cc.unitmesh.devins.ui.compose.chat.TopBarMenu import cc.unitmesh.devins.ui.compose.runconfig.FloatingRunButton import cc.unitmesh.devins.ui.compose.runconfig.RunConfigViewModel import cc.unitmesh.devins.ui.compose.runconfig.RunOutputDock import cc.unitmesh.devins.ui.compose.config.AcpAgentConfigDialog import cc.unitmesh.devins.ui.compose.config.CloudStorageConfigDialog import kotlinx.coroutines.launch import cc.unitmesh.devins.ui.compose.editor.DevInEditorInput import cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploader import cc.unitmesh.devins.ui.compose.editor.multimodal.VisionAnalysisService import cc.unitmesh.devins.ui.state.UIStateManager import cc.unitmesh.config.AutoDevConfigWrapper import cc.unitmesh.devins.workspace.WorkspaceManager import cc.unitmesh.llm.LLMService import cc.unitmesh.llm.LLMProviderType import cc.unitmesh.llm.NamedModelConfig @Composable fun CodingAgentPage( llmService: LLMService?, isTreeViewVisible: Boolean = false, // 保留供外部读取,但内部使用全局状态 onConfigWarning: () -> Unit, onToggleTreeView: (Boolean) -> Unit = {}, // 保留供外部回调,但不再主动调用 // 会话管理 chatHistoryManager: cc.unitmesh.devins.llm.ChatHistoryManager? = null, // Agent 类型(LOCAL or CODING) selectedAgentType: AgentType = AgentType.CODING, onAgentTypeChange: (AgentType) -> Unit = {}, // TopBar 参数 hasHistory: Boolean = false, hasDebugInfo: Boolean = false, currentModelConfig: cc.unitmesh.llm.ModelConfig? = null, selectedAgent: String = "Default", availableAgents: List = listOf("Default"), useAgentMode: Boolean = true, onOpenDirectory: () -> Unit = {}, onClearHistory: () -> Unit = {}, onModelConfigChange: (cc.unitmesh.llm.ModelConfig) -> Unit = {}, onAgentChange: (String) -> Unit = {}, onModeToggle: () -> Unit = {}, onConfigureRemote: () -> Unit = {}, onShowModelConfig: () -> Unit = {}, onShowToolConfig: () -> Unit = {}, showTopBar: Boolean = true, // 会话切换支持 onSessionSelected: ((String) -> Unit)? = null, onNewChat: (() -> Unit)? = null, onInternalSessionSelected: (((String) -> Unit) -> Unit)? = null, onInternalNewChat: ((() -> Unit) -> Unit)? = null, modifier: Modifier = Modifier ) { val isTreeViewVisibleState by UIStateManager.isTreeViewVisible.collectAsState() val scope = rememberCoroutineScope() val currentWorkspace by WorkspaceManager.workspaceFlow.collectAsState() // Populated by AutoDevApp after loading last workspace / default workspace val uiWorkspacePath by UIStateManager.workspacePath.collectAsState() // Cloud storage configuration state var cloudStorageConfig by remember { mutableStateOf(null) } var showCloudStorageDialog by remember { mutableStateOf(false) } // ACP agent configuration dialog state var showAcpConfigDialog by remember { mutableStateOf(false) } var imageUploader by remember { mutableStateOf(null) } var visionService by remember { mutableStateOf(null) } // Vision model state - allows user to select which vision model to use var selectedVisionModel by remember { mutableStateOf("glm-4.6v") } var selectedVisionApiKey by remember { mutableStateOf("") } // Load cloud storage config on start LaunchedEffect(Unit) { try { val configWrapper = ConfigManager.load() cloudStorageConfig = configWrapper.getCloudStorage() if (cloudStorageConfig?.isConfigured() == true) { imageUploader = ImageUploader(cloudStorageConfig!!) } // Get GLM API key for vision service (default) val glmConfig = configWrapper.getModelConfigByProvider(LLMProviderType.GLM.name) if (glmConfig != null && glmConfig.apiKey.isNotBlank()) { selectedVisionApiKey = glmConfig.apiKey visionService = VisionAnalysisService(glmConfig.apiKey, selectedVisionModel) } } catch (e: Exception) { println("Failed to load cloud storage config: ${e.message}") } } // Update vision service when model changes LaunchedEffect(selectedVisionModel, selectedVisionApiKey) { if (selectedVisionApiKey.isNotBlank()) { visionService = VisionAnalysisService(selectedVisionApiKey, selectedVisionModel) } } val viewModel = remember(llmService, currentWorkspace?.rootPath, uiWorkspacePath, chatHistoryManager) { val workspace = currentWorkspace // Prefer active workspace. If WorkspaceManager is not ready yet, use UIStateManager fallback. // Avoid defaulting to user home when starting ACP agents: Kimi's Shell commands run relative to this path. val rootPath = workspace?.rootPath ?.takeIf { it.isNotBlank() } ?: uiWorkspacePath.takeIf { it.isNotBlank() } ?: Platform.getUserHomeDir() CodingAgentViewModel( llmService = llmService, projectPath = rootPath, maxIterations = 100, chatHistoryManager = chatHistoryManager ) } LaunchedEffect(selectedAgentType) { viewModel.switchAgent(selectedAgentType) } val handleSessionSelected: (String) -> Unit = remember(viewModel) { { sessionId -> viewModel.switchSession(sessionId) onSessionSelected?.invoke(sessionId) } } val handleNewChat: () -> Unit = remember(viewModel) { { viewModel.newSession() onNewChat?.invoke() } } LaunchedEffect(handleSessionSelected, handleNewChat) { onInternalSessionSelected?.invoke(handleSessionSelected) onInternalNewChat?.invoke(handleNewChat) } // RunConfig ViewModel for project run configurations val runConfigViewModel = remember(currentWorkspace?.rootPath, llmService) { val rootPath = currentWorkspace?.rootPath ?: Platform.getUserHomeDir() RunConfigViewModel( projectPath = rootPath, llmService = llmService ) } val runConfigState by runConfigViewModel.state.collectAsState() val runConfigs by runConfigViewModel.configs.collectAsState() val defaultRunConfig by runConfigViewModel.defaultConfig.collectAsState() val isRunning by runConfigViewModel.isRunning.collectAsState() val runningConfigId by runConfigViewModel.runningConfigId.collectAsState() val runOutput by runConfigViewModel.output.collectAsState() val runAnalysisLog by runConfigViewModel.analysisLog.collectAsState() var showRunOutputDock by remember { mutableStateOf(false) } // Track which mode the dock is in (analysis or run output) val isAnalyzing = runConfigState == cc.unitmesh.agent.runconfig.RunConfigState.ANALYZING val dockTitle = if (isAnalyzing) "AI Analysis" else "Run Output" val dockOutput = if (isAnalyzing) runAnalysisLog else runOutput // Auto-open output dock when we have output, a run is in progress, or analyzing LaunchedEffect(isRunning, runOutput, isAnalyzing, runAnalysisLog) { if (isRunning || runOutput.isNotBlank() || isAnalyzing || runAnalysisLog.isNotBlank()) { showRunOutputDock = true } } // Extracted RunOutputDock composable to avoid duplication val runOutputDockContent: @Composable () -> Unit = { RunOutputDock( isVisible = showRunOutputDock, title = dockTitle, output = dockOutput, isRunning = isRunning, onClear = { runConfigViewModel.clearOutput() runConfigViewModel.clearAnalysisLog() }, onClose = { showRunOutputDock = false }, onStop = if (!isAnalyzing) { { runConfigViewModel.stopRunning() } } else null, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) ) } // Main content Box(modifier = modifier.fillMaxSize()) { if (isTreeViewVisibleState) { ResizableSplitPane( modifier = modifier.fillMaxSize(), initialSplitRatio = 0.6f, minRatio = 0.3f, maxRatio = 0.8f, first = { Column(modifier = Modifier.fillMaxSize()) { if (showTopBar) { TopBarMenu( hasHistory = hasHistory, hasDebugInfo = hasDebugInfo, currentModelConfig = currentModelConfig, selectedAgent = selectedAgent, availableAgents = availableAgents, isTreeViewVisible = isTreeViewVisibleState, currentAgentType = selectedAgentType, onAgentTypeChange = onAgentTypeChange, onOpenDirectory = onOpenDirectory, onClearHistory = onClearHistory, onModelConfigChange = onModelConfigChange, onAgentChange = onAgentChange, onModeToggle = onModeToggle, onToggleTreeView = { UIStateManager.toggleTreeView() }, onConfigureRemote = onConfigureRemote, onShowModelConfig = onShowModelConfig, onShowToolConfig = onShowToolConfig, modifier = Modifier.statusBarsPadding() ) } // Chat 消息列表 和 Task Panel val activeTasks = remember(viewModel.renderer.tasks) { viewModel.renderer.tasks.filter { it.status != TaskStatus.COMPLETED && it.status != TaskStatus.CANCELLED } } if (activeTasks.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { AgentMessageList( renderer = viewModel.renderer, modifier = Modifier .weight(0.65f) .fillMaxHeight(), onOpenFileViewer = { filePath -> viewModel.renderer.openFileViewer(filePath) } ) TaskPanel( tasks = activeTasks, modifier = Modifier .weight(0.35f) .fillMaxHeight() .padding(end = 12.dp), onClose = { /* Tasks auto-hide when completed */ } ) } } else { AgentMessageList( renderer = viewModel.renderer, modifier = Modifier .fillMaxWidth() .weight(1f), onOpenFileViewer = { filePath -> viewModel.renderer.openFileViewer(filePath) } ) } val callbacks = remember(viewModel) { createAgentCallbacks( viewModel = viewModel, onConfigWarning = onConfigWarning ) } when (selectedAgentType) { AgentType.LOCAL_CHAT, AgentType.CODING -> { runOutputDockContent() val engineOptions = remember(viewModel.acpAgents) { viewModel.getAvailableEngineOptions() + if (cc.unitmesh.devins.ui.compose.agent.acp.isAcpSupported()) listOf(ACP_CONFIGURE_LABEL) else emptyList() } DevInEditorInput( initialText = "", placeholder = "Describe your coding task...", callbacks = callbacks, completionManager = currentWorkspace?.completionManager, isCompactMode = true, isExecuting = viewModel.isExecuting, onStopClick = { viewModel.cancelTask() }, onModelConfigChange = { /* Handle model config change if needed */ }, engine = viewModel.currentEngineDisplay, engineOptions = engineOptions, onEngineChange = { display -> if (display == ACP_CONFIGURE_LABEL) { showAcpConfigDialog = true } else { viewModel.selectEngine(display) } }, renderer = viewModel.renderer, modifier = Modifier .fillMaxWidth() .imePadding() .padding(horizontal = 12.dp, vertical = 8.dp), // Multimodal support onImageUpload = if (imageUploader?.isConfigured() == true) { { imagePath, _, onProgress -> imageUploader!!.uploadImage(imagePath, onProgress) } } else { // Show config dialog when trying to upload without config { _, _, _ -> showCloudStorageDialog = true cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploadResult( success = false, error = "Please configure cloud storage first" ) } }, onImageUploadBytes = if (imageUploader?.isConfigured() == true) { { imageBytes, fileName, mimeType, _, onProgress -> imageUploader!!.uploadImageBytes(imageBytes, fileName, mimeType, onProgress) } } else { // Show config dialog when trying to upload without config { _, _, _, _, _ -> showCloudStorageDialog = true cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploadResult( success = false, error = "Please configure cloud storage first" ) } }, onMultimodalAnalysis = if (visionService != null) { { imageUrls, prompt, onChunk -> visionService!!.analyzeImages(imageUrls, prompt, onChunk) } } else null, onVisionModelChange = { config: NamedModelConfig -> selectedVisionModel = config.name selectedVisionApiKey = config.apiKey } ) } AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.CHAT_DB -> { // CODE_REVIEW, DOCUMENT_READER and CHAT_DB have their own full-page interfaces // They should not reach here - handled by AgentInterfaceRouter } AgentType.REMOTE -> { // REMOTE type should not reach here - it's handled by AgentInterfaceRouter // This is a fallback to prevent compilation errors } AgentType.WEB_EDIT -> { // WEB_EDIT has its own full-page interface // It should not reach here - handled by AgentInterfaceRouter } AgentType.ARTIFACT -> { // ARTIFACT has its own full-page interface (ArtifactPage) // It should not reach here - handled by AgentInterfaceRouter } } ToolLoadingStatusBar( viewModel = viewModel, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp) ) } }, second = { val hasFileViewer = viewModel.renderer.currentViewingFile != null if (hasFileViewer) { ResizableSplitPane( modifier = Modifier.fillMaxSize(), initialSplitRatio = 0.4f, minRatio = 0.2f, maxRatio = 0.6f, first = { FileSystemTreeView( rootPath = currentWorkspace?.rootPath ?: "", onFileClick = { filePath -> viewModel.renderer.openFileViewer(filePath) }, onClose = { UIStateManager.setTreeViewVisible(false) }, modifier = Modifier.fillMaxSize() ) }, second = { viewModel.renderer.currentViewingFile?.let { filePath -> FileViewerPanelWrapper( filePath = filePath, onClose = { viewModel.renderer.closeFileViewer() }, modifier = Modifier.fillMaxSize() ) } } ) } else { FileSystemTreeView( rootPath = currentWorkspace?.rootPath ?: "", onFileClick = { filePath -> viewModel.renderer.openFileViewer(filePath) }, onClose = { UIStateManager.setTreeViewVisible(false) }, modifier = Modifier.fillMaxSize() ) } } ) } else { Column(modifier = modifier.fillMaxSize()) { if (showTopBar) { cc.unitmesh.devins.ui.compose.chat.TopBarMenu( hasHistory = hasHistory, hasDebugInfo = hasDebugInfo, currentModelConfig = currentModelConfig, selectedAgent = selectedAgent, availableAgents = availableAgents, isTreeViewVisible = isTreeViewVisibleState, currentAgentType = selectedAgentType, onAgentTypeChange = onAgentTypeChange, onOpenDirectory = onOpenDirectory, onClearHistory = onClearHistory, onModelConfigChange = onModelConfigChange, onAgentChange = onAgentChange, onModeToggle = onModeToggle, onToggleTreeView = { UIStateManager.toggleTreeView() }, onConfigureRemote = onConfigureRemote, onShowModelConfig = onShowModelConfig, onShowToolConfig = onShowToolConfig, modifier = Modifier.statusBarsPadding() ) } // Chat 消息列表 和 Task Panel val activeTasks = remember(viewModel.renderer.tasks) { viewModel.renderer.tasks.filter { it.status != TaskStatus.COMPLETED && it.status != TaskStatus.CANCELLED } } if (activeTasks.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { AgentMessageList( renderer = viewModel.renderer, modifier = Modifier .weight(0.65f) .fillMaxHeight(), onOpenFileViewer = { filePath -> viewModel.renderer.openFileViewer(filePath) } ) TaskPanel( tasks = activeTasks, modifier = Modifier .weight(0.35f) .fillMaxHeight() .padding(end = 12.dp), onClose = { /* Tasks auto-hide when completed */ } ) } } else { AgentMessageList( renderer = viewModel.renderer, modifier = Modifier .fillMaxWidth() .weight(1f), onOpenFileViewer = { filePath -> viewModel.renderer.openFileViewer(filePath) } ) } val callbacks = remember(viewModel) { createAgentCallbacks( viewModel = viewModel, onConfigWarning = onConfigWarning ) } runOutputDockContent() val engineOptionsNonSplit = remember(viewModel.acpAgents) { viewModel.getAvailableEngineOptions() + if (cc.unitmesh.devins.ui.compose.agent.acp.isAcpSupported()) listOf(ACP_CONFIGURE_LABEL) else emptyList() } DevInEditorInput( initialText = "", placeholder = "Describe your coding task...", callbacks = callbacks, completionManager = currentWorkspace?.completionManager, isCompactMode = true, isExecuting = viewModel.isExecuting, onStopClick = { viewModel.cancelTask() }, onModelConfigChange = { /* Handle model config change if needed */ }, engine = viewModel.currentEngineDisplay, engineOptions = engineOptionsNonSplit, onEngineChange = { display -> if (display == ACP_CONFIGURE_LABEL) { showAcpConfigDialog = true } else { viewModel.selectEngine(display) } }, renderer = viewModel.renderer, modifier = Modifier .fillMaxWidth() .imePadding() .padding(horizontal = 12.dp, vertical = 8.dp), // Multimodal support onImageUpload = if (imageUploader?.isConfigured() == true) { { imagePath, _, onProgress -> imageUploader!!.uploadImage(imagePath, onProgress) } } else { // Show config dialog when trying to upload without config { _, _, _ -> showCloudStorageDialog = true cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploadResult( success = false, error = "Please configure cloud storage first" ) } }, onImageUploadBytes = if (imageUploader?.isConfigured() == true) { { imageBytes, fileName, mimeType, _, onProgress -> imageUploader!!.uploadImageBytes(imageBytes, fileName, mimeType, onProgress) } } else { // Show config dialog when trying to upload without config { _, _, _, _, _ -> showCloudStorageDialog = true cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploadResult( success = false, error = "Please configure cloud storage first" ) } }, onMultimodalAnalysis = if (visionService != null) { { imageUrls, prompt, onChunk -> visionService!!.analyzeImages(imageUrls, prompt, onChunk) } } else null, onVisionModelChange = { config: NamedModelConfig -> selectedVisionModel = config.name selectedVisionApiKey = config.apiKey } ) ToolLoadingStatusBar( viewModel = viewModel, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp) ) } } // Floating Run Button (IDE-like) - Top Right if (useAgentMode && selectedAgentType == AgentType.CODING) { FloatingRunButton( state = runConfigState, configs = runConfigs, defaultConfig = defaultRunConfig, isRunning = isRunning, runningConfigId = runningConfigId, analysisLog = runAnalysisLog, onConfigure = { runConfigViewModel.analyzeProject() }, onRunConfig = { cfg -> runConfigViewModel.runConfig(cfg) }, onStopRunning = { runConfigViewModel.stopRunning() }, modifier = Modifier .align(Alignment.TopEnd) .padding(end = 16.dp, top = 16.dp) ) } } // End of main Box // Cloud Storage Configuration Dialog if (showCloudStorageDialog) { CloudStorageConfigDialog( onDismiss = { showCloudStorageDialog = false }, onSave = { newConfig -> cloudStorageConfig = newConfig if (newConfig.isConfigured()) { imageUploader?.close() imageUploader = ImageUploader(newConfig) } } ) } // ACP Agent Configuration Dialog if (showAcpConfigDialog) { AcpAgentConfigDialog( currentAgents = viewModel.acpAgents, activeAgentKey = viewModel.currentAcpAgentConfig?.let { config -> viewModel.acpAgents.entries.find { it.value.name == config.name }?.key }, onDismiss = { showAcpConfigDialog = false }, onSave = { agents, activeKey -> scope.launch { AutoDevConfigWrapper.saveAcpAgents(agents, activeKey) viewModel.reloadAcpAgents() // If an active agent was set, switch to it if (activeKey != null) { viewModel.switchToAcpAgent(activeKey) } } showAcpConfigDialog = false } ) } } /** * Label for the "Configure ACP Agents..." entry in the engine dropdown. */ private const val ACP_CONFIGURE_LABEL = "Configure ACP..." @Composable private fun ToolLoadingStatusBar( viewModel: CodingAgentViewModel, modifier: Modifier = Modifier ) { val mcpPreloadingStatus = viewModel.mcpPreloadingStatus val mcpPreloadingMessage = viewModel.mcpPreloadingMessage val toolStatus by remember(mcpPreloadingStatus) { derivedStateOf { viewModel.getToolLoadingStatus() } } Card(modifier = modifier) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { ToolStatusChip( label = "SubAgents", count = toolStatus.subAgentsEnabled, total = toolStatus.subAgentsTotal, isLoading = false, color = MaterialTheme.colorScheme.secondary, tooltip = "AI agents: error-recovery, log-summary, codebase-investigator" ) ToolStatusChip( label = "MCP Tools", count = toolStatus.mcpToolsEnabled, total = if (toolStatus.isLoading) "∞" else toolStatus.mcpToolsEnabled.toString(), isLoading = toolStatus.isLoading, color = if (!toolStatus.isLoading && toolStatus.mcpToolsEnabled > 0) { MaterialTheme.colorScheme.tertiary } else { MaterialTheme.colorScheme.outline }, tooltip = "External tools from MCP servers (${toolStatus.mcpServersLoaded}/${toolStatus.mcpServersTotal} servers)" ) Spacer(modifier = Modifier.weight(1f)) if (mcpPreloadingMessage.isNotEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { if (toolStatus.isLoading) { CircularProgressIndicator( modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp, color = MaterialTheme.colorScheme.primary ) } Text( text = mcpPreloadingMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) } } else if (!toolStatus.isLoading && toolStatus.mcpServersLoaded > 0) { Text( text = "✓ All tools ready", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1 ) } } } } @Composable private fun ToolStatusChip( label: String, count: Int, total: Any, // Can be Int or String isLoading: Boolean, color: androidx.compose.ui.graphics.Color, tooltip: String = "", modifier: Modifier = Modifier ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Box( modifier = Modifier .size(10.dp) .background( color = if (isLoading) MaterialTheme.colorScheme.outline.copy(alpha = 0.6f) else color, shape = CircleShape ) ) { if (!isLoading && count > 0) { Box( modifier = Modifier .size(6.dp) .align(Alignment.Center) .background( color = color.copy(alpha = 0.3f), shape = CircleShape ) ) } } Text( text = "$label ($count/$total)", style = MaterialTheme.typography.labelMedium, color = if (isLoading) { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) } else { MaterialTheme.colorScheme.onSurfaceVariant } ) if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(10.dp), strokeWidth = 1.dp, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) ) } } }