Files
openclaw-openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift

256 lines
8.9 KiB
Swift

import SwiftMath
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
@MainActor
struct ChatCodeBlockView: View {
let block: ChatCodeBlock
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if let language = self.block.language {
Text(language)
.font(OpenClawChatTypography.caption2)
.foregroundStyle(.secondary)
}
ScrollView(.horizontal, showsIndicators: false) {
Text(self.attributedCode)
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
.foregroundStyle(OpenClawChatTheme.assistantText)
.lineSpacing(2)
.textSelection(.enabled)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(OpenClawChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private var attributedCode: AttributedString {
// Open (still-streaming) fences skip highlighting so each delta stays
// cheap; completed blocks hit the content-keyed highlight cache.
guard self.block.isComplete else { return AttributedString(self.block.code) }
return ChatCodeHighlightCache.highlighted(
code: self.block.code,
languageId: self.block.language)
}
}
@MainActor
struct ChatMathBlockView: View {
let block: ChatMathBlock
let textColor: Color
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = OpenClawChatTypography.bodySize
var body: some View {
if self.block.isComplete,
ChatMathParseCache.mathList(latex: self.block.latex) != nil
{
ScrollView(.horizontal, showsIndicators: false) {
ChatMathPlatformView(
latex: self.block.latex,
fontSize: self.fontSize,
textColor: self.textColor)
.fixedSize()
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(self.block.latex))
}
.defaultScrollAnchor(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
} else {
ChatCodeBlockView(block: ChatCodeBlock(
language: nil,
code: self.block.latex,
isComplete: false))
}
}
}
/// Parsed math is stable after its delimiter closes. A bounded cache avoids
/// repeating SwiftMath parsing as later streaming deltas rerender old blocks.
@MainActor
private enum ChatMathParseCache {
private enum Result {
case parsed(MTMathList)
case invalid
}
private static var cache: [String: Result] = [:]
private static let capacity = 80
private static let maxNestingDepth = 64
private static let maxCommandCount = 128
private static let unsafeCommands = [#"\color"#, #"\colorbox"#, #"\textcolor"#]
static func mathList(latex: String) -> MTMathList? {
guard !latex.isEmpty else { return nil }
// SwiftMath silently drops unsupported Unicode instead of reporting a
// parse error. Preserve the source through the raw-text fallback.
guard latex.unicodeScalars.allSatisfy(\.isASCII) else { return nil }
// SwiftMath recursively parses groups. Bound hostile nesting before
// entering the dependency so a short expression cannot exhaust stack.
guard self.isWithinParserLimits(latex) else { return nil }
// SwiftMath 1.7.3 traps while typesetting empty color-command bodies.
// Chat owns the surrounding color, so preserve these as raw source.
guard !self.unsafeCommands.contains(where: latex.contains) else { return nil }
if let hit = self.cache[latex] {
if case let .parsed(mathList) = hit { return mathList }
return nil
}
let result = MTMathListBuilder.build(fromString: latex)
.map(Result.parsed) ?? .invalid
if self.cache.count >= self.capacity {
self.cache.removeAll(keepingCapacity: true)
}
self.cache[latex] = result
if case let .parsed(mathList) = result { return mathList }
return nil
}
private static func isWithinParserLimits(_ latex: String) -> Bool {
var depth = 0
var commandCount = 0
var escaped = false
for character in latex {
if escaped {
escaped = false
continue
}
if character == "\\" {
commandCount += 1
if commandCount > self.maxCommandCount { return false }
escaped = true
} else if character == "{" {
depth += 1
if depth > self.maxNestingDepth { return false }
} else if character == "}" {
depth = max(0, depth - 1)
}
}
return true
}
}
#if os(macOS)
@MainActor
private struct ChatMathPlatformView: NSViewRepresentable {
let latex: String
let fontSize: CGFloat
let textColor: Color
func makeNSView(context: Context) -> MTMathUILabel {
MTMathUILabel()
}
func updateNSView(_ view: MTMathUILabel, context: Context) {
self.configure(view)
}
private func configure(_ view: MTMathUILabel) {
view.displayErrorInline = false
view.labelMode = .display
view.textAlignment = .center
view.fontSize = self.fontSize
view.textColor = NSColor(self.textColor)
if view.latex != self.latex {
view.latex = self.latex
}
}
}
#else
@MainActor
private struct ChatMathPlatformView: UIViewRepresentable {
let latex: String
let fontSize: CGFloat
let textColor: Color
func makeUIView(context: Context) -> MTMathUILabel {
MTMathUILabel()
}
func updateUIView(_ view: MTMathUILabel, context: Context) {
self.configure(view)
}
private func configure(_ view: MTMathUILabel) {
view.displayErrorInline = false
view.labelMode = .display
view.textAlignment = .center
view.fontSize = self.fontSize
view.textColor = UIColor(self.textColor)
if view.latex != self.latex {
view.latex = self.latex
}
}
}
#endif
@MainActor
struct ChatMarkdownTableView: View {
let table: ChatMarkdownTable
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 7) {
GridRow {
ForEach(self.table.header.indices, id: \.self) { column in
// One cell per column carries the GFM alignment.
self.cell(self.table.header[column], column: column, isHeader: true)
.gridColumnAlignment(self.columnAlignment(column))
}
}
Divider()
ForEach(self.table.rows.indices, id: \.self) { rowIndex in
GridRow {
ForEach(self.table.rows[rowIndex].indices, id: \.self) { column in
self.cell(self.table.rows[rowIndex][column], column: column, isHeader: false)
}
}
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(OpenClawChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private func cell(_ text: String, column: Int, isHeader: Bool) -> some View {
Text(self.inlineMarkdown(text))
.font(isHeader ? OpenClawChatTypography.footnoteSemiBold : OpenClawChatTypography.footnote)
.foregroundStyle(OpenClawChatTheme.assistantText)
.textSelection(.enabled)
}
private func inlineMarkdown(_ text: String) -> AttributedString {
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace,
failurePolicy: .returnPartiallyParsedIfPossible)
return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text)
}
private func columnAlignment(_ column: Int) -> HorizontalAlignment {
guard column < self.table.alignments.count else { return .leading }
switch self.table.alignments[column] {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
}