diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt index 11fee4b743..92fda46de6 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt @@ -40,7 +40,12 @@ enum class AgentType { /** * Remote agent mode - connects to remote mpp-server for distributed execution */ - REMOTE; + REMOTE, + + /** + * Web edit mode - browse, select DOM elements, and interact with web pages + */ + WEB_EDIT; fun getDisplayName(): String = when (this) { LOCAL_CHAT -> "Chat" @@ -49,6 +54,7 @@ enum class AgentType { KNOWLEDGE -> "Knowledge" CHAT_DB -> "ChatDB" REMOTE -> "Remote" + WEB_EDIT -> "WebEdit" } companion object { @@ -60,6 +66,7 @@ enum class AgentType { "codereview" -> CODE_REVIEW "documentreader", "documents" -> KNOWLEDGE "chatdb", "database" -> CHAT_DB + "webedit", "web" -> WEB_EDIT else -> LOCAL_CHAT } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt index 9a2357db57..730d04a202 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt @@ -89,7 +89,7 @@ private fun SegmentedAgentTabs( // Note: LOCAL_CHAT is intentionally excluded from the tabs as it represents // a different interaction mode (direct local chat without agent routing). // It's handled separately in IdeaAgentApp but not exposed as a user-selectable tab. - val agentTypes = listOf(AgentType.CODING, AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.REMOTE) + val agentTypes = listOf(AgentType.CODING, AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.REMOTE, AgentType.WEB_EDIT) Row( modifier = modifier @@ -259,6 +259,7 @@ private fun getAgentTypeColor(type: AgentType): Color = when (type) { AgentType.CHAT_DB -> IdeaAutoDevColors.Cyan.c400 AgentType.REMOTE -> IdeaAutoDevColors.Amber.c400 AgentType.LOCAL_CHAT -> JewelTheme.globalColors.text.normal + AgentType.WEB_EDIT -> IdeaAutoDevColors.Blue.c400 } /** @@ -271,5 +272,6 @@ private fun getAgentTypeIcon(type: AgentType): ImageVector = when (type) { AgentType.CHAT_DB -> IdeaComposeIcons.Database AgentType.REMOTE -> IdeaComposeIcons.Cloud AgentType.LOCAL_CHAT -> IdeaComposeIcons.Chat + AgentType.WEB_EDIT -> IdeaComposeIcons.Web } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index e5f7fc061a..5217e4ceff 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -20,6 +20,8 @@ import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId +import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditContent +import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditViewModel import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent @@ -128,6 +130,9 @@ fun IdeaAgentApp( // Remote Agent ViewModel (created lazily when needed) var remoteAgentViewModel by remember { mutableStateOf(null) } + // WebEdit ViewModel (created lazily when needed) + var webEditViewModel by remember { mutableStateOf(null) } + // Remote agent state for input handling var remoteProjectId by remember { mutableStateOf("") } var remoteGitUrl by remember { mutableStateOf("") } @@ -159,6 +164,9 @@ fun IdeaAgentApp( serverUrl = "http://localhost:8080" ) } + if (currentAgentType == AgentType.WEB_EDIT && webEditViewModel == null) { + webEditViewModel = IdeaWebEditViewModel(project, coroutineScope) + } } // Dispose ViewModels when leaving their tabs @@ -176,6 +184,10 @@ fun IdeaAgentApp( remoteAgentViewModel?.dispose() remoteAgentViewModel = null } + if (currentAgentType != AgentType.WEB_EDIT) { + webEditViewModel?.dispose() + webEditViewModel = null + } } } @@ -362,9 +374,15 @@ fun IdeaAgentApp( } ?: IdeaEmptyStateMessage("Loading Knowledge Agent...") } } + AgentType.WEB_EDIT -> { + // WebEdit mode - Browse web pages and select DOM elements + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + webEditViewModel?.let { vm -> + IdeaWebEditContent(viewModel = vm) + } ?: IdeaEmptyStateMessage("Loading WebEdit Agent...") + } + } AgentType.CHAT_DB -> { - // ChatDB mode - Text2SQL agent for database queries - // TODO: Implement ChatDB UI when ready Box(modifier = Modifier.fillMaxWidth().weight(1f)) { IdeaEmptyStateMessage("ChatDB Agent coming soon...") } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index d19ad56cd0..b6b7bdd164 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1793,5 +1793,158 @@ object IdeaComposeIcons { }.build() } + /** + * ArrowBack icon (left arrow) + */ + val ArrowBack: ImageVector by lazy { + ImageVector.Builder( + name = "ArrowBack", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(20f, 11f) + horizontalLineTo(7.83f) + lineToRelative(5.59f, -5.59f) + lineTo(12f, 4f) + lineToRelative(-8f, 8f) + lineToRelative(8f, 8f) + lineToRelative(1.41f, -1.41f) + lineTo(7.83f, 13f) + horizontalLineTo(20f) + verticalLineToRelative(-2f) + close() + } + }.build() + } + + /** + * ArrowForward icon (right arrow) + */ + val ArrowForward: ImageVector by lazy { + ImageVector.Builder( + name = "ArrowForward", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 4f) + lineToRelative(-1.41f, 1.41f) + lineTo(16.17f, 11f) + horizontalLineTo(4f) + verticalLineToRelative(2f) + horizontalLineToRelative(12.17f) + lineToRelative(-5.58f, 5.59f) + lineTo(12f, 20f) + lineToRelative(8f, -8f) + close() + } + }.build() + } + + /** + * TouchApp icon (finger touch) + */ + val TouchApp: ImageVector by lazy { + ImageVector.Builder( + name = "TouchApp", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(9f, 11.24f) + verticalLineTo(7.5f) + curveTo(9f, 6.12f, 10.12f, 5f, 11.5f, 5f) + reflectiveCurveTo(14f, 6.12f, 14f, 7.5f) + verticalLineToRelative(3.74f) + curveToRelative(1.21f, -0.81f, 2f, -2.18f, 2f, -3.74f) + curveTo(16f, 5.01f, 13.99f, 3f, 11.5f, 3f) + reflectiveCurveTo(7f, 5.01f, 7f, 7.5f) + curveToRelative(0f, 1.56f, 0.79f, 2.93f, 2f, 3.74f) + close() + moveTo(18.84f, 15.87f) + lineToRelative(-4.54f, -2.26f) + curveToRelative(-0.17f, -0.07f, -0.35f, -0.11f, -0.54f, -0.11f) + horizontalLineTo(13f) + verticalLineToRelative(-6f) + curveTo(13f, 6.67f, 12.33f, 6f, 11.5f, 6f) + reflectiveCurveTo(10f, 6.67f, 10f, 7.5f) + verticalLineToRelative(10.74f) + lineToRelative(-3.43f, -0.72f) + curveToRelative(-0.08f, -0.01f, -0.15f, -0.03f, -0.24f, -0.03f) + curveToRelative(-0.31f, 0f, -0.59f, 0.13f, -0.79f, 0.33f) + lineToRelative(-0.79f, 0.8f) + lineToRelative(4.94f, 4.94f) + curveToRelative(0.27f, 0.27f, 0.65f, 0.44f, 1.06f, 0.44f) + horizontalLineToRelative(6.79f) + curveToRelative(0.75f, 0f, 1.33f, -0.55f, 1.44f, -1.28f) + lineToRelative(0.75f, -5.27f) + curveToRelative(0.01f, -0.07f, 0.02f, -0.14f, 0.02f, -0.2f) + curveToRelative(0f, -0.62f, -0.38f, -1.16f, -0.91f, -1.38f) + close() + } + }.build() + } + + /** + * Web icon (globe) + */ + val Web: ImageVector by lazy { + ImageVector.Builder( + name = "Web", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + reflectiveCurveToRelative(10f, -4.48f, 10f, -10f) + reflectiveCurveTo(17.52f, 2f, 12f, 2f) + close() + moveTo(11f, 19.93f) + curveToRelative(-3.95f, -0.49f, -7f, -3.85f, -7f, -7.93f) + curveToRelative(0f, -0.62f, 0.08f, -1.21f, 0.21f, -1.79f) + lineTo(9f, 15f) + verticalLineToRelative(1f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + verticalLineToRelative(1.93f) + close() + moveTo(17.9f, 17.39f) + curveToRelative(-0.26f, -0.81f, -1f, -1.39f, -1.9f, -1.39f) + horizontalLineToRelative(-1f) + verticalLineToRelative(-3f) + curveToRelative(0f, -0.55f, -0.45f, -1f, -1f, -1f) + horizontalLineTo(8f) + verticalLineToRelative(-2f) + horizontalLineToRelative(2f) + curveToRelative(0.55f, 0f, 1f, -0.45f, 1f, -1f) + verticalLineTo(7f) + horizontalLineToRelative(2f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineToRelative(-0.41f) + curveToRelative(2.93f, 1.19f, 5f, 4.06f, 5f, 7.41f) + curveToRelative(0f, 2.08f, -0.8f, 3.97f, -2.1f, 5.39f) + close() + } + }.build() + } + } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditBridgeScript.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditBridgeScript.kt new file mode 100644 index 0000000000..920d1b6914 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditBridgeScript.kt @@ -0,0 +1,180 @@ +package cc.unitmesh.devins.idea.toolwindow.webedit + +/** + * JavaScript code to inject into JBCefBrowser for DOM selection and communication + * + * This script provides: + * - Selection mode with element highlighting + * - DOM tree extraction + * - Bidirectional communication with Kotlin via JBCefJSQuery + */ +object IdeaWebEditBridgeScript { + + /** + * Get the bridge script with the callback function name injected + */ + fun getScript(callbackFunctionName: String): String = """ +(function() { + // Prevent multiple injections + if (window.webEditBridge) return; + + // WebEdit Bridge object + window.webEditBridge = { + selectionMode: false, + highlightedElement: null, + selectedElement: null, + + // Enable/disable selection mode + setSelectionMode: function(enabled) { + this.selectionMode = enabled; + if (!enabled) { + this.clearHighlights(); + } + console.log('[WebEditBridge] Selection mode:', enabled); + }, + + // Highlight a specific element by selector + highlightElement: function(selector) { + this.clearHighlights(); + try { + var el = document.querySelector(selector); + if (el) { + this.highlightedElement = el; + el.style.outline = '2px solid #2196F3'; + el.style.outlineOffset = '2px'; + } + } catch(e) { + console.error('[WebEditBridge] highlightElement error:', e); + } + }, + + // Clear all highlights + clearHighlights: function() { + if (this.highlightedElement) { + this.highlightedElement.style.outline = ''; + this.highlightedElement.style.outlineOffset = ''; + this.highlightedElement = null; + } + if (this.selectedElement) { + this.selectedElement.style.outline = ''; + this.selectedElement.style.outlineOffset = ''; + } + }, + + // Scroll to element + scrollToElement: function(selector) { + try { + var el = document.querySelector(selector); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } catch(e) { + console.error('[WebEditBridge] scrollToElement error:', e); + } + }, + + // Get unique selector for element + getSelector: function(el) { + if (el.id) return '#' + el.id; + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + + var path = []; + while (el && el.nodeType === Node.ELEMENT_NODE) { + var selector = el.tagName.toLowerCase(); + if (el.id) { + selector = '#' + el.id; + path.unshift(selector); + break; + } else if (el.className && typeof el.className === 'string') { + var classes = el.className.trim().split(/\s+/).slice(0, 2); + if (classes.length > 0 && classes[0]) { + selector += '.' + classes.join('.'); + } + } + path.unshift(selector); + el = el.parentNode; + } + return path.join(' > '); + }, + + // Get DOM tree (simplified) + getDOMTree: function() { + var self = this; + function buildTree(el, depth) { + if (depth > 5) return null; + var rect = el.getBoundingClientRect(); + var node = { + id: Math.random().toString(36).substr(2, 9), + tagName: el.tagName.toLowerCase(), + selector: self.getSelector(el), + textContent: (el.textContent || '').trim().substring(0, 50), + attributes: {}, + boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + children: [] + }; + if (el.id) node.attributes.id = el.id; + if (el.className) node.attributes['class'] = el.className; + var childElements = Array.from(el.children).slice(0, 20); + for (var i = 0; i < childElements.length; i++) { + var childNode = buildTree(childElements[i], depth + 1); + if (childNode) node.children.push(childNode); + } + return node; + } + var tree = buildTree(document.body, 0); + this.sendToKotlin('DOMTreeUpdated', JSON.stringify({ root: tree })); + }, + + // Send message to Kotlin via JBCefJSQuery + sendToKotlin: function(type, data) { + try { + var message = JSON.stringify({ type: type, data: data }); + $callbackFunctionName(message); + } catch(e) { + console.error('[WebEditBridge] sendToKotlin error:', e); + } + } + }; + + // Mouse event handlers for selection mode + document.addEventListener('mouseover', function(e) { + if (!window.webEditBridge.selectionMode) return; + e.stopPropagation(); + window.webEditBridge.clearHighlights(); + window.webEditBridge.highlightedElement = e.target; + e.target.style.outline = '2px solid #2196F3'; + e.target.style.outlineOffset = '2px'; + }, true); + + document.addEventListener('click', function(e) { + if (!window.webEditBridge.selectionMode) return; + e.preventDefault(); + e.stopPropagation(); + var el = e.target; + window.webEditBridge.selectedElement = el; + el.style.outline = '3px solid #4CAF50'; + el.style.outlineOffset = '2px'; + var rect = el.getBoundingClientRect(); + window.webEditBridge.sendToKotlin('ElementSelected', JSON.stringify({ + element: { + id: Math.random().toString(36).substr(2, 9), + tagName: el.tagName.toLowerCase(), + selector: window.webEditBridge.getSelector(el), + textContent: (el.textContent || '').trim().substring(0, 100), + attributes: { id: el.id || '', 'class': el.className || '' }, + boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + })); + }, true); + + // Notify page loaded + window.webEditBridge.sendToKotlin('PageLoaded', JSON.stringify({ + url: window.location.href, + title: document.title + })); + + console.log('[WebEditBridge] Initialized'); +})(); +""".trimIndent() +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditContent.kt new file mode 100644 index 0000000000..52f7e08c04 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditContent.kt @@ -0,0 +1,731 @@ +package cc.unitmesh.devins.idea.toolwindow.webedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.render.TimelineItem +import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect +import cc.unitmesh.devins.idea.components.IdeaResizableSplitPane +import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane +import cc.unitmesh.devins.idea.theme.IdeaAutoDevColors +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.llm.MessageRole +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +/** + * Main content view for WebEdit Agent in IntelliJ IDEA. + * Provides web browsing with DOM selection and AI-powered Q&A. + * + * Layout: + * - Top: URL bar with navigation controls + * - Center: WebView (JBCefBrowser via SwingPanel) + * - Right: DOM tree sidebar (toggleable) + * - Bottom: AI Chat input + */ +@Composable +fun IdeaWebEditContent( + viewModel: IdeaWebEditViewModel, + modifier: Modifier = Modifier +) { + // Collect state + var state by remember { mutableStateOf(IdeaWebEditState()) } + var timeline by remember { mutableStateOf>(emptyList()) } + var streamingOutput by remember { mutableStateOf("") } + + IdeaLaunchedEffect(viewModel) { + viewModel.state.collect { state = it } + } + IdeaLaunchedEffect(viewModel.renderer) { + viewModel.renderer.timeline.collect { timeline = it } + } + IdeaLaunchedEffect(viewModel.renderer) { + viewModel.renderer.currentStreamingOutput.collect { streamingOutput = it } + } + + // Check JCEF support + if (!viewModel.isJcefSupported) { + JcefNotSupportedView(modifier) + return + } + + Column(modifier = modifier.fillMaxSize()) { + // Top toolbar with URL bar + WebEditToolbar( + currentUrl = state.currentUrl, + isLoading = state.isLoading, + isSelectionMode = state.isSelectionMode, + showDOMSidebar = state.showDOMSidebar, + onNavigate = { viewModel.navigateTo(it) }, + onReload = { viewModel.reload() }, + onGoBack = { viewModel.goBack() }, + onGoForward = { viewModel.goForward() }, + onToggleSelectionMode = { viewModel.toggleSelectionMode() }, + onToggleDOMSidebar = { viewModel.toggleDOMSidebar() } + ) + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Main content with vertical split (WebView + Chat) + IdeaVerticalResizableSplitPane( + modifier = Modifier.fillMaxSize(), + initialSplitRatio = 0.75f, + minRatio = 0.4f, + maxRatio = 0.9f, + top = { + // WebView + DOM Sidebar + if (state.showDOMSidebar) { + IdeaResizableSplitPane( + modifier = Modifier.fillMaxSize(), + initialSplitRatio = 0.7f, + minRatio = 0.5f, + maxRatio = 0.85f, + first = { + WebViewPanel(viewModel, state, Modifier.fillMaxSize()) + }, + second = { + DOMTreeSidebar( + domTree = state.domTree, + selectedElement = state.selectedElement, + onElementClick = { selector -> + viewModel.highlightElement(selector) + viewModel.scrollToElement(selector) + }, + onElementHover = { selector -> + if (selector != null) { + viewModel.highlightElement(selector) + } else { + viewModel.clearHighlights() + } + }, + onRefresh = { viewModel.refreshDOMTree() }, + modifier = Modifier.fillMaxSize() + ) + } + ) + } else { + WebViewPanel(viewModel, state, Modifier.fillMaxSize()) + } + }, + bottom = { + // Chat panel + WebEditChatPanel( + timeline = timeline, + streamingOutput = streamingOutput, + selectedElement = state.selectedElement, + onSendMessage = { viewModel.sendChatMessage(it) }, + onStopGeneration = { viewModel.stopGeneration() }, + onClearHistory = { viewModel.clearChatHistory() }, + modifier = Modifier.fillMaxSize() + ) + } + ) + } +} + +/** + * WebView panel using SwingPanel to embed JBCefBrowser + */ +@Composable +private fun WebViewPanel( + viewModel: IdeaWebEditViewModel, + state: IdeaWebEditState, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { + val browserComponent = viewModel.getBrowserComponent() + if (browserComponent != null) { + SwingPanel( + factory = { browserComponent }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Initializing browser...", + style = JewelTheme.defaultTextStyle + ) + } + } + + // Loading indicator + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .align(Alignment.TopCenter) + .background(IdeaAutoDevColors.Blue.c400) + ) + } + + // Selection mode indicator + if (state.isSelectionMode) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background(IdeaAutoDevColors.Blue.c400.copy(alpha = 0.9f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "Selection Mode", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = IdeaAutoDevColors.Neutral.c50 + ) + ) + } + } + + // Selected element info + state.selectedElement?.let { element -> + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background(IdeaAutoDevColors.Green.c400.copy(alpha = 0.9f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "Selected: ${element.getDisplayName()}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = IdeaAutoDevColors.Neutral.c50 + ) + ) + } + } + } +} + +/** + * Toolbar with URL input and navigation controls + */ +@Composable +private fun WebEditToolbar( + currentUrl: String, + isLoading: Boolean, + isSelectionMode: Boolean, + showDOMSidebar: Boolean, + onNavigate: (String) -> Unit, + onReload: () -> Unit, + onGoBack: () -> Unit, + onGoForward: () -> Unit, + onToggleSelectionMode: () -> Unit, + onToggleDOMSidebar: () -> Unit +) { + val urlTextFieldState = rememberTextFieldState(currentUrl) + + // Sync external URL changes + IdeaLaunchedEffect(currentUrl) { + if (urlTextFieldState.text.toString() != currentUrl) { + urlTextFieldState.setTextAndPlaceCursorAtEnd(currentUrl) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Navigation buttons + IconButton(onClick = onGoBack) { + Icon( + imageVector = IdeaComposeIcons.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + + IconButton(onClick = onGoForward) { + Icon( + imageVector = IdeaComposeIcons.ArrowForward, + contentDescription = "Forward", + modifier = Modifier.size(18.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + + IconButton(onClick = onReload) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Reload", + modifier = Modifier.size(18.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + + // URL input + TextField( + state = urlTextFieldState, + placeholder = { Text("Enter URL...") }, + modifier = Modifier.weight(1f), + onKeyboardAction = { + onNavigate(urlTextFieldState.text.toString()) + } + ) + + // Go button + DefaultButton( + onClick = { onNavigate(urlTextFieldState.text.toString()) } + ) { + Text("Go") + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Selection mode toggle + OutlinedButton( + onClick = onToggleSelectionMode + ) { + Icon( + imageVector = if (isSelectionMode) IdeaComposeIcons.Check else IdeaComposeIcons.TouchApp, + contentDescription = "Toggle Selection Mode", + modifier = Modifier.size(16.dp), + tint = if (isSelectionMode) IdeaAutoDevColors.Green.c400 else JewelTheme.globalColors.text.normal + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(if (isSelectionMode) "Select: ON" else "Select") + } + + // DOM sidebar toggle + IconButton(onClick = onToggleDOMSidebar) { + Icon( + imageVector = IdeaComposeIcons.AccountTree, + contentDescription = "Toggle DOM Sidebar", + modifier = Modifier.size(18.dp), + tint = if (showDOMSidebar) IdeaAutoDevColors.Blue.c400 else JewelTheme.globalColors.text.normal + ) + } + } +} + +/** + * DOM Tree sidebar + */ +@Composable +private fun DOMTreeSidebar( + domTree: IdeaDOMElement?, + selectedElement: IdeaDOMElement?, + onElementClick: (String) -> Unit, + onElementHover: (String?) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "DOM Tree", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + IconButton(onClick = onRefresh) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Refresh DOM", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (domTree == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No DOM tree available.\nLoad a page first.", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item { + DOMTreeNode( + element = domTree, + selectedSelector = selectedElement?.selector, + depth = 0, + onElementClick = onElementClick, + onElementHover = onElementHover + ) + } + } + } + } +} + +/** + * Single DOM tree node (recursive) + */ +@Composable +private fun DOMTreeNode( + element: IdeaDOMElement, + selectedSelector: String?, + depth: Int, + onElementClick: (String) -> Unit, + onElementHover: (String?) -> Unit +) { + var expanded by remember { mutableStateOf(depth < 2) } + val isSelected = element.selector == selectedSelector + val hasChildren = element.children.isNotEmpty() + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) IdeaAutoDevColors.Blue.c400.copy(alpha = 0.2f) + else JewelTheme.globalColors.panelBackground + ) + .clickable { onElementClick(element.selector) } + .padding(start = (depth * 12).dp, top = 2.dp, bottom = 2.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Expand/collapse icon + if (hasChildren) { + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = if (expanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info + ) + } + } else { + Spacer(modifier = Modifier.width(16.dp)) + } + + // Element display + Text( + text = element.getDisplayName(), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ), + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } + + // Children + if (expanded && hasChildren) { + element.children.forEach { child -> + DOMTreeNode( + element = child, + selectedSelector = selectedSelector, + depth = depth + 1, + onElementClick = onElementClick, + onElementHover = onElementHover + ) + } + } + } +} + +/** + * Chat panel for WebEdit Q&A + */ +@Composable +private fun WebEditChatPanel( + timeline: List, + streamingOutput: String, + selectedElement: IdeaDOMElement?, + onSendMessage: (String) -> Unit, + onStopGeneration: () -> Unit, + onClearHistory: () -> Unit, + modifier: Modifier = Modifier +) { + val inputTextFieldState = rememberTextFieldState() + var inputText by remember { mutableStateOf("") } + val listState = rememberLazyListState() + val isGenerating = streamingOutput.isNotEmpty() + + // Sync text field state + IdeaLaunchedEffect(Unit) { + snapshotFlow { inputTextFieldState.text.toString() } + .distinctUntilChanged() + .collect { inputText = it } + } + + // Auto-scroll + IdeaLaunchedEffect(timeline.size, streamingOutput) { + if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { + val targetIndex = if (streamingOutput.isNotEmpty()) timeline.size else timeline.lastIndex.coerceAtLeast(0) + if (targetIndex >= 0) { + listState.scrollToItem(targetIndex) + } + } + } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "WebEdit Assistant", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + IconButton(onClick = onClearHistory) { + Icon( + imageVector = IdeaComposeIcons.Delete, + contentDescription = "Clear history", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Selected element context + selectedElement?.let { element -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(IdeaAutoDevColors.Blue.c400.copy(alpha = 0.1f)) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Code, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = IdeaAutoDevColors.Blue.c400 + ) + Text( + text = "Context: ${element.getDisplayName()}", + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 1 + ) + } + } + + // Messages + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (timeline.isEmpty() && streamingOutput.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Ask questions about the web page or selected elements", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } else { + items(timeline, key = { it.id }) { item -> + SimpleChatMessageItem(item) + } + + if (streamingOutput.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + Text( + text = streamingOutput, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp) + ) + } + } + } + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Input area + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = inputTextFieldState, + placeholder = { Text("Ask about the page...") }, + modifier = Modifier.weight(1f), + enabled = !isGenerating + ) + + if (isGenerating) { + OutlinedButton(onClick = onStopGeneration) { + Text("Stop") + } + } else { + DefaultButton( + onClick = { + if (inputText.isNotBlank()) { + onSendMessage(inputText) + inputTextFieldState.edit { replace(0, length, "") } + } + }, + enabled = inputText.isNotBlank() + ) { + Text("Send") + } + } + } + } +} + +/** + * Simple chat message item + */ +@Composable +private fun SimpleChatMessageItem(item: TimelineItem) { + when (item) { + is TimelineItem.MessageItem -> { + val isUser = item.role == MessageRole.USER + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 400.dp) + .background( + if (isUser) IdeaAutoDevColors.Blue.c400.copy(alpha = 0.2f) + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) + ) + .padding(8.dp) + ) { + Text( + text = item.content, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp) + ) + } + } + } + + is TimelineItem.ErrorItem -> { + Box( + modifier = Modifier + .fillMaxWidth() + .background(IdeaAutoDevColors.Red.c400.copy(alpha = 0.2f)) + .padding(8.dp) + ) { + Text( + text = item.message, + style = JewelTheme.defaultTextStyle.copy( + color = IdeaAutoDevColors.Red.c400, + fontSize = 12.sp + ) + ) + } + } + + else -> { + // Other item types not shown in WebEdit chat + } + } +} + +/** + * View shown when JCEF is not supported + */ +@Composable +private fun JcefNotSupportedView(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(JewelTheme.globalColors.panelBackground), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = IdeaAutoDevColors.Red.c400 + ) + Text( + text = "WebView Not Available", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + ) + Text( + text = "JCEF (Java Chromium Embedded Framework) is not supported\non this platform or IDE configuration.", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditModels.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditModels.kt new file mode 100644 index 0000000000..320d70052f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditModels.kt @@ -0,0 +1,61 @@ +package cc.unitmesh.devins.idea.toolwindow.webedit + +/** + * State for WebEdit Agent in IntelliJ IDEA + */ +data class IdeaWebEditState( + val currentUrl: String = "", + val pageTitle: String = "", + val isLoading: Boolean = false, + val loadProgress: Int = 0, + val isSelectionMode: Boolean = false, + val selectedElement: IdeaDOMElement? = null, + val domTree: IdeaDOMElement? = null, + val showDOMSidebar: Boolean = true, + val isReady: Boolean = false, + val error: String? = null +) + +/** + * Represents a DOM element from the web page + */ +data class IdeaDOMElement( + val id: String, + val tagName: String, + val selector: String, + val textContent: String? = null, + val attributes: Map = emptyMap(), + val boundingBox: IdeaBoundingBox? = null, + val children: List = emptyList() +) { + /** + * Generate a display name for the DOM tree + */ + fun getDisplayName(): String { + val classAttr = attributes["class"]?.split(" ")?.firstOrNull()?.let { ".$it" } ?: "" + val idAttr = attributes["id"]?.let { "#$it" } ?: "" + val text = textContent?.take(30)?.let { " \"$it\"" } ?: "" + return "$tagName$idAttr$classAttr$text" + } +} + +/** + * Bounding box for element positioning + */ +data class IdeaBoundingBox( + val x: Double, + val y: Double, + val width: Double, + val height: Double +) + +/** + * Message types from WebView to Kotlin + */ +sealed class IdeaWebEditMessage { + data class DOMTreeUpdated(val root: IdeaDOMElement) : IdeaWebEditMessage() + data class ElementSelected(val element: IdeaDOMElement) : IdeaWebEditMessage() + data class PageLoaded(val url: String, val title: String) : IdeaWebEditMessage() + data class Error(val message: String) : IdeaWebEditMessage() + data class LoadProgress(val progress: Int) : IdeaWebEditMessage() +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditViewModel.kt new file mode 100644 index 0000000000..c6f6236139 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/webedit/IdeaWebEditViewModel.kt @@ -0,0 +1,408 @@ +package cc.unitmesh.devins.idea.toolwindow.webedit + +import cc.unitmesh.agent.config.McpToolConfigService +import cc.unitmesh.agent.config.ToolConfigFile +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.config.ConfigManager +import cc.unitmesh.llm.KoogLLMService +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.* +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import javax.swing.JComponent + +/** + * ViewModel for WebEdit Agent in IntelliJ IDEA plugin. + * Uses JBCefBrowser for WebView rendering with DOM selection capabilities. + */ +class IdeaWebEditViewModel( + private val project: Project, + private val coroutineScope: CoroutineScope +) : Disposable { + + // Renderer for agent output (chat) + val renderer = JewelRenderer() + + // State + private val _state = MutableStateFlow(IdeaWebEditState()) + val state: StateFlow = _state.asStateFlow() + + // JCEF Browser + private var browser: JBCefBrowser? = null + private var jsQuery: JBCefJSQuery? = null + private var scriptInjected = false + + // LLM Service for chat + private var llmService: KoogLLMService? = null + private var currentJob: Job? = null + + // Check if JCEF is supported + val isJcefSupported: Boolean = JBCefApp.isSupported() + + init { + if (isJcefSupported) { + initializeBrowser() + } + initializeLLMService() + } + + private fun initializeBrowser() { + try { + browser = JBCefBrowser.createBuilder() + .setEnableOpenDevToolsMenuItem(true) + .build() + + // Register this as parent disposable + browser?.let { Disposer.register(this, it) } + + // Create JS query for communication + jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) + jsQuery?.let { query -> + Disposer.register(this, query) + query.addHandler { message -> + handleJsMessage(message) + JBCefJSQuery.Response(null) + } + } + + // Add load handler + val cefBrowser = browser?.cefBrowser + if (cefBrowser != null) { + browser?.jbCefClient?.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + if (frame?.isMain == true) { + val url = browser?.url ?: "" + val title = frame.name ?: "" + updateState { + it.copy( + currentUrl = url, + pageTitle = title, + isLoading = false, + loadProgress = 100 + ) + } + // Inject bridge script after page load + injectBridgeScript() + } + } + + override fun onLoadStart( + browser: CefBrowser?, + frame: CefFrame?, + transitionType: org.cef.network.CefRequest.TransitionType? + ) { + if (frame?.isMain == true) { + scriptInjected = false + updateState { it.copy(isLoading = true, loadProgress = 0) } + } + } + }, cefBrowser) + } + + } catch (e: Exception) { + updateState { it.copy(error = "Failed to initialize browser: ${e.message}") } + } + } + + private fun initializeLLMService() { + coroutineScope.launch(Dispatchers.IO) { + try { + val configWrapper = ConfigManager.load() + val activeConfig = configWrapper.getActiveModelConfig() + if (activeConfig != null && activeConfig.isValid()) { + llmService = KoogLLMService.create(activeConfig) + } + } catch (e: Exception) { + // LLM service is optional for WebEdit + } + } + } + + /** + * Get the browser component for embedding in Compose + */ + fun getBrowserComponent(): JComponent? = browser?.component + + /** + * Navigate to a URL + */ + fun navigateTo(url: String) { + val normalizedUrl = if (!url.startsWith("http://") && !url.startsWith("https://")) { + "https://$url" + } else url + + updateState { it.copy(currentUrl = normalizedUrl, isLoading = true) } + browser?.loadURL(normalizedUrl) + } + + /** + * Reload current page + */ + fun reload() { + browser?.cefBrowser?.reload() + } + + /** + * Go back in history + */ + fun goBack() { + if (browser?.cefBrowser?.canGoBack() == true) { + browser?.cefBrowser?.goBack() + } + } + + /** + * Go forward in history + */ + fun goForward() { + if (browser?.cefBrowser?.canGoForward() == true) { + browser?.cefBrowser?.goForward() + } + } + + /** + * Toggle selection mode + */ + fun toggleSelectionMode() { + val newMode = !_state.value.isSelectionMode + updateState { it.copy(isSelectionMode = newMode) } + executeJavaScript("window.webEditBridge?.setSelectionMode($newMode);") + } + + /** + * Toggle DOM sidebar visibility + */ + fun toggleDOMSidebar() { + updateState { it.copy(showDOMSidebar = !it.showDOMSidebar) } + } + + /** + * Highlight an element by selector + */ + fun highlightElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + executeJavaScript("window.webEditBridge?.highlightElement('$escapedSelector');") + } + + /** + * Clear all highlights + */ + fun clearHighlights() { + executeJavaScript("window.webEditBridge?.clearHighlights();") + } + + /** + * Scroll to an element + */ + fun scrollToElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + executeJavaScript("window.webEditBridge?.scrollToElement('$escapedSelector');") + } + + /** + * Refresh DOM tree + */ + fun refreshDOMTree() { + executeJavaScript("window.webEditBridge?.getDOMTree();") + } + + /** + * Send a chat message about the page/element + */ + fun sendChatMessage(message: String) { + if (_state.value.isLoading) return + + currentJob?.cancel() + currentJob = coroutineScope.launch(Dispatchers.IO) { + try { + renderer.addUserMessage(message) + + val service = llmService + if (service == null) { + renderer.renderError("LLM service not configured. Please set up your model in settings.") + return@launch + } + + // Build context with selected element info + val context = buildChatContext() + val fullPrompt = """ + |Context: User is browsing a web page. + |$context + | + |User question: $message + """.trimMargin() + + // Stream response using JewelRenderer's streaming API + renderer.renderLLMResponseStart() + service.streamPrompt(fullPrompt).collect { chunk -> + renderer.renderLLMResponseChunk(chunk) + } + renderer.renderLLMResponseEnd() + + } catch (e: CancellationException) { + renderer.forceStop() + } catch (e: Exception) { + renderer.renderError("Error: ${e.message}") + } + } + } + + /** + * Stop current chat generation + */ + fun stopGeneration() { + currentJob?.cancel() + currentJob = null + renderer.forceStop() + } + + /** + * Clear chat history + */ + fun clearChatHistory() { + renderer.clearTimeline() + currentJob?.cancel() + currentJob = null + } + + private fun buildChatContext(): String { + val state = _state.value + val sb = StringBuilder() + sb.appendLine("Current URL: ${state.currentUrl}") + sb.appendLine("Page Title: ${state.pageTitle}") + + state.selectedElement?.let { element -> + sb.appendLine("Selected Element:") + sb.appendLine(" Tag: ${element.tagName}") + sb.appendLine(" Selector: ${element.selector}") + element.textContent?.let { sb.appendLine(" Text: $it") } + if (element.attributes.isNotEmpty()) { + sb.appendLine(" Attributes: ${element.attributes}") + } + } + + return sb.toString() + } + + private fun injectBridgeScript() { + if (scriptInjected) return + + val callbackFunction = jsQuery?.inject("message") ?: return + val script = IdeaWebEditBridgeScript.getScript(callbackFunction) + executeJavaScript(script) + scriptInjected = true + updateState { it.copy(isReady = true) } + + // Request initial DOM tree + coroutineScope.launch { + delay(500) // Wait for script to initialize + refreshDOMTree() + } + } + + private fun executeJavaScript(script: String) { + browser?.cefBrowser?.executeJavaScript(script, browser?.cefBrowser?.url ?: "", 0) + } + + private fun handleJsMessage(message: String) { + try { + val json = Json.parseToJsonElement(message).jsonObject + val type = json["type"]?.jsonPrimitive?.content ?: return + val data = json["data"]?.jsonPrimitive?.content ?: "{}" + + when (type) { + "PageLoaded" -> { + val pageData = Json.parseToJsonElement(data).jsonObject + val url = pageData["url"]?.jsonPrimitive?.content ?: "" + val title = pageData["title"]?.jsonPrimitive?.content ?: "" + updateState { + it.copy( + currentUrl = url, + pageTitle = title, + isLoading = false + ) + } + } + + "ElementSelected" -> { + val elementData = Json.parseToJsonElement(data).jsonObject + val elementJson = elementData["element"]?.jsonObject + if (elementJson != null) { + val element = parseElement(elementJson) + updateState { it.copy(selectedElement = element) } + } + } + + "DOMTreeUpdated" -> { + val treeData = Json.parseToJsonElement(data).jsonObject + val rootJson = treeData["root"]?.jsonObject + if (rootJson != null) { + val root = parseElement(rootJson) + updateState { it.copy(domTree = root) } + } + } + + "Error" -> { + val errorData = Json.parseToJsonElement(data).jsonObject + val errorMessage = errorData["message"]?.jsonPrimitive?.content ?: "Unknown error" + updateState { it.copy(error = errorMessage) } + } + } + } catch (e: Exception) { + println("[IdeaWebEditViewModel] Error parsing message: ${e.message}") + } + } + + private fun parseElement(json: JsonObject): IdeaDOMElement { + val attributes = json["attributes"]?.jsonObject?.let { attrs -> + attrs.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") } + } ?: emptyMap() + + val boundingBox = json["boundingBox"]?.jsonObject?.let { bb -> + IdeaBoundingBox( + x = bb["x"]?.jsonPrimitive?.double ?: 0.0, + y = bb["y"]?.jsonPrimitive?.double ?: 0.0, + width = bb["width"]?.jsonPrimitive?.double ?: 0.0, + height = bb["height"]?.jsonPrimitive?.double ?: 0.0 + ) + } + + val children = json["children"]?.jsonArray?.mapNotNull { child -> + try { + parseElement(child.jsonObject) + } catch (e: Exception) { + null + } + } ?: emptyList() + + return IdeaDOMElement( + id = json["id"]?.jsonPrimitive?.content ?: "", + tagName = json["tagName"]?.jsonPrimitive?.content ?: "", + selector = json["selector"]?.jsonPrimitive?.content ?: "", + textContent = json["textContent"]?.jsonPrimitive?.contentOrNull, + attributes = attributes, + boundingBox = boundingBox, + children = children + ) + } + + private fun updateState(update: (IdeaWebEditState) -> IdeaWebEditState) { + _state.value = update(_state.value) + } + + override fun dispose() { + currentJob?.cancel() + // Browser and jsQuery are disposed via Disposer.register + } +} diff --git a/mpp-ui/build.gradle.kts b/mpp-ui/build.gradle.kts index 5bc7697fe6..b92163eea6 100644 --- a/mpp-ui/build.gradle.kts +++ b/mpp-ui/build.gradle.kts @@ -131,6 +131,8 @@ kotlin { implementation(project(":mpp-core")) implementation(project(":mpp-codegraph")) implementation(project(":mpp-viewer")) + implementation(project(":mpp-viewer-web")) + implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) @@ -163,6 +165,8 @@ kotlin { // Ktor HTTP Client (for remote agent) implementation(libs.ktor.client.core) + implementation(libs.compose.webview) + // i18n4k - Internationalization implementation(libs.i18n4k.core) } diff --git a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.android.kt b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.android.kt index 0a87d31e7e..3c1e03fe4e 100644 --- a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.android.kt +++ b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.android.kt @@ -15,7 +15,6 @@ import cc.unitmesh.devins.llm.ChatHistoryManager import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.ui.app.MobileNavLayout import cc.unitmesh.devins.ui.app.AppScreen -import cc.unitmesh.devins.ui.compose.agent.AgentChatInterface import cc.unitmesh.devins.ui.compose.agent.AgentInterfaceRouter import cc.unitmesh.agent.AgentType import cc.unitmesh.devins.ui.compose.chat.* diff --git a/mpp-ui/src/appleMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.apple.kt b/mpp-ui/src/appleMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.apple.kt index acb2ac2197..2996e633b1 100644 --- a/mpp-ui/src/appleMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.apple.kt +++ b/mpp-ui/src/appleMain/kotlin/cc/unitmesh/devins/ui/compose/AutoDevApp.apple.kt @@ -8,15 +8,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType -import cc.unitmesh.devins.completion.CompletionManager import cc.unitmesh.devins.llm.ChatHistoryManager import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.ui.app.AppleNavLayout import cc.unitmesh.devins.ui.app.AppScreen -import cc.unitmesh.devins.ui.compose.agent.AgentChatInterface import cc.unitmesh.devins.ui.compose.agent.AgentInterfaceRouter import cc.unitmesh.devins.ui.compose.chat.createChatCallbacks -import cc.unitmesh.devins.ui.compose.editor.DevInEditorInput import cc.unitmesh.devins.ui.compose.editor.ModelConfigDialog import cc.unitmesh.devins.ui.compose.theme.AutoDevTheme import cc.unitmesh.devins.ui.compose.theme.ThemeManager diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt index cdd5750306..873497e17a 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt @@ -1,12 +1,12 @@ package cc.unitmesh.devins.ui.compose.agent -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import cc.unitmesh.agent.AgentType import cc.unitmesh.devins.ui.compose.agent.chatdb.ChatDBPage import cc.unitmesh.devins.ui.compose.agent.codereview.CodeReviewPage -import cc.unitmesh.devins.ui.remote.RemoteAgentChatInterface +import cc.unitmesh.devins.ui.compose.agent.webedit.WebEditPage +import cc.unitmesh.devins.ui.remote.RemoteAgentPage import cc.unitmesh.devins.workspace.Workspace import cc.unitmesh.llm.KoogLLMService @@ -93,7 +93,7 @@ fun AgentInterfaceRouter( } AgentType.REMOTE -> { - RemoteAgentChatInterface( + RemoteAgentPage( serverUrl = serverUrl, useServerConfig = useServerConfig, isTreeViewVisible = isTreeViewVisible, @@ -130,9 +130,20 @@ fun AgentInterfaceRouter( ) } + AgentType.WEB_EDIT -> { + WebEditPage( + llmService = llmService, + modifier = modifier, + onBack = { + onAgentTypeChange(AgentType.CODING) + }, + onNotification = onNotification + ) + } + AgentType.LOCAL_CHAT, AgentType.CODING -> { - AgentChatInterface( + CodingAgentPage( llmService = llmService, isTreeViewVisible = isTreeViewVisible, onConfigWarning = onConfigWarning, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt similarity index 99% rename from mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt rename to mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt index 76da81c1ac..985f529663 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt @@ -26,7 +26,7 @@ import cc.unitmesh.llm.LLMProviderType import cc.unitmesh.llm.NamedModelConfig @Composable -fun AgentChatInterface( +fun CodingAgentPage( llmService: KoogLLMService?, isTreeViewVisible: Boolean = false, // 保留供外部读取,但内部使用全局状态 onConfigWarning: () -> Unit, @@ -291,6 +291,11 @@ fun AgentChatInterface( // 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 + } } ToolLoadingStatusBar( diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/DOMTreeSidebar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/DOMTreeSidebar.kt new file mode 100644 index 0000000000..607c1e7f19 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/DOMTreeSidebar.kt @@ -0,0 +1,196 @@ +package cc.unitmesh.devins.ui.compose.agent.webedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cc.unitmesh.viewer.web.webedit.DOMElement + +/** + * DOM Tree Sidebar - displays the DOM structure of the current page + */ +@Composable +fun DOMTreeSidebar( + domTree: DOMElement? = null, + selectedElement: DOMElement? = null, + modifier: Modifier = Modifier, + onElementClick: (String) -> Unit = {}, + onElementHover: (String?) -> Unit = {} +) { + var searchQuery by remember { mutableStateOf("") } + + // Convert DOMElement tree to flat list with depth + val flattenedTree = remember(domTree) { + domTree?.let { flattenDOMTree(it, 0) } ?: emptyList() + } + + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + // Header + Text( + text = "DOM Tree", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Search field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth().height(36.dp), + placeholder = { Text("Search...", style = MaterialTheme.typography.bodySmall) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall, + shape = RoundedCornerShape(6.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // DOM tree list + if (flattenedTree.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Load a page to see DOM tree", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(flattenedTree) { item -> + DOMTreeItemRow( + item = item, + isSelected = selectedElement?.selector == item.selector, + onClick = { onElementClick(item.selector) }, + onHover = { onElementHover(if (it) item.selector else null) } + ) + } + } + } + } +} + +/** + * Data class for flattened DOM tree items with depth + */ +data class DOMTreeItem( + val tagName: String, + val selector: String, + val depth: Int, + val attributes: List, + val textContent: String? = null +) + +/** + * Flatten a DOMElement tree into a list with depth information + */ +private fun flattenDOMTree(element: DOMElement, depth: Int): List { + val result = mutableListOf() + result.add( + DOMTreeItem( + tagName = element.tagName, + selector = element.selector, + depth = depth, + attributes = element.attributes.map { "${it.key}=${it.value}" }, + textContent = element.textContent + ) + ) + for (child in element.children) { + result.addAll(flattenDOMTree(child, depth + 1)) + } + return result +} + +/** + * Single row in the DOM tree + */ +@Composable +private fun DOMTreeItemRow( + item: DOMTreeItem, + isSelected: Boolean = false, + onClick: () -> Unit, + onHover: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + isHovered -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) + } + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(start = (item.depth * 12).dp), + shape = RoundedCornerShape(4.dp), + color = backgroundColor + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Tag name + Text( + text = "<${item.tagName}>", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + + // Attributes preview + if (item.attributes.isNotEmpty()) { + Text( + text = item.attributes.firstOrNull() ?: "", + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Text content preview + item.textContent?.takeIf { it.isNotBlank() }?.let { text -> + Text( + text = "\"${text.take(20)}${if (text.length > 20) "..." else ""}\"", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditChatInput.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditChatInput.kt new file mode 100644 index 0000000000..e62ecd522f --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditChatInput.kt @@ -0,0 +1,85 @@ +package cc.unitmesh.devins.ui.compose.agent.webedit + +import androidx.compose.foundation.layout.* +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp + +/** + * Chat input area for WebEdit Q&A functionality + */ +@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 +) { + Surface( + modifier = modifier, + tonalElevation = 2.dp + ) { + 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), + placeholder = { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyMedium + ) + }, + enabled = enabled, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { + if (input.isNotBlank()) { + onSend(input) + } + } + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) + + // Send button + FilledIconButton( + onClick = { + if (input.isNotBlank()) { + onSend(input) + } + }, + enabled = enabled && input.isNotBlank(), + modifier = Modifier.size(44.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(20.dp) + ) + } + } + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditPage.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditPage.kt new file mode 100644 index 0000000000..b04b068eaf --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditPage.kt @@ -0,0 +1,177 @@ +package cc.unitmesh.devins.ui.compose.agent.webedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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.llm.KoogLLMService +import cc.unitmesh.viewer.web.webedit.* +import kotlinx.coroutines.launch + +/** + * WebEdit Page - Browse, select DOM elements, and interact with web pages + * + * Layout: + * - Top: URL bar with navigation controls + * - Center: WebView with selection overlay + * - Right: DOM tree sidebar (toggleable) + * - Bottom: Chat/Q&A input area + */ +@Composable +fun WebEditPage( + llmService: KoogLLMService?, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onNotification: (String, String) -> Unit = { _, _ -> } +) { + val scope = rememberCoroutineScope() + + // Create the WebEdit bridge + val bridge = remember { createWebEditBridge() } + + // Collect state from bridge + val currentUrl by bridge.currentUrl.collectAsState() + val pageTitle by bridge.pageTitle.collectAsState() + val isLoading by bridge.isLoading.collectAsState() + val isSelectionMode by bridge.isSelectionMode.collectAsState() + val selectedElement by bridge.selectedElement.collectAsState() + val domTree by bridge.domTree.collectAsState() + + // Local UI state + var inputUrl by remember { mutableStateOf("") } + var showDOMSidebar by remember { mutableStateOf(true) } + var chatInput by remember { mutableStateOf("") } + + Column(modifier = modifier.fillMaxSize()) { + // Top bar with URL input and controls + WebEditToolbar( + currentUrl = currentUrl, + inputUrl = inputUrl, + isLoading = isLoading, + isSelectionMode = isSelectionMode, + showDOMSidebar = showDOMSidebar, + onUrlChange = { inputUrl = it }, + onNavigate = { url -> + val normalizedUrl = if (!url.startsWith("https://") && !url.startsWith("https://")) { + "https://$url" + } else url + inputUrl = normalizedUrl + scope.launch { + bridge.navigateTo(normalizedUrl) + } + }, + onBack = onBack, + onReload = { + scope.launch { bridge.reload() } + }, + onToggleSelectionMode = { + scope.launch { bridge.setSelectionMode(!isSelectionMode) } + }, + onToggleDOMSidebar = { showDOMSidebar = !showDOMSidebar } + ) + + Row(modifier = Modifier.weight(1f).fillMaxWidth()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface) + ) { + WebEditView( + bridge = bridge, + modifier = Modifier.fillMaxSize(), + onPageLoaded = { url, title -> + inputUrl = url + println("[WebEditPage] Page loaded: $title ($url)") + }, + onElementSelected = { element -> + println("[WebEditPage] Element selected: ${element.getDisplayName()}") + onNotification("Element Selected", element.getDisplayName()) + }, + onDOMTreeUpdated = { root -> + println("[WebEditPage] DOM tree updated with ${root.children.size} children") + } + ) + + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) + ) + } + + if (isSelectionMode) { + Surface( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "Selection Mode", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + selectedElement?.let { element -> + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = "Selected: ${element.getDisplayName()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + + if (showDOMSidebar) { + DOMTreeSidebar( + domTree = domTree, + selectedElement = selectedElement, + onElementClick = { selector -> + scope.launch { + bridge.highlightElement(selector) + bridge.scrollToElement(selector) + } + }, + onElementHover = { selector -> + if (selector != null) { + scope.launch { bridge.highlightElement(selector) } + } else { + scope.launch { bridge.clearHighlights() } + } + }, + modifier = Modifier.width(280.dp).fillMaxHeight() + ) + } + } + + WebEditChatInput( + input = chatInput, + onInputChange = { chatInput = it }, + onSend = { message -> + scope.launch { + // TODO: Handle chat message with LLM + // Could use selectedElement context for more targeted Q&A + chatInput = "" + } + }, + modifier = Modifier.fillMaxWidth() + ) + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditToolbar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditToolbar.kt new file mode 100644 index 0000000000..2c8868b033 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/webedit/WebEditToolbar.kt @@ -0,0 +1,181 @@ +package cc.unitmesh.devins.ui.compose.agent.webedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +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.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * WebEdit Toolbar with URL input and navigation controls + */ +@Composable +fun WebEditToolbar( + currentUrl: String, + inputUrl: String, + isLoading: Boolean, + isSelectionMode: Boolean, + showDOMSidebar: Boolean, + onUrlChange: (String) -> Unit, + onNavigate: (String) -> Unit, + onBack: () -> Unit, + onReload: () -> Unit, + onToggleSelectionMode: () -> Unit, + onToggleDOMSidebar: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Back to main app button + IconButton( + onClick = onBack, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close WebEdit", + modifier = Modifier.size(18.dp) + ) + } + + // Navigation buttons + IconButton( + onClick = { /* Go back in browser history */ }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp) + ) + } + + IconButton( + onClick = { /* Go forward in browser history */ }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Forward", + modifier = Modifier.size(18.dp) + ) + } + + IconButton( + onClick = onReload, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload", + modifier = Modifier.size(18.dp) + ) + } + + // URL input field - use BasicTextField for compact height + BasicTextField( + value = inputUrl, + onValueChange = onUrlChange, + modifier = Modifier + .weight(1f) + .height(28.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(6.dp) + ) + .border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + RoundedCornerShape(6.dp) + ) + .padding(horizontal = 10.dp), + textStyle = TextStyle( + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { onNavigate(inputUrl) }), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart + ) { + if (inputUrl.isEmpty()) { + Text( + "Enter URL (e.g., https://www.baidu.com)", + style = TextStyle(fontSize = 13.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + innerTextField() + } + } + ) + + // Selection mode toggle + IconButton( + onClick = onToggleSelectionMode, + modifier = Modifier.size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (isSelectionMode) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + ) { + Icon( + imageVector = Icons.Default.TouchApp, + contentDescription = "Toggle Selection Mode", + modifier = Modifier.size(18.dp) + ) + } + + // DOM sidebar toggle + IconButton( + onClick = onToggleDOMSidebar, + modifier = Modifier.size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (showDOMSidebar) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + ) { + Icon( + imageVector = Icons.Default.AccountTree, + contentDescription = "Toggle DOM Sidebar", + modifier = Modifier.size(18.dp) + ) + } + } + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/DesktopTitleBarTabs.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/DesktopTitleBarTabs.kt index e855d8fd29..4309163964 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/DesktopTitleBarTabs.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/DesktopTitleBarTabs.kt @@ -32,7 +32,9 @@ fun DesktopTitleBarTabs( modifier: Modifier = Modifier ) { LaunchedEffect(currentAgentType) { - if (currentAgentType == AgentType.CODE_REVIEW || currentAgentType == AgentType.KNOWLEDGE) { + if (currentAgentType == AgentType.CODE_REVIEW || + currentAgentType == AgentType.KNOWLEDGE || + currentAgentType == AgentType.WEB_EDIT) { UIStateManager.setSessionSidebarVisible(false) } else { UIStateManager.setSessionSidebarVisible(true) diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuDesktop.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuDesktop.kt index 7ebcb345e1..c5160aaf6d 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuDesktop.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuDesktop.kt @@ -287,6 +287,7 @@ private fun AgentTypeTab( AgentType.CODING -> AutoDevComposeIcons.Code AgentType.LOCAL_CHAT -> AutoDevComposeIcons.Chat AgentType.CHAT_DB -> AutoDevComposeIcons.Database + AgentType.WEB_EDIT -> AutoDevComposeIcons.Language }, contentDescription = null, tint = contentColor, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuMobile.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuMobile.kt index b9aa2700c6..e4d09ce8be 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuMobile.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenuMobile.kt @@ -225,6 +225,7 @@ fun TopBarMenuMobile( AgentType.CODING -> AutoDevComposeIcons.Code AgentType.LOCAL_CHAT -> AutoDevComposeIcons.Chat AgentType.CHAT_DB -> AutoDevComposeIcons.Database + AgentType.WEB_EDIT -> AutoDevComposeIcons.Language }, contentDescription = null, modifier = Modifier.size(20.dp) @@ -260,6 +261,7 @@ fun TopBarMenuMobile( AgentType.CODING -> AutoDevComposeIcons.Code AgentType.LOCAL_CHAT -> AutoDevComposeIcons.Chat AgentType.CHAT_DB -> AutoDevComposeIcons.Database + AgentType.WEB_EDIT -> AutoDevComposeIcons.Language }, contentDescription = null, modifier = Modifier.size(20.dp) diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/remote/RemoteAgentChatInterface.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/remote/RemoteAgentChatInterface.kt index f4cb7fe31c..101b1179e7 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/remote/RemoteAgentChatInterface.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/remote/RemoteAgentChatInterface.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.launch * to connect to a remote mpp-server instead of running locally. */ @Composable -fun RemoteAgentChatInterface( +fun RemoteAgentPage( serverUrl: String, useServerConfig: Boolean = false, isTreeViewVisible: Boolean = false, diff --git a/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/DOMElement.kt b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/DOMElement.kt new file mode 100644 index 0000000000..cd9218101d --- /dev/null +++ b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/DOMElement.kt @@ -0,0 +1,119 @@ +package cc.unitmesh.viewer.web.webedit + +import kotlinx.serialization.Serializable + +/** + * Represents a DOM element from the web page + * + * @param id Unique identifier for the element + * @param tagName HTML tag name (e.g., "div", "span", "button") + * @param selector CSS selector to locate this element + * @param textContent Text content of the element (truncated if too long) + * @param attributes Key attributes of the element (id, class, etc.) + * @param boundingBox Bounding box of the element (x, y, width, height) + * @param children Child elements (for tree structure) + */ +@Serializable +data class DOMElement( + val id: String, + val tagName: String, + val selector: String, + val textContent: String? = null, + val attributes: Map = emptyMap(), + val boundingBox: BoundingBox? = null, + val children: List = emptyList() +) { + /** + * Generate a display name for the DOM tree + */ + fun getDisplayName(): String { + val classAttr = attributes["class"]?.split(" ")?.firstOrNull()?.let { ".$it" } ?: "" + val idAttr = attributes["id"]?.let { "#$it" } ?: "" + val text = textContent?.take(30)?.let { " \"$it\"" } ?: "" + return "$tagName$idAttr$classAttr$text" + } +} + +/** + * Bounding box for element positioning + */ +@Serializable +data class BoundingBox( + val x: Double, + val y: Double, + val width: Double, + val height: Double +) + +/** + * Message types from WebView to Kotlin + */ +@Serializable +sealed class WebEditMessage { + /** + * DOM tree updated message + */ + @Serializable + data class DOMTreeUpdated(val root: DOMElement) : WebEditMessage() + + /** + * Element selected by user + */ + @Serializable + data class ElementSelected(val element: DOMElement) : WebEditMessage() + + /** + * Page loaded event + */ + @Serializable + data class PageLoaded(val url: String, val title: String) : WebEditMessage() + + /** + * Error message + */ + @Serializable + data class Error(val message: String) : WebEditMessage() + + /** + * Page load progress + */ + @Serializable + data class LoadProgress(val progress: Int) : WebEditMessage() +} + +/** + * Commands from Kotlin to WebView + */ +@Serializable +sealed class WebEditCommand { + /** + * Enable/disable selection mode + */ + @Serializable + data class SetSelectionMode(val enabled: Boolean) : WebEditCommand() + + /** + * Highlight a specific element + */ + @Serializable + data class HighlightElement(val selector: String) : WebEditCommand() + + /** + * Clear all highlights + */ + @Serializable + object ClearHighlights : WebEditCommand() + + /** + * Get DOM tree + */ + @Serializable + object GetDOMTree : WebEditCommand() + + /** + * Scroll to element + */ + @Serializable + data class ScrollToElement(val selector: String) : WebEditCommand() +} + diff --git a/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.kt b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.kt new file mode 100644 index 0000000000..8f1e0452e9 --- /dev/null +++ b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.kt @@ -0,0 +1,133 @@ +package cc.unitmesh.viewer.web.webedit + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Abstract bridge for communication between Kotlin and WebView + * + * This interface defines the contract for bidirectional communication + * between the Kotlin/Compose layer and the embedded WebView for web editing. + */ +interface WebEditBridge { + /** + * Current URL being viewed + */ + val currentUrl: StateFlow + + /** + * Page title + */ + val pageTitle: StateFlow + + /** + * Whether the page is loading + */ + val isLoading: StateFlow + + /** + * Load progress (0-100) + */ + val loadProgress: StateFlow + + /** + * Current DOM tree + */ + val domTree: StateFlow + + /** + * Currently selected element + */ + val selectedElement: StateFlow + + /** + * Whether selection mode is enabled + */ + val isSelectionMode: StateFlow + + /** + * Whether the bridge is ready + */ + val isReady: StateFlow + + /** + * Navigate to a URL + */ + suspend fun navigateTo(url: String) + + /** + * Reload the current page + */ + suspend fun reload() + + /** + * Go back in history + */ + suspend fun goBack() + + /** + * Go forward in history + */ + suspend fun goForward() + + /** + * Enable or disable selection mode + */ + suspend fun setSelectionMode(enabled: Boolean) + + /** + * Highlight a specific element + */ + suspend fun highlightElement(selector: String) + + /** + * Clear all highlights + */ + suspend fun clearHighlights() + + /** + * Scroll to an element + */ + suspend fun scrollToElement(selector: String) + + /** + * Refresh the DOM tree + */ + suspend fun refreshDOMTree() + + /** + * Get the HTML content of the selected element + */ + suspend fun getSelectedElementHtml(): String? + + /** + * Mark bridge as ready + */ + fun markReady() + + /** + * Handle message from WebView + */ + fun handleMessage(message: WebEditMessage) +} + +/** + * State holder for WebEdit + */ +data class WebEditState( + val currentUrl: String = "", + val pageTitle: String = "", + val isLoading: Boolean = false, + val loadProgress: Int = 0, + val domTree: DOMElement? = null, + val selectedElement: DOMElement? = null, + val isSelectionMode: Boolean = false, + val isReady: Boolean = false +) + +/** + * Factory function for creating WebEditBridge instances + */ +expect fun createWebEditBridge(): WebEditBridge + diff --git a/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridgeScript.kt b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridgeScript.kt new file mode 100644 index 0000000000..998c42edd6 --- /dev/null +++ b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridgeScript.kt @@ -0,0 +1,171 @@ +package cc.unitmesh.viewer.web.webedit + +/** + * JavaScript code to inject into WebView for DOM selection and communication + * + * This script provides: + * - Selection mode with element highlighting + * - DOM tree extraction + * - Bidirectional communication with Kotlin via kmpJsBridge + */ +fun getWebEditBridgeScript(): String = """ +(function() { + // Prevent multiple injections + if (window.webEditBridge) return; + + // WebEdit Bridge object + window.webEditBridge = { + selectionMode: false, + highlightedElement: null, + selectedElement: null, + + // Enable/disable selection mode + setSelectionMode: function(enabled) { + this.selectionMode = enabled; + if (!enabled) { + this.clearHighlights(); + } + console.log('[WebEditBridge] Selection mode:', enabled); + }, + + // Highlight a specific element by selector + highlightElement: function(selector) { + this.clearHighlights(); + try { + const el = document.querySelector(selector); + if (el) { + this.highlightedElement = el; + el.style.outline = '2px solid #2196F3'; + el.style.outlineOffset = '2px'; + } + } catch(e) { + console.error('[WebEditBridge] highlightElement error:', e); + } + }, + + // Clear all highlights + clearHighlights: function() { + if (this.highlightedElement) { + this.highlightedElement.style.outline = ''; + this.highlightedElement.style.outlineOffset = ''; + this.highlightedElement = null; + } + if (this.selectedElement) { + this.selectedElement.style.outline = ''; + this.selectedElement.style.outlineOffset = ''; + } + }, + + // Scroll to element + scrollToElement: function(selector) { + try { + const el = document.querySelector(selector); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } catch(e) { + console.error('[WebEditBridge] scrollToElement error:', e); + } + }, + + // Get unique selector for element + getSelector: function(el) { + if (el.id) return '#' + el.id; + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + + const path = []; + while (el && el.nodeType === Node.ELEMENT_NODE) { + let selector = el.tagName.toLowerCase(); + if (el.id) { + selector = '#' + el.id; + path.unshift(selector); + break; + } else if (el.className && typeof el.className === 'string') { + const classes = el.className.trim().split(/\\s+/).slice(0, 2); + if (classes.length > 0 && classes[0]) { + selector += '.' + classes.join('.'); + } + } + path.unshift(selector); + el = el.parentNode; + } + return path.join(' > '); + }, + + // Get DOM tree (simplified) + getDOMTree: function() { + function buildTree(el, depth) { + if (depth > 5) return null; + const rect = el.getBoundingClientRect(); + const node = { + id: Math.random().toString(36).substr(2, 9), + tagName: el.tagName.toLowerCase(), + selector: window.webEditBridge.getSelector(el), + textContent: (el.textContent || '').trim().substring(0, 50), + attributes: {}, + boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + children: [] + }; + if (el.id) node.attributes.id = el.id; + if (el.className) node.attributes.class = el.className; + Array.from(el.children).slice(0, 20).forEach(child => { + const childNode = buildTree(child, depth + 1); + if (childNode) node.children.push(childNode); + }); + return node; + } + const tree = buildTree(document.body, 0); + this.sendToKotlin('DOMTreeUpdated', JSON.stringify({ root: tree })); + }, + + // Send message to Kotlin + sendToKotlin: function(type, data) { + if (window.kmpJsBridge && window.kmpJsBridge.callNative) { + window.kmpJsBridge.callNative('webEditMessage', + JSON.stringify({ type: type, data: data }), function() {}); + } + } + }; + + // Mouse event handlers for selection mode + document.addEventListener('mouseover', function(e) { + if (!window.webEditBridge.selectionMode) return; + e.stopPropagation(); + window.webEditBridge.clearHighlights(); + window.webEditBridge.highlightedElement = e.target; + e.target.style.outline = '2px solid #2196F3'; + e.target.style.outlineOffset = '2px'; + }, true); + + document.addEventListener('click', function(e) { + if (!window.webEditBridge.selectionMode) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target; + window.webEditBridge.selectedElement = el; + el.style.outline = '3px solid #4CAF50'; + el.style.outlineOffset = '2px'; + const rect = el.getBoundingClientRect(); + window.webEditBridge.sendToKotlin('ElementSelected', JSON.stringify({ + element: { + id: Math.random().toString(36).substr(2, 9), + tagName: el.tagName.toLowerCase(), + selector: window.webEditBridge.getSelector(el), + textContent: (el.textContent || '').trim().substring(0, 100), + attributes: { id: el.id || '', class: el.className || '' }, + boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + } + })); + }, true); + + // Notify page loaded + window.webEditBridge.sendToKotlin('PageLoaded', JSON.stringify({ + url: window.location.href, + title: document.title + })); + + console.log('[WebEditBridge] Initialized'); +})(); +""".trimIndent() + diff --git a/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.kt b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.kt new file mode 100644 index 0000000000..629b45ef98 --- /dev/null +++ b/mpp-viewer-web/src/commonMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.kt @@ -0,0 +1,25 @@ +package cc.unitmesh.viewer.web.webedit + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * WebEdit WebView component for browsing and selecting DOM elements + * + * This is the common interface - platform-specific implementations provide the actual WebView. + * + * @param bridge The WebEditBridge for communication + * @param modifier The modifier for layout + * @param onPageLoaded Callback when page finishes loading + * @param onElementSelected Callback when an element is selected + * @param onDOMTreeUpdated Callback when DOM tree is updated + */ +@Composable +expect fun WebEditView( + bridge: WebEditBridge, + modifier: Modifier = Modifier, + onPageLoaded: (url: String, title: String) -> Unit = { _, _ -> }, + onElementSelected: (DOMElement) -> Unit = {}, + onDOMTreeUpdated: (DOMElement) -> Unit = {} +) + diff --git a/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.jvm.kt b/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.jvm.kt new file mode 100644 index 0000000000..ab86f2b163 --- /dev/null +++ b/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.jvm.kt @@ -0,0 +1,140 @@ +package cc.unitmesh.viewer.web.webedit + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * JVM implementation of WebEditBridge using WebViewNavigator + */ +class JvmWebEditBridge : WebEditBridge { + private val _currentUrl = MutableStateFlow("") + override val currentUrl: StateFlow = _currentUrl.asStateFlow() + + private val _pageTitle = MutableStateFlow("") + override val pageTitle: StateFlow = _pageTitle.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + override val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _loadProgress = MutableStateFlow(0) + override val loadProgress: StateFlow = _loadProgress.asStateFlow() + + private val _domTree = MutableStateFlow(null) + override val domTree: StateFlow = _domTree.asStateFlow() + + private val _selectedElement = MutableStateFlow(null) + override val selectedElement: StateFlow = _selectedElement.asStateFlow() + + private val _isSelectionMode = MutableStateFlow(false) + override val isSelectionMode: StateFlow = _isSelectionMode.asStateFlow() + + private val _isReady = MutableStateFlow(false) + override val isReady: StateFlow = _isReady.asStateFlow() + + // Callback to execute JavaScript in WebView + var executeJavaScript: ((String) -> Unit)? = null + + // Callback to navigate in WebView + var navigateCallback: ((String) -> Unit)? = null + var reloadCallback: (() -> Unit)? = null + var goBackCallback: (() -> Unit)? = null + var goForwardCallback: (() -> Unit)? = null + + override suspend fun navigateTo(url: String) { + _isLoading.value = true + _currentUrl.value = url + navigateCallback?.invoke(url) + } + + override suspend fun reload() { + _isLoading.value = true + reloadCallback?.invoke() + } + + override suspend fun goBack() { + goBackCallback?.invoke() + } + + override suspend fun goForward() { + goForwardCallback?.invoke() + } + + override suspend fun setSelectionMode(enabled: Boolean) { + _isSelectionMode.value = enabled + val script = "window.webEditBridge?.setSelectionMode($enabled);" + executeJavaScript?.invoke(script) + } + + override suspend fun highlightElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + val script = "window.webEditBridge?.highlightElement('$escapedSelector');" + executeJavaScript?.invoke(script) + } + + override suspend fun clearHighlights() { + val script = "window.webEditBridge?.clearHighlights();" + executeJavaScript?.invoke(script) + } + + override suspend fun scrollToElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + val script = "window.webEditBridge?.scrollToElement('$escapedSelector');" + executeJavaScript?.invoke(script) + } + + override suspend fun refreshDOMTree() { + val script = "window.webEditBridge?.getDOMTree();" + executeJavaScript?.invoke(script) + } + + override suspend fun getSelectedElementHtml(): String? { + return _selectedElement.value?.let { + val script = "document.querySelector('${it.selector}')?.outerHTML || '';" + // This is a simplified version - in practice you'd use a callback + null + } + } + + override fun markReady() { + _isReady.value = true + } + + override fun handleMessage(message: WebEditMessage) { + when (message) { + is WebEditMessage.DOMTreeUpdated -> { + _domTree.value = message.root + } + is WebEditMessage.ElementSelected -> { + _selectedElement.value = message.element + } + is WebEditMessage.PageLoaded -> { + _currentUrl.value = message.url + _pageTitle.value = message.title + _isLoading.value = false + _loadProgress.value = 100 + } + is WebEditMessage.Error -> { + println("[WebEditBridge] Error: ${message.message}") + } + is WebEditMessage.LoadProgress -> { + _loadProgress.value = message.progress + } + } + } + + fun setUrl(url: String) { + _currentUrl.value = url + } + + fun setTitle(title: String) { + _pageTitle.value = title + } + + fun setLoading(loading: Boolean) { + _isLoading.value = loading + } +} + +actual fun createWebEditBridge(): WebEditBridge = JvmWebEditBridge() + diff --git a/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.jvm.kt b/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.jvm.kt new file mode 100644 index 0000000000..c935c731ef --- /dev/null +++ b/mpp-viewer-web/src/jvmMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.jvm.kt @@ -0,0 +1,197 @@ +package cc.unitmesh.viewer.web.webedit + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.multiplatform.webview.jsbridge.IJsMessageHandler +import com.multiplatform.webview.jsbridge.JsMessage +import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge +import com.multiplatform.webview.util.KLogSeverity +import com.multiplatform.webview.web.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * JVM implementation of WebEditView using compose-webview-multiplatform + */ +@Composable +actual fun WebEditView( + bridge: WebEditBridge, + modifier: Modifier, + onPageLoaded: (url: String, title: String) -> Unit, + onElementSelected: (DOMElement) -> Unit, + onDOMTreeUpdated: (DOMElement) -> Unit +) { + val currentUrl by bridge.currentUrl.collectAsState() + + val webViewState = rememberWebViewState(url = "https://ide.unitmesh.cc") + LaunchedEffect(Unit) { + webViewState.webSettings.apply { + logSeverity = KLogSeverity.Info + backgroundColor = Color.Red + allowUniversalAccessFromFileURLs = true + customUserAgentString = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1) AppleWebKit/625.20 (KHTML, like Gecko) Version/14.3.43 Safari/625.20" + } + } + + val loadingState = webViewState.loadingState + if (loadingState is LoadingState.Loading) { + LinearProgressIndicator( + progress = loadingState.progress, + modifier = Modifier.fillMaxWidth(), + ) + } + + val webViewNavigator = rememberWebViewNavigator() + val jsBridge = rememberWebViewJsBridge() + + // Track if script has been injected for this page + var scriptInjected by remember { mutableStateOf(false) } + + // Configure the JVM bridge with WebView callbacks + LaunchedEffect(Unit) { + if (bridge is JvmWebEditBridge) { + bridge.executeJavaScript = { script -> + webViewNavigator.evaluateJavaScript(script) + } + bridge.navigateCallback = { url -> + webViewNavigator.loadUrl(url) + } + bridge.reloadCallback = { + webViewNavigator.reload() + } + bridge.goBackCallback = { + if (webViewNavigator.canGoBack) { + webViewNavigator.navigateBack() + } + } + bridge.goForwardCallback = { + if (webViewNavigator.canGoForward) { + webViewNavigator.navigateForward() + } + } + } + } + + // Navigate when URL changes from the bridge + LaunchedEffect(currentUrl) { + if (currentUrl.isNotEmpty() && currentUrl != webViewState.lastLoadedUrl) { + println("[WebEditView] URL changed to: $currentUrl") + webViewNavigator.loadUrl(currentUrl) + } + } + + // Register JS bridge handlers for messages from WebView + LaunchedEffect(Unit) { + jsBridge.register(object : IJsMessageHandler { + override fun methodName(): String = "webEditMessage" + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + try { + val json = Json.parseToJsonElement(message.params).jsonObject + val type = json["type"]?.jsonPrimitive?.content ?: return + val data = json["data"]?.jsonPrimitive?.content ?: "{}" + + when (type) { + "PageLoaded" -> { + val pageData = Json.parseToJsonElement(data).jsonObject + val url = pageData["url"]?.jsonPrimitive?.content ?: "" + val title = pageData["title"]?.jsonPrimitive?.content ?: "" + bridge.handleMessage(WebEditMessage.PageLoaded(url, title)) + onPageLoaded(url, title) + } + "ElementSelected" -> { + val elementData = Json.parseToJsonElement(data).jsonObject + val elementJson = elementData["element"]?.jsonObject + if (elementJson != null) { + val element = parseElement(elementJson.toString()) + if (element != null) { + bridge.handleMessage(WebEditMessage.ElementSelected(element)) + onElementSelected(element) + } + } + } + "DOMTreeUpdated" -> { + val treeData = Json.parseToJsonElement(data).jsonObject + val rootJson = treeData["root"]?.jsonObject + if (rootJson != null) { + val root = parseElement(rootJson.toString()) + if (root != null) { + bridge.handleMessage(WebEditMessage.DOMTreeUpdated(root)) + onDOMTreeUpdated(root) + } + } + } + "Error" -> { + val errorData = Json.parseToJsonElement(data).jsonObject + val errorMessage = errorData["message"]?.jsonPrimitive?.content ?: "Unknown error" + bridge.handleMessage(WebEditMessage.Error(errorMessage)) + } + } + } catch (e: Exception) { + println("[WebEditView] Error parsing message: ${e.message}") + } + callback("ok") + } + }) + } + + // Monitor loading state and inject script when page loads + LaunchedEffect(webViewState.isLoading, webViewState.lastLoadedUrl) { + if (!webViewState.isLoading && webViewState.loadingState is LoadingState.Finished) { + // Reset script injection flag for new page + if (webViewState.lastLoadedUrl != null && webViewState.lastLoadedUrl != "about:blank") { + scriptInjected = false + + // Update bridge state + if (bridge is JvmWebEditBridge) { + bridge.setLoading(false) + bridge.setUrl(webViewState.lastLoadedUrl ?: "") + bridge.setTitle(webViewState.pageTitle ?: "") + } + + // Inject the WebEdit bridge script + kotlinx.coroutines.delay(300) // Wait for page to stabilize + val script = getWebEditBridgeScript() + webViewNavigator.evaluateJavaScript(script) + scriptInjected = true + + bridge.markReady() + println("[WebEditView] Bridge script injected for: ${webViewState.lastLoadedUrl}") + } + } else if (webViewState.isLoading) { + if (bridge is JvmWebEditBridge) { + bridge.setLoading(true) + } + } + } + + WebView( + state = webViewState, + navigator = webViewNavigator, + modifier = modifier, + captureBackPresses = false, + webViewJsBridge = jsBridge + ) +} + +/** + * Parse a JSON string into a DOMElement + */ +private fun parseElement(jsonString: String): DOMElement? { + return try { + Json.decodeFromString(jsonString) + } catch (e: Exception) { + println("[WebEditView] Failed to parse element: ${e.message}") + null + } +} + diff --git a/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.wasmJs.kt b/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.wasmJs.kt new file mode 100644 index 0000000000..d132697331 --- /dev/null +++ b/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditBridge.wasmJs.kt @@ -0,0 +1,133 @@ +package cc.unitmesh.viewer.web.webedit + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * WASM JS implementation of WebEditBridge + */ +class WasmWebEditBridge : WebEditBridge { + private val _currentUrl = MutableStateFlow("") + override val currentUrl: StateFlow = _currentUrl.asStateFlow() + + private val _pageTitle = MutableStateFlow("") + override val pageTitle: StateFlow = _pageTitle.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + override val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _loadProgress = MutableStateFlow(0) + override val loadProgress: StateFlow = _loadProgress.asStateFlow() + + private val _domTree = MutableStateFlow(null) + override val domTree: StateFlow = _domTree.asStateFlow() + + private val _selectedElement = MutableStateFlow(null) + override val selectedElement: StateFlow = _selectedElement.asStateFlow() + + private val _isSelectionMode = MutableStateFlow(false) + override val isSelectionMode: StateFlow = _isSelectionMode.asStateFlow() + + private val _isReady = MutableStateFlow(false) + override val isReady: StateFlow = _isReady.asStateFlow() + + var executeJavaScript: ((String) -> Unit)? = null + var navigateCallback: ((String) -> Unit)? = null + var reloadCallback: (() -> Unit)? = null + var goBackCallback: (() -> Unit)? = null + var goForwardCallback: (() -> Unit)? = null + + override suspend fun navigateTo(url: String) { + _isLoading.value = true + _currentUrl.value = url + navigateCallback?.invoke(url) + } + + override suspend fun reload() { + _isLoading.value = true + reloadCallback?.invoke() + } + + override suspend fun goBack() { + goBackCallback?.invoke() + } + + override suspend fun goForward() { + goForwardCallback?.invoke() + } + + override suspend fun setSelectionMode(enabled: Boolean) { + _isSelectionMode.value = enabled + val script = "window.webEditBridge?.setSelectionMode($enabled);" + executeJavaScript?.invoke(script) + } + + override suspend fun highlightElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + val script = "window.webEditBridge?.highlightElement('$escapedSelector');" + executeJavaScript?.invoke(script) + } + + override suspend fun clearHighlights() { + val script = "window.webEditBridge?.clearHighlights();" + executeJavaScript?.invoke(script) + } + + override suspend fun scrollToElement(selector: String) { + val escapedSelector = selector.replace("'", "\\'") + val script = "window.webEditBridge?.scrollToElement('$escapedSelector');" + executeJavaScript?.invoke(script) + } + + override suspend fun refreshDOMTree() { + val script = "window.webEditBridge?.getDOMTree();" + executeJavaScript?.invoke(script) + } + + override suspend fun getSelectedElementHtml(): String? { + return null + } + + override fun markReady() { + _isReady.value = true + } + + override fun handleMessage(message: WebEditMessage) { + when (message) { + is WebEditMessage.DOMTreeUpdated -> { + _domTree.value = message.root + } + is WebEditMessage.ElementSelected -> { + _selectedElement.value = message.element + } + is WebEditMessage.PageLoaded -> { + _currentUrl.value = message.url + _pageTitle.value = message.title + _isLoading.value = false + _loadProgress.value = 100 + } + is WebEditMessage.Error -> { + println("[WebEditBridge] Error: ${message.message}") + } + is WebEditMessage.LoadProgress -> { + _loadProgress.value = message.progress + } + } + } + + fun setUrl(url: String) { + _currentUrl.value = url + } + + fun setTitle(title: String) { + _pageTitle.value = title + } + + fun setLoading(loading: Boolean) { + _isLoading.value = loading + } +} + +actual fun createWebEditBridge(): WebEditBridge = WasmWebEditBridge() + diff --git a/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.wasmJs.kt b/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.wasmJs.kt new file mode 100644 index 0000000000..7386f367e6 --- /dev/null +++ b/mpp-viewer-web/src/wasmJsMain/kotlin/cc/unitmesh/viewer/web/webedit/WebEditView.wasmJs.kt @@ -0,0 +1,39 @@ +package cc.unitmesh.viewer.web.webedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * WASM implementation of WebEditView + * + * Note: WebView is not available in WASM, so we show a placeholder message. + * In a real implementation, you might use an iframe or redirect to a browser. + */ +@Composable +actual fun WebEditView( + bridge: WebEditBridge, + modifier: Modifier, + onPageLoaded: (url: String, title: String) -> Unit, + onElementSelected: (DOMElement) -> Unit, + onDOMTreeUpdated: (DOMElement) -> Unit +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + text = "WebView is not available in WASM. Please use the desktop version.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +