diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownContent.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownContent.kt index 95a788b..6c2a064 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownContent.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownContent.kt @@ -206,7 +206,7 @@ actual fun MarkdownContent( occurrenceOffset += segmentOccurrences } else { val hasCitations = remember(segment.text) { - segment.text.contains(CITATION_REGEX) + segment.text.contains(CITATION_DETECT_REGEX) } if (hasCitations) { CitationText( @@ -326,420 +326,6 @@ private fun MarkdownTextSegment( } } -internal sealed interface MarkdownSegment { - data class TextBlock(val text: String) : MarkdownSegment - data class CodeBlock(val code: String, val language: String?) : MarkdownSegment - data class LatexBlock(val latex: String) : MarkdownSegment - data class InlineLatexText(val segments: List) : MarkdownSegment - data class Table( - val headers: List, - val alignments: List, - val rows: List>, - ) : MarkdownSegment -} - -/** Column alignment parsed from the separator row of a markdown table. */ -internal enum class TableCellAlignment { - LEFT, CENTER, RIGHT, -} - -internal sealed interface InlineSegment { - data class Text(val text: String) : InlineSegment - data class Latex(val latex: String) : InlineSegment -} - -internal sealed interface InlineSpan { - val text: String - - data class Plain(override val text: String) : InlineSpan - data class Bold(override val text: String) : InlineSpan - data class Italic(override val text: String) : InlineSpan - data class InlineCode(override val text: String) : InlineSpan -} - -// Pre-compiled regex patterns for markdown/LaTeX parsing (avoid recreating on every call) -private val CODE_BLOCK_REGEX = Regex("```(\\w*)[^\\S\\n]*\\n([\\s\\S]*?)```") -private val BLOCK_LATEX_REGEX = Regex("\\$\\$([\\s\\S]+?)\\$\\$|\\\\\\[([\\s\\S]+?)\\\\\\]") -private val INLINE_LATEX_REGEX = Regex("(? content.contains(keyword) } -} - -/** - * Returns true if the text appears to be a raw HTML block that should be rendered - * as a code block rather than passed to the markdown renderer (which would garble it). - * - * Heuristic: starts with an HTML tag whose name is in [HTML_BLOCK_TAG_NAMES] or is - * a DOCTYPE declaration, AND contains at least one closing tag or is self-contained. - */ -private fun looksLikeHtmlBlock(text: String): Boolean { - val match = HTML_OPENING_TAG_REGEX.find(text) ?: return false - val tagName = match.groupValues[1].lowercase().let { - // Normalize "!doctype html" to "html" - if (it.startsWith("!doctype")) "html" else it - } - if (tagName !in HTML_BLOCK_TAG_NAMES) return false - // Require a closing tag or self-closing indicator to avoid false positives - // on lines that just happen to start with "") - return hasClosingTag -} - -/** - * Scans TextBlock segments for raw HTML blocks and converts them to CodeBlock - * segments with language="html". This prevents the mikepenz markdown renderer - * from silently dropping HTML tags. - * - * A TextBlock is treated as a raw HTML block if [looksLikeHtmlBlock] returns true. - * Mixed content (text + HTML) is split: lines before the HTML become a TextBlock, - * the HTML block becomes a CodeBlock, and lines after become a TextBlock. - */ -private fun extractHtmlBlocks(segments: List): List { - val result = mutableListOf() - - for (segment in segments) { - if (segment !is MarkdownSegment.TextBlock) { - result.add(segment) - continue - } - - val text = segment.text - if (looksLikeHtmlBlock(text)) { - // Entire block is HTML -- render as a code block - result.add(MarkdownSegment.CodeBlock(text.trim(), "html")) - continue - } - - // Check if the block contains an embedded HTML section starting on its own line. - // Split into lines and find the first line that opens an HTML block. - val lines = text.split('\n') - var htmlStart = -1 - for (i in lines.indices) { - val lineText = lines.subList(i, lines.size).joinToString("\n") - if (looksLikeHtmlBlock(lineText)) { - htmlStart = i - break - } - } - - if (htmlStart < 0) { - // No HTML found; keep as-is - result.add(segment) - continue - } - - // Flush preceding text lines - if (htmlStart > 0) { - val preceding = lines.subList(0, htmlStart).joinToString("\n").trim() - if (preceding.isNotEmpty()) { - result.add(MarkdownSegment.TextBlock(preceding)) - } - } - - // The rest from htmlStart onward is the HTML block - val htmlContent = lines.subList(htmlStart, lines.size).joinToString("\n").trim() - result.add(MarkdownSegment.CodeBlock(htmlContent, "html")) - } - - return result -} - -/** - * Splits raw text into code-block, LaTeX-block, table, inline-LaTeX, and text-block segments. - * - * Parsing order: - * 1. Extract fenced code blocks (``` ... ```) - * 2. Detect raw HTML blocks in remaining TextBlocks and convert to CodeBlocks - * 3. Within non-code segments, extract block LaTeX (`$$...$$`) - * 4. Within remaining text blocks, extract GFM-style tables - * 5. Within remaining text, detect inline LaTeX (`$...$`) and split accordingly - */ -internal fun parseMarkdownSegments(text: String): List { - // --- Pass 1: split on fenced code blocks --- - val afterCodeBlocks = mutableListOf() - var lastIndex = 0 - - CODE_BLOCK_REGEX.findAll(text).forEach { match -> - if (match.range.first > lastIndex) { - val textBefore = text.substring(lastIndex, match.range.first).trim() - if (textBefore.isNotEmpty()) { - afterCodeBlocks.add(MarkdownSegment.TextBlock(textBefore)) - } - } - val language = match.groupValues[1].ifEmpty { null } - val code = match.groupValues[2].trimEnd() - val langLower = language?.lowercase() - if (langLower == "latex" || langLower == "tex" || langLower == "math") { - // Treat latex/tex/math code blocks as text so LaTeX extraction - // passes handle $...$ and $$...$$ delimiters properly - afterCodeBlocks.add(MarkdownSegment.TextBlock(code)) - } else { - afterCodeBlocks.add(MarkdownSegment.CodeBlock(code, language)) - } - lastIndex = match.range.last + 1 - } - - if (lastIndex < text.length) { - val remaining = text.substring(lastIndex).trim() - if (remaining.isNotEmpty()) { - afterCodeBlocks.add(MarkdownSegment.TextBlock(remaining)) - } - } - - if (afterCodeBlocks.isEmpty() && text.isNotBlank()) { - afterCodeBlocks.add(MarkdownSegment.TextBlock(text)) - } - - // --- Pass 2: detect raw HTML blocks and convert to CodeBlocks --- - val afterHtmlBlocks = extractHtmlBlocks(afterCodeBlocks) - - // --- Pass 3: split TextBlocks on block LaTeX ($$...$$ or \[...\]) --- - val afterBlockLatex = mutableListOf() - - for (segment in afterHtmlBlocks) { - if (segment !is MarkdownSegment.TextBlock) { - afterBlockLatex.add(segment) - continue - } - var segLastIndex = 0 - val segText = segment.text - - BLOCK_LATEX_REGEX.findAll(segText).forEach { match -> - if (match.range.first > segLastIndex) { - val before = segText.substring(segLastIndex, match.range.first).trim() - if (before.isNotEmpty()) { - afterBlockLatex.add(MarkdownSegment.TextBlock(before)) - } - } - val latexContent = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() - if (latexContent.isNotEmpty()) { - afterBlockLatex.add(MarkdownSegment.LatexBlock(latexContent)) - } - segLastIndex = match.range.last + 1 - } - - if (segLastIndex < segText.length) { - val remaining = segText.substring(segLastIndex).trim() - if (remaining.isNotEmpty()) { - afterBlockLatex.add(MarkdownSegment.TextBlock(remaining)) - } - } else if (segLastIndex == 0) { - // No block LaTeX found; keep original segment - afterBlockLatex.add(segment) - } - } - - // --- Pass 4: extract markdown tables from TextBlocks --- - val afterTables = mutableListOf() - - for (segment in afterBlockLatex) { - if (segment !is MarkdownSegment.TextBlock) { - afterTables.add(segment) - continue - } - afterTables.addAll(extractTables(segment.text)) - } - - // --- Pass 5: detect inline LaTeX ($...$ or \(...\)) within remaining TextBlocks --- - val finalSegments = mutableListOf() - - for (segment in afterTables) { - if (segment !is MarkdownSegment.TextBlock) { - finalSegments.add(segment) - continue - } - - val matches = INLINE_LATEX_REGEX.findAll(segment.text) - .filter { match -> - // \(...\) is always LaTeX; $...$ needs heuristic check - val dollarContent = match.groupValues[1] - val parenContent = match.groupValues[2] - if (parenContent.isNotBlank()) { - true - } else { - dollarContent.isNotBlank() && looksLikeLatex(dollarContent) - } - } - .toList() - - if (matches.isEmpty()) { - finalSegments.add(segment) - continue - } - - // Build mixed inline segments - val inlineSegments = mutableListOf() - var segLastIndex = 0 - val segText = segment.text - - for (match in matches) { - if (match.range.first > segLastIndex) { - val before = segText.substring(segLastIndex, match.range.first) - if (before.isNotEmpty()) { - inlineSegments.add(InlineSegment.Text(before)) - } - } - val content = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() - inlineSegments.add(InlineSegment.Latex(content)) - segLastIndex = match.range.last + 1 - } - - if (segLastIndex < segText.length) { - val remaining = segText.substring(segLastIndex) - if (remaining.isNotEmpty()) { - inlineSegments.add(InlineSegment.Text(remaining)) - } - } - - finalSegments.add(MarkdownSegment.InlineLatexText(inlineSegments)) - } - - return finalSegments -} - -/** - * Extracts markdown tables from a text block, splitting it into alternating - * TextBlock and Table segments. A valid table requires: - * - A header row with pipe-delimited cells - * - A separator row matching [TABLE_SEPARATOR_REGEX] - * - At least one data row - */ -private fun extractTables(text: String): List { - val lines = text.split('\n') - val result = mutableListOf() - val buffer = mutableListOf() - var i = 0 - - while (i < lines.size) { - // Check if lines[i] could be a table header row and lines[i+1] a separator - if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) { - // Flush any buffered text before the table - if (buffer.isNotEmpty()) { - val preceding = buffer.joinToString("\n").trim() - if (preceding.isNotEmpty()) { - result.add(MarkdownSegment.TextBlock(preceding)) - } - buffer.clear() - } - - val headerCells = parseTableRow(lines[i]) - val alignments = parseAlignments(lines[i + 1], headerCells.size) - val dataRows = mutableListOf>() - var j = i + 2 - - while (j < lines.size && isTableRow(lines[j])) { - val rowCells = parseTableRow(lines[j]) - // Pad or truncate to match header column count - val normalized = List(headerCells.size) { col -> - rowCells.getOrElse(col) { "" } - } - dataRows.add(normalized) - j++ - } - - if (dataRows.isNotEmpty()) { - result.add(MarkdownSegment.Table(headerCells, alignments, dataRows)) - i = j - } else { - // Not a valid table (no data rows); treat header+separator as text - buffer.add(lines[i]) - buffer.add(lines[i + 1]) - i += 2 - } - } else { - buffer.add(lines[i]) - i++ - } - } - - // Flush remaining buffered text - if (buffer.isNotEmpty()) { - val remaining = buffer.joinToString("\n").trim() - if (remaining.isNotEmpty()) { - result.add(MarkdownSegment.TextBlock(remaining)) - } - } - - return result -} - -/** - * Returns true if the line looks like a pipe-delimited table row. - * Requires either leading/trailing pipes or at least two pipe characters, - * to avoid false positives on prose that contains a single `|`. - */ -private fun isTableRow(line: String): Boolean { - val trimmed = line.trim() - if (trimmed.isEmpty()) return false - return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2 -} - -/** Returns true if the line matches a GFM table separator pattern. */ -private fun isTableSeparator(line: String): Boolean { - return TABLE_SEPARATOR_REGEX.matches(line.trim()) -} - -/** Splits a pipe-delimited table row into trimmed cell values. */ -private fun parseTableRow(line: String): List { - val trimmed = line.trim() - // Remove leading and trailing pipes if present - val inner = trimmed.removePrefix("|").removeSuffix("|") - return inner.split('|').map { it.trim() } -} - -/** - * Parses alignment indicators from a separator row. - * `:---` = LEFT, `:---:` = CENTER, `---:` = RIGHT, `---` = LEFT (default) - */ -private fun parseAlignments(separatorLine: String, columnCount: Int): List { - val cells = parseTableRow(separatorLine) - return List(columnCount) { col -> - val cell = cells.getOrElse(col) { "---" }.trim() - val startsColon = cell.startsWith(':') - val endsColon = cell.endsWith(':') - when { - startsColon && endsColon -> TableCellAlignment.CENTER - endsColon -> TableCellAlignment.RIGHT - else -> TableCellAlignment.LEFT - } - } -} /** * Wraps a [MarkdownTable] in a [Box] with a fullscreen expand button overlaid on @@ -1005,32 +591,3 @@ private fun TableCell( } } -/** - * Parses inline markdown (bold, italic, inline code) within a text block. - * Calls [onSpan] for each discovered span. - */ -internal fun parseInlineMarkdown(text: String, onSpan: (InlineSpan) -> Unit) { - var lastIndex = 0 - - INLINE_MARKDOWN_REGEX.findAll(text).forEach { match -> - if (match.range.first > lastIndex) { - onSpan(InlineSpan.Plain(text.substring(lastIndex, match.range.first))) - } - when { - match.groupValues[1].isNotEmpty() -> { - onSpan(InlineSpan.InlineCode(match.groupValues[1])) - } - match.groupValues[2].isNotEmpty() -> { - onSpan(InlineSpan.Bold(match.groupValues[2])) - } - match.groupValues[3].isNotEmpty() -> { - onSpan(InlineSpan.Italic(match.groupValues[3])) - } - } - lastIndex = match.range.last + 1 - } - - if (lastIndex < text.length) { - onSpan(InlineSpan.Plain(text.substring(lastIndex))) - } -} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownParsing.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownParsing.kt new file mode 100644 index 0000000..7d948ed --- /dev/null +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/MarkdownParsing.kt @@ -0,0 +1,329 @@ +package com.garfiec.librechat.feature.chat.components + +// Shared markdown + LaTeX parsing logic used by both Android and iOS MarkdownContent. + +internal sealed interface MarkdownSegment { + data class TextBlock(val text: String) : MarkdownSegment + data class CodeBlock(val code: String, val language: String?) : MarkdownSegment + data class LatexBlock(val latex: String) : MarkdownSegment + data class InlineLatexText(val segments: List) : MarkdownSegment + data class Table( + val headers: List, + val alignments: List, + val rows: List>, + ) : MarkdownSegment +} + +internal enum class TableCellAlignment { + LEFT, CENTER, RIGHT, +} + +internal sealed interface InlineSegment { + data class Text(val text: String) : InlineSegment + data class Latex(val latex: String) : InlineSegment +} + +// Pre-compiled regex patterns +private val CODE_BLOCK_REGEX = Regex("```(\\w*)[^\\S\\n]*\\n([\\s\\S]*?)```") +private val BLOCK_LATEX_REGEX = Regex("\\$\\$([\\s\\S]+?)\\$\\$|\\\\\\[([\\s\\S]+?)\\\\\\]") +private val INLINE_LATEX_REGEX = Regex("(? content.contains(keyword) } +} + +private fun looksLikeHtmlBlock(text: String): Boolean { + val match = HTML_OPENING_TAG_REGEX.find(text) ?: return false + val tagName = match.groupValues[1].lowercase().let { + if (it.startsWith("!doctype")) "html" else it + } + if (tagName !in HTML_BLOCK_TAG_NAMES) return false + val hasClosingTag = text.contains("") + return hasClosingTag +} + +private fun extractHtmlBlocks(segments: List): List { + val result = mutableListOf() + + for (segment in segments) { + if (segment !is MarkdownSegment.TextBlock) { + result.add(segment) + continue + } + + val text = segment.text + if (looksLikeHtmlBlock(text)) { + result.add(MarkdownSegment.CodeBlock(text.trim(), "html")) + continue + } + + val lines = text.split('\n') + var htmlStart = -1 + for (i in lines.indices) { + val lineText = lines.subList(i, lines.size).joinToString("\n") + if (looksLikeHtmlBlock(lineText)) { + htmlStart = i + break + } + } + + if (htmlStart < 0) { + result.add(segment) + continue + } + + if (htmlStart > 0) { + val preceding = lines.subList(0, htmlStart).joinToString("\n").trim() + if (preceding.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(preceding)) + } + } + + val htmlContent = lines.subList(htmlStart, lines.size).joinToString("\n").trim() + result.add(MarkdownSegment.CodeBlock(htmlContent, "html")) + } + + return result +} + +/** + * 5-phase markdown parser that extracts code blocks, HTML blocks, block LaTeX, + * tables, and inline LaTeX from raw text. + */ +internal fun parseMarkdownSegments(text: String): List { + // --- Pass 1: split on fenced code blocks --- + val afterCodeBlocks = mutableListOf() + var lastIndex = 0 + + CODE_BLOCK_REGEX.findAll(text).forEach { match -> + if (match.range.first > lastIndex) { + val textBefore = text.substring(lastIndex, match.range.first).trim() + if (textBefore.isNotEmpty()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(textBefore)) + } + } + val language = match.groupValues[1].ifEmpty { null } + val code = match.groupValues[2].trimEnd() + val langLower = language?.lowercase() + if (langLower == "latex" || langLower == "tex" || langLower == "math") { + afterCodeBlocks.add(MarkdownSegment.TextBlock(code)) + } else { + afterCodeBlocks.add(MarkdownSegment.CodeBlock(code, language)) + } + lastIndex = match.range.last + 1 + } + + if (lastIndex < text.length) { + val remaining = text.substring(lastIndex).trim() + if (remaining.isNotEmpty()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(remaining)) + } + } + + if (afterCodeBlocks.isEmpty() && text.isNotBlank()) { + afterCodeBlocks.add(MarkdownSegment.TextBlock(text)) + } + + // --- Pass 2: detect raw HTML blocks and convert to CodeBlocks --- + val afterHtmlBlocks = extractHtmlBlocks(afterCodeBlocks) + + // --- Pass 3: split TextBlocks on block LaTeX ($$...$$ or \[...\]) --- + val afterBlockLatex = mutableListOf() + + for (segment in afterHtmlBlocks) { + if (segment !is MarkdownSegment.TextBlock) { + afterBlockLatex.add(segment) + continue + } + var segLastIndex = 0 + val segText = segment.text + + BLOCK_LATEX_REGEX.findAll(segText).forEach { match -> + if (match.range.first > segLastIndex) { + val before = segText.substring(segLastIndex, match.range.first).trim() + if (before.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.TextBlock(before)) + } + } + val latexContent = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() + if (latexContent.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.LatexBlock(latexContent)) + } + segLastIndex = match.range.last + 1 + } + + if (segLastIndex < segText.length) { + val remaining = segText.substring(segLastIndex).trim() + if (remaining.isNotEmpty()) { + afterBlockLatex.add(MarkdownSegment.TextBlock(remaining)) + } + } else if (segLastIndex == 0) { + afterBlockLatex.add(segment) + } + } + + // --- Pass 4: extract markdown tables from TextBlocks --- + val afterTables = mutableListOf() + + for (segment in afterBlockLatex) { + if (segment !is MarkdownSegment.TextBlock) { + afterTables.add(segment) + continue + } + afterTables.addAll(extractTables(segment.text)) + } + + // --- Pass 5: detect inline LaTeX ($...$ or \(...\)) within remaining TextBlocks --- + val finalSegments = mutableListOf() + + for (segment in afterTables) { + if (segment !is MarkdownSegment.TextBlock) { + finalSegments.add(segment) + continue + } + + val matches = INLINE_LATEX_REGEX.findAll(segment.text) + .filter { match -> + val dollarContent = match.groupValues[1] + val parenContent = match.groupValues[2] + if (parenContent.isNotBlank()) { + true + } else { + dollarContent.isNotBlank() && looksLikeLatex(dollarContent) + } + } + .toList() + + if (matches.isEmpty()) { + finalSegments.add(segment) + continue + } + + val inlineSegments = mutableListOf() + var segLastIndex = 0 + val segText = segment.text + + for (match in matches) { + if (match.range.first > segLastIndex) { + val before = segText.substring(segLastIndex, match.range.first) + if (before.isNotEmpty()) { + inlineSegments.add(InlineSegment.Text(before)) + } + } + val content = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim() + inlineSegments.add(InlineSegment.Latex(content)) + segLastIndex = match.range.last + 1 + } + + if (segLastIndex < segText.length) { + val remaining = segText.substring(segLastIndex) + if (remaining.isNotEmpty()) { + inlineSegments.add(InlineSegment.Text(remaining)) + } + } + + finalSegments.add(MarkdownSegment.InlineLatexText(inlineSegments)) + } + + return finalSegments +} + +private fun extractTables(text: String): List { + val lines = text.split('\n') + val result = mutableListOf() + val buffer = mutableListOf() + var i = 0 + + while (i < lines.size) { + if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) { + if (buffer.isNotEmpty()) { + val preceding = buffer.joinToString("\n").trim() + if (preceding.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(preceding)) + } + buffer.clear() + } + + val headerCells = parseTableRow(lines[i]) + val alignments = parseAlignments(lines[i + 1], headerCells.size) + val dataRows = mutableListOf>() + var j = i + 2 + + while (j < lines.size && isTableRow(lines[j])) { + val rowCells = parseTableRow(lines[j]) + val normalized = List(headerCells.size) { col -> + rowCells.getOrElse(col) { "" } + } + dataRows.add(normalized) + j++ + } + + if (dataRows.isNotEmpty()) { + result.add(MarkdownSegment.Table(headerCells, alignments, dataRows)) + i = j + } else { + buffer.add(lines[i]) + buffer.add(lines[i + 1]) + i += 2 + } + } else { + buffer.add(lines[i]) + i++ + } + } + + if (buffer.isNotEmpty()) { + val remaining = buffer.joinToString("\n").trim() + if (remaining.isNotEmpty()) { + result.add(MarkdownSegment.TextBlock(remaining)) + } + } + + return result +} + +private fun isTableRow(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.isEmpty()) return false + return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2 +} + +private fun isTableSeparator(line: String): Boolean = TABLE_SEPARATOR_REGEX.matches(line.trim()) + +internal fun parseTableRow(line: String): List { + val inner = line.trim().removePrefix("|").removeSuffix("|") + return inner.split('|').map { it.trim() } +} + +internal fun parseAlignments(separatorLine: String, columnCount: Int): List { + val cells = parseTableRow(separatorLine) + return List(columnCount) { col -> + val cell = cells.getOrElse(col) { "---" }.trim() + when { + cell.startsWith(':') && cell.endsWith(':') -> TableCellAlignment.CENTER + cell.endsWith(':') -> TableCellAlignment.RIGHT + else -> TableCellAlignment.LEFT + } + } +} + + diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt index 5b3edce..75a839f 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt @@ -78,6 +78,8 @@ import org.jetbrains.compose.resources.stringResource private const val ACTION_AUTO_HIDE_MILLIS = 30_000L +private val GFM_FLAVOUR = GFMFlavourDescriptor() + // ─── MessageBubble ─────────────────────────────────────────────────── @Composable @@ -352,17 +354,13 @@ actual fun ContentPartRenderer( // ─── MarkdownContent ───────────────────────────────────────────────── -private val TABLE_SEPARATOR_REGEX = Regex("^\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?$") - -private enum class TableCellAlignment { LEFT, CENTER, RIGHT } - @Composable actual fun MarkdownContent( text: String, modifier: Modifier, fontSizeMultiplier: Float, useKatex: Boolean, searchQuery: String?, searchFocusedOccurrence: Int, onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, ) { - val segments = remember(text) { splitTableSegments(text) } + val segments = remember(text) { parseMarkdownSegments(text) } val colors = markdownColor( text = MaterialTheme.colorScheme.onSurface, @@ -388,10 +386,59 @@ actual fun MarkdownContent( Column(modifier = modifier.fillMaxWidth()) { segments.forEachIndexed { index, segment -> when (segment) { - is MdSegment.Text -> key(fontSizeMultiplier, index) { - Markdown(content = segment.content, colors = colors, typography = typography, flavour = GFMFlavourDescriptor(), modifier = Modifier.fillMaxWidth()) + is MarkdownSegment.CodeBlock -> { + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + if (segment.language?.lowercase() == "mermaid") { + MermaidDiagram( + code = segment.code, + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + CodeBlock( + code = segment.code, + language = segment.language, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) } - is MdSegment.Table -> { + is MarkdownSegment.LatexBlock -> { + if (index > 0) Spacer(modifier = Modifier.height(8.dp)) + LatexBlock( + latex = segment.latex, + modifier = Modifier.padding(vertical = 4.dp), + useKatex = useKatex, + ) + if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) + } + is MarkdownSegment.InlineLatexText -> { + Column { + segment.segments.forEach { inlineSegment -> + when (inlineSegment) { + is InlineSegment.Text -> { + if (inlineSegment.text.isNotBlank()) { + key(fontSizeMultiplier) { + Markdown( + content = inlineSegment.text, + colors = colors, + typography = typography, + flavour = GFM_FLAVOUR, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + is InlineSegment.Latex -> { + LatexInline( + latex = inlineSegment.latex, + useKatex = useKatex, + ) + } + } + } + } + } + is MarkdownSegment.Table -> { if (index > 0) Spacer(modifier = Modifier.height(8.dp)) IosMarkdownTableWithFullscreen( headers = segment.headers, @@ -402,86 +449,22 @@ actual fun MarkdownContent( ) if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp)) } + is MarkdownSegment.TextBlock -> { + key(fontSizeMultiplier, index) { + Markdown( + content = segment.text, + colors = colors, + typography = typography, + flavour = GFM_FLAVOUR, + modifier = Modifier.fillMaxWidth(), + ) + } + } } } } } -// ─── Table segment model ──────────────────────────────────────────── - -private sealed interface MdSegment { - data class Text(val content: String) : MdSegment - data class Table( - val headers: List, - val alignments: List, - val rows: List>, - ) : MdSegment -} - -private fun splitTableSegments(text: String): List { - val lines = text.split('\n') - val result = mutableListOf() - val buffer = mutableListOf() - var i = 0 - - while (i < lines.size) { - if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) { - if (buffer.isNotEmpty()) { - val preceding = buffer.joinToString("\n").trim() - if (preceding.isNotEmpty()) result.add(MdSegment.Text(preceding)) - buffer.clear() - } - val headerCells = parseTableRow(lines[i]) - val alignments = parseAlignments(lines[i + 1], headerCells.size) - val dataRows = mutableListOf>() - var j = i + 2 - while (j < lines.size && isTableRow(lines[j])) { - val rowCells = parseTableRow(lines[j]) - dataRows.add(List(headerCells.size) { col -> rowCells.getOrElse(col) { "" } }) - j++ - } - if (dataRows.isNotEmpty()) { - result.add(MdSegment.Table(headerCells, alignments, dataRows)) - i = j - } else { - buffer.add(lines[i]); buffer.add(lines[i + 1]); i += 2 - } - } else { - buffer.add(lines[i]); i++ - } - } - if (buffer.isNotEmpty()) { - val remaining = buffer.joinToString("\n").trim() - if (remaining.isNotEmpty()) result.add(MdSegment.Text(remaining)) - } - return result -} - -private fun isTableRow(line: String): Boolean { - val trimmed = line.trim() - if (trimmed.isEmpty()) return false - return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2 -} - -private fun isTableSeparator(line: String): Boolean = TABLE_SEPARATOR_REGEX.matches(line.trim()) - -private fun parseTableRow(line: String): List { - val inner = line.trim().removePrefix("|").removeSuffix("|") - return inner.split('|').map { it.trim() } -} - -private fun parseAlignments(separatorLine: String, columnCount: Int): List { - val cells = parseTableRow(separatorLine) - return List(columnCount) { col -> - val cell = cells.getOrElse(col) { "---" }.trim() - when { - cell.startsWith(':') && cell.endsWith(':') -> TableCellAlignment.CENTER - cell.endsWith(':') -> TableCellAlignment.RIGHT - else -> TableCellAlignment.LEFT - } - } -} - // ─── Table with fullscreen expand ─────────────────────────────────── @Composable diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformMediaComponents.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformMediaComponents.ios.kt index 4d271a9..c75ee9f 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformMediaComponents.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformMediaComponents.ios.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.interop.UIKitView import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -421,68 +422,51 @@ actual fun VideoContentPlayer( VideoContent(url = videoUrl, modifier = modifier) } -@OptIn(ExperimentalForeignApi::class) @Composable actual fun LatexBlock( latex: String, modifier: Modifier, useKatex: Boolean, ) { - val isDarkTheme = MaterialTheme.colorScheme.surface.let { color -> - (color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5 - } - val textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a" - val html = remember(latex, textColor) { - buildKatexHtml(latex, displayMode = true, textColor = textColor) - } - - var contentHeight by remember { mutableStateOf(80.dp) } - - UIKitView( - modifier = modifier - .fillMaxWidth() - .height(contentHeight) - .padding(vertical = 4.dp), - factory = { - val config = WKWebViewConfiguration() - val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config) - webView.setOpaque(false) - webView.scrollView.setScrollEnabled(false) - webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol { - override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) { - webView.evaluateJavaScript("document.body.scrollHeight") { result, _ -> - val height = (result as? Number)?.toDouble() - if (height != null && height > 0) { - contentHeight = height.dp - } - } - } - } - webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net")) - webView - }, - update = { webView -> - webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net")) - }, + KatexWebView( + latex = latex, + displayMode = true, + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + initialHeight = 80.dp, ) } -@OptIn(ExperimentalForeignApi::class) @Composable actual fun LatexInline( latex: String, modifier: Modifier, useKatex: Boolean, ) { - val isDarkTheme = MaterialTheme.colorScheme.surface.let { color -> - (color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5 - } + KatexWebView( + latex = latex, + displayMode = false, + modifier = modifier, + initialHeight = 24.dp, + ) +} + +@OptIn(ExperimentalForeignApi::class) +@Composable +private fun KatexWebView( + latex: String, + displayMode: Boolean, + modifier: Modifier, + initialHeight: Dp, +) { + val bgComposeColor = MaterialTheme.colorScheme.background + val isDarkTheme = (bgComposeColor.red * 0.299 + bgComposeColor.green * 0.587 + bgComposeColor.blue * 0.114) < 0.5 val textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a" - val html = remember(latex, textColor) { - buildKatexHtml(latex, displayMode = false, textColor = textColor) + val bgColor = remember(bgComposeColor) { colorToCssHex(bgComposeColor) } + val html = remember(latex, textColor, bgColor) { + buildKatexHtml(latex, displayMode = displayMode, textColor = textColor, bgColor = bgColor) } - var contentHeight by remember { mutableStateOf(24.dp) } + var contentHeight by remember { mutableStateOf(initialHeight) } UIKitView( modifier = modifier @@ -490,7 +474,14 @@ actual fun LatexInline( factory = { val config = WKWebViewConfiguration() val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config) - webView.setOpaque(false) + val nativeBg = platform.UIKit.UIColor( + red = bgComposeColor.red.toDouble(), + green = bgComposeColor.green.toDouble(), + blue = bgComposeColor.blue.toDouble(), + alpha = 1.0, + ) + webView.setBackgroundColor(nativeBg) + webView.scrollView.setBackgroundColor(nativeBg) webView.scrollView.setScrollEnabled(false) webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol { override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) { @@ -511,7 +502,14 @@ actual fun LatexInline( ) } -private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: String): String { +private fun colorToCssHex(color: androidx.compose.ui.graphics.Color): String { + val r = (color.red * 255).toInt() + val g = (color.green * 255).toInt() + val b = (color.blue * 255).toInt() + return "#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}" +} + +private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: String, bgColor: String): String { val escapedLatex = latex .replace("\\", "\\\\") .replace("`", "\\`") @@ -531,7 +529,7 @@ private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: Strin display: flex; justify-content: ${if (displayMode) "center" else "flex-start"}; align-items: center; - background: transparent; + background: $bgColor; color: $textColor; overflow: hidden; min-height: 100%;