package cc.unitmesh.devins.ui.compose.agent.webedit import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cc.unitmesh.devins.ui.compose.editor.multimodal.* import kotlinx.coroutines.launch import kotlinx.coroutines.yield /** * A chip/tag component for displaying a selected DOM element. * Supports double-click to remove. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ElementTagChip( tag: ElementTag, onRemove: () -> Unit, onViewDetails: (ElementTag) -> Unit = {}, modifier: Modifier = Modifier ) { Surface( modifier = modifier .combinedClickable( onClick = { }, onDoubleClick = { onRemove() } ), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.secondaryContainer, tonalElevation = 2.dp ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { // Tag icon based on element type Text( text = getTagIcon(tag.tagName), style = MaterialTheme.typography.labelSmall ) // Tag label Text( text = tag.getShortLabel(), style = MaterialTheme.typography.labelMedium.copy( fontFamily = FontFamily.Monospace ), color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1, overflow = TextOverflow.Ellipsis ) // View details button - adds to chat history IconButton( onClick = { onViewDetails(tag) }, modifier = Modifier.size(16.dp) ) { Icon( imageVector = Icons.Default.Info, contentDescription = "View element details", modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.primary ) } // Close button IconButton( onClick = onRemove, modifier = Modifier.size(16.dp) ) { Icon( imageVector = Icons.Default.Close, contentDescription = "Remove element", modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) ) } } } } /** * Get an icon emoji based on the HTML tag type */ private fun getTagIcon(tagName: String): String { return when (tagName.lowercase()) { "button" -> "B" "input" -> "I" "a" -> "L" "img" -> "P" "div" -> "D" "span" -> "S" "form" -> "F" "table" -> "T" "ul", "ol", "li" -> "*" "h1", "h2", "h3", "h4", "h5", "h6" -> "H" "p" -> "P" "nav" -> "N" "header" -> "H" "footer" -> "F" "section" -> "S" "article" -> "A" else -> "E" } } /** * Dialog showing detailed element information with full HTML code */ @Composable fun ElementDetailsDialog( tag: ElementTag, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text( text = "Element Details", style = MaterialTheme.typography.titleMedium ) }, text = { Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 500.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // HTML Code Section Text( text = "HTML Code:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { val htmlCode = buildHtmlCode(tag) Text( text = htmlCode, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(12.dp) ) } Divider() // Basic Info DetailRow("Tag Name", "<${tag.tagName}>") DetailRow("CSS Selector", tag.selector) // Attributes Section if (tag.attributes.isNotEmpty()) { Text( text = "Attributes:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { tag.attributes.forEach { (key, value) -> Text( text = "$key = \"$value\"", style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } // Text Content tag.textContent?.let { Text( text = "Text Content:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { Text( text = "\"$it\"", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(12.dp) ) } } // Source Hint tag.sourceHint?.let { Text( text = "Source Location Hint:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.tertiaryContainer ) { Text( text = it, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(12.dp) ) } } } }, confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } } ) } /** * Build HTML code representation of the element */ private fun buildHtmlCode(tag: ElementTag): String { val attrs = tag.attributes.entries.joinToString(" ") { (key, value) -> "$key=\"$value\"" } val openingTag = if (attrs.isNotEmpty()) { "<${tag.tagName} $attrs>" } else { "<${tag.tagName}>" } val content = tag.textContent ?: "..." val closingTag = "" return if (isSelfClosingTag(tag.tagName)) { if (attrs.isNotEmpty()) { "<${tag.tagName} $attrs />" } else { "<${tag.tagName} />" } } else { "$openingTag\n $content\n$closingTag" } } /** * Check if the tag is self-closing */ private fun isSelfClosingTag(tagName: String): Boolean { return tagName.lowercase() in setOf( "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" ) } @Composable private fun DetailRow(label: String, value: String) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = "$label:", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = value, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace ), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis ) } } /** * Row of element tag chips */ @Composable fun ElementTagRow( tags: ElementTagCollection, onRemoveTag: (String) -> Unit, onClearAll: () -> Unit, onViewDetails: (ElementTag) -> Unit = {}, modifier: Modifier = Modifier ) { if (tags.isEmpty()) return Row( modifier = modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically ) { // Label Text( text = "Selected:", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) // Element chips tags.tags.forEach { tag -> ElementTagChip( tag = tag, onRemove = { onRemoveTag(tag.id) }, onViewDetails = onViewDetails ) } // Clear all button if (tags.tags.size > 1) { TextButton( onClick = onClearAll, modifier = Modifier.height(24.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) ) { Text( text = "Clear all", style = MaterialTheme.typography.labelSmall ) } } } } /** * Chat input area for WebEdit Q&A functionality. * Now supports element tags for selected DOM elements and image attachments (screenshots). * Keyboard shortcuts: * - Enter: Send message * - Shift+Enter: New line */ @OptIn(ExperimentalComposeUiApi::class) @Composable fun WebEditChatInput( input: String, onInputChange: (String) -> Unit, onSend: (String) -> Unit, modifier: Modifier = Modifier, placeholder: String = "Ask about the page or selected element...", enabled: Boolean = true, isProcessing: Boolean = false, elementTags: ElementTagCollection = ElementTagCollection(), onRemoveTag: (String) -> Unit = {}, onClearTags: () -> Unit = {}, onSendWithContext: ((String, ElementTagCollection) -> Unit)? = null, onViewElementDetails: (ElementTag) -> Unit = {}, // Multimodal support multimodalState: MultimodalState? = null, imageUploadManager: ImageUploadManager? = null, previewingImage: AttachedImage? = null, onPreviewImage: (AttachedImage) -> Unit = {}, onDismissPreview: () -> Unit = {}, visionHelper: Any? = null, // WebEditVisionHelper (using Any to avoid expect/actual issues) onSendDirect: ((String) -> Unit)? = null // Direct send without multimodal (for backward compatibility) ) { val scope = rememberCoroutineScope() Surface( modifier = modifier, tonalElevation = 2.dp ) { Column { // Processing indicator if (isProcessing) { LinearProgressIndicator( modifier = Modifier.fillMaxWidth() ) } // Element tags row (shown when elements are selected) if (elementTags.isNotEmpty()) { ElementTagRow( tags = elementTags, onRemoveTag = onRemoveTag, onClearAll = onClearTags, onViewDetails = onViewElementDetails, modifier = Modifier.background( MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ) ) } // Image attachment bar (shown when screenshots are attached) if (multimodalState != null && multimodalState.hasImages) { ImageAttachmentBar( images = multimodalState.images, onRemoveImage = { image -> imageUploadManager?.removeImage(image.id) }, onImageClick = { image -> onPreviewImage(image) }, onRetryUpload = { image -> imageUploadManager?.retryUpload(image) }, isAnalyzing = multimodalState.isAnalyzing, isUploading = multimodalState.isUploading, uploadedCount = multimodalState.uploadedCount, analysisProgress = multimodalState.analysisProgress, visionModel = multimodalState.visionModel, onVisionModelChange = null // TODO: Add vision model selector if needed ) } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Input field OutlinedTextField( value = input, onValueChange = onInputChange, modifier = Modifier .weight(1f) .heightIn(min = 44.dp, max = 120.dp) .onPreviewKeyEvent { keyEvent -> // Handle Enter key: send message (unless Shift is pressed) if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) { if (!keyEvent.isShiftPressed) { // Enter without Shift: send message if (input.isNotBlank() || elementTags.isNotEmpty()) { // Defer send to avoid mutating state during the same AWT key event dispatch scope.launch { yield() // Check if we should use multimodal send if (multimodalState != null && multimodalState.allImagesUploaded) { onSend(input) // This will trigger multimodal analysis } else if (onSendWithContext != null && elementTags.isNotEmpty()) { onSendWithContext(input, elementTags) } else if (onSendDirect != null) { onSendDirect(input) } else { onSend(input) } } } true // consume event } else { // Shift+Enter: insert newline (default behavior) false // don't consume, let TextField handle it } } else { false } }, placeholder = { Text( text = if (elementTags.isNotEmpty()) { "Ask about selected elements... (Enter to send, Shift+Enter for new line)" } else { "$placeholder (Enter to send, Shift+Enter for new line)" }, style = MaterialTheme.typography.bodyMedium ) }, enabled = enabled, maxLines = 5, shape = RoundedCornerShape(12.dp), keyboardOptions = KeyboardOptions.Default, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) ) ) // Send button FilledIconButton( onClick = { if (input.isNotBlank() || elementTags.isNotEmpty() || (multimodalState?.allImagesUploaded == true)) { // Check if we should use multimodal send if (multimodalState != null && multimodalState.allImagesUploaded) { onSend(input) // This will trigger multimodal analysis } else if (onSendWithContext != null && elementTags.isNotEmpty()) { onSendWithContext(input, elementTags) } else if (onSendDirect != null) { onSendDirect(input) } else if (input.isNotBlank()) { onSend(input) } } }, enabled = enabled && (input.isNotBlank() || elementTags.isNotEmpty() || (multimodalState?.allImagesUploaded == true)) && !isProcessing && (multimodalState?.canSend != false), modifier = Modifier.size(44.dp) ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send", modifier = Modifier.size(20.dp) ) } } } } }