package cc.unitmesh.devins.ui.compose.sketch import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import cc.unitmesh.agent.Platform import cc.unitmesh.viewer.web.KcefInitState import cc.unitmesh.viewer.web.KcefManager import cc.unitmesh.viewer.web.MermaidRenderer import com.mikepenz.markdown.compose.LocalMarkdownTypography import com.mikepenz.markdown.compose.MarkdownElement import com.mikepenz.markdown.compose.components.CurrentComponentsBridge import com.mikepenz.markdown.compose.components.MarkdownComponents import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownCodeFence import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.model.DefaultMarkdownTypography import com.mikepenz.markdown.model.State import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.SyntaxThemes import org.intellij.markdown.ast.ASTNode /** * JVM 平台的 Markdown Sketch 渲染器实现 * 使用 Compose + multiplatform-markdown-renderer */ actual object MarkdownSketchRenderer { @Composable actual fun RenderMarkdown( markdown: String, isComplete: Boolean, isDarkTheme: Boolean, modifier: Modifier ) { // Defensive fallback for Compose Desktop (Skiko): // We have observed StringIndexOutOfBoundsException from ParagraphBuilder when the markdown library // produces AnnotatedString spans with invalid ranges (often triggered by large / malformed markdown, // or unbalanced code fences). Plain text rendering avoids spans entirely and prevents crashes. if (shouldFallbackToPlainText(markdown = markdown, isComplete = isComplete)) { RenderPlainText(text = markdown, modifier = modifier) return } val darkTheme = isDarkTheme || isSystemInDarkTheme() val highlightsBuilder = remember(darkTheme) { Highlights.Builder().theme(SyntaxThemes.atom(darkMode = darkTheme)) } // Check KCEF state for Mermaid rendering val kcefInitState by KcefManager.initState.collectAsState() val isKcefAvailable = remember(kcefInitState) { kcefInitState is KcefInitState.Initialized || KcefManager.isInstalled() } // Keep markdown compact on desktop too (line-height and paragraph/list spacing are driven by text styles). // Users can still zoom via the title bar font scale control. val typography = if (Platform.isAndroid) { DefaultMarkdownTypography( h1 = MaterialTheme.typography.titleLarge, h2 = MaterialTheme.typography.titleMedium, h3 = MaterialTheme.typography.titleSmall, h4 = MaterialTheme.typography.labelLarge, h5 = MaterialTheme.typography.labelMedium, h6 = MaterialTheme.typography.labelSmall, text = MaterialTheme.typography.bodySmall, code = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), quote = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), paragraph = MaterialTheme.typography.bodySmall, ordered = MaterialTheme.typography.bodySmall, bullet = MaterialTheme.typography.bodySmall, list = MaterialTheme.typography.bodySmall, table = MaterialTheme.typography.bodySmall, inlineCode = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), textLink = TextLinkStyles( style = SpanStyle(color = MaterialTheme.colorScheme.primary) ) ) } else { DefaultMarkdownTypography( // Desktop: slightly smaller headings and compact body to reduce perceived "block gaps" h1 = MaterialTheme.typography.titleLarge, h2 = MaterialTheme.typography.titleMedium, h3 = MaterialTheme.typography.titleSmall, h4 = MaterialTheme.typography.labelLarge, h5 = MaterialTheme.typography.labelMedium, h6 = MaterialTheme.typography.labelSmall, text = MaterialTheme.typography.bodySmall, code = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), quote = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic), paragraph = MaterialTheme.typography.bodySmall, ordered = MaterialTheme.typography.bodySmall, bullet = MaterialTheme.typography.bodySmall, list = MaterialTheme.typography.bodySmall, table = MaterialTheme.typography.bodySmall, inlineCode = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), textLink = TextLinkStyles( style = SpanStyle(color = MaterialTheme.colorScheme.primary) ) ) } Markdown( markdown, modifier = modifier, typography = typography, components = markdownComponents( table = { tableContent -> val style = LocalMarkdownTypography.current.table MarkdownTable( content = tableContent.content, node = tableContent.node, style = style, ) }, heading1 = CurrentComponentsBridge.heading3, heading2 = CurrentComponentsBridge.heading4, heading3 = CurrentComponentsBridge.heading5, heading4 = CurrentComponentsBridge.heading6, heading5 = CurrentComponentsBridge.heading6, codeBlock = { MarkdownHighlightedCodeBlock( content = it.content, node = it.node, highlightsBuilder = highlightsBuilder, showHeader = true, // optional enable header with code language + copy button ) }, codeFence = { val style = LocalMarkdownTypography.current.code MarkdownCodeFence(it.content, it.node, style) { code, language, style -> val language = language?.lowercase() when { (language == "plantuml" || language == "puml") && isComplete -> { PlantUmlRenderer( code = code, isDarkTheme = darkTheme, modifier = Modifier.fillMaxSize(), null ) } language == "mermaid" && isComplete -> { // Use WebView-based MermaidRenderer if KCEF is available if (isKcefAvailable) { MermaidRenderer( mermaidCode = code, isDarkTheme = darkTheme, modifier = Modifier.fillMaxSize(), onRenderComplete = null ) } else { // Fallback to code highlighting if KCEF is not available MarkdownHighlightedCode( code = code, language = language, style = style, highlightsBuilder = highlightsBuilder, showHeader = true, ) } } else -> { MarkdownHighlightedCode( code = code, language = language, style = style, highlightsBuilder = highlightsBuilder, showHeader = true, ) } } } }, ), success = { state, components, modifier -> MarkdownSuccess(state, components, modifier) }, ) } @Composable actual fun RenderResponse(content: String, isComplete: Boolean, isDarkTheme: Boolean, modifier: Modifier) { // Reuse SketchRenderer for fence-aware rendering (markdown + code + diff + diagrams). SketchRenderer.RenderResponse( content = content, isComplete = isComplete, modifier = modifier ) } @Composable actual fun RenderPlainText(text: String, modifier: Modifier) { SelectionContainer { Text( text = text, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = getUtf8FontFamily()), color = MaterialTheme.colorScheme.onSurface, modifier = modifier ) } } } /** * Heuristics to keep Desktop markdown rendering stable: * - During streaming, content is often incomplete and can cause parser span issues. * - Very large markdown blocks are more likely to hit edge cases in span generation. * - Unbalanced code fences frequently correlate with invalid AST offsets. */ private fun shouldFallbackToPlainText(markdown: String, isComplete: Boolean): Boolean { if (!isComplete) return true // Keep full markdown only for reasonably sized blocks. // Large "plain text" responses (often logs / raw outputs) don't benefit much from markdown anyway. if (markdown.length > 4000) return true // If code fences are unbalanced, the markdown AST can end up with invalid ranges. val fenceCount = markdown.windowed(size = 3, step = 1, partialWindows = false).count { it == "```" } if (fenceCount % 2 != 0) return true return false } @Composable fun MarkdownSuccess( state: State.Success, components: MarkdownComponents, modifier: Modifier = Modifier, ) { var showFileViewerDialog by remember { mutableStateOf(false) } var selectedFilePath by remember { mutableStateOf(null) } CompositionLocalProvider( LocalOnOpenFile provides { filePath -> selectedFilePath = filePath showFileViewerDialog = true } ) { Column(modifier) { state.node.children.forEach { node -> // Skip nodes with invalid offsets to prevent "Range out of bounds" errors if (isNodeValid(node, state.content.length)) { MarkdownElementWithFileLinks( node = node, components = components, content = state.content ) } } } } // Show file viewer dialog if (showFileViewerDialog && selectedFilePath != null) { cc.unitmesh.devins.ui.compose.agent.codereview.FileViewerDialog( filePath = selectedFilePath!!, onClose = { showFileViewerDialog = false } ) } } /** * Check if an AST node and all its children have valid offsets within the content bounds. * This prevents "Range [x, y) out of bounds for length z" errors. */ private fun isNodeValid(node: ASTNode, contentLength: Int): Boolean { // Check current node bounds if (node.startOffset < 0 || node.endOffset < node.startOffset || node.endOffset > contentLength) { return false } // Recursively check all children return node.children.all { isNodeValid(it, contentLength) } } fun safeSubstring(content: String, start: Int, end: Int): String { val safeStart = start.coerceIn(0, content.length) val safeEnd = end.coerceIn(safeStart, content.length) return content.substring(safeStart, safeEnd) } /** * Custom MarkdownElement that handles file:// links with icons. * Includes safe bounds checking to prevent "Range out of bounds" errors. */ @Composable fun MarkdownElementWithFileLinks( node: ASTNode, components: MarkdownComponents, content: String ) { val onOpenFile = LocalOnOpenFile.current // Additional safety check before processing if (!isNodeValid(node, content.length)) { return } if (node.type == org.intellij.markdown.MarkdownElementTypes.INLINE_LINK) { val linkDestination = node.children.firstOrNull { it.type == org.intellij.markdown.MarkdownElementTypes.LINK_DESTINATION } val linkText = node.children.firstOrNull { it.type == org.intellij.markdown.MarkdownElementTypes.LINK_TEXT } val url = linkDestination?.let { safeSubstring(content, it.startOffset, it.endOffset) } ?: "" val text = linkText?.let { safeSubstring(content, it.startOffset, it.endOffset).removeSurrounding("[", "]") } ?: url if (url.startsWith("file://")) { val filePath = url.removePrefix("file://") Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable { val root = cc.unitmesh.devins.workspace.WorkspaceManager.getCurrentOrEmpty().rootPath val absolutePath = if (filePath.startsWith("/")) { filePath } else if (root != null) { "$root/$filePath" } else { filePath } onOpenFile(absolutePath) } .padding(horizontal = 2.dp, vertical = 1.dp) ) { Icon( imageVector = cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons.Visibility, contentDescription = "View File", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp) ) Spacer(modifier = Modifier.width(4.dp)) androidx.compose.material3.Text( text = text, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium.copy( textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline ) ) } return } } MarkdownElement(node, components, content) }