mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 23:24:01 +00:00
feat(ios): render LaTeX display math in chat via SwiftMath (#100829)
This commit is contained in:
committed by
GitHub
parent
827402243d
commit
2a741bebb5
26
apps/ios/Resources/Licenses/SwiftMath.txt
Normal file
26
apps/ios/Resources/Licenses/SwiftMath.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
SwiftMath
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Computer Inspirations
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
SwiftMath bundles math fonts licensed separately under the GUST Font License
|
||||
and the SIL Open Font License.
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
struct SwiftUIRenderSmokeTests {
|
||||
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
|
||||
@@ -111,6 +112,34 @@ struct SwiftUIRenderSmokeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `display math builds valid and fallback view hierarchies`() {
|
||||
for typeSize in [DynamicTypeSize.large, .accessibility2] {
|
||||
let root = VStack {
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: #"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"#,
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: #"\notARealCommand{"#,
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: "α + β = γ",
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: String(repeating: "{", count: 65) + "x",
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: String(repeating: #"\bar"#, count: 129) + "x",
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
ChatMathBlockView(block: ChatMathBlock(
|
||||
latex: #"x\textcolor{#fff}{}"#,
|
||||
isComplete: true), textColor: OpenClawChatTheme.assistantText)
|
||||
}
|
||||
.environment(\.dynamicTypeSize, typeSize)
|
||||
|
||||
_ = Self.host(root, size: CGSize(width: 393, height: 240))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `root tabs builds device orientation shell matrix`() {
|
||||
for scenario in Self.rootTabsShellScenarios() {
|
||||
let appModel = NodeAppModel()
|
||||
|
||||
@@ -126,6 +126,15 @@
|
||||
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
|
||||
"version" : "1.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftmath",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mgriebling/SwiftMath",
|
||||
"state" : {
|
||||
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
|
||||
"version" : "1.7.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -19,6 +19,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"),
|
||||
.package(url: "https://github.com/mgriebling/SwiftMath", exact: "1.7.3"),
|
||||
.package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.8.0"),
|
||||
],
|
||||
targets: [
|
||||
@@ -46,6 +47,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
"OpenClawKit",
|
||||
.product(name: "Markdown", package: "swift-markdown"),
|
||||
.product(name: "SwiftMath", package: "SwiftMath"),
|
||||
],
|
||||
path: "Sources/OpenClawChatUI",
|
||||
swiftSettings: [
|
||||
|
||||
@@ -2,10 +2,11 @@ import Foundation
|
||||
import Markdown
|
||||
|
||||
/// One renderable block of a chat message. Prose stays on the
|
||||
/// AttributedString pipeline; fenced code and GFM tables get dedicated views.
|
||||
/// AttributedString pipeline; fenced code, display math, and GFM tables get dedicated views.
|
||||
enum ChatMarkdownBlock: Equatable {
|
||||
case prose(String)
|
||||
case code(ChatCodeBlock)
|
||||
case math(ChatMathBlock)
|
||||
case table(ChatMarkdownTable)
|
||||
}
|
||||
|
||||
@@ -17,6 +18,12 @@ struct ChatCodeBlock: Equatable {
|
||||
let isComplete: Bool
|
||||
}
|
||||
|
||||
struct ChatMathBlock: Equatable {
|
||||
let latex: String
|
||||
/// True when the delimiter was closed or the message finished streaming.
|
||||
let isComplete: Bool
|
||||
}
|
||||
|
||||
struct ChatMarkdownTable: Equatable {
|
||||
enum ColumnAlignment: Equatable {
|
||||
case leading
|
||||
@@ -50,45 +57,45 @@ enum ChatMarkdownBlockSyntax {
|
||||
}
|
||||
|
||||
enum ChatMarkdownBlockSegmenter {
|
||||
static let maxMathBytes = 5000
|
||||
static let maxTableBytes = 20000
|
||||
static let maxTableRows = 100
|
||||
static let maxTableColumns = 12
|
||||
static let maxTableCells = 600
|
||||
|
||||
/// Extracts only top-level fenced code and GFM tables. The parser owns
|
||||
/// Extracts only top-level fenced code, display math, and GFM tables. The parser owns
|
||||
/// CommonMark container and reference semantics; nested blocks stay in the
|
||||
/// surrounding prose range unchanged.
|
||||
static func segments(markdown: String, isComplete: Bool) -> [ChatMarkdownBlock] {
|
||||
let source = SourceBuffer(markdown)
|
||||
let document = Document(parsing: source.markdown)
|
||||
var blocks: [ChatMarkdownBlock] = []
|
||||
var proseStart = 0
|
||||
|
||||
func appendProse(until end: Int) {
|
||||
guard proseStart < end else { return }
|
||||
blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..<end])))
|
||||
}
|
||||
let mathResult = self.mathExtractions(
|
||||
source: source,
|
||||
document: document,
|
||||
isComplete: isComplete)
|
||||
var extractions = mathResult.extractions
|
||||
|
||||
for child in document.children {
|
||||
guard let lineRange = source.lineRange(for: child.range), lineRange.lowerBound >= proseStart else {
|
||||
guard let lineRange = source.lineRange(for: child.range) else { continue }
|
||||
if mathResult.protectedRanges.contains(where: { $0.contains(lineRange.lowerBound) }) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let code = child as? Markdown.CodeBlock,
|
||||
let opener = FenceOpener.parse(source.lines[lineRange.lowerBound])
|
||||
{
|
||||
appendProse(until: lineRange.lowerBound)
|
||||
let language = code.language?
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.first
|
||||
.map { $0.lowercased() }
|
||||
let closed = lineRange.count > 1
|
||||
&& opener.isClose(source.lines[lineRange.index(before: lineRange.endIndex)])
|
||||
blocks.append(.code(ChatCodeBlock(
|
||||
language: language,
|
||||
code: self.dropStructuralCodeNewline(code.code),
|
||||
isComplete: closed || isComplete)))
|
||||
proseStart = lineRange.upperBound
|
||||
extractions.append(Extraction(
|
||||
lineRange: lineRange,
|
||||
block: .code(ChatCodeBlock(
|
||||
language: language,
|
||||
code: self.dropStructuralCodeNewline(code.code),
|
||||
isComplete: closed || isComplete))))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -105,12 +112,31 @@ enum ChatMarkdownBlockSegmenter {
|
||||
{
|
||||
continue
|
||||
}
|
||||
appendProse(until: tableRange.lowerBound)
|
||||
blocks.append(.table(rendered))
|
||||
proseStart = tableRange.upperBound
|
||||
extractions.append(Extraction(lineRange: tableRange, block: .table(rendered)))
|
||||
}
|
||||
}
|
||||
|
||||
extractions.sort { left, right in
|
||||
if left.lineRange.lowerBound != right.lineRange.lowerBound {
|
||||
return left.lineRange.lowerBound < right.lineRange.lowerBound
|
||||
}
|
||||
return left.lineRange.upperBound > right.lineRange.upperBound
|
||||
}
|
||||
|
||||
var blocks: [ChatMarkdownBlock] = []
|
||||
var proseStart = 0
|
||||
|
||||
func appendProse(until end: Int) {
|
||||
guard proseStart < end else { return }
|
||||
blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..<end])))
|
||||
}
|
||||
|
||||
for extraction in extractions where extraction.lineRange.lowerBound >= proseStart {
|
||||
appendProse(until: extraction.lineRange.lowerBound)
|
||||
blocks.append(extraction.block)
|
||||
proseStart = extraction.lineRange.upperBound
|
||||
}
|
||||
|
||||
appendProse(until: source.lines.count)
|
||||
if blocks.count > 1, self.containsReferenceLink(document, source: source) {
|
||||
return self.proseOnly(source.lines)
|
||||
@@ -118,6 +144,89 @@ enum ChatMarkdownBlockSegmenter {
|
||||
return blocks
|
||||
}
|
||||
|
||||
private static func mathExtractions(
|
||||
source: SourceBuffer,
|
||||
document: Document,
|
||||
isComplete: Bool) -> MathExtractionResult
|
||||
{
|
||||
guard source.markdown.contains("$$") || source.markdown.contains(#"\["#) else {
|
||||
return MathExtractionResult(extractions: [], protectedRanges: [])
|
||||
}
|
||||
var topLevelParagraphLines = Set<Int>()
|
||||
var inlineCodeLines = Set<Int>()
|
||||
func collectInlineCodeLines(from markup: any Markup) {
|
||||
if markup is Markdown.InlineCode, let lineRange = source.lineRange(for: markup.range) {
|
||||
inlineCodeLines.formUnion(lineRange)
|
||||
}
|
||||
for child in markup.children {
|
||||
collectInlineCodeLines(from: child)
|
||||
}
|
||||
}
|
||||
for child in document.children where child is Markdown.Paragraph {
|
||||
if let lineRange = source.lineRange(for: child.range) {
|
||||
topLevelParagraphLines.formUnion(lineRange)
|
||||
}
|
||||
collectInlineCodeLines(from: child)
|
||||
}
|
||||
var extractions: [Extraction] = []
|
||||
var protectedRanges: [Range<Int>] = []
|
||||
var lineIndex = 0
|
||||
|
||||
while lineIndex < source.lines.count {
|
||||
guard topLevelParagraphLines.contains(lineIndex),
|
||||
!inlineCodeLines.contains(lineIndex),
|
||||
let opener = MathDelimiter.parse(source.lines[lineIndex])
|
||||
else {
|
||||
lineIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if let sameLineLatex = opener.sameLineLatex {
|
||||
let lineRange = lineIndex..<(lineIndex + 1)
|
||||
if sameLineLatex.utf8.count <= self.maxMathBytes {
|
||||
extractions.append(Extraction(
|
||||
lineRange: lineRange,
|
||||
block: .math(ChatMathBlock(latex: sameLineLatex, isComplete: true))))
|
||||
} else {
|
||||
protectedRanges.append(lineRange)
|
||||
}
|
||||
lineIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let contentStart = lineIndex + 1
|
||||
var closeIndex = contentStart
|
||||
while closeIndex < source.lines.count,
|
||||
!opener.isClose(source.lines[closeIndex])
|
||||
{
|
||||
closeIndex += 1
|
||||
}
|
||||
|
||||
let closed = closeIndex < source.lines.count
|
||||
guard closed || isComplete else {
|
||||
// The first unmatched opener owns the remaining stream. Stop
|
||||
// here so later opener-looking lines do not trigger rescans.
|
||||
protectedRanges.append(lineIndex..<source.lines.count)
|
||||
return MathExtractionResult(extractions: extractions, protectedRanges: protectedRanges)
|
||||
}
|
||||
|
||||
let contentEnd = closed ? closeIndex : source.lines.count
|
||||
let lineRange = lineIndex..<(closed ? closeIndex + 1 : source.lines.count)
|
||||
let latex = source.lines[contentStart..<contentEnd]
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if latex.utf8.count <= self.maxMathBytes {
|
||||
extractions.append(Extraction(
|
||||
lineRange: lineRange,
|
||||
block: .math(ChatMathBlock(latex: latex, isComplete: closed || isComplete))))
|
||||
} else {
|
||||
protectedRanges.append(lineRange)
|
||||
}
|
||||
lineIndex = lineRange.upperBound
|
||||
}
|
||||
return MathExtractionResult(extractions: extractions, protectedRanges: protectedRanges)
|
||||
}
|
||||
|
||||
private static func proseOnly(_ lines: [String]) -> [ChatMarkdownBlock] {
|
||||
// Boundary blank lines only separate extracted blocks; the rendered
|
||||
// VStack provides that spacing. Interior blanks remain paragraphs.
|
||||
@@ -150,6 +259,52 @@ enum ChatMarkdownBlockSegmenter {
|
||||
code.hasSuffix("\n") ? String(code.dropLast()) : code
|
||||
}
|
||||
|
||||
private struct Extraction {
|
||||
let lineRange: Range<Int>
|
||||
let block: ChatMarkdownBlock
|
||||
}
|
||||
|
||||
private struct MathExtractionResult {
|
||||
let extractions: [Extraction]
|
||||
/// Rejected math stays prose and owns any block-looking syntax inside its span.
|
||||
let protectedRanges: [Range<Int>]
|
||||
}
|
||||
|
||||
private struct MathDelimiter {
|
||||
let close: String
|
||||
let sameLineLatex: String?
|
||||
|
||||
static func parse(_ line: String) -> MathDelimiter? {
|
||||
let (indent, afterIndent) = FenceOpener.leadingSpaces(of: line)
|
||||
guard indent <= 3, afterIndent < line.endIndex else { return nil }
|
||||
let suffix = line[afterIndent...]
|
||||
let pair: (open: String, close: String)
|
||||
if suffix.hasPrefix("$$") {
|
||||
pair = ("$$", "$$")
|
||||
} else if suffix.hasPrefix(#"\["#) {
|
||||
pair = (#"\["#, #"\]"#)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contentStart = suffix.index(suffix.startIndex, offsetBy: pair.open.count)
|
||||
let remainder = suffix[contentStart...]
|
||||
if remainder.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return MathDelimiter(close: pair.close, sameLineLatex: nil)
|
||||
}
|
||||
guard let closeRange = remainder.range(of: pair.close, options: .backwards),
|
||||
remainder[closeRange.upperBound...].trimmingCharacters(in: .whitespaces).isEmpty
|
||||
else { return nil }
|
||||
let latex = remainder[..<closeRange.lowerBound]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return MathDelimiter(close: pair.close, sameLineLatex: latex)
|
||||
}
|
||||
|
||||
func isClose(_ line: String) -> Bool {
|
||||
line.trimmingCharacters(in: .whitespaces) == self.close
|
||||
}
|
||||
}
|
||||
|
||||
private static func table(
|
||||
_ table: Markdown.Table,
|
||||
source: SourceBuffer,
|
||||
@@ -324,7 +479,7 @@ enum ChatMarkdownBlockSegmenter {
|
||||
return count >= self.count && line[cursor...].allSatisfy(\.isWhitespace)
|
||||
}
|
||||
|
||||
private static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) {
|
||||
fileprivate static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) {
|
||||
var count = 0
|
||||
var cursor = line.startIndex
|
||||
while cursor < line.endIndex, line[cursor] == " " {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import SwiftMath
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct ChatCodeBlockView: View {
|
||||
@@ -39,6 +45,157 @@ struct ChatCodeBlockView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -51,6 +51,8 @@ struct ChatMarkdownRenderer: View {
|
||||
.lineSpacing(self.variant == .compact ? 2 : 4)
|
||||
case let .code(code):
|
||||
ChatCodeBlockView(block: code)
|
||||
case let .math(math):
|
||||
ChatMathBlockView(block: math, textColor: self.textColor)
|
||||
case let .table(table):
|
||||
ChatMarkdownTableView(table: table)
|
||||
}
|
||||
@@ -68,7 +70,7 @@ struct ChatMarkdownRenderer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fenced code and GFM tables are split out by `ChatMarkdownBlockSegmenter`
|
||||
/// Fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter`
|
||||
/// before this runs, so prose only needs chat-style soft-break preservation.
|
||||
enum ChatMarkdownDisplayPreprocessor {
|
||||
static func preserveChatSoftBreaks(in markdown: String) -> String {
|
||||
|
||||
@@ -5,6 +5,8 @@ import AppKit
|
||||
#endif
|
||||
|
||||
enum OpenClawChatTypography {
|
||||
static let bodySize: CGFloat = 17
|
||||
|
||||
static var title3: Font {
|
||||
display(size: 22, weight: .bold, relativeTo: .title2)
|
||||
}
|
||||
@@ -22,7 +24,7 @@ enum OpenClawChatTypography {
|
||||
}
|
||||
|
||||
static var body: Font {
|
||||
body(size: 17, weight: .regular, relativeTo: .body)
|
||||
body(size: self.bodySize, weight: .regular, relativeTo: .body)
|
||||
}
|
||||
|
||||
static var footnote: Font {
|
||||
|
||||
@@ -115,6 +115,107 @@ struct ChatMarkdownBlockSegmenterTests {
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Display math
|
||||
|
||||
@Test func `same line dollar delimiters extract display math`() {
|
||||
let blocks = self.segments("before\n$$ x^2 + y^2 $$\nafter")
|
||||
#expect(blocks == [
|
||||
.prose("before"),
|
||||
.math(ChatMathBlock(latex: "x^2 + y^2", isComplete: true)),
|
||||
.prose("after"),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `own line dollar delimiters extract multiline display math`() {
|
||||
let blocks = self.segments("""
|
||||
$$
|
||||
\\begin{aligned}
|
||||
x &= 1 \\\\
|
||||
y &= 2
|
||||
\\end{aligned}
|
||||
$$
|
||||
""")
|
||||
#expect(blocks == [
|
||||
.math(ChatMathBlock(
|
||||
latex: "\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}",
|
||||
isComplete: true)),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `bracket delimiters extract display math`() {
|
||||
let blocks = self.segments(#"\[\frac{a}{b}\]"#)
|
||||
#expect(blocks == [
|
||||
.math(ChatMathBlock(latex: #"\frac{a}{b}"#, isComplete: true)),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `inline math delimiters stay prose`() {
|
||||
let markdown = #"single $x$, parenthesized \(y\), and prose around $$z$$"#
|
||||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `unclosed math while streaming stays prose`() {
|
||||
let markdown = "$$\nx + y"
|
||||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `unclosed math in complete message renders as math`() {
|
||||
let blocks = self.segments("$$\nx + y")
|
||||
#expect(blocks == [
|
||||
.math(ChatMathBlock(latex: "x + y", isComplete: true)),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `math composes with prose and fenced code blocks`() {
|
||||
let blocks = self.segments("before\n$$E = mc^2$$\n```swift\nlet value = 1\n```\nafter")
|
||||
#expect(blocks == [
|
||||
.prose("before"),
|
||||
.math(ChatMathBlock(latex: "E = mc^2", isComplete: true)),
|
||||
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
|
||||
.prose("after"),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `oversized math stays raw prose`() {
|
||||
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
|
||||
let markdown = "$$\n\(latex)\n$$"
|
||||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `oversized math keeps nested code fence in prose`() {
|
||||
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
|
||||
let markdown = "$$\n\(latex)\n```swift\nlet value = 1\n```\n$$"
|
||||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `math delimiters inside code fences stay code`() {
|
||||
let code = "$x$\n$$\nx + y\n$$\n\\[z\\]"
|
||||
let blocks = self.segments("```tex\n\(code)\n```")
|
||||
#expect(blocks == [
|
||||
.code(ChatCodeBlock(language: "tex", code: code, isComplete: true)),
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `math delimiters inside multiline code span stay prose`() {
|
||||
let markdown = "`literal\n$$ x + y $$\nend`"
|
||||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `math delimiters inside list stay prose`() {
|
||||
let markdown = "- item\n $$x + y$$"
|
||||
#expect(self.segments(markdown) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `unclosed streaming math leaves later opener lines as prose`() {
|
||||
let markdown = "\\[\nfirst\n\\[\nsecond"
|
||||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
@Test func `unclosed streaming math keeps later code fence in prose`() {
|
||||
let markdown = "before\n$$\nx + y\n```swift\nlet value = 1\n```"
|
||||
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
|
||||
}
|
||||
|
||||
// MARK: - Streaming fallbacks
|
||||
|
||||
@Test func `unclosed fence while streaming stays plain`() {
|
||||
|
||||
Reference in New Issue
Block a user